From 0dbed0a0b1f3f7e8aa23c6ac2fe48673e1325c73 Mon Sep 17 00:00:00 2001 From: alpha912 Date: Sat, 18 Jul 2026 12:52:37 +0000 Subject: [PATCH] Release 0.3.0: Core-DP conformance harness, requirements enrichment, and governance fixes - Add the Core-DP 0.1.0-lab applicability profile: schemas, conformance vectors, EPCIS/CBV fixtures, and requirement index - Add an executable conformance harness (run-conformance.js, npm run conformance:core-dp) validating vectors against schemas and their declared semantic checks, cross-referenced against requirement IDs - Enrich core-dp-requirements.json with rationale, owner, testability statement, and evidence output per requirement - Add npm run check:domains script wrapper - Fix a conflict between rfc-process.md and GOVERNANCE.md's RFC process by pointing the former to the latter as authoritative for states/timing - Fix a stale localloop.github.io domain reference in an audit doc - Move governance/audit docs (previously untracked, outside any repo) into docs/governance/ and docs/audit/ --- CHANGELOG.md | 23 + PROJECT_STRUCTURE.md | 26 + README.md | 3 + docs/audit/discovery.md | 75 + docs/audit/infrastructure-readiness.md | 74 + docs/audit/requirements-matrix.md | 70 + docs/audit/spec-implementation-divergence.md | 56 + docs/audit/state-of-development.md | 277 ++ docs/audit/technical-debt.md | 70 + docs/backup-restore-runbook.md | 147 ++ docs/governance/CLAIMS-AND-MATURITY.md | 40 + docs/governance/GOVERNANCE.md | 38 + docs/governance/RELEASE-CHECKLIST.md | 20 + docs/governance/rfc-process.md | 10 + openapi.json | 82 +- package.json | 4 +- profiles/README.md | 9 + profiles/core-dp/README.md | 104 + profiles/core-dp/conformance/README.md | 19 + .../core-dp/conformance/run-conformance.js | 332 +++ .../conformance/trust/accepted-peer-keys.json | 56 + .../conformance/vectors/core-dp-vectors.json | 2335 +++++++++++++++++ profiles/core-dp/epcis/README.md | 12 + .../core-dp-transfer-object-event.json | 24 + .../core-dp/epcis/unsupported-features.json | 37 + .../requirements/core-dp-requirements.json | 107 + .../spec-v0.2.0-normative-manifest.json | 815 ++++++ .../schemas/choreography-message.schema.json | 395 +++ .../core-dp/schemas/dna-operation.schema.json | 241 ++ profiles/core-dp/schemas/envelope.schema.json | 251 ++ .../core-dp/schemas/epcis-mapping.schema.json | 85 + profiles/core-dp/schemas/error.schema.json | 32 + .../schemas/evidence-entry.schema.json | 95 + .../schemas/peer-key-trust.schema.json | 50 + .../schemas/search-contract.schema.json | 174 ++ .../core-dp/schemas/trust-store.schema.json | 76 + scripts/validate-schemas.js | 1573 ++++++++++- 37 files changed, 7814 insertions(+), 23 deletions(-) create mode 100644 docs/audit/discovery.md create mode 100644 docs/audit/infrastructure-readiness.md create mode 100644 docs/audit/requirements-matrix.md create mode 100644 docs/audit/spec-implementation-divergence.md create mode 100644 docs/audit/state-of-development.md create mode 100644 docs/audit/technical-debt.md create mode 100644 docs/backup-restore-runbook.md create mode 100644 docs/governance/CLAIMS-AND-MATURITY.md create mode 100644 docs/governance/GOVERNANCE.md create mode 100644 docs/governance/RELEASE-CHECKLIST.md create mode 100644 profiles/README.md create mode 100644 profiles/core-dp/README.md create mode 100644 profiles/core-dp/conformance/README.md create mode 100644 profiles/core-dp/conformance/run-conformance.js create mode 100644 profiles/core-dp/conformance/trust/accepted-peer-keys.json create mode 100644 profiles/core-dp/conformance/vectors/core-dp-vectors.json create mode 100644 profiles/core-dp/epcis/README.md create mode 100644 profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json create mode 100644 profiles/core-dp/epcis/unsupported-features.json create mode 100644 profiles/core-dp/requirements/core-dp-requirements.json create mode 100644 profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json create mode 100644 profiles/core-dp/schemas/choreography-message.schema.json create mode 100644 profiles/core-dp/schemas/dna-operation.schema.json create mode 100644 profiles/core-dp/schemas/envelope.schema.json create mode 100644 profiles/core-dp/schemas/epcis-mapping.schema.json create mode 100644 profiles/core-dp/schemas/error.schema.json create mode 100644 profiles/core-dp/schemas/evidence-entry.schema.json create mode 100644 profiles/core-dp/schemas/peer-key-trust.schema.json create mode 100644 profiles/core-dp/schemas/search-contract.schema.json create mode 100644 profiles/core-dp/schemas/trust-store.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b1169cb..c61bbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-07-18 + +### Added +- Core-DP `0.1.0-lab` applicability profile with normative v0.2.0 requirement manifest, profile requirement index, JSON Schemas, conformance vectors, EPCIS/CBV fixture metadata, and profile index documentation. +- Validation coverage for Core-DP schemas, requirement links, normative `SPECIFICATION.md` RFC 2119 clause mapping, conformance vectors, semantic vector checks, and EPCIS fixture guardrails. +- `npm run check:domains` script wrapper for the repository domain policy check. +- Portable Core-DP schema conditionals for envelope body binding, choreography authority/state matrices, cross-node search auth/consistency, replay-window checks, and OpenAPI multi-header node-signature auth. +- Executable Core-DP conformance harness (`profiles/core-dp/conformance/run-conformance.js`, + `npm run conformance:core-dp`) validating all conformance vectors against schemas and + their declared semantic checks, cross-referenced against requirement IDs. +- `core-dp-requirements.json` enriched with rationale, owner, testability statement, + and evidence output per requirement. + +### Changed +- OpenAPI now keeps the base v0.2.0 endpoints and adds the lab-only Core-DP Product search endpoint with direct refs to the profile search request/response definitions. + +### Fixed +- `docs/governance/rfc-process.md` now points to `GOVERNANCE.md` §2 as the + authoritative RFC process for states/timing, resolving a conflict between + the two documents. +- Corrected a stale `localloop.github.io` domain reference in the + `spec-implementation-divergence.md` audit doc. + ## [0.2.3] - 2026-05-26 ### Added diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 5b6aeac..6056f61 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -48,6 +48,32 @@ loop-protocol/ │ ├── 13-conformity-claims.json │ ├── 14-product-reuse-registration.json │ └── 15-product-offer-flow.json +├── profiles/ +│ ├── README.md +│ └── core-dp/ +│ ├── README.md +│ ├── conformance/ +│ │ ├── README.md +│ │ ├── trust/ +│ │ │ └── accepted-peer-keys.json +│ │ └── vectors/ +│ │ └── core-dp-vectors.json +│ ├── epcis/ +│ │ ├── README.md +│ │ ├── fixtures/ +│ │ │ └── core-dp-transfer-object-event.json +│ │ └── unsupported-features.json +│ ├── requirements/ +│ │ ├── core-dp-requirements.json +│ │ └── spec-v0.2.0-normative-manifest.json +│ └── schemas/ +│ ├── choreography-message.schema.json +│ ├── dna-operation.schema.json +│ ├── envelope.schema.json +│ ├── epcis-mapping.schema.json +│ ├── error.schema.json +│ ├── evidence-entry.schema.json +│ └── search-contract.schema.json ├── docs/ │ ├── faq.md │ ├── federation-handshake.md diff --git a/README.md b/README.md index 39f1e39..03185c0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Local Optimization with Overflow Protocol. A federated circular economy concept - `schemas/`: JSON Schema definitions. - `examples/`: sample payloads for interop. - `contexts/`: JSON-LD contexts. +- `profiles/`: applicability profiles, including the lab-only Core-DP contract and conformance vectors. - `docs/regulatory-alignment-roadmap.md`: long-horizon EU and German compatibility plan. - `rfcs/`: RFCs and design notes. @@ -21,12 +22,14 @@ Local Optimization with Overflow Protocol. A federated circular economy concept ```bash npm ci npm test +npm run check:domains ``` ## Usage notes - Specs and schemas are draft and subject to change. - No certified implementations exist at this time. - Use `examples/` to validate tooling and docs. +- Core-DP is a lab-only applicability profile, not a full LOOP conformance claim. ## Links - Docs hub: https://localloop.urbnia.com diff --git a/docs/audit/discovery.md b/docs/audit/discovery.md new file mode 100644 index 0000000..dca5b01 --- /dev/null +++ b/docs/audit/discovery.md @@ -0,0 +1,75 @@ +# Phase 1 - Discovery & Context Gathering + +> CURRENT-USE WARNING: This is a historical discovery snapshot, not current implementation, deployment, or readiness evidence. Its original snapshot date and analyzed commits were not recorded in this source; do not infer them. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. + +## 1.1 Project Structure Analysis + +### Top-level layout +- Root contains four git repos plus shared `AGENTS.md` and `CLAUDE.md`. +- Repos: + - `localloop.github.io` — public docs hub (Next.js static export). + - `localloop-backend` — Bun + Fastify API (interest registry + lab demo). + - `loop-protocol` — protocol specification + schemas + RFCs. + - `org-github-profile` — GitHub org profile docs. + +### Source directories +- `localloop.github.io` + - `app/` — Next.js App Router pages and layouts. + - `public/` — static assets + mirrored protocol content. + - `scripts/` — domain checking + aggregation. + - `tests/` — smoke + Playwright e2e. +- `localloop-backend` + - `src/` — Fastify server, routes, DB, SSE, queue, schemas. + - `prisma/` — schema and Prisma config. + - `scripts/` — lab demo + federation simulations. + - `tests/` — Bun test suite (routes, SSE, queue, auth). + - `deploy/` — systemd + nginx example config. +- `loop-protocol` + - `schemas/` — JSON schemas for core entities. + - `contexts/` — JSON-LD contexts. + - `docs/` — governance, security, implementation guidance. + - `rfcs/` — RFC index and drafts. + - `examples/` — JSON examples. +- `org-github-profile` + - `profile/` — public org profile README. + +### Key config files +- `localloop.github.io` + - `package.json`, `next.config.js`, `playwright.config.ts`, `scripts/check-domains.cjs` +- `localloop-backend` + - `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml`, `.env.example`, `.env.docker.example`, `prisma.config.ts` +- `loop-protocol` + - `package.json`, `package-lock.json`, `scripts/validate-schemas.js` + +### Context files +- Root: `AGENTS.md`, `CLAUDE.md` +- `org-github-profile`: `AGENTS.md`, `CLAUDE.md` + +### Requirements / PRD / Specs +- **No PRD or REQUIREMENTS.md found.** +- Primary requirements-like document: `loop-protocol/SPECIFICATION.md` (Protocol v0.1.1). + +## 1.2 Documentation Inventory + +### API documentation +- Backend runtime OpenAPI via Fastify swagger (`GET /openapi.json`, `GET /docs`). +- Protocol OpenAPI artifact: `loop-protocol/openapi.json` (mirrored in docs hub). + +### Architecture docs +- No dedicated `ARCHITECTURE.md` found. +- Protocol docs: `loop-protocol/docs/implementation-guide.md` and security/compliance docs. + +### Deployment docs +- Backend: `localloop-backend/Dockerfile`, `docker-compose.yml`, `deploy/localloop-backend.service`, `deploy/nginx.conf`. +- No Kubernetes manifests found. + +### Developer guides +- `localloop.github.io/CONTRIBUTING.md` +- `localloop-backend/CONTRIBUTING.md` +- `loop-protocol/CONTRIBUTING.md` + +## Tech Stack Summary +- **Frontend**: Next.js 16 (App Router), React 19, Playwright tests. +- **Backend**: Bun runtime, Fastify 5, Prisma 7, PostgreSQL (PostGIS), Redis + BullMQ, MinIO (S3). +- **Protocol**: JSON-LD + JSON Schema; AJV for schema validation. +- **Infra**: Docker + Traefik on VPS; optional systemd + Nginx config. diff --git a/docs/audit/infrastructure-readiness.md b/docs/audit/infrastructure-readiness.md new file mode 100644 index 0000000..0c39779 --- /dev/null +++ b/docs/audit/infrastructure-readiness.md @@ -0,0 +1,74 @@ +# Phase 4 - Infrastructure & Deployment Analysis + +> CURRENT-USE WARNING: This is a historical audit snapshot, not current deployment, security, backup, or readiness evidence. Its original snapshot date and analyzed commits were not recorded in this source; do not infer them. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. + +## 4.1 Docker Configuration Review + +### Dockerfile (`localloop-backend/Dockerfile`) +- Base image: `oven/bun:1.3.5` (pinned) ✅ +- Multi-stage build: ❌ +- Runs as non-root: ✅ (`User=app`) +- Health check: ✅ (HTTP `/health` via Bun fetch) +- Signal handling: relies on Bun process default; no explicit `STOPSIGNAL`. +- Installs prod deps + Prisma client inside image ✅ + +### docker-compose (`localloop-backend/docker-compose.yml`) +- Services: api, postgres, redis, minio +- Dependencies: `depends_on` + health checks ✅ +- Volumes: `./data/postgres`, `./data/minio`, `./data/redis` ✅ +- Network: `proxy` external + default network ✅ +- Resource limits: ❌ +- Restart policy: `unless-stopped` ✅ +- Secrets management: ❌ (env_file contains secrets) +- Image pinning: ✅ (redis/minio pinned by digest) + +## 4.2 Production Readiness Checklist + +□ Environment Configuration + - [ ] All secrets externalized (defaults include `change-me`) + - [ ] Environment-specific configs separated (no `.env.production.example`) + - [ ] `.env.production.example` exists + +□ Logging & Monitoring + - [ ] Structured logging implemented (Fastify logger is structured, but no log shipping) + - [x] Log levels configurable (via Fastify logger config) + - [x] Health check endpoints (`/health`) + - [x] Metrics endpoint (`/api/metrics`, in-memory) + +□ Security + - [x] HTTPS/TLS configured via proxy (Traefik labels) + - [x] CORS configured (allowed origins list) + - [x] Rate limiting implemented + - [ ] Input sanitization (no central PII or payload sanitization beyond schemas) + - [x] SQL injection prevention (Prisma + parameterized queries) + - [ ] XSS prevention (frontend is static; no CSP configured server-side) + +□ Performance + - [x] Database connection pooling + - [ ] Caching strategy (Redis used for queue, not cache) + - [x] Static asset optimization (Next static export) + - [ ] Compression enabled (no Fastify compression plugin) + +□ Reliability + - [x] Graceful shutdown handling (`SIGINT`/`SIGTERM`) + - [x] Database migrations versioned + - [x] Backup strategy documented (`docs/backup-restore-runbook.md`) + - [x] Rollback procedure documented + +## 4.3 VPS / Server Deployment Analysis + +### Reverse proxy +- `deploy/nginx.conf` is HTTP-only (no TLS termination, no HTTP→HTTPS redirect). +- Traefik labels exist in docker-compose for TLS, but Nginx config may be stale. +- SSE endpoints may need proxy buffering disabled in Nginx/Traefik for reliability. + +### Process management +- `deploy/localloop-backend.service` now configured for a non-root user; ensure the user exists on the VPS. +- No log rotation config included. + +### Backups +- Manual backup/restore runbook plus systemd timer/script artifacts added in `docs/backup-restore-runbook.md` and `localloop-backend/deploy/`. + +## 4.4 Required Infrastructure Work (Top Items) +1. Add log rotation and alerting. +2. Verify restore drills and add offsite sync for scheduled backups. diff --git a/docs/audit/requirements-matrix.md b/docs/audit/requirements-matrix.md new file mode 100644 index 0000000..6f5b74b --- /dev/null +++ b/docs/audit/requirements-matrix.md @@ -0,0 +1,70 @@ +# Phase 2 - PRD/Requirements Evaluation + +> CURRENT-USE WARNING: This is a historical audit snapshot, not current implementation or conformance evidence. Its original snapshot date and analyzed commits were not recorded in this source; do not infer them. It is a historical spec-feature evaluation and is superseded as the current requirements inventory by the Core-DP profile requirements at `loop-protocol/profiles/core-dp/requirements/core-dp-requirements.json`. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. + +## 2.1 Feature Matrix (Derived from `loop-protocol/SPECIFICATION.md`) + +| Feature ID | Feature Name | PRD Status | Implementation Status | Completeness % | Notes | +| --- | --- | --- | --- | --- | --- | +| F-001 | MaterialDNA registration | Required | Partial | 50% | Implemented as lab demo `POST /api/v1/material`; search and broader discovery are still missing. | +| F-002 | Offer publication | Required | Partial | 50% | Implemented as lab demo `POST /api/v1/offer`; no offer discovery. | +| F-003 | Match acceptance | Required | Partial | 50% | Implemented as lab demo `POST /api/v1/match`. | +| F-004 | Transfer completion | Required | Partial | 50% | Implemented as lab demo `POST /api/v1/transfer`. | +| F-005 | Material lookup (GET by id) | Required | Implemented | 100% | `/api/v1/material/{id}` is available for lab material records. | +| F-006 | Material search | Required | Not implemented | 0% | `/api/v1/material/search` missing. | +| F-007 | Node info endpoint | Required | Partial | 60% | `/api/v1/node/info` now returns minimal lab node metadata; spec-style location/statistics fields are not implemented. | +| F-008 | LoopSignal config endpoint | Required | Not implemented | 0% | `/api/v1/signals` missing. | +| F-009 | Transaction creation | Required | Not implemented | 0% | `/api/v1/transaction` missing. | +| F-010 | Federation announce/offer | Required | Not implemented | 0% | `/api/v1/federate/announce` + `/offer` missing. | +| F-011 | Federation handshake protocol | Required | Partial | 40% | Implemented as `/api/v1/federation/handshake` (lab-only); no signature validation; schema differs from spec endpoint. | +| F-012 | LoopCoin issuance/transfer | Required | Not implemented | 0% | Schemas exist; no API/service implementation. | +| F-013 | LoopSignal voting | Required | Not implemented | 0% | Schemas exist; no API/service implementation. | +| F-014 | LoopCost calculation | Required | Not implemented | 0% | No routing/cost calculator implemented. | +| F-015 | Auth per spec (Bearer + TLS) | Required | Partial | 40% | API key + Better Auth optional; Bearer token support not enforced; TLS via proxy only. | +| F-016 | Lab data minimization (no PII) | Required | Partial | 30% | Loop schemas enforce structure; no explicit PII redaction checks. | + +### Undocumented / Scope-Creep Features (Implemented but not in spec) +- S-001 Interest registry + SSE (`/api/interest`, `/api/interest/stream`) +- S-002 Payments intake (`/api/payments/intent`, `/api/payments/webhook`) +- S-003 City directory/geojson (`/api/cities`, `/api/cities/:slug`, `/api/cities/geojson`) +- S-004 Metrics counters (`/api/metrics`) +- S-005 Public privacy notice (`/api/privacy`) + +## 2.2 API Contract Validation + +### Spec vs Implementation (Backend) + +| Method | Spec Endpoint | Implemented? | Closest Backend Endpoint | Notes | +| --- | --- | --- | --- | --- | +| POST | `/api/v1/material` | ✅ | `/api/v1/material` | Lab-only payloads; path aligned. | +| GET | `/api/v1/material/{id}` | ✅ | `/api/v1/material/:id` | Implemented for lab material records. | +| POST | `/api/v1/material/search` | ❌ | `/api/interest?search=` | Search exists but only for interest registry (not protocol). | +| GET | `/api/v1/node/info` | ⚠️ | `/api/v1/node/info` | Minimal lab metadata exists; spec-style location/statistics are still missing. | +| GET | `/api/v1/signals` | ❌ | — | Missing. | +| POST | `/api/v1/transaction` | ❌ | — | Missing. | +| POST | `/api/v1/federate/announce` | ❌ | `/api/v1/relay` | Relay is lab-only event log, not spec announce. | +| POST | `/api/v1/federate/offer` | ❌ | `/api/v1/relay` | Same as above. | + +### Backend Docs vs Actual Routes + +| Method | Endpoint | Documented? | Implemented? | Notes | +| --- | --- | --- | --- | --- | +| GET | `/health` | ✅ (README) | ✅ | Root `AGENTS.md` says `/api/health` but code uses `/health`. | +| GET | `/api/cities/:slug` | ❌ | ✅ | Undocumented in README. | +| GET | `/openapi.json` | ✅ | ✅ | Swagger generated at runtime. | +| GET | `/docs` | ✅ | ✅ | Redoc HTML. | + +### Missing Endpoints (Per Spec) +- `/api/v1/material/search` (POST) +- `/api/v1/signals` (GET) +- `/api/v1/transaction` (POST) +- `/api/v1/federate/announce` (POST) +- `/api/v1/federate/offer` (POST) + +### Undocumented Endpoints (Per Implementation) +- `GET /api/cities/:slug` + +## Divergence Documentation + +- Explicit divergence summary: `docs/audit/spec-implementation-divergence.md` +- Protocol OpenAPI artifact: `loop-protocol/openapi.json` (mirrored to docs hub) diff --git a/docs/audit/spec-implementation-divergence.md b/docs/audit/spec-implementation-divergence.md new file mode 100644 index 0000000..13f6bb7 --- /dev/null +++ b/docs/audit/spec-implementation-divergence.md @@ -0,0 +1,56 @@ +# Protocol vs Implementation Divergence + +> CURRENT-USE WARNING: This is a historical divergence snapshot, not current implementation or conformance evidence. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. + +**Original snapshot date (historical):** 2026-03-09 + +This document explicitly records known divergences between the LOOP specification (v0.1.1) and the current backend + docs hub implementations. It is intended to prevent ambiguity for contributors and reviewers. + +## 1) API Surface + +### Spec-required endpoints (v0.1.1) +The spec mandates `/api/v1/*` endpoints for core protocol operations. + +| Spec Endpoint | Status | Current Backend Endpoint | Notes | +| --- | --- | --- | --- | +| `POST /api/v1/material` | Implemented | `/api/v1/material` | Lab-only endpoint; path aligned with the spec. | +| `GET /api/v1/material/{id}` | Implemented | `/api/v1/material/:id` | Read-by-id is available for lab materials. | +| `POST /api/v1/material/search` | Missing | — | Not implemented. | +| `GET /api/v1/node/info` | Partial | `/api/v1/node/info` | Implemented as a minimal lab metadata response; spec-style location/statistics fields are not exposed yet. | +| `GET /api/v1/signals` | Missing | — | Not implemented. | +| `POST /api/v1/transaction` | Missing | — | Not implemented. | +| `POST /api/v1/federate/announce` | Missing | `/api/v1/relay` | Relay is lab event log; not spec announcement. | +| `POST /api/v1/federate/offer` | Missing | `/api/v1/relay` | Relay is lab event log; not spec offer. | + +### Implemented endpoints not in spec +| Backend Endpoint | Purpose | Notes | +| --- | --- | --- | +| `/api/interest` + `/api/interest/stream` | Public interest registry | Non-protocol feature. | +| `/api/cities` + `/api/cities/:slug` + `/api/cities/geojson` | Demo data | Lab UI support. | +| `/api/payments/*` | Manual payment intake | Disabled by default. | +| `/api/metrics` | In-memory counters | Observability for lab only. | +| `/api/privacy` | Lab data policy | Non-protocol. | + +## 2) Authentication & Security +- Spec assumes Bearer token auth + TLS for protocol messages. +- Backend uses optional API key protection and optional Better Auth (disabled by default). +- TLS is provided via reverse proxy (Traefik/Nginx), not enforced in app logic. + +## 3) Content-Type & Schema Enforcement +- Spec mandates `application/ld+json` and strict JSON-LD context usage. +- Backend lab endpoints accept JSON schemas but do not enforce `@context` values beyond schema-level `const` checks. +- `/api/v1/relay` now limits relayed traffic to the current lab event/entity families, but it still does not validate cryptographic signatures or full spec announce/offer payloads. + +## 4) Federation Handshake +- Spec documents federation endpoints (`/api/v1/federate/*`), while backend provides a lab-only registry at `/api/v1/federation/handshake`. +- Signature validation is **not** implemented; docs note signatures are placeholders. +- The handshake response now uses the preferred v0.2.0 context and schema version, but the endpoint path remains lab-specific. + +## 5) Docs Hub vs Protocol Repo +- The docs hub mirrors protocol artifacts, but lag can occur if mirroring is not run after updates. +- Protocol OpenAPI artifact is now published as `loop-protocol/openapi.json` and mirrored at `https://localloop.urbnia.com/projects/loop-protocol/openapi.json` (corrected from a stale `localloop.github.io` reference — that domain does not exist; see `DOMAIN-POLICY.md`). + +## 6) Resolution Plan +- Keep documenting the lab-only compatibility surface until `/api/v1/material/search`, `/api/v1/signals`, `/api/v1/transaction`, and `/api/v1/federate/*` are implemented. +- Add signature validation if federation moves beyond controlled demos. +- Align auth model (Bearer token vs API key) and document deviations. diff --git a/docs/audit/state-of-development.md b/docs/audit/state-of-development.md new file mode 100644 index 0000000..10058cc --- /dev/null +++ b/docs/audit/state-of-development.md @@ -0,0 +1,277 @@ +# State of Development + +> CURRENT-USE WARNING: This is a historical audit snapshot, not current implementation, readiness, or claim evidence. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. TD-001 and TD-002 are recorded below as **open as observed in this snapshot**; that does not resolve the conflicting historical observation in [technical-debt.md](technical-debt.md). + +**Original snapshot date (historical):** 2026-03-09 +**Historical analyzed commits:** +- localloop-backend: 21318e4 (branch `fix/ci-typecheck`) +- localloop.github.io: 937d35e (branch `fix/ci-domain-check`) +- loop-protocol: 4ba85b2 (branch `fix/domain-check-fallback`) +- org-github-profile: e130ac8 (branch `main`) +**Branch:** multi-repo + +## Executive Summary + +localLOOP is a multi-repo system with a clear lab-only posture: the protocol specification is mature enough to define a minimal interop flow, while the backend implements a limited demo API and the docs hub mirrors protocol artifacts. The documentation set is strong (specs, governance, compliance), but the protocol’s required API surface is largely unimplemented in the backend. The site and backend are aligned around lab demo flows and interest capture rather than full protocol compliance. + +The primary blockers to production readiness are protocol/API alignment (spec endpoints, authentication semantics, and federation flows) and infra automation (resource limits, rollback, automated backups). Infrastructure hardening has begun (non-root execution, pinned images, health checks), and an OpenAPI artifact is now published for the protocol. + +Recommended priorities are: implement or explicitly scope the protocol endpoints for v0.1.1, complete infra automation (backup scheduling + rollback runbook), and close the remaining gaps between spec and implementation. + +### Health Indicators + +| Metric | Status | Notes | +| --- | --- | --- | +| Feature Completeness | ~30% | Lab demo flow implemented; most spec endpoints missing. | +| Test Coverage | N/A | No coverage report; tests exist for backend routes + site e2e. | +| Technical Debt Items | 7 | 2 high, 4 medium, 1 low (see registry). | +| Production Readiness | 5/10 | Spec/API alignment + automation gaps remain; infra hardening partially addressed. | + +--- + +## 1. Feature Implementation Status + +### 1.1 Completed Features + +| Feature | Implementation | Tests | Docs | Notes | +| --- | --- | --- | --- | --- | +| Interest capture + public list | ✅ | ✅ | ✅ | Non-protocol feature; SSE supported. | +| Docs hub (protocol mirror) | ✅ | ✅ | ✅ | Static export with mirrored spec artifacts. | + +### 1.2 Partially Implemented + +| Feature | Completeness | Blocking Issues | Priority | +| --- | --- | --- | --- | +| MaterialDNA → Offer → Match → Transfer (lab flow) | 50% | Spec endpoints missing; lab-only payloads | High | +| Federation handshake (lab registry) | 40% | No signatures, different endpoint path | High | +| Payments intake | 40% | No provider integrations, disabled by default | Medium | +| Auth (Better Auth) | 30% | Disabled by default; not wired to spec auth | Medium | + +### 1.3 Not Started + +| Feature | PRD Reference | Dependencies | Estimated Effort | +| --- | --- | --- | --- | +| Node info endpoint | Spec §8.1 | Response expansion (location/statistics) | S | +| Material search endpoint | Spec §8.1 | Search index + query API | M | +| LoopSignal config endpoint | Spec §8.1 | Data model + storage | M | +| Transaction endpoint | Spec §8.1 | Data model + settlement flow | L | +| Federation announce/offer endpoints | Spec §8.2 | Messaging + signature validation | M | +| LoopCoin issuance/transfer | Spec §5 | Ledger + rules | L | +| LoopSignal voting | Spec §6 | Governance flow | L | +| LoopCost calculation | Spec §7 | Algorithm + inputs | M | + +--- + +## 2. Technical Debt Registry + +### 2.1 Critical (Must Fix Before Production) + +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| — | — | None identified at code level | — | — | + +### 2.2 High Priority + +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| TD-001 | `localloop-backend/deploy/localloop-backend.service` | Historical observation: non-root user configured but must be created/provisioned on VPS. | Security | S | +| TD-002 | `localloop-backend/src/config.ts` | Historical observation: defaults use `change-me` credentials if env missing. | Security | S | + +### 2.3 Medium Priority + +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| TD-004 | `localloop-backend/src/storage/s3.ts` | Unused S3 client; feature incomplete. | Incomplete feature | M | +| TD-005 | `localloop-backend/src/metrics.ts` | In-memory metrics reset on restart. | Observability | S | +| TD-006 | `localloop-backend/src/routes/loop.ts` | `/api/v1/relay` accepts arbitrary payload. | Data quality | M | +| TD-007 | `localloop.github.io/public/assets/js/config.js` | Hardcoded API base; no env override. | Config flexibility | S | + +### 2.4 Low Priority / Nice-to-Have + +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| TD-008 | `loop-protocol/docs/federation-handshake.md` | Placeholder signature guidance. | Spec clarity | M | + +--- + +## 3. TODO/FIXME/FUTURE Inventory + +### 3.1 Summary Statistics + +- Total TODOs: 0 +- Total FIXMEs: 0 +- Total HACK/WORKAROUND: 0 +- Total FUTURE/PLANNED: 0 + +### 3.2 By Category + +No TODO/FIXME markers found in source files. + +--- + +## 4. API Implementation Status + +### 4.1 Endpoint Coverage (Backend) + +| Method | Endpoint | Documented | Implemented | Tested | Auth | +| --- | --- | --- | --- | --- | --- | +| GET | `/health` | ✅ | ✅ | ✅ | None | +| GET | `/api/interest` | ✅ | ✅ | ✅ | None | +| POST | `/api/interest` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/interest/stream` | ✅ | ✅ | ✅ | None | +| POST | `/api/v1/material` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/v1/material/:id` | ✅ | ✅ | ✅ | None | +| GET | `/api/v1/material` | ✅ | ✅ | ✅ | None | +| GET | `/api/v1/node/info` | ✅ | ✅ | ✅ | None | +| POST | `/api/v1/offer` | ✅ | ✅ | ✅ | API Key (optional) | +| POST | `/api/v1/match` | ✅ | ✅ | ✅ | API Key (optional) | +| POST | `/api/v1/transfer` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/v1/events` | ✅ | ✅ | ✅ | None | +| GET | `/api/v1/stream` | ✅ | ✅ | ✅ | None | +| POST | `/api/v1/relay` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/v1/federation/nodes` | ✅ | ✅ | ✅ | None | +| POST | `/api/v1/federation/handshake` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/cities` | ✅ | ✅ | ✅ | None | +| GET | `/api/cities/:slug` | ❌ | ✅ | ✅ | None | +| GET | `/api/cities/geojson` | ✅ | ✅ | ✅ | None | +| POST | `/api/payments/intent` | ✅ | ✅ | ✅ | API Key (optional) | +| POST | `/api/payments/webhook` | ✅ | ✅ | ✅ | API Key (optional) | +| GET | `/api/metrics` | ✅ | ✅ | ✅ | None | +| GET | `/api/privacy` | ✅ | ✅ | ✅ | None | +| GET | `/openapi.json` | ✅ | ✅ | ✅ | None | +| GET | `/docs` | ✅ | ✅ | ✅ | None | + +### 4.2 Missing Endpoints (Per Spec) + +- `/api/v1/material/search` (POST) +- `/api/v1/signals` (GET) +- `/api/v1/transaction` (POST) +- `/api/v1/federate/announce` (POST) +- `/api/v1/federate/offer` (POST) + +### 4.3 Undocumented Endpoints + +- `GET /api/cities/:slug` + +--- + +## 5. Infrastructure & Deployment Status + +### 5.1 Docker Configuration + +| Aspect | Status | Notes | +| --- | --- | --- | +| Dockerfile optimization | ⚠️ | Single-stage; now runs as non-root with healthcheck. | +| docker-compose (dev/prod) | ⚠️ | One compose file; no env separation. | +| Health checks | ✅ | Health checks added for api/postgres/redis/minio. | +| Image pinning | ✅ | Redis/MinIO pinned by digest. | + +### 5.2 Production Readiness Matrix + +| Category | Status | Gaps | +| --- | --- | --- | +| Security | ⚠️ | Default creds, auth optional. | +| Logging | ⚠️ | No log shipping/rotation; Fastify logs only. | +| Monitoring | ⚠️ | Metrics are in-memory; no alerts. | +| Scaling | ⚠️ | Single-instance assumptions; no horizontal scaling plan. | +| Backup | ⚠️ | Manual runbook plus timer/script artifacts now exist; restore drill still pending. | + +### 5.3 Required Infrastructure Work + +1. Add log rotation / alerting and verify restore drills. +2. Wire offsite copy for the new scheduled backup job. + +--- + +## 6. Test Coverage Analysis + +### 6.1 Coverage Summary + +| Type | Files | Covered | Coverage % | +| --- | --- | --- | --- | +| Unit | Backend route tests + validation | Partial | N/A | +| Integration | SSE + queue tests | Partial | N/A | +| E2E | Playwright on site | Partial | N/A | + +### 6.2 Untested Critical Paths + +- [ ] S3 storage integration (unused) +- [ ] Database migrations rollback behavior +- [ ] Auth flows when `AUTH_ENABLED=true` + +### 6.3 Skipped Tests + +None detected. + +--- + +## 7. Dependency Analysis + +### 7.1 Outdated Dependencies + +Not assessed in this pass (no lockfiles for Bun/Next repos; loop-protocol not checked for outdated packages). + +### 7.2 Security Vulnerabilities + +- `loop-protocol`: `npm audit` reports **0 vulnerabilities** (2025-12-24). +- Other repos: not audited (no lockfiles). + +### 7.3 Unused Dependencies + +Not assessed; consider running `depcheck` per repo. + +--- + +## 8. Recommended Action Plan + +### Immediate (This Sprint) +1. [x] Document spec vs implementation divergence. +2. [x] Add non-root execution for Docker + systemd service. + +### Short-term (Next 2-4 Weeks) +1. [x] Publish OpenAPI artifact for protocol. +2. [x] Add health checks + backup/restore runbook. + +### Medium-term (1-3 Months) +1. [ ] Implement remaining core spec endpoints (Material search, Signals, Transactions, Federation announce/offer). +2. [ ] Implement LoopCost + LoopSignal/LoopCoin flows or mark as future scope. + +--- + +## Appendices + +### A. Full TODO List + +No TODO markers found. + +### B. Full FIXME List + +No FIXME markers found. + +### C. Configuration Files Inventory + +- Root: `AGENTS.md`, `CLAUDE.md` +- `localloop-backend`: `Dockerfile`, `docker-compose.yml`, `tsconfig.json`, `.env.example`, `.env.docker.example`, `prisma.config.ts` +- `localloop.github.io`: `next.config.js`, `playwright.config.ts`, `scripts/check-domains.cjs` +- `loop-protocol`: `package.json`, `package-lock.json`, `scripts/validate-schemas.js` + +### D. Environment Variables Reference (Backend) + +| Variable | Description | +| --- | --- | +| PORT | API port (default 8088) | +| PUBLIC_BASE_URL | Public URL for OpenAPI `servers` | +| NODE_ID / NODE_NAME / NODE_CAPABILITIES | Lab node metadata | +| DATABASE_URL / DATABASE_SSL / DB_POOL_SIZE | Postgres config | +| REDIS_URL | Redis connection string | +| MINIO_ENDPOINT / MINIO_PORT / MINIO_ACCESS_KEY / MINIO_SECRET_KEY / MINIO_BUCKET / MINIO_USE_SSL | MinIO/S3 config | +| ALLOWED_ORIGINS | CORS allowlist | +| RATE_LIMIT_MAX / RATE_LIMIT_WRITE_MAX | Rate limit settings | +| RATE_LIMIT_WINDOW / RATE_LIMIT_WRITE_WINDOW | Rate limit windows | +| SSE_KEEPALIVE_MS / SSE_MAX_CLIENTS | SSE configuration | +| BODY_LIMIT | Request body size limit | +| RUN_MIGRATIONS / SEARCH_REFRESH_ON_WRITE | DB behavior | +| AUTH_ENABLED / AUTH_TRUSTED_ORIGINS / BETTER_AUTH_SECRET | Better Auth config | +| API_KEY_ENABLED / API_KEY | Optional API key protection | +| WORKER_ENABLED | Queue worker toggle | +| PAYMENTS_ENABLED | Payments endpoint toggle | diff --git a/docs/audit/technical-debt.md b/docs/audit/technical-debt.md new file mode 100644 index 0000000..ce6e043 --- /dev/null +++ b/docs/audit/technical-debt.md @@ -0,0 +1,70 @@ +# Phase 3 - Code Quality & Technical Debt Audit + +> CURRENT-USE WARNING: This is a historical audit snapshot, not current implementation, security, or readiness evidence. Its original snapshot date and analyzed commits were not recorded in this source; do not infer them. Use [CLAIMS-AND-MATURITY.md](../governance/CLAIMS-AND-MATURITY.md) and [RELEASE-CHECKLIST.md](../governance/RELEASE-CHECKLIST.md) for current decisions. TD-001 and TD-002 are **resolved as observed in this historical snapshot**, which conflicts with their **open as observed** status in [state-of-development.md](state-of-development.md); neither document establishes current truth. + +## 3.1 Marker Comment Extraction + +Searched for TODO/FIXME/HACK/WORKAROUND/FUTURE/LATER/PLANNED/DEPRECATED in source files. + +- TODOs: 0 +- FIXMEs: 0 +- HACK/WORKAROUND/XXX: 0 +- FUTURE/LATER/PLANNED: 0 +- DEPRECATED: 0 + +Notable placeholder language appears in docs/UI: +- `loop-protocol/docs/federation-handshake.md` and `rfcs/0002-federation-handshake.md` mention optional signature placeholders. +- `localloop.github.io/app/(platform)/DEMO-City/page.jsx` contains “illustrative placeholders.” + +## 3.2 Implementation Completeness Analysis + +### Stub / Unused Code +- `localloop-backend/src/storage/s3.ts` exports `s3Client` but is unused (no storage integration). + +### Error Handling Gaps +- Queue worker failures only `console.error` and do not persist failure metadata. +- SSE broadcast (`realtime/interestStream.ts`, `realtime/loopStream.ts`) does not handle per-client write errors beyond silent catch. + +### Validation Gaps +- `POST /api/v1/relay` now restricts event/entity pairs, but the nested payload remains generic and signatures are still not validated. +- `POST /api/v1/federation/handshake` schema validates shape but does not enforce `@context` value or signature. +- Interest + payments have JSON schema + zod validation, but schemas are looser than zod checks (email/url format enforced only in zod). + +### Test Coverage Gaps (Qualitative) +- Backend tests cover routes, SSE, queue, auth toggles. +- Missing direct tests for: + - `src/storage/s3.ts` (storage integration) + - `src/db/*` data access (beyond route tests) + - `src/metrics.ts` aggregation behavior +- Frontend tests cover a subset of pages; many docs/portal pages are untested. +- Protocol repo only runs schema validation; no tests for docs integrity or examples. + +## 3.3 Hardcoded Values & Configuration Issues + +- Frontend hardcodes API base in `localloop.github.io/public/assets/js/config.js` and `app/docs/api/page.jsx`. +- Backend config defaults include `change-me` credentials in `src/config.ts`, `prisma.config.ts`, and docker-compose defaults. +- Docker images are now pinned by digest for Redis/MinIO. + +## 3.4 Technical Debt Registry + +### Critical (Must Fix Before Production) +- None identified at code level. Infra/security issues captured below as High. + +### High Priority +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| ~~TD-001~~ | `localloop-backend/deploy/` | ~~Non-root user configured but must be created/provisioned on VPS.~~ **RESOLVED as observed in this historical snapshot**: Added `setup.sh` script and security-hardened systemd service. | Security | S | +| ~~TD-002~~ | `localloop-backend/src/config.ts` | ~~Default credentials (`change-me`) can be used if env missing.~~ **RESOLVED as observed in this historical snapshot**: Production now requires explicit secrets, app refuses to start without them. | Security | S | + +### Medium Priority +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| TD-004 | `localloop-backend/src/storage/s3.ts` | Unused S3 client; storage integration incomplete. | Incomplete feature | M | +| TD-005 | `localloop-backend/src/metrics.ts` | In-memory counters reset on restart. | Observability | S | +| TD-006 | `localloop-backend/src/routes/loop.ts` | `/api/v1/relay` restricts event/entity pairs, but nested payload validation and signature checks are still missing. | Data quality | M | +| TD-007 | `localloop.github.io/public/assets/js/config.js` | Hardcoded API base; no env override for staging/prod. | Config flexibility | S | + +### Low Priority / Nice-to-Have +| ID | Location | Description | Impact | Effort | +| --- | --- | --- | --- | --- | +| TD-008 | `loop-protocol/docs/federation-handshake.md` | Signature placeholders noted; add concrete guidance. | Spec clarity | M | diff --git a/docs/backup-restore-runbook.md b/docs/backup-restore-runbook.md new file mode 100644 index 0000000..ac4e2d5 --- /dev/null +++ b/docs/backup-restore-runbook.md @@ -0,0 +1,147 @@ +# Backup & Restore Runbook (VPS / Docker) + +**Generated:** 2025-12-24 + +This runbook covers backups for the localLOOP backend stack (Postgres, Redis, MinIO). Adjust paths if your compose file or data volumes differ. + +## 0) Preconditions +- Access to the VPS host running Docker + `localloop-backend/docker-compose.yml`. +- Sufficient free disk space for snapshots. +- Backups stored off-host (recommended) or on a mounted backup volume. + +## 1) Postgres Backup + +### 1.1 Create a logical backup +```bash +cd /root/code/local-loop-io/localloop-backend +mkdir -p ./backups/postgres + +docker compose exec -T postgres \ + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc \ + > ./backups/postgres/localloop-$(date +%F).dump +``` + +### 1.2 Verify backup integrity +```bash +pg_restore -l ./backups/postgres/localloop-YYYY-MM-DD.dump | head -n 20 +``` + +## 2) Redis Backup + +Redis uses RDB/AOF persistence. For a manual snapshot (ensure `./data/redis` is mounted as a volume): + +```bash +cd /root/code/local-loop-io/localloop-backend +mkdir -p ./backups/redis + +docker compose exec -T redis redis-cli SAVE +cp ./data/redis/dump.rdb ./backups/redis/dump-$(date +%F).rdb +``` + +If AOF is enabled, also back up `appendonly.aof`. + +## 3) MinIO Backup + +MinIO data lives in `./data/minio`. + +```bash +cd /root/code/local-loop-io/localloop-backend +mkdir -p ./backups/minio + +tar -czf ./backups/minio/minio-data-$(date +%F).tar.gz ./data/minio +``` + +## 4) Restore Procedures + +### 4.1 Postgres Restore (logical dump) +```bash +cd /root/code/local-loop-io/localloop-backend + +docker compose exec -T postgres \ + pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists \ + < ./backups/postgres/localloop-YYYY-MM-DD.dump +``` + +### 4.2 Redis Restore +```bash +cd /root/code/local-loop-io/localloop-backend + +docker compose stop redis +cp ./backups/redis/dump-YYYY-MM-DD.rdb ./data/redis/dump.rdb +docker compose start redis +``` + +### 4.3 MinIO Restore +```bash +cd /root/code/local-loop-io/localloop-backend + +docker compose stop minio +rm -rf ./data/minio +mkdir -p ./data/minio + +tar -xzf ./backups/minio/minio-data-YYYY-MM-DD.tar.gz -C ./ +docker compose start minio +``` + +## 5) Verification Checklist +- [ ] API health check: `curl -sf http://127.0.0.1:8088/health` +- [ ] Interest list loads: `curl -sf http://127.0.0.1:8088/api/interest` +- [ ] MinIO console reachable (if exposed) + +## 6) Scheduled Backup Automation + +The repo now includes: +- `localloop-backend/deploy/backup.sh` +- `localloop-backend/deploy/localloop-backend-backup.service` +- `localloop-backend/deploy/localloop-backend-backup.timer` + +### 6.1 Install the systemd units +```bash +cd /root/code/local-loop-io/localloop-backend + +sudo cp deploy/localloop-backend-backup.service /etc/systemd/system/ +sudo cp deploy/localloop-backend-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now localloop-backend-backup.timer +``` + +### 6.2 Run a manual backup job +```bash +cd /root/code/local-loop-io/localloop-backend +sudo PROJECT_DIR=/root/code/local-loop-io/localloop-backend deploy/backup.sh +``` + +### 6.3 Inspect timer status +```bash +sudo systemctl status localloop-backend-backup.timer +sudo systemctl list-timers | grep localloop-backend-backup +``` + +The backup script writes timestamped directories under `./backups/` and updates `./backups/latest`. + +## 7) Rollback Procedure + +### 7.1 Application rollback +```bash +cd /root/code/local-loop-io/localloop-backend +git fetch origin +git checkout +bun install +sudo systemctl restart localloop-backend +``` + +### 7.2 Database-safe rollback sequence +1. Stop incoming writes: `sudo systemctl stop localloop-backend` +2. Confirm a recent backup exists under `./backups/latest` +3. Restore Postgres, Redis, and MinIO using Section 4 +4. Restart the backend: `sudo systemctl start localloop-backend` +5. Run the verification checklist below before reopening traffic + +### 7.3 Verification checklist after rollback +- [ ] `systemctl status localloop-backend` +- [ ] `curl -sf http://127.0.0.1:8088/health` +- [ ] `curl -sf http://127.0.0.1:8088/api/v1/node/info` +- [ ] `curl -sf http://127.0.0.1:8088/api/interest` + +## 8) Recommended Next Step +- Add offsite sync for `./backups/` after each scheduled run. diff --git a/docs/governance/CLAIMS-AND-MATURITY.md b/docs/governance/CLAIMS-AND-MATURITY.md new file mode 100644 index 0000000..19f9ab7 --- /dev/null +++ b/docs/governance/CLAIMS-AND-MATURITY.md @@ -0,0 +1,40 @@ +# Claims and Maturity Policy + +This policy is the project-wide vocabulary for artifacts, implementation evidence, and outward statements. It applies to specifications, schemas, code, demos, sites, release notes, presentations, and conversations on behalf of the project. + +## 1. Use Two Separate Labels + +Do not use one label to mean both document quality and working-system proof. + +| Label type | Allowed values | Meaning | +| --- | --- | --- | +| Artifact maturity | Draft, Review, Candidate, Published, Superseded | The review and publication state of a document, schema, example, or release artifact. It says nothing about implementation. | +| Evidence-backed implementation status | Proposed, Implemented, Tested, Demonstrated, Operationally evidenced | The strongest status supported by linked evidence for the stated scope. | + +`Preview` may describe limited visibility or an early user experience only. It does not mean production-ready, deployed, secure, compliant, conformant, accessible, or operationally proven. A preview must state its scope and the status labels above. + +`vNext/Core-DP direction` is a planning term for the intended next core data-plane direction. It is neither a version commitment nor evidence that any part ships, is implemented, or is on a release path. + +## 2. Evidence-Backed Status + +- **Proposed:** documented intent; no implementation claim. +- **Implemented:** a specific change exists at an identified revision; link the change. +- **Tested:** implemented scope has repeatable passing checks; link results and date. +- **Demonstrated:** the stated scenario was run in a controlled environment; link the scenario, result, date, and limits. +- **Operationally evidenced:** bounded real operation has current monitoring or drill evidence; link scope, period, environment, and limitations. This is not a synonym for production readiness. + +Status applies only to the named artifact, feature, environment, and time period. A status never transfers automatically to another repo, mirror, environment, version, or release. + +## 3. Claim Rules + +Unsupported claims are prohibited. Do not claim or imply pilots, deployments, production use/readiness, compliance, certification, regulatory alignment/readiness, provenance/traceability, security, accessibility, or conformance unless the claim has current, scoped evidence. + +Acceptable evidence is reviewable and linked: immutable revision or release references; reproducible test or conformance results; dated demo records; independent assessment reports; approved security reviews; accessibility test results; deployment/change records; and backup plus successful restore-drill records. Evidence must identify its environment, date, owner, method, result, and limits. A policy, plan, schema, badge, or unverified assertion is not evidence of implementation or outcome. + +Each outward claim needs a claim owner, evidence link, reviewer, review date, and expiry. Default expiry is 90 days; use a shorter expiry when the environment, dependency, or risk changes faster. On expiry, remove or downgrade the claim until re-evidenced. The owner must correct a known inaccurate or outdated claim promptly, including the affected release notes, site copy, and announcement surfaces. + +Before publication, every outward claim must be reviewed by a distinct eligible, non-recused maintainer who is neither its author nor its owner. If no such reviewer is available, publication is blocked. High-risk claims (security, privacy, accessibility, conformance, compliance, certification, regulatory, pilot, deployment, or production) additionally require the two-person governance approval in [GOVERNANCE.md](GOVERNANCE.md). When evidence is absent, use qualified language such as "proposed," "lab demo only," or "not independently assessed." + +## 4. Current-Use Boundary + +Historical audits are not evidence of current implementation. Read their snapshot warning, then use this policy and [RELEASE-CHECKLIST.md](RELEASE-CHECKLIST.md) for a current claim or release decision. diff --git a/docs/governance/GOVERNANCE.md b/docs/governance/GOVERNANCE.md new file mode 100644 index 0000000..1830e15 --- /dev/null +++ b/docs/governance/GOVERNANCE.md @@ -0,0 +1,38 @@ +# Governance Charter + +This lightweight charter is for a 3-5 person project. It governs project-wide decisions, RFCs, releases, and claims. Technical work remains owned by the relevant area maintainer. + +## 1. Maintainers and Ownership + +| Role | Initial roster | Primary ownership | Decision backup | +| --- | --- | --- | --- | +| Project steward and merge authority | `alpha912` | release approval, governance, external claims | protocol maintainer | +| Protocol maintainer | unassigned | specification, schemas, contexts, compatibility | project steward | +| Backend maintainer | unassigned | API, data handling, operations | project steward | +| Docs/site maintainer | unassigned | public docs, mirrors, accessibility evidence | project steward | + +An **active maintainer** is a distinct natural person with a recorded role who has participated in the preceding 30 days or has a recorded leave, handoff, or availability date. An **eligible maintainer** is an active maintainer whose nomination and acceptance are recorded; they may participate unless recused. A record names the person, role and owned area, date, nominator, and the person's acceptance, in this table or a linked RFC/release record. Handles may be used, but must identify the natural person in the private project record. + +The steward records a named person before assigning work or approving a release in an unassigned area. A new or changed maintainer appointment requires a recorded nomination, the nominee's recorded acceptance, and approval by two distinct eligible non-recused natural persons; the nominee cannot appoint themself unilaterally. Bootstrap exception: before two eligible maintainers exist, the initial steward may nominate exactly one second natural person, who must explicitly accept in the record. This bootstrap appointment creates no approval by itself, cannot be used if another eligible maintainer already exists, and releases and high-risk claims remain blocked until that second person is appointed and independently approves under normal quorum. Afterward, every appointment uses the two-person rule. One person may hold multiple roles, but a reviewer must not approve their own high-risk claim alone. Maintainer changes are recorded in this table and announced in the relevant RFC or release note. + +## 2. RFC and Decision Process + +Use an RFC for protocol changes, compatibility changes, security/privacy posture, public claims, governance changes, or work that affects more than one owned area. An RFC states the problem, scope, alternatives, decision, owner, compatibility impact, evidence plan, and target decision date. + +States are `Draft`, `Review`, `Accepted`, `Rejected`, `Superseded`, and `Withdrawn`. Review is at least 5 calendar days, or 48 hours for a documented urgent security/privacy response. The proposer may close a routine area decision after the review window if no maintainer objects. + +For every quorum decision, **quorum is exactly two distinct eligible, non-recused natural persons**. The steward is not a required quorum member. Cross-area, compatibility, release, governance, and high-risk claim decisions require that quorum and an explicit recorded approval from every affected-area owner; an owner may supply one of the two quorum approvals. Other comments or acknowledgements do not expand the quorum. A tie or unresolved objection defers the decision; it is not silently approved. + +## 3. Appeals, Conflicts, and Continuity + +Any contributor may appeal an Accepted or Rejected RFC within 7 days with new evidence or a concrete process concern. The non-recused maintainers reconsider within 7 days and record the result. A maintainer must recuse from approval when they have a material personal, financial, employer, vendor, or authorship conflict that could reasonably affect impartiality. A recused steward has no approval, merge, or appointment authority for that matter; the listed decision backup acts only for that matter if eligible and non-recused. If that backup is unavailable, two eligible non-recused maintainers record an ad hoc facilitator. Recusal never reduces the two-person quorum or affected-owner requirement. + +If a maintainer is inactive for 30 days without a handoff, two eligible non-recused maintainers may appoint an interim owner using the recorded nomination and acceptance process. If the steward is inactive or permanently leaves, two eligible non-recused maintainers may appoint an interim steward and record the appointment and acceptance; this authority comes from this charter, not from the absent steward. A steward may also arrange a planned successor through the normal two-person appointment process before leaving. An abandoned area remains frozen for releases and outward claims until an interim owner accepts it, inventories open work and evidence, and records a handoff note. + +No approval, release, or high-risk claim may proceed when two eligible non-recused people are unavailable, or when an affected-area owner is unavailable. The scope must instead be deferred, narrowed to an unaffected area, or recorded as blocked; no interim appointment can be self-authorized to bypass this rule. + +## 4. Release and Claim Gates + +Every release follows [RELEASE-CHECKLIST.md](RELEASE-CHECKLIST.md). Every outward statement follows [CLAIMS-AND-MATURITY.md](CLAIMS-AND-MATURITY.md). These gates do not establish that a feature ships; they establish the decision record required before saying it does. + +Every outward claim requires review by a distinct eligible, non-recused maintainer who is neither its author nor its owner. If no such reviewer is available, publication is blocked. High-risk claims additionally require the two-person governance approval in Section 2; the independent claim reviewer may count only if otherwise eligible for that approval. diff --git a/docs/governance/RELEASE-CHECKLIST.md b/docs/governance/RELEASE-CHECKLIST.md new file mode 100644 index 0000000..2520097 --- /dev/null +++ b/docs/governance/RELEASE-CHECKLIST.md @@ -0,0 +1,20 @@ +# Unified Release Checklist + +Use this checklist for any public artifact, protocol, backend, site, or coordinated release. Every item needs an evidence link. Mark `N/A` only with a short rationale and the approving maintainer. The release owner keeps the completed checklist with the release record. + +| Gate | Evidence required | Status / link / N/A rationale | +| --- | --- | --- | +| Scope and owner | release scope, owner, approvers, target date | | +| Version consistency | version/tag is consistent across changed artifacts, metadata, and release notes | | +| Canonical artifacts and mirrors | canonical source identified; required docs/schema/OpenAPI/context mirrors compared or updated | | +| Documentation integrity | dated Markdown validation result; local-link and external-link results; canonical-domain/policy check result; and any documented exceptions | | +| Changelog and release notes | user-facing changes, limitations, upgrade notes, and corrections recorded | | +| Compatibility and migrations | compatibility decision, migration/rollback path, and affected versions | | +| Security and privacy | scoped review, secret handling, data/PII impact, and unresolved-risk decision | | +| Accessibility | applicable accessibility checks, known limits, and remediation owner | | +| Conformance and tests | applicable conformance, schema, unit/integration, and manual test results | | +| Backup and restore drill | dated backup evidence and successful restore-drill evidence, or N/A rationale | | +| Outward claims | review against [CLAIMS-AND-MATURITY.md](CLAIMS-AND-MATURITY.md); owner, evidence, scope, expiry, review date, and a distinct eligible reviewer who is neither author nor owner | | +| Approval | approvals required by [GOVERNANCE.md](GOVERNANCE.md), conflicts/recusals recorded | | + +Do not substitute a checklist tick for evidence. A failed, missing, stale, or out-of-scope item blocks the associated claim or release scope. Risk acceptance may only narrow the release scope; it can never waive outward-claim evidence, required approvals, or legal, security, or privacy blockers. Use the [backup and restore runbook](../backup-restore-runbook.md) as a procedure reference; the checklist still requires dated drill evidence. diff --git a/docs/governance/rfc-process.md b/docs/governance/rfc-process.md index 0173aa0..8dde620 100644 --- a/docs/governance/rfc-process.md +++ b/docs/governance/rfc-process.md @@ -1,5 +1,15 @@ # RFC Process (v0.1) +> **Superseded (states and timing only):** [GOVERNANCE.md](GOVERNANCE.md#2-rfc-and-decision-process) +> §2 is now the authoritative RFC process — states `Draft, Review, Accepted, +> Rejected, Superseded, Withdrawn` and a 5-calendar-day review window (48h for +> urgent security/privacy). This document's states/timing (`Draft, Discussion, +> Accepted/Rejected, Implemented, Superseded`; 10-business-day discussion) are +> historical — kept for context and because +> [rfcs/0001-rfc-process.md](../../rfcs/0001-rfc-process.md) references them +> as the decision record for adopting this process. The submission mechanics +> below (copy the template, open a PR) still apply. + This RFC process is a lightweight governance workflow for the LOOP protocol while the project remains in **lab-only, low-TRL** stage. It is intended to create clear, reviewable decisions without implying production readiness. diff --git a/openapi.json b/openapi.json index 76e25e9..128682a 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "LOOP Protocol API", "version": "0.2.0", - "description": "Reference protocol contract for LOOP v0.2.0. Lab-only backend extensions are documented in prose but intentionally excluded from this artifact." + "description": "Reference protocol contract for LOOP v0.2.0 plus explicitly tagged additive Core-DP lab components and endpoints. Core-DP additions are not a full LOOP conformance claim." }, "servers": [ { @@ -22,13 +22,35 @@ { "name": "Node" }, { "name": "Signals" }, { "name": "Transactions" }, - { "name": "Federation" } + { "name": "Federation" }, + { + "name": "Core-DP", + "description": "Lab-only applicability profile components; not full LOOP conformance and not production security certification." + } ], "components": { "securitySchemes": { "BearerAuth": { "type": "http", "scheme": "bearer" + }, + "NodeSignature": { + "type": "apiKey", + "in": "header", + "name": "X-Node-Signature", + "description": "Core-DP cross-node request signature. Valid only with X-Node-Key-Id and X-Node-Signature-Input-SHA256 in the same security requirement." + }, + "NodeKeyId": { + "type": "apiKey", + "in": "header", + "name": "X-Node-Key-Id", + "description": "Core-DP sender key id. Valid only with X-Node-Signature and X-Node-Signature-Input-SHA256 in the same security requirement." + }, + "NodeSignatureInputSHA256": { + "type": "apiKey", + "in": "header", + "name": "X-Node-Signature-Input-SHA256", + "description": "Core-DP canonical request signing input hash. Valid only with X-Node-Signature and X-Node-Key-Id in the same security requirement." } }, "schemas": { @@ -88,6 +110,12 @@ "next": { "type": "string" } } }, + "ProductSearchRequest": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json#/$defs/searchRequest" + }, + "ProductSearchResponse": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json#/$defs/searchResponse" + }, "MaterialAnnouncement": { "type": "object", "properties": { @@ -124,6 +152,25 @@ } } } + }, + "CoreDpEnvelope": { + "description": "Lab-only Core-DP signed message envelope.", + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/envelope.schema.json" + }, + "CoreDpChoreographyMessage": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json" + }, + "CoreDpDnaOperation": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json" + }, + "CoreDpSearchContract": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json" + }, + "CoreDpEvidenceEntry": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/evidence-entry.schema.json" + }, + "CoreDpError": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json" } } }, @@ -245,6 +292,37 @@ } } }, + "/api/v1/product/search": { + "post": { + "tags": ["Product", "Core-DP"], + "summary": "Search ProductDNA records", + "description": "Additive Core-DP profile endpoint for ProductDNA search parity with MaterialDNA. Local public-lab requests MAY use no authentication, bearer deployments MAY use BearerAuth, and cross-node requests with auth.mode=node-signature MUST include the Core-DP node signature headers.", + "security": [ + {}, + { "BearerAuth": [] }, + { "NodeSignature": [], "NodeKeyId": [], "NodeSignatureInputSHA256": [] } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json#/$defs/searchRequest" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json#/$defs/searchResponse" } + } + } + }, + "400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, "/api/v1/node/info": { "get": { "tags": ["Node"], diff --git a/package.json b/package.json index 0357b80..0eb862c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@local-loop/loop-protocol", - "version": "0.2.3", + "version": "0.3.0", "private": true, "description": "LOOP specification and schemas", "author": "Mycel UG (haftungsbeschränkt) ", @@ -9,7 +9,9 @@ "node": ">=20.0.0" }, "scripts": { + "check:domains": "bash scripts/check-domains.sh", "validate:schemas": "node scripts/validate-schemas.js", + "conformance:core-dp": "node profiles/core-dp/conformance/run-conformance.js", "test": "npm run validate:schemas" }, "devDependencies": { diff --git a/profiles/README.md b/profiles/README.md new file mode 100644 index 0000000..818978b --- /dev/null +++ b/profiles/README.md @@ -0,0 +1,9 @@ +# LOOP Applicability Profiles + +Applicability profiles narrow the draft LOOP specification for a specific interoperability target without changing the base protocol schemas. + +## Available Profiles + +- `core-dp/`: Core-DP `0.1.0-lab`, a lab-only delivery profile for two controlled nodes. It maps every RFC 2119 normative clause in `SPECIFICATION.md` v0.2.0, classifies each clause as in scope, out of scope, or deferred, and includes conformance vectors for signed envelopes, DNA search, choreography, evidence, errors, version negotiation, and EPCIS fixture parsing. + +Profiles do not imply production readiness, public pilot status, legal DPP certification, payment safety, or full LOOP conformance unless a profile states that explicitly. diff --git a/profiles/core-dp/README.md b/profiles/core-dp/README.md new file mode 100644 index 0000000..41e8ab8 --- /dev/null +++ b/profiles/core-dp/README.md @@ -0,0 +1,104 @@ +# Core-DP Applicability Profile 0.1.0-lab + +**Status:** LAB ONLY contract foundation +**Profile version:** `0.1.0-lab` +**Pinned base specification:** `SPECIFICATION.md` v0.2.0 +**Conformance claim:** Core-DP profile conformance only. This is explicitly **not** full LOOP conformance. + +Core-DP is the smallest machine-checkable delivery profile for two controlled lab nodes. It narrows the broad LOOP v0.2.0 draft to discovery, DNA registry/read/search, Offer -> Match -> Transfer choreography, signed two-node messages, append-only evidence, version negotiation, and errors. + +Core-DP does not claim blockchain permanence, production security, public pilot readiness, payment settlement, legal DPP conformance, VC/DID conformance, or generalized federation safety. + +## Scope Classification + +| Area | Core-DP status | Rationale | +| --- | --- | --- | +| Node and capability discovery | IN | Required before a lab node can decide whether the peer supports `core-dp` and the pinned profile version. | +| MaterialDNA register/read/search | IN | Minimal material identity surface for lab transfer demos. | +| ProductDNA register/read/search | IN | ProductDNA exists in v0.2.0 and must have parity with MaterialDNA for the profile. | +| Offer -> Match -> Transfer | IN | The minimum exchange choreography needed for two-node delivery. | +| Append-only evidence | IN | Required for lab auditability and conformance harness replay. This is append-only storage, not immutable-chain permanence. | +| Signed two-node delivery | IN | Required for deterministic lab authentication, replay checks, idempotency, and duplicate handling. | +| Errors and version negotiation | IN | Required for predictable interop failures. | +| LoopSignal | OUT | Governance voting and preference calculation are outside Core-DP delivery. Existing v0.2.0 statements are not profiled here. | +| LoopCost | OUT | Cost optimization is not required for delivery contract proof and depends on LoopSignal/settlement semantics. | +| Transactions | OUT | v0.2.0 transaction objects imply settlement semantics that Core-DP does not implement. | +| LoopCoin | OUT | Currency issuance, clearing, decay, and payment are outside the lab delivery foundation. | +| Voting | OUT | Democratic governance is a future governance profile, not part of two-node delivery. | +| Payments | OUT | No payment authorization, clearing, custody, or production financial control is claimed. | +| Generalized federation | DEFERRED | Core-DP is exactly two nodes. Flooding, peer meshes, and multi-hop convergence need a later profile. | +| VC/DID/DPP profiles | DEFERRED | Core-DP may carry opaque references, but it does not validate or claim legal/standards conformance for these profiles. | +| EPCIS/CBV | DEFERRED | Core-DP pins a conservative mapping subset for fixtures only; it does not claim full EPCIS conformance. | + +## Version Reconciliation + +The repository package release is `0.2.3`, while the normative base specification remains pinned at `0.2.0`. Core-DP treats this as: + +- package `0.2.3`: repository maintenance release containing docs, examples, schema corrections, and validation tooling; +- spec `0.2.0`: pinned normative text inventoried by `profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json`; +- profile `0.1.0-lab`: additive applicability profile that narrows v0.2.0 for lab delivery without changing v0.2.0 semantics or schema IDs. + +## Two-Node Authority and Convergence + +Core-DP has exactly two nodes: `origin_node` and `counterparty_node`. Each choreography message carries both `authoritative_role` (`origin` or `counterparty`) and `authoritative_node`; the schema fixes the role for each message type, and the conformance harness verifies `authoritative_node` equals the node named by that role. + +| Step | Authoritative node | Accepted inputs | Terminal states | Convergence rule | +| --- | --- | --- | --- | --- | +| `capabilities.announce` | Origin node advertises; receiving node decides compatibility | `capabilities.response` | `capabilities-confirmed`, `offer-rejected` via version error | Both nodes converge when each stores the same accepted profile version and peer key id. | +| `offer.publish` | Origin node that owns the MaterialDNA/ProductDNA record | `offer.ack` or `offer.reject` authored by the counterparty/receiver | `offer-acked`, `offer-rejected`, `timed-out` | Publication is origin-authored. Offer ack/reject is receiver-authored and authoritative for the receiver's acceptance decision. | +| `match.propose` | Origin node proposes the selected match after offer acknowledgement | `match.accept` or `match.reject` authored by the counterparty/receiver | `match-accepted`, `match-rejected`, `timed-out` | A match is active only after the receiver accepts and both nodes store the matching evidence hash. | +| `transfer.dispatch` | Origin node dispatches; receiving node records receipt facts | `transfer.receive` authored by the counterparty/receiver, then `transfer.ack` authored by the origin | `transfer-acked`, `timed-out`, `partitioned` | Dispatch facts are authoritative at origin; receipt facts are authoritative at receiver; final ack is `origin-confirms-receiver-receipt` and requires matching transfer id, subject id, and evidence hashes on both nodes. | + +Retries use the same `idempotency_key` and increasing `attempt` values from 1 through 5. A duplicate with the same `idempotency_key` and identical signing input returns the stored result. A duplicate with the same `idempotency_key` and different signing input is `conflict`. Reordered messages are accepted only when their `previous_state` matches local state; otherwise the receiver returns `conflict` or `stale_message` and includes its authoritative state. + +Timeouts and partitions are conformant local recovery observations over the last accepted choreography action, not new contradictory action states. A `timed-out` or `partitioned` observation keeps the original action's authority and sender role, sets `terminal: true`, names the accepted action state in `previous_state`, and carries `last_accepted_message_id` so reconciliation can compare append-only evidence by `(subject.id, sequence, payload_hash_sha256)`. Recovery observations are not duplicate retry results and cannot carry `duplicate_of`, `duplicate_behavior`, or duplicate fingerprint result fields; retries remain governed by the shared `idempotency_key`, incrementing `attempt`, and `duplicate_of` rules above. `transfer.ack` is a final acknowledgement, not a recovery observation. Terminal states cannot return to non-terminal states. For transfer finality, `transfer-acked` is the only convergence exception over a local timeout when the ack was created before the timeout, explicitly acknowledges `transfer.receive`, validates against the same dispatch, and carries equal local and peer transfer evidence hashes. The convergence hash is `sha256(subject.id + ":" + local_evidence_hash_sha256 + ":" + peer_evidence_hash_sha256)`. + +Key rotation is announced as evidence event `key-rotated`. Old keys remain valid only for messages with `created_at` before the rotation event and inside their replay window. New messages after rotation use the new `key_id`; peers reject messages signed by revoked keys with `signature_invalid`. + +## Search Contract + +Core-DP defines local and cross-node search for MaterialDNA and ProductDNA through `search-contract.schema.json`. The base OpenAPI contract preserves the existing Material search shape and adds `POST /api/v1/product/search` as the profile path for ProductDNA search parity; the Product search operation references the profile's `searchRequest` and `searchResponse` definitions directly. + +Allowed filters are `category_prefix`, `id_prefix`, `origin_city`, `current_city`, `available_from_gte`, `available_from_lt`, `quantity_min`, `condition`, and `updated_since`. Product search may use `condition`; material search ignores it with `invalid_request` if strict filtering is enabled. + +Authorization modes are `public-lab`, `bearer`, and `node-signature`. Cross-node search requires an `auth` object with `auth.mode=node-signature`; local search keeps `public-lab` and `bearer` modes. Results are limited to 100 records. Ordering is by `updated_at_asc` with `id_asc` as the stable tie-break. Cursors are opaque `cur_...` tokens bound to the original filters, ordering, and consistency mode. + +Local search can be `snapshot` or `eventual`. Cross-node requests are `eventual`; a cross-node response may claim `snapshot` only when it includes a shared `snapshot_id`. Every result carries `source_node`, `record_hash_sha256`, and `updated_at`; responses list queried and omitted nodes. + +## DNA Operations + +`dna-operation.schema.json` defines minimal machine-checkable MaterialDNA and ProductDNA register/read requests and results. Register requests compose the pinned v0.2.0 `material-dna.schema.json` and `product-dna.schema.json`; read requests use profile-level id patterns. Results carry `source_node`, `record_hash_sha256`, `recorded_at`, and optional evidence/envelope references. Error results reuse `error.schema.json`. Core-DP treats ProductDNA `material_ids` composition and ProductDNA Offer -> Match -> Transfer participation as IN. External legal DPP, VC, and DID profile validation remains DEFERRED. + +## Signed Envelope + +`envelope.schema.json` defines the canonical lab envelope. The deterministic signing input is canonical JSON with lexicographically sorted object keys over: + +```json +{ + "profile": "...", + "profile_version": "...", + "message_id": "...", + "message_type": "...", + "created_at": "...", + "expires_at": "...", + "sender": {...}, + "receiver": {...}, + "idempotency_key": "...", + "body_schema": "...", + "body": {...} +} +``` + +The supported lab algorithm is `Ed25519-lab-detached-v1` with an OKP Ed25519 JWK public key. `signature.value` is the base64url detached Ed25519 signature over the canonical UTF-8 JSON signing input above. Every envelope requires `replay_window_seconds`; the semantic check enforces `created_at < expires_at <= created_at + replay_window_seconds`. The conformance harness verifies deterministic `signing_input_sha256`, requires `signature.key_id` to match `sender.key_id`, checks the advertised lab key-binding metadata, verifies the detached signature with the public JWK, and requires valid signed-envelope vectors to run hash, crypto, body-contract, and replay checks. + +Cryptographic validity is not trust by itself. Implementations bind the advertised `key_id` and public JWK to the accepted peer capability/key lifecycle, reject unbound or revoked keys, and treat the included key-binding fields as lab-scope evidence of that accepted peer binding rather than a production PKI. + +The additive Product search OpenAPI operation keeps three authentication alternatives: public lab access, Bearer auth, or one node-signature security requirement containing `X-Node-Signature`, `X-Node-Key-Id`, and `X-Node-Signature-Input-SHA256` together. Partial node-signature headers are not a valid generated-client contract. + +## Evidence + +`evidence-entry.schema.json` defines append-only evidence. The immutable subset is `event_id`, `sequence`, `subject`, `event_type`, and `payload_hash_sha256`. Redaction can remove mutable/export fields or create a tombstone, but it cannot rewrite the immutable subset. Retention/export/redaction fields are required so lab operators can test evidence export without claiming indefinite permanence. + +## EPCIS and CBV Pin + +Core-DP pins **EPCIS 2.0.1** and **CBV 2.0** for conservative mapping fixtures only. Supported fixture mapping is limited to object-style lifecycle events for register, offer, match, and transfer evidence. Unsupported EPCIS features are listed in `epcis/unsupported-features.json`. No full EPCIS conformance is claimed. diff --git a/profiles/core-dp/conformance/README.md b/profiles/core-dp/conformance/README.md new file mode 100644 index 0000000..9dbb87b --- /dev/null +++ b/profiles/core-dp/conformance/README.md @@ -0,0 +1,19 @@ +# Core-DP Conformance Harness Scaffold + +This directory contains implementation-neutral conformance vectors for the lab-only Core-DP profile. Vectors are metadata-first: each vector links to one or more requirement IDs, declares an expected `valid` or `invalid` outcome, names the schema to use, and optionally requests deterministic semantic checks. + +The repository validator currently checks: + +- vector JSON shape and unique IDs; +- requirement links against the v0.2.0 normative manifest and Core-DP requirement index; +- expected valid/invalid outcomes; +- payload validation against the referenced JSON Schema; +- external receiver trust store (`trust/accepted-peer-keys.json`) schema conformance against `trust-store.schema.json`; +- required positive vector coverage for MaterialDNA register requests, ProductDNA read results, local Material search, cross-node Product search, choreography authority, retry duplicates, and final transfer convergence; +- deterministic envelope signing-input SHA-256, Ed25519 detached signatures (key resolved from external trust store), body-schema/message-type binding, and replay-window bounds; +- append-only evidence immutable subset consistency; +- search authorization/provenance semantics, including schema-level cross-node `node-signature` auth and eventual-consistency request requirements; +- DNA operation semantics; +- choreography authority role/node equality, retry/idempotency, stale-message, terminal-state, recovery overlays, and transfer-convergence semantics. + +This is a scaffold, not a certification program. diff --git a/profiles/core-dp/conformance/run-conformance.js b/profiles/core-dp/conformance/run-conformance.js new file mode 100644 index 0000000..5456074 --- /dev/null +++ b/profiles/core-dp/conformance/run-conformance.js @@ -0,0 +1,332 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Core-DP conformance harness runner. + * + * Loads profiles/core-dp/conformance/vectors/core-dp-vectors.json and, for every vector: + * 1. validates its structural shape against the schema it names (payload_schema), plus the + * body schema for envelope vectors; + * 2. runs whatever semantic checks the vector itself declares (vector.semantic_checks) using + * the exact semantic-check implementations already shipped in scripts/validate-schemas.js + * (signing-input hash recomputation, Ed25519 signature verification against the fixture + * trust store, replay-window arithmetic, key lifecycle checks, choreography/idempotency + * rules, etc.); + * 3. compares the outcome against the vector's declared `expected` (valid/invalid) and, for + * invalid vectors, the `expected_failure_contains` substring. + * + * This script does NOT duplicate AJV setup or semantic-check logic: it requires + * scripts/validate-schemas.js as a module and reuses its makeAjv/compileSchemas plumbing and its + * validateSemanticCheck implementations verbatim. What this script adds on top is the + * per-vector / per-requirement-id conformance report and exit code. + * + * Anything a vector's declared semantic_checks name that is NOT implemented in + * scripts/validate-schemas.js's CORE_DP_SEMANTIC_CHECKS registry (which, as of this writing, + * covers every check name used in the vectors file) is reported as "skipped" with an explicit + * reason, never scored as a fabricated pass or fail. As of this vectors file, every declared + * semantic check is a static, self-contained computation (hash recomputation, signature + * verification against the checked-in trust store fixture, timestamp/field arithmetic) and + * needs no live backend or database — so in practice the skip bucket should be empty; the + * branch exists so a future vector that genuinely requires a live backend degrades to "skipped" + * rather than silently passing or failing. + */ + +const fg = require('fast-glob'); +const { + loadJson, + formatErrors, + compileSchemas, + validateSemanticCheck, + CORE_DP_SEMANTIC_CHECKS, +} = require('../../../scripts/validate-schemas.js'); + +const REQUIREMENTS_PATH = 'profiles/core-dp/requirements/core-dp-requirements.json'; +const MANIFEST_PATH = 'profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json'; +const VECTORS_PATH = 'profiles/core-dp/conformance/vectors/core-dp-vectors.json'; +const TRUST_STORE_PATH = 'profiles/core-dp/conformance/trust/accepted-peer-keys.json'; + +function pad(value, width) { + const str = String(value); + return str.length >= width ? `${str} ` : str + ' '.repeat(width - str.length); +} + +function indentLines(text, prefix) { + return text + .split('\n') + .map((line) => `${prefix}${line}`) + .join('\n'); +} + +// Mirrors the per-vector grading rules in scripts/validate-schemas.js's assertCoreDpVectors, +// but reports a structured status per vector instead of only accumulating flat failure strings, +// so this script can roll results up by requirement id. All schema validation and semantic-check +// evaluation calls into the reused, already-tested implementations — nothing here re-implements +// AJV or the check logic itself. +function evaluateVector(vector, validators, trustStore) { + const failures = []; + const skips = []; + + const validator = validators.get(vector.payload_schema); + if (!validator) { + return { status: 'fail', details: [`payload_schema is not registered with AJV: ${vector.payload_schema}`] }; + } + + const schemaValid = validator(vector.payload); + if (!schemaValid) { + failures.push(`schema validation failed against ${vector.payload_schema}:\n${formatErrors(validator.errors || [])}`); + } + + if (schemaValid && vector.payload_schema.endsWith('/envelope.schema.json')) { + const bodyValidator = validators.get(vector.payload.body_schema); + if (!bodyValidator) { + failures.push(`envelope body_schema is not registered with AJV: ${vector.payload.body_schema}`); + } else if (!bodyValidator(vector.payload.body)) { + failures.push(`envelope body failed ${vector.payload.body_schema}:\n${formatErrors(bodyValidator.errors || [])}`); + } + } + + const context = { evaluationTime: vector.evaluation_time, trustStore }; + for (const checkName of vector.semantic_checks || []) { + if (!CORE_DP_SEMANTIC_CHECKS.has(checkName)) { + skips.push( + `semantic check "${checkName}" is not implemented by the reused validate-schemas.js registry ` + + '(would require a live backend/database or logic this harness does not have access to) — ' + + 'vector reported as skipped rather than scored.', + ); + continue; + } + try { + failures.push(...validateSemanticCheck(checkName, vector.payload, context)); + } catch (error) { + failures.push(`semantic check "${checkName}" threw: ${error.message}`); + } + } + + // A real schema/semantic-check failure must never be masked as "skip": skip only + // means "we couldn't evaluate one of the declared checks," not "there was no + // failure." If both occur on the same vector, the failure takes precedence and is + // reported as FAIL (with exit code 1), and the skip reason is carried along in the + // details rather than dropped. (Previously `skips.length > 0` short-circuited + // before failures were ever inspected, so a vector with both a real failure and an + // unimplemented semantic check silently reported as SKIP and exited 0.) + if (skips.length > 0 && failures.length === 0) { + return { status: 'skip', details: skips }; + } + + const skipNote = + skips.length > 0 + ? [ + `Note: this vector also declared ${skips.length} unimplemented semantic check(s), not evaluated ` + + '(not scored as pass or fail on their own, but a co-occurring real failure below is never masked by this):', + ...skips, + ] + : []; + + if (vector.expected === 'valid') { + if (failures.length === 0) { + return { status: 'pass', details: [] }; + } + return { status: 'fail', details: ['expected valid but failed:', ...failures, ...skipNote] }; + } + + if (vector.expected === 'invalid') { + if (failures.length === 0) { + return { + status: 'fail', + details: ['expected invalid, but schema validation and all semantic checks passed.', ...skipNote], + }; + } + if (typeof vector.expected_failure_contains === 'string' && vector.expected_failure_contains.length > 0) { + const matched = failures.some((failure) => failure.includes(vector.expected_failure_contains)); + if (!matched) { + return { + status: 'fail', + details: [ + `did not fail for the declared reason (expected a failure containing "${vector.expected_failure_contains}"). Actual failures:`, + ...failures, + ...skipNote, + ], + }; + } + } + return { status: 'pass', details: skipNote }; + } + + return { status: 'fail', details: [`vector has unrecognized expected value: ${vector.expected}`] }; +} + +function newBucket() { + return { total: 0, pass: 0, fail: 0, skip: 0, failing: [], skipping: [] }; +} + +function recordInBucket(bucket, vectorId, status) { + bucket.total += 1; + if (status === 'pass') bucket.pass += 1; + else if (status === 'fail') { + bucket.fail += 1; + bucket.failing.push(vectorId); + } else { + bucket.skip += 1; + bucket.skipping.push(vectorId); + } +} + +function main() { + const schemaPaths = fg.sync(['schemas/*.schema.json']); + const profileSchemaPaths = fg.sync(['profiles/core-dp/schemas/*.schema.json']); + const validators = new Map(); + const schemaIds = new Set(); + const schemaById = new Map(); + const setupFailures = []; + + compileSchemas([...schemaPaths, ...profileSchemaPaths], validators, schemaIds, schemaById, setupFailures); + + if (setupFailures.length > 0 || validators.size === 0) { + console.error('Failed to set up AJV validators for the Core-DP conformance run:\n'); + setupFailures.forEach((failure) => console.error(failure)); + process.exit(1); + } + + let requirements; + let manifest; + let suite; + let trustStore; + try { + requirements = loadJson(REQUIREMENTS_PATH); + manifest = loadJson(MANIFEST_PATH); + suite = loadJson(VECTORS_PATH); + trustStore = loadJson(TRUST_STORE_PATH); + } catch (error) { + console.error(`Failed to load conformance inputs: ${error.message}`); + process.exit(1); + } + + console.log('Core-DP Conformance Harness'); + console.log('==========================='); + console.log(`Suite: ${suite.suite}`); + console.log(`Vectors: ${suite.vectors.length}`); + console.log(`Schemas compiled: ${schemaPaths.length} core + ${profileSchemaPaths.length} Core-DP profile`); + console.log(''); + console.log('Per-vector results:'); + + const requirementBuckets = new Map(requirements.requirements.map((requirement) => [requirement.id, newBucket()])); + const manifestOnlyBuckets = new Map(); + + let overallPass = 0; + let overallFail = 0; + let overallSkip = 0; + const exercisedChecks = new Set(); + const unimplementedChecks = new Set(); + + for (const vector of suite.vectors) { + let result; + try { + result = evaluateVector(vector, validators, trustStore); + } catch (error) { + result = { status: 'fail', details: [`harness threw while evaluating vector: ${error.message}`] }; + } + + if (result.status === 'pass') overallPass += 1; + else if (result.status === 'fail') overallFail += 1; + else overallSkip += 1; + + (vector.semantic_checks || []).forEach((checkName) => { + if (CORE_DP_SEMANTIC_CHECKS.has(checkName)) { + exercisedChecks.add(checkName); + } else { + unimplementedChecks.add(checkName); + } + }); + + const tag = result.status === 'pass' ? 'PASS' : result.status === 'fail' ? 'FAIL' : 'SKIP'; + console.log( + ` [${tag}] ${vector.id} (expected: ${vector.expected}) requirements: ${(vector.requirement_ids || []).join(', ') || '(none)'}`, + ); + if (result.status !== 'pass') { + result.details.forEach((detail) => console.log(indentLines(detail, ' '))); + } + + for (const requirementId of vector.requirement_ids || []) { + let bucket = requirementBuckets.get(requirementId); + if (!bucket) { + if (!manifestOnlyBuckets.has(requirementId)) { + const manifestEntry = (manifest.entries || []).find((entry) => entry.id === requirementId); + manifestOnlyBuckets.set(requirementId, { + ...newBucket(), + statement: manifestEntry ? manifestEntry.statement : '(id not found in normative manifest either)', + }); + } + bucket = manifestOnlyBuckets.get(requirementId); + } + recordInBucket(bucket, vector.id, result.status); + } + } + + console.log(''); + console.log('Per-requirement-id summary (cross-referenced against profiles/core-dp/requirements/core-dp-requirements.json):'); + console.log(''); + console.log(`${pad('REQUIREMENT ID', 38)}${pad('CLASS', 11)}${pad('VECTORS', 9)}${pad('PASS', 6)}${pad('FAIL', 6)}${pad('SKIP', 6)}NOTE`); + for (const requirement of requirements.requirements) { + const bucket = requirementBuckets.get(requirement.id); + let note = ''; + if (bucket.total === 0) { + note = `no vectors reference this requirement in the harness (${requirement.implementation_status}; per its testability_statement this is reviewable by inspection / a live backend test, not vector-testable here) — reported as not-exercised, not a pass`; + } else if (bucket.fail > 0) { + note = `FAILING: ${bucket.failing.join(', ')}`; + } else if (bucket.skip > 0) { + note = `SKIPPED: ${bucket.skipping.join(', ')}`; + } + console.log( + `${pad(requirement.id, 38)}${pad(requirement.classification, 11)}${pad(bucket.total, 9)}${pad(bucket.pass, 6)}${pad(bucket.fail, 6)}${pad(bucket.skip, 6)}${note}`, + ); + } + + if (manifestOnlyBuckets.size > 0) { + console.log(''); + console.log('Requirement IDs referenced by vectors but NOT present in core-dp-requirements.json'); + console.log('(these come from the SPECIFICATION.md v0.2.0 normative manifest instead — shown here so no vector result is silently dropped):'); + console.log(''); + for (const [requirementId, bucket] of manifestOnlyBuckets.entries()) { + console.log(` ${requirementId} vectors=${bucket.total} pass=${bucket.pass} fail=${bucket.fail} skip=${bucket.skip}`); + console.log(` manifest statement: ${bucket.statement}`); + } + } + + console.log(''); + console.log('Semantic checks registered in scripts/validate-schemas.js and their exercise status in this run:'); + for (const checkName of CORE_DP_SEMANTIC_CHECKS) { + console.log(` - ${checkName}${exercisedChecks.has(checkName) ? '' : ' (registered, but not exercised by any vector in this run)'}`); + } + + if (unimplementedChecks.size > 0) { + console.log(''); + console.log('Semantic checks declared by vectors but skipped (not implemented in this static harness — would require a live backend/database):'); + unimplementedChecks.forEach((checkName) => console.log(` - ${checkName}`)); + } else { + console.log(''); + console.log( + 'No semantic check required a live backend or database: every check declared by these vectors is a static, ' + + 'self-contained computation (signing-input hash recomputation, Ed25519 signature verification against the ' + + 'checked-in trust store fixture, replay-window/timestamp arithmetic, key lifecycle status, field cross-referencing).', + ); + } + + console.log(''); + console.log(`Overall: ${suite.vectors.length} vectors -> ${overallPass} pass, ${overallFail} fail, ${overallSkip} skip`); + console.log(''); + console.log( + 'Scope note: this harness checks vector/contract conformance only (schema shape plus each vector\'s own declared\n' + + 'semantic checks). It never makes live HTTP calls against localloop-backend. Requirements whose full real-world\n' + + 'conformance depends on live runtime behavior (e.g. actual DB-backed search pagination, a real capabilities-\n' + + 'handshake round trip over the network) are only exercised here at the level of wire-format/contract vectors,\n' + + 'not end-to-end — see profiles/core-dp/requirements/core-dp-requirements.json implementation_status/evidence_output\n' + + 'for what backs each requirement beyond this harness. Requirements with zero referencing vectors above are reported\n' + + 'as not-exercised-by-this-harness, never fabricated as a pass.', + ); + + if (overallFail > 0) { + process.exitCode = 1; + } +} + +main(); diff --git a/profiles/core-dp/conformance/trust/accepted-peer-keys.json b/profiles/core-dp/conformance/trust/accepted-peer-keys.json new file mode 100644 index 0000000..badb5e5 --- /dev/null +++ b/profiles/core-dp/conformance/trust/accepted-peer-keys.json @@ -0,0 +1,56 @@ +{ + "version": "1.0.0", + "updated_at": "2026-07-17T00:00:00Z", + "keys": [ + { + "node_id": "munich.loop", + "key_id": "key_munich_2026_07", + "public_key_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "-7NRbVl-6ZYZBpXbKxksXkQF3V18rS8__ZRaGyt-zlI" + }, + "lifecycle_status": "active", + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2027-01-01T00:00:00Z" + }, + { + "node_id": "munich.loop", + "key_id": "key_munich_2025_01", + "public_key_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "rQnzfl0gpS1bK3DjZPI-SYr_l1fuTAnYVzlKgA8Fc2A" + }, + "lifecycle_status": "rotated", + "valid_from": "2025-01-01T00:00:00Z", + "valid_until": "2026-07-01T00:00:00Z", + "rotated_to_key_id": "key_munich_2026_07" + }, + { + "node_id": "munich.loop", + "key_id": "key_munich_revoked_001", + "public_key_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7hSZj3mZV8us1A05GMuJ1ks3t8wRsHIQBpUBGq-sxQ4" + }, + "lifecycle_status": "revoked", + "valid_from": "2025-01-01T00:00:00Z", + "valid_until": "2026-01-01T00:00:00Z", + "revoked_at": "2026-07-01T00:00:00Z" + }, + { + "node_id": "berlin.loop", + "key_id": "key_berlin_2026_07", + "public_key_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "EjpCQsnGqy7NeU5IMyDm_65Y3o_OXiMJJlqerJC_3Z4" + }, + "lifecycle_status": "active", + "valid_from": "2026-01-01T00:00:00Z", + "valid_until": "2027-01-01T00:00:00Z" + } + ] +} diff --git a/profiles/core-dp/conformance/vectors/core-dp-vectors.json b/profiles/core-dp/conformance/vectors/core-dp-vectors.json new file mode 100644 index 0000000..5b3eea2 --- /dev/null +++ b/profiles/core-dp/conformance/vectors/core-dp-vectors.json @@ -0,0 +1,2335 @@ +{ + "suite": "core-dp-0.1.0-lab", + "vectors": [ + { + "id": "core-dp-envelope-valid-001", + "title": "Valid signed offer acknowledgement envelope", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE", + "CORE-DP-REQ-CHOREOGRAPHY", + "REQ-92_NODE_AUTHENTICATION-MUST-66F1F0D8B55D" + ], + "expected": "valid", + "coverage_tags": [ + "positive:offer-authority" + ], + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-signing-input-sha256", + "core-dp-envelope-ed25519-signature", + "core-dp-envelope-body-contract", + "core-dp-choreography-message-contract", + "core-dp-envelope-replay-window", + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "164dbf698ab242f1b671323a85c257171d526ba71f7228dec406c8ff8c64c0ac", + "value": "v1zAi20NW44VjbreJ9sPzSosN93qd6d_Q61wRa2cGZJ6UkqXpmp1i40Wi75Vyg6O303NU3G48m_WHkkFDQ9zCA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-signature-value-001", + "title": "Reject signed envelope with altered detached Ed25519 signature", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE", + "REQ-92_NODE_AUTHENTICATION-MUST-66F1F0D8B55D" + ], + "expected": "invalid", + "expected_failure_contains": "detached Ed25519 signature verification failed", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-ed25519-signature" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "164dbf698ab242f1b671323a85c257171d526ba71f7228dec406c8ff8c64c0ac", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-key-id-mismatch-001", + "title": "Reject signed envelope whose signature key id does not match sender key id", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE", + "REQ-92_NODE_AUTHENTICATION-MUST-66F1F0D8B55D" + ], + "expected": "invalid", + "expected_failure_contains": "signature.key_id must match sender.key_id", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-ed25519-signature" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_08", + "signing_input_sha256": "164dbf698ab242f1b671323a85c257171d526ba71f7228dec406c8ff8c64c0ac", + "value": "s6mJJeJcy8t3X3i4qfLyrETR9PMkvCbJwxRDsvpDrlhfuJfeQ9u5HJU1mDm_XrQPrXK6VFt8NCB-WkJLMCPrCw" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-dna-valid-material-register-request-001", + "title": "Valid MaterialDNA register request", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "valid", + "coverage_tags": [ + "positive:material-dna-register-request" + ], + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "material.register", + "operation": "register_request", + "entity_type": "material", + "request_id": "req_materialRegister0001", + "record": { + "@context": "https://localloop.urbnia.com/projects/loop-protocol/contexts/loop-v0.2.0.jsonld", + "@type": "MaterialDNA", + "schema_version": "0.2.0", + "id": "MAT-DE-MUC-2025-PLASTIC-B847F3", + "category": "plastic-pet", + "quantity": { + "value": 1000, + "unit": "kg" + }, + "quality": 0.95, + "origin_city": "Munich", + "current_city": "Munich", + "location": { + "lat": 48.1351, + "lon": 11.582, + "address": "Recycling Center Munich" + }, + "available_from": "2025-05-27T10:00:00Z", + "expires": "2025-06-03T10:00:00Z", + "certifications": [ + "food-grade", + "iso-14001" + ], + "images": [ + "https://example.com/material-photo.jpg" + ], + "passport": { + "passport_id": "DPP-DE-MUC-2025-B847F3", + "passport_url": "https://example.com/passports/DPP-DE-MUC-2025-B847F3", + "backup_copy_url": "https://example.com/passports/DPP-DE-MUC-2025-B847F3/backup", + "passport_status": "available", + "data_carrier_id": "urn:epc:id:sgtin:426001.123456.2025B847F3", + "issuer": "lab-hub.loop", + "access_scope": "role-based", + "supported_regimes": [ + "espr-dpp", + "waste-shipment", + "nkws" + ] + }, + "classification": { + "ewc_code": "15 01 02", + "hs_code": "391510", + "cn_code": "39151000" + }, + "traceability": { + "batch_id": "2025-W22-01", + "source_operator_id": "munich-recycling-center", + "facility_id": "munich-sort-line-02", + "retention_until": "2030-06-03T10:00:00Z", + "document_refs": [ + "https://example.com/documents/dispatch-note-2025-W22-01" + ], + "due_diligence_ref": "DD-2025-PLASTIC-0042" + }, + "metadata": { + "source": "consumer-collection", + "batch_number": "2025-W22-01", + "notes": "Clean, sorted PET bottles" + } + } + } + }, + { + "id": "core-dp-dna-valid-product-read-result-001", + "title": "Valid ProductDNA read result", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "valid", + "coverage_tags": [ + "positive:product-dna-read-result" + ], + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "product.read", + "operation": "read_result", + "entity_type": "product", + "request_id": "req_productReadResult01", + "record_id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "result": { + "status": "found", + "provenance": { + "source_node": "munich.loop", + "record_hash_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_at": "2026-07-17T00:01:00Z", + "evidence_event_id": "evt_productReadResult01", + "envelope_message_id": "msg_productReadResult01" + } + }, + "record": { + "@context": "https://localloop.urbnia.com/projects/loop-protocol/contexts/loop-v0.2.0.jsonld", + "@type": "ProductDNA", + "schema_version": "0.2.0", + "id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "product_category": "furniture-office", + "name": "Standing Desk - Ergotron WorkFit", + "condition": "good", + "quantity": { + "value": 12, + "unit": "piece" + }, + "origin_city": "Munich", + "current_city": "Munich", + "available_from": "2026-03-15T08:00:00Z", + "manufacturer": "Ergotron", + "model": "WorkFit-S", + "manufacture_year": 2021, + "functional_status": "fully-functional", + "lifecycle_stage": "end-of-first-use", + "material_ids": [ + "MAT-DE-MUC-2025-METAL-4EB84C", + "MAT-DE-MUC-2025-PLASTIC-96FE78" + ], + "reuse_potential": "Fully functional standing desks from office clearance. Minor cosmetic wear." + } + } + }, + { + "id": "core-dp-capabilities-valid-announce-001", + "title": "Valid capability announcement choreography body", + "requirement_ids": [ + "CORE-DP-REQ-DISCOVERY-CAPABILITIES" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-capabilities-valid-response-001", + "title": "Valid capability response with accepted profile version", + "requirement_ids": [ + "CORE-DP-REQ-DISCOVERY-CAPABILITIES" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_capResponse00001", + "message_type": "capabilities.response", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "previous_state": "announced", + "state": "capabilities-confirmed", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-envelope-valid-capabilities-announce-001", + "title": "Valid capability announcement envelope body binding", + "requirement_ids": [ + "CORE-DP-REQ-DISCOVERY-CAPABILITIES", + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-body-contract", + "core-dp-choreography-message-contract", + "core-dp-envelope-signing-input-sha256", + "core-dp-envelope-ed25519-signature", + "core-dp-envelope-replay-window", + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_capAnnounce00001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_capAnnounce000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "98dafd23611069e1a9ba7d18e2cd2574868011ec5f848a5dedb48b0c42340a25", + "value": "w-LCQInFWhnJf_fLLf4Ps4URHDyGZ17Xm2w4GpqPc0i9Up-CeiB-Qc82lHi7vCHKRkfMx7ZWwmxWAPisZGMtCg" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-dna-invalid-register-read-operation-001", + "title": "Reject MaterialDNA register message carrying a read request operation", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "does not match message_type", + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "material.register", + "operation": "read_request", + "entity_type": "material", + "request_id": "req_materialRegisterBad1", + "record_id": "MAT-DE-MUC-2026-METAL-ABC123" + } + }, + { + "id": "core-dp-dna-invalid-read-register-operation-001", + "title": "Reject ProductDNA read message carrying a register result operation", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "does not match message_type", + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "product.read", + "operation": "register_result", + "entity_type": "product", + "request_id": "req_productReadBad0001", + "record_id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "result": { + "status": "registered", + "provenance": { + "source_node": "munich.loop", + "record_hash_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_at": "2026-07-17T00:01:00Z" + } + } + } + }, + { + "id": "core-dp-dna-invalid-read-result-missing-record-001", + "title": "Reject found read result that omits the returned record", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "must include the record", + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "product.read", + "operation": "read_result", + "entity_type": "product", + "request_id": "req_productReadNoRecord", + "record_id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "result": { + "status": "found", + "provenance": { + "source_node": "munich.loop", + "record_hash_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_at": "2026-07-17T00:01:00Z" + } + } + } + }, + { + "id": "core-dp-dna-invalid-read-result-record-type-001", + "title": "Reject ProductDNA read result carrying a MaterialDNA record", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "record @type must be ProductDNA", + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "product.read", + "operation": "read_result", + "entity_type": "product", + "request_id": "req_productReadWrongTyp", + "record_id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "result": { + "status": "found", + "provenance": { + "source_node": "munich.loop", + "record_hash_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "recorded_at": "2026-07-17T00:01:00Z" + } + }, + "record": { + "@context": "https://localloop.urbnia.com/projects/loop-protocol/contexts/loop-v0.2.0.jsonld", + "@type": "MaterialDNA", + "schema_version": "0.2.0", + "id": "MAT-DE-MUC-2025-PLASTIC-B847F3", + "category": "plastic-pet", + "quantity": { + "value": 1000, + "unit": "kg" + }, + "quality": 0.95, + "origin_city": "Munich", + "current_city": "Munich", + "location": { + "lat": 48.1351, + "lon": 11.582, + "address": "Recycling Center Munich" + }, + "available_from": "2025-05-27T10:00:00Z" + } + } + }, + { + "id": "core-dp-search-valid-local-material-001", + "title": "Valid local MaterialDNA search request", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "valid", + "coverage_tags": [ + "positive:material-search-local" + ], + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "semantic_checks": [ + "core-dp-search-contract-semantics" + ], + "payload": { + "entity_type": "material", + "scope": "local", + "filters": { + "category_prefix": "plastic", + "origin_city": "Munich", + "quantity_min": 250 + }, + "auth": { + "mode": "public-lab" + }, + "strict_filtering": true, + "limit": 25, + "consistency": "snapshot" + } + }, + { + "id": "core-dp-search-valid-cross-node-product-request-001", + "title": "Valid cross-node ProductDNA search request with node signature auth", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH", + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "valid", + "coverage_tags": [ + "positive:product-search-cross-node-request" + ], + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "semantic_checks": [ + "core-dp-search-contract-semantics" + ], + "payload": { + "entity_type": "product", + "scope": "cross-node", + "filters": { + "category_prefix": "furniture", + "current_city": "Berlin", + "condition": "good", + "updated_since": "2026-07-16T00:00:00Z" + }, + "auth": { + "mode": "node-signature", + "subject_node": "berlin.loop" + }, + "limit": 10, + "consistency": "eventual" + } + }, + { + "id": "core-dp-search-valid-cross-node-product-response-001", + "title": "Valid cross-node ProductDNA search response with provenance", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "valid", + "coverage_tags": [ + "positive:product-search-cross-node-response" + ], + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "semantic_checks": [ + "core-dp-search-contract-semantics" + ], + "payload": { + "entity_type": "product", + "results": [ + { + "id": "PRD-DE-MUC-2025-DESK-F4A7B2", + "source_node": "munich.loop", + "record_hash_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "updated_at": "2026-07-17T00:02:00Z" + } + ], + "ordering": { + "primary": "updated_at_asc", + "tie_break": "id_asc" + }, + "consistency": { + "mode": "eventual", + "as_of": "2026-07-17T00:02:30Z" + }, + "provenance": { + "queried_nodes": [ + "munich.loop", + "berlin.loop" + ], + "omitted_nodes": [] + } + } + }, + { + "id": "core-dp-choreography-valid-match-authority-001", + "title": "Valid match acceptance authority case", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "valid", + "coverage_tags": [ + "positive:match-authority" + ], + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_matchAccept000001", + "message_type": "match.accept", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "match", + "id": "MCH-DE-MUC-2026-000001" + }, + "previous_state": "match-proposed", + "state": "match-accepted", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-valid-transfer-authority-001", + "title": "Valid transfer receive authority case", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "valid", + "coverage_tags": [ + "positive:transfer-authority" + ], + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_transferRecv000001", + "message_type": "transfer.receive", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-dispatched", + "state": "transfer-received", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-valid-duplicate-stored-result-001", + "title": "Valid retry duplicate that returns stored result", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "valid", + "coverage_tags": [ + "positive:retry-duplicate-stored-result" + ], + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract", + "core-dp-choreography-idempotency-retry" + ], + "payload": { + "conversation_id": "conv_offerRetry000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 2, + "idempotency_key": "idem_offerAck0000000001", + "duplicate_of": "msg_originalOfferAck01", + "duplicate_behavior": "stored_result", + "original_fingerprint_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "idempotency_fingerprint_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-valid-transfer-convergence-001", + "title": "Valid final transfer convergence with matching evidence hash", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY", + "REQ-36_ENTITY_STATUS_TRANSITIONS-MUST_NOT-C229BE2C5C73" + ], + "expected": "valid", + "coverage_tags": [ + "positive:transfer-final-convergence" + ], + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract", + "core-dp-choreography-transfer-convergence" + ], + "payload": { + "conversation_id": "conv_transferConv0001", + "message_type": "transfer.ack", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-received", + "state": "transfer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "acknowledges_message_id": "msg_transferReceive001", + "final_ack_role": "origin-confirms-receiver-receipt", + "local_evidence_hash_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "peer_evidence_hash_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "convergence_hash_sha256": "454b30d34a86dc82f1027fac39e32096e61f452d2810fe9f63f20e1c917d93a2", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-choreography-valid-timed-out-001", + "title": "Valid local timeout recovery observation for an offer publish action", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract", + "core-dp-choreography-transfer-convergence" + ], + "payload": { + "conversation_id": "conv_offerTimeout0001", + "message_type": "offer.publish", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "timed-out", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "last_accepted_message_id": "msg_offerPublish0001", + "reason": "No acceptable acknowledgement arrived before timeout_at.", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-choreography-valid-partitioned-001", + "title": "Valid local partition recovery observation for a transfer receive action", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract", + "core-dp-choreography-transfer-convergence" + ], + "payload": { + "conversation_id": "conv_transferPart0001", + "message_type": "transfer.receive", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-received", + "state": "partitioned", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "last_accepted_message_id": "msg_transferReceive001", + "reason": "Receiver accepted the transfer receive action locally while acknowledgement transport was partitioned.", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-envelope-invalid-body-schema-001", + "title": "Reject envelope body schema that does not match message type", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "must use body_schema", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-body-contract" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_bodySchemaWrong0001", + "message_type": "material.search", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_bodySchemaWrong0001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json", + "body": { + "message_type": "material.read", + "operation": "read_request", + "entity_type": "material", + "request_id": "req_bodySchemaWrong0001", + "record_id": "MAT-DE-MUC-2026-METAL-ABC123" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "1edca91b0b463db9a4ee135a580f7b5c4a8a24db16beb7b3e08da82757f1e790", + "value": "K583TJk80sWz1jCj9kyILonBW9ogVeLayRl4KjQXx1-Mtsy1rbZbGItB60rYTD12Oxo3w5fsFnMoNILW76DJBg" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-capabilities-version-negotiation-001", + "title": "Reject capability envelope whose version negotiation omits the Core-DP profile version", + "requirement_ids": [ + "CORE-DP-REQ-DISCOVERY-CAPABILITIES", + "CORE-DP-REQ-ERRORS-VERSION" + ], + "expected": "invalid", + "expected_failure_contains": "contains", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-body-contract" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_capVersionBad001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_capVersionBad0001", + "version_negotiation": { + "requested": "0.1.0-lab", + "supported": [ + "0.2.0-lab" + ] + }, + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounceBad01", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "5887994d03fdef6ef187e80d50bd5d0aebad8f60e974f0a72174aca64f9e7a03", + "value": "fUoupLbXpOS1r_n7RxksY-yTTcwJ7mfa3xkH8XytgD_YmR8C7wb9DUPLWni6JFffYWPNn_SuRTI5cNrKLeeNDw" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-capabilities-invalid-response-version-001", + "title": "Reject capability response that confirms the wrong profile version", + "requirement_ids": [ + "CORE-DP-REQ-DISCOVERY-CAPABILITIES", + "CORE-DP-REQ-ERRORS-VERSION" + ], + "expected": "invalid", + "expected_failure_contains": "accepted_profile_version must be 0.1.0-lab", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_capVersionBad02", + "message_type": "capabilities.response", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "previous_state": "announced", + "state": "capabilities-confirmed", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.2.0-lab", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-dna-invalid-product-id-001", + "title": "Reject ProductDNA reads using a MaterialDNA identifier", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "does not match product id pattern", + "payload_schema": "profiles/core-dp/schemas/dna-operation.schema.json", + "semantic_checks": [ + "core-dp-dna-operation-contract" + ], + "payload": { + "message_type": "product.read", + "operation": "read_request", + "entity_type": "product", + "request_id": "req_productReadWrong001", + "record_id": "MAT-DE-MUC-2026-METAL-ABC123" + } + }, + { + "id": "core-dp-choreography-invalid-recovery-missing-last-message-001", + "title": "Reject local recovery observation without the last accepted message id", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "recovery observation must include last_accepted_message_id", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract", + "core-dp-choreography-transfer-convergence" + ], + "payload": { + "conversation_id": "conv_offerTimeoutBad1", + "message_type": "offer.publish", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "timed-out", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-search-invalid-limit-001", + "title": "Reject search requests above the profile limit", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "must be <= 100", + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "payload": { + "entity_type": "product", + "scope": "cross-node", + "filters": { + "category_prefix": "furniture" + }, + "auth": { + "mode": "node-signature", + "subject_node": "berlin.loop" + }, + "limit": 101, + "consistency": "eventual" + } + }, + { + "id": "core-dp-search-invalid-cross-node-auth-001", + "title": "Reject cross-node searches without node-signature auth", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "cross-node search must use node-signature auth", + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "semantic_checks": [ + "core-dp-search-contract-semantics" + ], + "payload": { + "entity_type": "material", + "scope": "cross-node", + "filters": { + "category_prefix": "metal" + }, + "auth": { + "mode": "public-lab" + }, + "limit": 25, + "consistency": "eventual" + } + }, + { + "id": "core-dp-error-invalid-retryable-001", + "title": "Reject retryable unsupported profile version errors", + "requirement_ids": [ + "CORE-DP-REQ-ERRORS-VERSION" + ], + "expected": "invalid", + "expected_failure_contains": "unsupported_profile_version errors are not retryable", + "payload_schema": "profiles/core-dp/schemas/error.schema.json", + "semantic_checks": [ + "core-dp-error-contract-semantics" + ], + "payload": { + "code": "unsupported_profile_version", + "message": "Peer requested an unsupported profile version.", + "retryable": true, + "correlation_id": "msg_failedOperation01", + "details": { + "requested": "0.9.0-lab", + "supported": [ + "0.1.0-lab" + ] + } + } + }, + { + "id": "core-dp-epcis-valid-supported-fixture-001", + "title": "Valid supported Core-DP EPCIS ObjectEvent mapping fixture", + "requirement_ids": [ + "CORE-DP-REQ-EPCIS-CBV-SUBSET" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/epcis-mapping.schema.json", + "semantic_checks": [ + "core-dp-epcis-mapping-semantics" + ], + "payload": { + "fixture_id": "core-dp-epcis-transfer-object-event-001", + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "core_dp_mapping": { + "evidence_event_type": "transfer-dispatched", + "subject_type": "transfer", + "subject_id": "TRF-DE-MUC-2026-000001" + }, + "epcis_subset_event": { + "type": "ObjectEvent", + "eventID": "ni:///sha-256;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "eventTime": "2026-07-17T00:02:00Z", + "eventTimeZoneOffset": "+00:00", + "action": "OBSERVE", + "bizStep": "shipping", + "disposition": "in_transit", + "readPoint": { + "id": "urn:loop:node:munich.loop" + }, + "bizLocation": { + "id": "urn:loop:node:berlin.loop" + }, + "epcList": [ + "urn:loop:transfer:TRF-DE-MUC-2026-000001" + ] + } + } + }, + { + "id": "core-dp-epcis-valid-unsupported-metadata-001", + "title": "Valid unsupported EPCIS feature metadata guardrail", + "requirement_ids": [ + "CORE-DP-REQ-EPCIS-CBV-SUBSET" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/epcis-mapping.schema.json", + "semantic_checks": [ + "core-dp-epcis-mapping-semantics" + ], + "payload": { + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "full_epcis_conformance_claimed": false, + "unsupported_features": [ + { + "feature": "AggregationEvent", + "rationale": "Bundle and parent-child aggregation are outside the first Core-DP delivery contract." + }, + { + "feature": "TransformationEvent", + "rationale": "Material transformation semantics are outside Offer -> Match -> Transfer delivery proof." + } + ] + } + }, + { + "id": "core-dp-epcis-invalid-subject-map-001", + "title": "Reject EPCIS mapping whose EPC list omits the Core-DP subject", + "requirement_ids": [ + "CORE-DP-REQ-EPCIS-CBV-SUBSET" + ], + "expected": "invalid", + "expected_failure_contains": "EPCIS epcList must include mapped Core-DP subject", + "payload_schema": "profiles/core-dp/schemas/epcis-mapping.schema.json", + "semantic_checks": [ + "core-dp-epcis-mapping-semantics" + ], + "payload": { + "fixture_id": "core-dp-transfer-object-event-invalid-subject", + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "core_dp_mapping": { + "evidence_event_type": "transfer-acked", + "subject_type": "transfer", + "subject_id": "TRF-DE-MUC-2026-000001" + }, + "epcis_subset_event": { + "type": "ObjectEvent", + "eventID": "ni:///sha-256;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "eventTime": "2026-07-17T00:03:00Z", + "action": "OBSERVE", + "bizStep": "accepting", + "epcList": [ + "urn:loop:transfer:TRF-DE-MUC-2026-OTHER" + ] + } + } + }, + { + "id": "core-dp-epcis-invalid-event-fields-001", + "title": "Reject EPCIS mapping with invalid eventID and action fields", + "requirement_ids": [ + "CORE-DP-REQ-EPCIS-CBV-SUBSET" + ], + "expected": "invalid", + "expected_failure_contains": "must match pattern", + "payload_schema": "profiles/core-dp/schemas/epcis-mapping.schema.json", + "semantic_checks": [ + "core-dp-epcis-mapping-semantics" + ], + "payload": { + "fixture_id": "core-dp-transfer-object-event-invalid-fields", + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "core_dp_mapping": { + "evidence_event_type": "transfer-acked", + "subject_type": "transfer", + "subject_id": "TRF-DE-MUC-2026-000001" + }, + "epcis_subset_event": { + "type": "ObjectEvent", + "eventID": "not-a-valid-event-id", + "eventTime": "2026-07-17T00:03:00Z", + "action": "DELETE", + "bizStep": "accepting", + "epcList": [ + "urn:loop:transfer:TRF-DE-MUC-2026-000001" + ] + } + } + }, + { + "id": "core-dp-evidence-valid-001", + "title": "Valid append-only evidence entry", + "requirement_ids": [ + "CORE-DP-REQ-EVIDENCE", + "REQ-105_AUDIT_TRAIL-MUST-EA473379C41F" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/evidence-entry.schema.json", + "semantic_checks": [ + "core-dp-evidence-immutable-subset" + ], + "payload": { + "event_id": "evt_transferAck000001", + "sequence": 7, + "predecessor_event_id": "evt_transferRecv00001", + "recorded_at": "2026-07-17T00:03:00Z", + "observed_at": "2026-07-17T00:02:55Z", + "node_id": "munich.loop", + "actor_node_id": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "event_type": "transfer-acked", + "immutable": { + "event_id": "evt_transferAck000001", + "sequence": 7, + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "event_type": "transfer-acked", + "payload_hash_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "payload_hash_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "envelope_message_id": "msg_validEnvelope0001", + "retention": { + "retain_until": "2027-07-17T00:00:00Z", + "exportable": true, + "redaction_status": "none" + }, + "redactions": [] + } + }, + { + "id": "core-dp-choreography-invalid-terminal-regression-001", + "title": "Reject terminal transfer acknowledgement regression", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY", + "REQ-36_ENTITY_STATUS_TRANSITIONS-MUST_NOT-C229BE2C5C73" + ], + "expected": "invalid", + "expected_failure_contains": "terminal state transfer-acked cannot regress", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-terminal-regression" + ], + "payload": { + "conversation_id": "conv_transferRegression1", + "message_type": "transfer.dispatch", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-acked", + "state": "transfer-dispatched", + "terminal": false, + "attempt": 2, + "timeout_at": "2026-07-17T00:10:00Z", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-choreography-invalid-terminal-mismatch-001", + "title": "Reject choreography messages whose terminal flag contradicts the state/message mapping", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY", + "REQ-36_ENTITY_STATUS_TRANSITIONS-MUST_NOT-C229BE2C5C73" + ], + "expected": "invalid", + "expected_failure_contains": "transfer.receive terminal must be false", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_terminalMismatch1", + "message_type": "transfer.receive", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-dispatched", + "state": "transfer-received", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-invalid-idempotency-retry-001", + "title": "Reject retry attempts that omit duplicate linkage", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "retry attempts above 1 must identify duplicate_of", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-idempotency-retry" + ], + "payload": { + "conversation_id": "conv_offerRetry000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 2, + "idempotency_key": "idem_offerAck0000000001", + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-invalid-stale-reordered-001", + "title": "Reject reordered messages that do not match local state", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "does not match local_state", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-stale-reordered" + ], + "payload": { + "conversation_id": "conv_matchStale000001", + "message_type": "match.accept", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "match", + "id": "MAT-DE-MUC-2026-MATCH-0001" + }, + "previous_state": "offer-acked", + "local_state": "match-proposed", + "state": "match-accepted", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-invalid-transfer-convergence-001", + "title": "Reject transfer convergence with mismatched evidence hashes", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "transfer convergence evidence hashes must match", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-transfer-convergence" + ], + "payload": { + "conversation_id": "conv_transferConv0001", + "message_type": "transfer.ack", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-received", + "state": "transfer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "acknowledges_message_id": "msg_transferReceive0001", + "final_ack_role": "origin-confirms-receiver-receipt", + "local_evidence_hash_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "peer_evidence_hash_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "convergence_hash_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-envelope-invalid-wrong-body-001", + "title": "Reject envelope whose body message type contradicts message_type", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE", + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_wrongBody000001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_wrongBody0000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "921b85a8348a22b5698dc94c6426c89896dd1d2c82836013f4d35b63a03f16df", + "value": "DzYfW0zitpkViEsgVMOicYbbTsyQEbCtlG48rc5Tyjl-1m5LZPmdh_5mInNBvbJ-JBw5KySCBYcJ81VmuEexDw" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-wrong-body-schema-002", + "title": "Reject envelope whose body_schema contradicts message_type", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_wrongSchema00001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_wrongSchema000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "8bb078762257938789744d64b4bfb5018d38816218aec8ae625ec985fd13819f", + "value": "imtZzMlxoISmm51kCfjo3kWamjc6JFHsKzsLNVOlcfYwF35kiFd8jtbiI9DU2hQRFaIxXA5j7ZZujjuVVgEOBA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-valid-replay-window-001", + "title": "Valid signed envelope inside replay window", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-signing-input-sha256", + "core-dp-envelope-ed25519-signature", + "core-dp-envelope-body-contract", + "core-dp-envelope-replay-window", + "core-dp-choreography-message-contract", + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_replayWindow0001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T01:00:00Z", + "expires_at": "2026-07-17T01:03:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_replayWindow0001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "0279f792da6c102e1b55829ca95f6b28cce81cfdcc17ccd4acf954450e5198a5", + "value": "wsgWhy0e-aG43th6cf9jgtXqbuqnMTJaUMl_Mhgbp_Ot3Omd-1d5GSRAxSRIf3e0YVLlKVZx5ZnHZBidZCyhDA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-replay-window-001", + "title": "Reject signed envelope whose expiry exceeds replay window", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "expires_at must be within replay_window_seconds", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-replay-window" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_replayWindowBad1", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T01:00:00Z", + "expires_at": "2026-07-17T01:03:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_replayWindowBad01", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "180518b888966c5e187f916e9853da370d6d5813e576048be581151e46b9100a", + "value": "6uqq3W_0U90uqbzjy0ZI0nb-yNGtCdtBVZdlY1QXUQ7AeNPRrIFAvS1zjoF3ErDzeG_Er_uAopad4Vd3vZTnAA" + }, + "replay_window_seconds": 60 + } + }, + { + "id": "core-dp-choreography-invalid-offer-ack-subject-001", + "title": "Reject offer.ack with contradictory subject type", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "payload": { + "conversation_id": "conv_offerAckContra01", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "match", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-invalid-match-role-001", + "title": "Reject match.accept with contradictory authoritative role", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "payload": { + "conversation_id": "conv_matchRoleBad001", + "message_type": "match.accept", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "match", + "id": "MCH-DE-MUC-2026-000001" + }, + "previous_state": "match-proposed", + "state": "match-accepted", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + } + }, + { + "id": "core-dp-choreography-invalid-recovery-duplicate-result-001", + "title": "Reject recovery observation carrying duplicate result fields", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "must NOT be valid", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "payload": { + "conversation_id": "conv_recoveryDupBad01", + "message_type": "offer.publish", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "timed-out", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "last_accepted_message_id": "msg_offerPublish0001", + "reason": "No acceptable acknowledgement arrived before timeout_at.", + "authoritative_role": "origin", + "duplicate_behavior": "stored_result" + } + }, + { + "id": "core-dp-choreography-invalid-transfer-ack-recovery-001", + "title": "Reject transfer.ack modeled as a recovery observation", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "payload": { + "conversation_id": "conv_transferAckRecoveryBad1", + "message_type": "transfer.ack", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "transfer", + "id": "TRF-DE-MUC-2026-000001" + }, + "previous_state": "transfer-acked", + "state": "timed-out", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin", + "last_accepted_message_id": "msg_transferAck000001" + } + }, + { + "id": "core-dp-search-invalid-cross-node-missing-auth-001", + "title": "Reject cross-node searches without an auth object", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "must have required property 'auth'", + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "payload": { + "entity_type": "product", + "scope": "cross-node", + "filters": { + "category_prefix": "furniture", + "current_city": "Berlin", + "condition": "good", + "updated_since": "2026-07-16T00:00:00Z" + }, + "limit": 10, + "consistency": "eventual" + } + }, + { + "id": "core-dp-search-invalid-cross-node-snapshot-request-001", + "title": "Reject cross-node request claiming snapshot consistency without shared snapshot modeling", + "requirement_ids": [ + "CORE-DP-REQ-DNA-SEARCH" + ], + "expected": "invalid", + "expected_failure_contains": "must be equal to constant", + "payload_schema": "profiles/core-dp/schemas/search-contract.schema.json", + "payload": { + "entity_type": "product", + "scope": "cross-node", + "filters": { + "category_prefix": "furniture", + "current_city": "Berlin", + "condition": "good", + "updated_since": "2026-07-16T00:00:00Z" + }, + "auth": { + "mode": "node-signature", + "subject_node": "berlin.loop" + }, + "limit": 10, + "consistency": "snapshot" + } + }, + { + "id": "core-dp-envelope-valid-trust-resolution-001", + "title": "Valid envelope with sender key in receiver trust context", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-trusted-key-resolution", + "core-dp-envelope-ed25519-signature", + "core-dp-envelope-signing-input-sha256", + "core-dp-envelope-body-contract", + "core-dp-envelope-replay-window" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "164dbf698ab242f1b671323a85c257171d526ba71f7228dec406c8ff8c64c0ac", + "value": "v1zAi20NW44VjbreJ9sPzSosN93qd6d_Q61wRa2cGZJ6UkqXpmp1i40Wi75Vyg6O303NU3G48m_WHkkFDQ9zCA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-trust-revoked-key-001", + "title": "Reject envelope signed with a revoked key in trust context", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "has been revoked", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_revoked_001" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_revoked_001", + "signing_input_sha256": "1f83b305062b54d353a845837505c23b2aaaf98516c6c723b8555668bcce2b1e", + "value": "P8bW_45CUtOVVmnRQEGMtHdy8E8SROESp7I7KJDqDKeONfOIABc9W5SlL2M3VbsnzDO1qr3oXnwTKt0n4XNqBA" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-invalid-trust-unknown-key-001", + "title": "Reject envelope where sender key is unknown to receiver trust context", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "not found in trust store", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_validEnvelope0001", + "message_type": "offer.ack", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_unknown_00000000" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_offerAck0000000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_offerAck00000001", + "message_type": "offer.ack", + "origin_node": "berlin.loop", + "counterparty_node": "munich.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "previous_state": "offer-published", + "state": "offer-acked", + "terminal": true, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "accepted_profile_version": "0.1.0-lab", + "authoritative_role": "counterparty" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_unknown_00000000", + "signing_input_sha256": "164dbf698ab242f1b671323a85c257171d526ba71f7228dec406c8ff8c64c0ac", + "value": "s6mJJeJcy8t3X3i4qfLyrETR9PMkvCbJwxRDsvpDrlhfuJfeQ9u5HJU1mDm_XrQPrXK6VFt8NCB-WkJLMCPrCw" + }, + "replay_window_seconds": 300 + } + }, + { + "id": "core-dp-envelope-valid-replay-evaluation-time-001", + "title": "Accept envelope when evaluation_time equals created_at", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "valid", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-signing-input-sha256", + "core-dp-envelope-ed25519-signature", + "core-dp-envelope-trusted-key-resolution", + "core-dp-envelope-body-contract", + "core-dp-envelope-replay-window" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_replayEvalTime001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T01:00:00Z", + "expires_at": "2026-07-17T01:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_replayEvalTime001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_replayEvalTime01", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T01:05:00Z", + "authoritative_role": "origin" + }, + "replay_window_seconds": 300, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "7f1f6ba2f1ded5c29bfd9d05ba05942a6bbfd4089d21d6f49e656098a79e81ec", + "value": "UT4vJctB_pP-7ZX9PiYuxD1o542k-0zGCxy8lAwThT5BweHBJVOokt7B1bPSCf1UFORfGxrH0HZjOUJbOwqdAw" + } + }, + "evaluation_time": "2026-07-17T01:00:00Z" + }, + { + "id": "core-dp-envelope-invalid-replay-expired-evaluation-time-001", + "title": "Reject envelope when evaluation_time >= expires_at", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "message has expired", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-replay-window" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_replayExpired001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T01:00:00Z", + "expires_at": "2026-07-17T01:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_replayExpired001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_replayExpired001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T01:05:00Z", + "authoritative_role": "origin" + }, + "replay_window_seconds": 300, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "a05fa18658fbb9ea5cd4914cc13b8cc964d7cf0b9d4bebdd124b8dec71ffb950", + "value": "wD-n7_Ss46BWLRgGGmGDiGrnQ-UAreq4r-jijB9_xDJZCe5UQ6HTccQa1-F_NYGCaewP2gxJvgQ2JysuCAXGAQ" + } + }, + "evaluation_time": "2026-07-17T01:05:00Z" + }, + { + "id": "core-dp-envelope-invalid-replay-evaluation-before-created-001", + "title": "Reject envelope when evaluation_time < created_at", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "evaluation_time must be >= created_at", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-replay-window" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_replayEvalBefore001", + "message_type": "capabilities.announce", + "created_at": "2026-07-17T01:00:00Z", + "expires_at": "2026-07-17T01:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_replayEvalBefore01", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_replayEvalBefore01", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "replay_window_seconds": 300, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2026_07", + "signing_input_sha256": "9021a343407d4c3083df42fa55883a65eb07893fbebb4595af65f8ed322b9b19", + "value": "7h0LCknzE2RNb7qD-LfLp2xVC2Q8FHJK3wtAO--NpIMTF_oQi24vlXN9ciRMHlsD5ajYtoE8_uO5lyq2CR4yAA" + } + }, + "evaluation_time": "2026-07-17T00:59:00Z" + }, + { + "id": "core-dp-choreography-invalid-wrong-authoritative-role-injection-001", + "title": "Reject offer.publish asserting counterparty authority", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY" + ], + "expected": "invalid", + "expected_failure_contains": "authoritative_role must be origin", + "payload_schema": "profiles/core-dp/schemas/choreography-message.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "conversation_id": "conv_roleInject001", + "message_type": "offer.publish", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "state": "offer-published", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "counterparty" + } + }, + { + "id": "core-dp-choreography-invalid-wrong-sender-injection-001", + "title": "Reject envelope where sender does not match authoritative role", + "requirement_ids": [ + "CORE-DP-REQ-CHOREOGRAPHY", + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "sender.node_id must be", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-choreography-message-contract" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_wrongSender001", + "message_type": "offer.publish", + "created_at": "2026-07-17T00:00:00Z", + "expires_at": "2026-07-17T00:05:00Z", + "sender": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "receiver": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2026_07" + }, + "idempotency_key": "idem_wrongSender001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_wrongSender001", + "message_type": "offer.publish", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "offer", + "id": "OFF-DE-MUC-2026-000001" + }, + "state": "offer-published", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "replay_window_seconds": 300, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_berlin_2026_07", + "signing_input_sha256": "436fcf2725154a0590b275fd784362fcb74f4a43a6374b0642b1750a3bd58cca", + "value": "4Lz5DwJhyH2cH0Vb2-0pDOQCk4Ue1tMQj_R9C1yH4PUwMlMnpqODx-apxy3vVFxq2QasOCqJ8nwOvhB3h88VAg" + } + } + }, + { + "id": "core-dp-envelope-invalid-trust-rotated-key-001", + "title": "Reject envelope signed with a rotated key past its validity", + "requirement_ids": [ + "CORE-DP-REQ-SIGNED-ENVELOPE" + ], + "expected": "invalid", + "expected_failure_contains": "not valid for message created_at", + "payload_schema": "profiles/core-dp/schemas/envelope.schema.json", + "semantic_checks": [ + "core-dp-envelope-trusted-key-resolution" + ], + "payload": { + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "message_id": "msg_rotatedKey00000001", + "message_type": "capabilities.announce", + "created_at": "2026-07-15T00:00:00Z", + "expires_at": "2026-07-15T00:05:00Z", + "sender": { + "node_id": "munich.loop", + "endpoint": "https://munich.loop/api/v1", + "key_id": "key_munich_2025_01" + }, + "receiver": { + "node_id": "berlin.loop", + "endpoint": "https://berlin.loop/api/v1", + "key_id": "key_berlin_2026_07" + }, + "idempotency_key": "idem_rotatedKey000001", + "body_schema": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "body": { + "conversation_id": "conv_capAnnounce00001", + "message_type": "capabilities.announce", + "origin_node": "munich.loop", + "counterparty_node": "berlin.loop", + "subject": { + "type": "capabilities", + "id": "core-dp-0.1.0-lab" + }, + "state": "announced", + "terminal": false, + "attempt": 1, + "timeout_at": "2026-07-17T00:05:00Z", + "authoritative_role": "origin" + }, + "signature": { + "alg": "Ed25519-lab-detached-v1", + "key_id": "key_munich_2025_01", + "signing_input_sha256": "93663e1b0f470918958703dec586aba6402eb7dff6d5e0a469f9d47bbcde9858", + "value": "gEInpt40Dz66dlHHDG9rOKd-mF9k7FVl9m3TS3mokXMChHV8u7443T570az6-y_s7Or4ZA2xNvL3kMUWc2GwAA" + }, + "replay_window_seconds": 300 + } + } + ] +} diff --git a/profiles/core-dp/epcis/README.md b/profiles/core-dp/epcis/README.md new file mode 100644 index 0000000..87f86fa --- /dev/null +++ b/profiles/core-dp/epcis/README.md @@ -0,0 +1,12 @@ +# Core-DP EPCIS/CBV Mapping Fixtures + +Core-DP pins **EPCIS 2.0.1** and **CBV 2.0** only for a conservative mapping fixture subset. These fixtures are for implementer alignment and validator parsing; they are not a claim of full EPCIS conformance. + +Supported subset: + +- object-style event shape for MaterialDNA/ProductDNA registration evidence; +- object-style event shape for Offer -> Match -> Transfer evidence milestones; +- CBV-style business step strings limited to `commissioning`, `shipping`, `receiving`, and `accepting`; +- opaque references from Core-DP evidence entries to EPCIS-like fixture event IDs. + +Unsupported features are enumerated in `unsupported-features.json`. diff --git a/profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json b/profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json new file mode 100644 index 0000000..44c1737 --- /dev/null +++ b/profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json @@ -0,0 +1,24 @@ +{ + "fixture_id": "core-dp-epcis-transfer-object-event-001", + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "core_dp_mapping": { + "evidence_event_type": "transfer-dispatched", + "subject_type": "transfer", + "subject_id": "TRF-DE-MUC-2026-000001" + }, + "epcis_subset_event": { + "type": "ObjectEvent", + "eventID": "ni:///sha-256;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "eventTime": "2026-07-17T00:02:00Z", + "eventTimeZoneOffset": "+00:00", + "action": "OBSERVE", + "bizStep": "shipping", + "disposition": "in_transit", + "readPoint": { "id": "urn:loop:node:munich.loop" }, + "bizLocation": { "id": "urn:loop:node:berlin.loop" }, + "epcList": ["urn:loop:transfer:TRF-DE-MUC-2026-000001"] + } +} diff --git a/profiles/core-dp/epcis/unsupported-features.json b/profiles/core-dp/epcis/unsupported-features.json new file mode 100644 index 0000000..2647f85 --- /dev/null +++ b/profiles/core-dp/epcis/unsupported-features.json @@ -0,0 +1,37 @@ +{ + "pinned_versions": { + "epcis": "2.0.1", + "cbv": "2.0" + }, + "full_epcis_conformance_claimed": false, + "unsupported_features": [ + { + "feature": "AggregationEvent", + "rationale": "Bundle and parent-child aggregation are outside the first Core-DP delivery contract." + }, + { + "feature": "TransformationEvent", + "rationale": "Material transformation semantics are outside Offer -> Match -> Transfer delivery proof." + }, + { + "feature": "TransactionEvent", + "rationale": "Transactions and payments are OUT for Core-DP." + }, + { + "feature": "AssociationEvent", + "rationale": "General association modeling is deferred until profile-specific DPP/VC work." + }, + { + "feature": "sensorElementList", + "rationale": "IoT sensor streams are not part of the lab-only foundation." + }, + { + "feature": "master data vocabulary validation", + "rationale": "Core-DP validates only the conservative fixture fields and does not maintain a full EPCIS vocabulary registry." + }, + { + "feature": "CBV full vocabulary", + "rationale": "Only the fixture business steps listed in the profile README are used." + } + ] +} diff --git a/profiles/core-dp/requirements/core-dp-requirements.json b/profiles/core-dp/requirements/core-dp-requirements.json new file mode 100644 index 0000000..ffd50ed --- /dev/null +++ b/profiles/core-dp/requirements/core-dp-requirements.json @@ -0,0 +1,107 @@ +{ + "profile": "core-dp", + "profile_version": "0.1.0-lab", + "base_specification": { + "path": "SPECIFICATION.md", + "version": "0.2.0" + }, + "requirements": [ + { + "id": "CORE-DP-REQ-SCOPE-LAB-ONLY", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#core-dp-applicability-profile-010-lab", + "summary": "Core-DP conformance is lab-only profile conformance, not full LOOP conformance.", + "rationale": "Sets the boundary for everything else in this inventory: Core-DP is an implementer preview subset, so every other requirement here is scoped to lab-only claims (no pilot, deployment, or compliance language).", + "owner": "Protocol/architecture lead", + "testability_statement": "Reviewable by inspection: the applicability statement and every outward-facing claim referencing Core-DP must use lab-only/experimental/implementer-preview language, never pilot/deployment/compliance language.", + "implementation_status": "profile_contract", + "evidence_output": "profiles/core-dp/README.md applicability statement" + }, + { + "id": "CORE-DP-REQ-DISCOVERY-CAPABILITIES", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "summary": "Two nodes exchange capabilities and converge on an accepted profile version and peer key id.", + "rationale": "Federation cannot proceed safely unless both sides agree on protocol version and trusted key material before exchanging substantive messages.", + "owner": "Backend/security engineer", + "testability_statement": "Send a capabilities.announce envelope and assert the peer responds with capabilities.response carrying an accepted_profile_version and last_accepted_message_id.", + "positive_test_location": "profiles/core-dp/conformance/vectors/core-dp-vectors.json (capabilities vectors)", + "implementation_status": "schema_contract", + "evidence_output": "envelope.schema.json + choreography-message.schema.json capabilities.* message types; localloop-backend does not yet implement the capabilities handshake endpoint (tracked as follow-up alongside the signed envelope module)." + }, + { + "id": "CORE-DP-REQ-DNA-SEARCH", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#search-contract", + "summary": "MaterialDNA and ProductDNA local/cross-node search use exact filters, limit, cursor, ordering, consistency, and provenance rules.", + "rationale": "Discovery is a Core-DP entry point; without a deterministic, paginated, provenance-carrying search contract, two nodes cannot reliably reconcile catalog state.", + "owner": "Backend/security engineer", + "testability_statement": "POST /api/v1/material/search and /api/v1/product/search with filter combinations, cursor continuation, and strict_filtering; assert deterministic updated_at_asc,id_asc ordering with no gaps or duplicates across pages.", + "positive_test_location": "localloop-backend/tests/loop.search.test.ts", + "negative_test_location": "localloop-backend/tests/loop.search.test.ts (condition filter rejected on materials under strict_filtering; cross-node scope rejected at the route layer)", + "implementation_status": "code_complete", + "evidence_output": "localloop-backend/src/db/loop.ts (searchLoopMaterials/searchLoopProducts), src/routes/loop.ts (POST .../search routes). Local scope only; cross-node scope is explicitly rejected pending the federation/signed-envelope layer." + }, + { + "id": "CORE-DP-REQ-CHOREOGRAPHY", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "summary": "Offer, match, and transfer messages define authoritative nodes, retries, duplicates, reordering, timeouts, partitions, terminal states, and convergence.", + "rationale": "Cross-node lifecycle exchange only converges safely if both sides agree on who is authoritative for each transition and how retries/duplicates/timeouts are resolved.", + "owner": "Backend/security engineer", + "testability_statement": "Replay a conversation_id's message sequence out of order, with duplicates and after a timeout window; assert the receiver converges on the same terminal state as a clean run.", + "positive_test_location": "profiles/core-dp/conformance/vectors/core-dp-vectors.json (choreography vectors)", + "implementation_status": "schema_contract", + "evidence_output": "choreography-message.schema.json. localloop-backend's REST offer/match/transfer endpoints implement local single-node lifecycle hardening (idempotency keys via localloop-backend/src/idempotency.ts, tests/idempotency.test.ts) but not yet the full two-node choreography state machine (authoritative role tracking, timeout/partition handling) — tracked as follow-up, out of scope for this session." + }, + { + "id": "CORE-DP-REQ-SIGNED-ENVELOPE", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "summary": "All node-to-node Core-DP messages use the canonical signed envelope and deterministic signing input.", + "rationale": "Without message-level signing, a receiver cannot verify which node actually sent a choreography or search message, undermining every other Core-DP guarantee.", + "owner": "Backend/security engineer", + "testability_statement": "Sign an envelope with a trusted key, verify acceptance; tamper with the body or use a revoked/untrusted key and verify rejection with signature_invalid.", + "positive_test_location": "profiles/core-dp/conformance/vectors/core-dp-vectors.json (envelope vectors)", + "implementation_status": "schema_contract", + "evidence_output": "envelope.schema.json, peer-key-trust.schema.json, trust-store.schema.json. Backend Ed25519 signing/verification module not yet implemented — tracked as the next follow-up after this session." + }, + { + "id": "CORE-DP-REQ-EVIDENCE", + "classification": "IN", + "contract_location": "profiles/core-dp/README.md#evidence", + "summary": "Evidence is append-only with an immutable subset and explicit retention, export, and redaction metadata.", + "rationale": "A lifecycle claim (\"this offer was published\", \"this transfer was received\") is only trustworthy if the record of it cannot be silently altered after the fact.", + "owner": "Backend/security engineer", + "testability_statement": "Create a material/offer/match/transfer and assert a matching evidence entry is recorded; attempt UPDATE/DELETE against the evidence table directly and assert the database rejects both.", + "positive_test_location": "localloop-backend/tests/evidence.test.ts", + "negative_test_location": "localloop-backend/tests/evidence.test.ts (append-only trigger blocks UPDATE and DELETE)", + "implementation_status": "code_complete", + "evidence_output": "localloop-backend/src/db/migrations/013_loop_evidence.sql (append-only trigger), src/db/evidence.ts, GET /api/v1/evidence/:event_id, GET /api/v1/evidence, POST /api/v1/evidence/search. Evidence entries are created automatically as a side effect of material/product/offer/match/transfer creation." + }, + { + "id": "CORE-DP-REQ-ERRORS-VERSION", + "classification": "IN", + "contract_location": "profiles/core-dp/schemas/error.schema.json#", + "summary": "Errors and profile version negotiation are machine-checkable through profile schemas.", + "rationale": "A consistent, typed error shape lets a Core-DP client distinguish retryable failures from permanent ones and correlate errors across a conversation without parsing free-text messages.", + "owner": "Backend/security engineer", + "testability_statement": "Trigger each Core-DP error path (invalid filter, unknown search cursor, idempotency-key conflict, unknown evidence event_id) and assert the response matches error.schema.json exactly.", + "positive_test_location": "localloop-backend/tests/idempotency.test.ts (conflict), tests/loop.search.test.ts (invalid_request)", + "implementation_status": "code_complete", + "evidence_output": "localloop-backend/src/errors.ts (CoreDpError), applied to the search and evidence endpoints and to idempotency-key conflicts. Not retrofitted onto the pre-existing basic material/product/offer/match/transfer create routes' legacy {error} shape, to avoid an unrelated breaking change to already-deployed consumers; profile_version negotiation itself is defined in envelope.schema.json but not yet exercised by a running endpoint (depends on the signed envelope module)." + }, + { + "id": "CORE-DP-REQ-EPCIS-CBV-SUBSET", + "classification": "DEFERRED", + "contract_location": "profiles/core-dp/README.md#epcis-and-cbv-pin", + "summary": "EPCIS 2.0.1 and CBV 2.0 are pinned only for conservative mapping fixtures; full conformance is deferred.", + "rationale": "Full EPCIS/CBV conformance requires a much larger fixture and mapping surface than a lab preview can validate; a conservative, explicitly-partial mapping avoids overclaiming standards conformance.", + "owner": "Protocol/architecture lead", + "testability_statement": "Round-trip the pinned example fixture through the mapping and confirm every field listed in unsupported-features.json is documented as lossy, not silently dropped.", + "positive_test_location": "profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json", + "implementation_status": "profile_contract", + "evidence_output": "profiles/core-dp/schemas/epcis-mapping.schema.json, profiles/core-dp/epcis/unsupported-features.json" + } + ] +} diff --git a/profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json b/profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json new file mode 100644 index 0000000..ab6e447 --- /dev/null +++ b/profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json @@ -0,0 +1,815 @@ +{ + "manifest_version": "1.0.0", + "pinned_specification": { + "path": "SPECIFICATION.md", + "version": "0.2.0" + }, + "extraction_algorithm": "core-dp-normative-v1", + "normative_statement_count": 56, + "status_enums": { + "classification": [ + "IN", + "OUT", + "DEFERRED" + ], + "implementation_status": [ + "profile_contract", + "schema_contract", + "harness_scaffold", + "out_of_scope", + "deferred" + ], + "test_status": [ + "covered", + "scaffolded", + "not_applicable", + "deferred" + ] + }, + "entries": [ + { + "id": "REQ-14_CONFORMANCE-MUST_NOT-ECE45DE39621", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "ece45de39621a96f62397bbf9dc07c8f8d33926639141a9bdb41e821772b69ef", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-SHALL_NOT-ADF0702B8EC5", + "keyword": "SHALL NOT", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "adf0702b8ec5d1358c3eed9d727edf064a5e0a52faf976c0fc0da51edf064a18", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-SHOULD_NOT-B599EC30217E", + "keyword": "SHOULD NOT", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "b599ec30217e4f60c52443d6d60e7b8dd8639e92bee098859007e13d5aa659a9", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-REQUIRED-F4AC46F0B4EA", + "keyword": "REQUIRED", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "f4ac46f0b4ea8642bdee7a548edc74ac38e46bfbf3cf2cdcaf331f201c04fc6b", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-RECOMMENDED-307E5DCA715A", + "keyword": "RECOMMENDED", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "307e5dca715a9137087df96c734e493ca6149b95fd8e560c65a397e11034d5f3", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-OPTIONAL-444BCBACCF4F", + "keyword": "OPTIONAL", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "444bcbaccf4f64245b24d2b9e6445a823aae04d3765169adbba880bba559f95c", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-MUST-D1B9715B8009", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "d1b9715b8009e6b20191b91592839a9865d5ad8ba05838de723dbd00a3ae4c63", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-SHALL-19FDB5923881", + "keyword": "SHALL", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "19fdb5923881a4d0e02563615568c6ba7636c0d279a7b41fb05afec069388921", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-SHOULD-4EB6EF8D4375", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "4eb6ef8d43755551c8e07ebf2b365cee3bc75913cc12de26d5f10857db8958cc", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-14_CONFORMANCE-MAY-5965E681AFC9", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 90, + "source_anchor": "14-conformance", + "statement_hash": "5965e681afc9bf56ab085e7bf31464ded52e13244c2860e4b3eb89baea980c3f", + "statement": "The key words \"MUST\", \"MUST NOT\", \"REQUIRED\", \"SHALL\", \"SHALL NOT\", \"SHOULD\", \"SHOULD NOT\", \"RECOMMENDED\", \"MAY\", and \"OPTIONAL\" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).", + "classification": "IN", + "rationale": "Keyword interpretation is adopted by the manifest.", + "contract_location": "profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json#", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-21_KEY_CONCEPTS-MAY-C44597011261", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 127, + "source_anchor": "21-key-concepts", + "statement_hash": "c445970112610cf2526d0e8c848b795372a8d1ef137ca5d7165f8f80a7e6253b", + "statement": "ProductDNA is a globally unique digital identity for a finished or semi-finished product (or product batch) within the LOOP protocol. It is aligned with the EU Digital Product Passport framework (ESPR Art. 9–10) and captures product category, name, condition, manufacturer, model, manufacture year, functional status, and lifecycle stage. A ProductDNA entry MAY reference zero or more MaterialDNA identifiers via a composition link (`material_ids`), enabling full traceability from product to constituent materials. ProductDNA is the product layer in LOOP's two-tier identity hierarchy; MaterialDNA is the composition layer. The same lifecycle flow (Offer, Match, Transfer) applies to both MaterialDNA and ProductDNA entities.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-21_KEY_CONCEPTS-MUST_NOT-B8651B9D4B86", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 133, + "source_anchor": "21-key-concepts", + "statement_hash": "b8651b9d4b86fc31f5dfc5fbf4ff4702fa8f24a0123dd2b6824392bf011b4d86", + "statement": "A LoopSignal is a community-expressed preference signal for material categories, published by a node. Each signal is a percentage (between 0.0 and 1.0) that represents the community's desire to retain or attract materials in a given category - higher values indicate stronger preference. LoopSignals are set through democratic voting (LoopVote) and are subject to constraints: changes MUST NOT exceed 0.10 per voting period, and signals MUST apply equally to imports and exports. LoopSignals serve as inputs to the LoopCost calculation (§7), where they determine the export and import penalty components. A LoopSignal is the preference input; LoopCost is the computed outcome.", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-21_KEY_CONCEPTS-MUST-C7B5C46254DF", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 133, + "source_anchor": "21-key-concepts", + "statement_hash": "c7b5c46254df5ad213edc6a1cd887afd49286b77a3a116c97ed002e8c315ddb0", + "statement": "A LoopSignal is a community-expressed preference signal for material categories, published by a node. Each signal is a percentage (between 0.0 and 1.0) that represents the community's desire to retain or attract materials in a given category - higher values indicate stronger preference. LoopSignals are set through democratic voting (LoopVote) and are subject to constraints: changes MUST NOT exceed 0.10 per voting period, and signals MUST apply equally to imports and exports. LoopSignals serve as inputs to the LoopCost calculation (§7), where they determine the export and import penalty components. A LoopSignal is the preference input; LoopCost is the computed outcome.", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-32_CORE_COMPONENTS-MUST-070239C18EFB", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 158, + "source_anchor": "32-core-components", + "statement_hash": "070239c18efb832a5e16c4b984c10dee5b36b6ec34104e0c84810a674058f80a", + "statement": "Each LOOP node MUST implement:", + "classification": "OUT", + "rationale": "Full LOOP, payments, transactions, or user auth are out of Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-34_DATA_FORMAT-MUST-5BA37CC68905", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 182, + "source_anchor": "34-data-format", + "statement_hash": "5ba37cc68905fd3e7a549247bce87b4a345aaf9ce69a6751bc8d02a7749c1a3e", + "statement": "All protocol messages MUST use:", + "classification": "IN", + "rationale": "Mapped to signed envelope and lab HTTPS transport.", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-REQUIRED_ENTITIES-MUST-FF20A815DD25", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 202, + "source_anchor": "required-entities", + "statement_hash": "ff20a815dd25d3dc597494c4bee7f4b1a0513b3705d0f494bb3264538bfce069", + "statement": "All payloads MUST include `@context`, `@type`, and `schema_version`.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-REQUIRED_ENTITIES-SHOULD-290D2D59F70C", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 203, + "source_anchor": "required-entities", + "statement_hash": "290d2d59f70c8c18c1cbd288ea12c7e9016daef06f5abcc00adfb14f40c983af", + "statement": "Emitters targeting the v0.2.0 baseline SHOULD set `schema_version` to `0.2.0`.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-REQUIRED_ENTITIES-SHOULD-4A858675FD46", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 204, + "source_anchor": "required-entities", + "statement_hash": "4a858675fd46561e66664e1b92cd23af8d88e491d39fdbd392bc9dcd4e45be3b", + "statement": "Receivers SHOULD accept additive minor/patch versions when unknown fields can be preserved or ignored without data loss.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-DATA_MINIMIZATION-MUST_NOT-3D6D2D1779EE", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 222, + "source_anchor": "data-minimization", + "statement_hash": "3d6d2d1779ee650999fa72b9e6f161bce8297a3973fec537a079614357df80d8", + "statement": "To remain GDPR-aligned for lab demos, payloads MUST NOT include personal data. Node identifiers, city names, and organization identifiers are permitted; emails, phone numbers, and names are not permitted in these entities.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-ADDITIVE_COMPATIBILITY_EXTENSIONS-MAY-1187ED43BE76", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 226, + "source_anchor": "additive-compatibility-extensions", + "statement_hash": "1187ed43be7607659d70f1c438af0080e11b1edcfa1cafad236e65e8221c0ce1", + "statement": "To improve forward compatibility with emerging regulatory data requirements, implementations MAY attach additive extension blocks such as `passport`, `classification`, and `traceability`.", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + }, + { + "id": "REQ-ADDITIVE_COMPATIBILITY_EXTENSIONS-MUST-8CD229ADD237", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 229, + "source_anchor": "additive-compatibility-extensions", + "statement_hash": "8cd229add237060157ca448aee0affb9a1c5621f977455ec65bb1c12cff6a5b8", + "statement": "MUST remain optional in the v0.1.1 baseline", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + }, + { + "id": "REQ-ADDITIVE_COMPATIBILITY_EXTENSIONS-MUST_NOT-67D7F8940D44", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 230, + "source_anchor": "additive-compatibility-extensions", + "statement_hash": "67d7f8940d442971feb8e5c8a1cf99d2cf55cf296594750e3e167f99ef8a7cca", + "statement": "MUST NOT weaken the data-minimization rule above", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + }, + { + "id": "REQ-ADDITIVE_COMPATIBILITY_EXTENSIONS-SHOULD-EFD9DC3A92BC", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 231, + "source_anchor": "additive-compatibility-extensions", + "statement_hash": "efd9dc3a92bc688f724108321556c336f5cb372274d1dbf09032253e59186fc6", + "statement": "SHOULD use the canonical JSON-LD context or explicit namespaced terms", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + }, + { + "id": "REQ-ADDITIVE_COMPATIBILITY_EXTENSIONS-SHOULD-1F10A5D6CD7C", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 232, + "source_anchor": "additive-compatibility-extensions", + "statement_hash": "1f10a5d6cd7c5fd8f4e70b77278dcc6da4763ef18f74e9b96a4611ba1b703a9a", + "statement": "SHOULD preserve unknown fields when relaying payloads between nodes", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + }, + { + "id": "REQ-36_ENTITY_STATUS_TRANSITIONS-MUST_NOT-C229BE2C5C73", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 246, + "source_anchor": "36-entity-status-transitions", + "statement_hash": "c229be2c5c7313b35fc9dd204e6bf1d42f8e368ea9eda98abd3e43a86a2681c2", + "statement": "An entity MUST NOT transition to a non-terminal status once it has reached a terminal state. Nodes that receive a status update contradicting this rule SHOULD reject it with a `409 Conflict` response.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-36_ENTITY_STATUS_TRANSITIONS-SHOULD-5553F88B8FBA", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 246, + "source_anchor": "36-entity-status-transitions", + "statement_hash": "5553f88b8fba1c4c01b00c16b0ad3839fdf07e0e39810d10fc974e195008353c", + "statement": "An entity MUST NOT transition to a non-terminal status once it has reached a terminal state. Nodes that receive a status update contradicting this rule SHOULD reject it with a `409 Conflict` response.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-7975F6EC6486", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 250, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "7975f6ec6486c5673672c964ca8ef38407bbc8544c1aa13a35def0543dfc2985", + "statement": "The per-entity transitions above are not independent. Creating a downstream entity is gated on, and may transition, its upstream entity. Nodes MUST enforce the following invariants:", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-FCE09F4E4818", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 252, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "fce09f4e4818e913c3277f70f5d3d9b10861a5b1e50b729fa52496010ed132a7", + "statement": "Match requires an open Offer. A Match MAY be created only against an Offer whose status is `open`. Creating an active Match (`proposed` or `accepted`) MUST transition that Offer to `reserved`. Matching a `reserved` or `withdrawn` Offer MUST be rejected with `400`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-FCE09F4E4818-2", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 252, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "fce09f4e4818e913c3277f70f5d3d9b10861a5b1e50b729fa52496010ed132a7", + "statement": "Match requires an open Offer. A Match MAY be created only against an Offer whose status is `open`. Creating an active Match (`proposed` or `accepted`) MUST transition that Offer to `reserved`. Matching a `reserved` or `withdrawn` Offer MUST be rejected with `400`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MAY-51524E029251", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 252, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "51524e0292515452e943015ac5712a212c0323aed59ca92b653a27d638800a05", + "statement": "Match requires an open Offer. A Match MAY be created only against an Offer whose status is `open`. Creating an active Match (`proposed` or `accepted`) MUST transition that Offer to `reserved`. Matching a `reserved` or `withdrawn` Offer MUST be rejected with `400`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-2B7B6ADCFCA9", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 253, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "2b7b6adcfca94087af7f7586b044bcd1d7490dacd83ff60ecae3f4d38e75f1c7", + "statement": "One active Match per Offer. At most one Match in an active state (`proposed` or `accepted`) may exist for a given Offer at any time. A concurrent or duplicate attempt MUST be rejected with `409 Conflict`. A Match reaching a terminal state (`rejected`, `expired`) releases the Offer.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-97DFEA7CADD5", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 254, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "97dfea7cadd5a3eeba4dbe4b80734aff9912f200020a378ca61519f0db1fed9d", + "statement": "Transfer requires an accepted Match. A Transfer MAY be created only against a Match whose status is `accepted`. Otherwise it MUST be rejected with `400`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MAY-D4CC25CA71AE", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 254, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "d4cc25ca71aec65658423920cde1cdbe50a845df5e8767cd3d1243d4513836f1", + "statement": "Transfer requires an accepted Match. A Transfer MAY be created only against a Match whose status is `accepted`. Otherwise it MUST be rejected with `400`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST-1A08F604C8A1", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 255, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "1a08f604c8a11e2d81fa708d325e911e06cb96c57b1b455d108ae8a2bff61840", + "statement": "One live Transfer per Match. At most one non-`cancelled` Transfer may exist for a given Match. A duplicate attempt MUST be rejected with `409 Conflict`.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-MUST_NOT-2F9E70F8FFB4", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 256, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "2f9e70f8ffb41ef84d2b8d712e8701f517ed21e80691e1527eea8111685ffdb5", + "statement": "Offer quantity is bounded by its subject. An Offer's quantity MUST NOT exceed the available quantity of the MaterialDNA it references.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-361_CROSS_ENTITY_LIFECYCLE_INVARIANTS-SHOULD-F3EC745C6AF7", + "keyword": "SHOULD", + "source_path": "SPECIFICATION.md", + "source_line": 258, + "source_anchor": "361-cross-entity-lifecycle-invariants", + "statement_hash": "f3ec745c6af7ccbc86615d120360920835e5863c817f64336da66c35308d4f4a", + "statement": "These invariants are the contract the lab reference backend enforces at the database level (partial unique indexes plus row-level locking); other implementations SHOULD enforce them equivalently to remain interoperable.", + "classification": "IN", + "rationale": "Used by Core-DP Offer -> Match -> Transfer choreography.", + "contract_location": "profiles/core-dp/README.md#two-node-authority-and-convergence", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-41_IDENTIFIER_FORMAT-MUST-A84A7B65BBC1", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 266, + "source_anchor": "41-identifier-format", + "statement_hash": "a84a7b65bbc19f32199a0114fac2d233015097e345218acf19b88e72ca5ee085", + "statement": "MaterialDNA identifiers MUST follow this pattern:", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-452_HIERARCHY-MAY-AA96A1B56540", + "keyword": "MAY", + "source_path": "SPECIFICATION.md", + "source_line": 403, + "source_anchor": "452-hierarchy", + "statement_hash": "aa96a1b5654051f08af446023d94414088e75ba6aae73216afb6d6140ecbae7d", + "statement": "A ProductDNA entry MAY reference zero or more MaterialDNA entries via `material_ids`. This composition link enables traceability from product to constituent materials.", + "classification": "IN", + "rationale": "Included for ProductDNA material_ids composition handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "covered" + }, + { + "id": "REQ-453_IDENTIFIER_FORMAT-MUST-8DBBD3768D47", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 407, + "source_anchor": "453-identifier-format", + "statement_hash": "8dbbd3768d47cfa506090898bc1e0cfb1ec05343dc589c7b65bf05747f0ffb7d", + "statement": "ProductDNA identifiers MUST follow this pattern:", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-457_OFFERMATCHTRANSFER_WITH_PRODUCTS-MUST-778B92DFD050", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 502, + "source_anchor": "457-offermatchtransfer-with-products", + "statement_hash": "778b92dfd05007e49c8ca1f7364ef3ee398fc47d13ceb5a4ff0fe32afe4e7ca9", + "statement": "The Offer, Match, and Transfer schemas accept either `material_id` or `product_id`. At least one MUST be present. This allows the same lifecycle flow (Offer -> Match -> Transfer) to work for both materials and products.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-62_SIGNAL_CONSTRAINTS-MUST-54DB062ECA46", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 622, + "source_anchor": "62-signal-constraints", + "statement_hash": "54db062eca46dba3d98909a41897d2c1979691bd3495486f27b3660207dc2ceb", + "statement": "Values MUST be between 0.0 and 1.0 (0% to 100%)", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-62_SIGNAL_CONSTRAINTS-MUST_NOT-3AF1BBADC0EB", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 623, + "source_anchor": "62-signal-constraints", + "statement_hash": "3af1bbadc0eb1372c2479df80f86813394d282115a577484291f893594321289", + "statement": "Changes MUST NOT exceed 0.10 (10%) per voting period", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-62_SIGNAL_CONSTRAINTS-MUST-EC2D5211173F", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 624, + "source_anchor": "62-signal-constraints", + "statement_hash": "ec2d5211173f2030fd733bd8e8feff476917008e4e35a5b6abd4e2101f3dd713", + "statement": "Nodes MUST publish signals publicly", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-62_SIGNAL_CONSTRAINTS-MUST-1095ACD61211", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 625, + "source_anchor": "62-signal-constraints", + "statement_hash": "1095acd612119e8870fa191ae0cc386cf1ed3e937d2abc007d2d0869a9b4a89d", + "statement": "Signals MUST apply equally to imports and exports", + "classification": "OUT", + "rationale": "LoopSignal/LoopCost/voting are outside Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-81_REQUIRED_ENDPOINTS-MUST-B4D7B41E963F", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 744, + "source_anchor": "81-required-endpoints", + "statement_hash": "b4d7b41e963f7a050560c75efd9176642c9fe6d9d7958cc4865a6c6db66020da", + "statement": "All LOOP nodes MUST implement these RESTful endpoints.", + "classification": "OUT", + "rationale": "Full LOOP, payments, transactions, or user auth are out of Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-PRODUCT_MANAGEMENT-MUST-8411ADD232B3", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 814, + "source_anchor": "product-management", + "statement_hash": "8411add232b33258bafcb581bf8db2d8e444db1c5c786e3ec70a8ae1df50a07d", + "statement": "Nodes implementing the baseline MUST expose ProductDNA using the same JSON-LD and versioning rules as MaterialDNA.", + "classification": "IN", + "rationale": "Included for MaterialDNA/ProductDNA handling.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-92_NODE_AUTHENTICATION-MUST-66F1F0D8B55D", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1060, + "source_anchor": "92-node-authentication", + "statement_hash": "66f1f0d8b55dfb4fbab3c1faafbf4a6248dece082d5b5dfbce0af10c38feedce", + "statement": "Node-to-node requests MUST include:", + "classification": "IN", + "rationale": "Mapped to signed envelope and lab HTTPS transport.", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-101_TRANSPORT_SECURITY-MUST-8BCFFBF2D578", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1089, + "source_anchor": "101-transport-security", + "statement_hash": "8bcffbf2d578b358e26b9bff178d4a7f0c50fe371b6536ecd5f992b1362a292e", + "statement": "All connections MUST use TLS 1.3 or higher", + "classification": "IN", + "rationale": "Mapped to signed envelope and lab HTTPS transport.", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-101_TRANSPORT_SECURITY-MUST-2F77ED40BC8E", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1090, + "source_anchor": "101-transport-security", + "statement_hash": "2f77ed40bc8e62517deb56fa0594c78297bd868f35eb248f75986485d3a0df8d", + "statement": "Nodes MUST validate certificates", + "classification": "IN", + "rationale": "Mapped to signed envelope and lab HTTPS transport.", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-101_TRANSPORT_SECURITY-REQUIRED-6C7C2240A269", + "keyword": "REQUIRED", + "source_path": "SPECIFICATION.md", + "source_line": 1091, + "source_anchor": "101-transport-security", + "statement_hash": "6c7c2240a26974acc4072f11d7f1214fb705a8b06646c3bfc2680f0d7dec7d32", + "statement": "Perfect Forward Secrecy REQUIRED", + "classification": "IN", + "rationale": "Mapped to signed envelope and lab HTTPS transport.", + "contract_location": "profiles/core-dp/README.md#signed-envelope", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-102_AUTHENTICATION-MUST-DFC9C0BB6D75", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1097, + "source_anchor": "102-authentication", + "statement_hash": "dfc9c0bb6d75aa17d6de137fb8a91db02f0626ebbfca5ffb436c4fa15c51671c", + "statement": "API tokens MUST expire (recommended: 1 hour)", + "classification": "OUT", + "rationale": "Full LOOP, payments, transactions, or user auth are out of Core-DP.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "out_of_scope", + "test_status": "not_applicable" + }, + { + "id": "REQ-103_DATA_PRIVACY-MUST_NOT-89BA34B57104", + "keyword": "MUST NOT", + "source_path": "SPECIFICATION.md", + "source_line": 1101, + "source_anchor": "103-data-privacy", + "statement_hash": "89ba34b57104f6a5837879d4f519ca442cff5aa0ccea4abedcf847d31a75cc9a", + "statement": "Personal data MUST NOT be included in MaterialDNA", + "classification": "IN", + "rationale": "Carried into the lab-only profile where applicable.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-103_DATA_PRIVACY-MUST-498998D417E3", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1103, + "source_anchor": "103-data-privacy", + "statement_hash": "498998d417e369ad110f86a7147bb39914a7d785a531f1ea7d98d080317e6866", + "statement": "Nodes MUST comply with local privacy laws (GDPR, etc.)", + "classification": "IN", + "rationale": "Carried into the lab-only profile where applicable.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "profile_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-105_AUDIT_TRAIL-MUST-EA473379C41F", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1115, + "source_anchor": "105-audit-trail", + "statement_hash": "ea473379c41fbc35d332490aef80f6e1091047763ac41c04e104f6a2e32c0db1", + "statement": "Nodes MUST maintain immutable logs of:", + "classification": "IN", + "rationale": "Mapped to append-only evidence.", + "contract_location": "profiles/core-dp/README.md#evidence", + "implementation_status": "schema_contract", + "test_status": "scaffolded" + }, + { + "id": "REQ-114_TESTING-MUST-6589C976930E", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1152, + "source_anchor": "114-testing", + "statement_hash": "6589c976930e4966c6a77073403dd381829dbe00b1e98e161b73494af8867e22", + "statement": "Test suite MUST cover:", + "classification": "IN", + "rationale": "Covered by conformance harness scaffold.", + "contract_location": "profiles/core-dp/conformance/README.md", + "implementation_status": "harness_scaffold", + "test_status": "covered" + }, + { + "id": "REQ-APPENDIX_A_JSON_LD_CONTEXT-MUST-836FA4E4A387", + "keyword": "MUST", + "source_path": "SPECIFICATION.md", + "source_line": 1367, + "source_anchor": "appendix-a-json-ld-context", + "statement_hash": "836fa4e4a387dc6f6ca01368f59a9b6af925f36a8ec317ccb9121f966d99e850", + "statement": "Implementations MUST treat the published context files as the source of truth.", + "classification": "DEFERRED", + "rationale": "DPP/VC/DID/additive extension conformance is deferred.", + "contract_location": "profiles/core-dp/README.md#scope-classification", + "implementation_status": "deferred", + "test_status": "deferred" + } + ] +} diff --git a/profiles/core-dp/schemas/choreography-message.schema.json b/profiles/core-dp/schemas/choreography-message.schema.json new file mode 100644 index 0000000..91fe713 --- /dev/null +++ b/profiles/core-dp/schemas/choreography-message.schema.json @@ -0,0 +1,395 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "title": "Core-DP Choreography Message", + "type": "object", + "additionalProperties": false, + "required": [ + "conversation_id", + "message_type", + "origin_node", + "counterparty_node", + "authoritative_role", + "subject", + "state", + "terminal", + "attempt", + "timeout_at" + ], + "properties": { + "conversation_id": { + "type": "string", + "pattern": "^conv_[A-Za-z0-9_-]{16,80}$" + }, + "message_type": { + "type": "string", + "enum": [ + "capabilities.announce", + "capabilities.response", + "offer.publish", + "offer.ack", + "offer.reject", + "match.propose", + "match.accept", + "match.reject", + "transfer.dispatch", + "transfer.receive", + "transfer.ack" + ] + }, + "origin_node": { "type": "string", "minLength": 3 }, + "counterparty_node": { "type": "string", "minLength": 3 }, + "authoritative_role": { + "type": "string", + "enum": ["origin", "counterparty"] + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["capabilities", "material", "product", "offer", "match", "transfer"] + }, + "id": { "type": "string", "minLength": 1 } + } + }, + "previous_state": { "type": "string", "minLength": 1 }, + "state": { + "type": "string", + "enum": [ + "announced", + "capabilities-confirmed", + "offer-published", + "offer-acked", + "offer-rejected", + "match-proposed", + "match-accepted", + "match-rejected", + "transfer-dispatched", + "transfer-received", + "transfer-acked", + "timed-out", + "partitioned" + ] + }, + "terminal": { "type": "boolean" }, + "attempt": { "type": "integer", "minimum": 1, "maximum": 5 }, + "idempotency_key": { + "type": "string", + "pattern": "^idem_[A-Za-z0-9_-]{16,120}$" + }, + "duplicate_of": { "type": "string", "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" }, + "duplicate_behavior": { + "type": "string", + "enum": ["stored_result", "conflict"] + }, + "original_fingerprint_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "idempotency_fingerprint_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "local_state": { "type": "string", "minLength": 1 }, + "timeout_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "maxLength": 500 }, + "accepted_profile_version": { "type": "string", "pattern": "^0\\.\\d+\\.\\d+(-lab)?$" }, + "last_accepted_message_id": { + "type": "string", + "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" + }, + "local_evidence_hash_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "peer_evidence_hash_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "convergence_hash_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "acknowledges_message_id": { + "type": "string", + "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" + }, + "final_ack_role": { + "type": "string", + "enum": ["origin-confirms-receiver-receipt"] + } + }, + "allOf": [ + { + "if": { "properties": { "state": { "enum": ["announced", "offer-published", "match-proposed", "transfer-dispatched", "transfer-received"] } }, "required": ["state"] }, + "then": { "properties": { "terminal": { "const": false } }, "required": ["terminal"] } + }, + { + "if": { "properties": { "state": { "enum": ["capabilities-confirmed", "offer-acked", "offer-rejected", "match-accepted", "match-rejected", "transfer-acked", "timed-out", "partitioned"] } }, "required": ["state"] }, + "then": { "properties": { "terminal": { "const": true } }, "required": ["terminal"] } + }, + { + "if": { "properties": { "message_type": { "const": "capabilities.announce" } }, "required": ["message_type"] }, + "then": { + "properties": { + "subject": { "properties": { "type": { "const": "capabilities" } }, "required": ["type"] }, + "state": { "const": "announced" }, + "terminal": { "const": false }, + "authoritative_role": { "const": "origin" } + }, + "not": { "required": ["previous_state"] } + } + }, + { + "if": { "properties": { "message_type": { "const": "capabilities.response" } }, "required": ["message_type"] }, + "then": { + "required": ["previous_state", "accepted_profile_version"], + "properties": { + "subject": { "properties": { "type": { "const": "capabilities" } }, "required": ["type"] }, + "previous_state": { "const": "announced" }, + "state": { "const": "capabilities-confirmed" }, + "terminal": { "const": true }, + "authoritative_role": { "const": "counterparty" }, + "accepted_profile_version": { "const": "0.1.0-lab" } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "offer.publish" } }, "required": ["message_type"] }, + "then": { + "properties": { + "subject": { "properties": { "type": { "const": "offer" } }, "required": ["type"] }, + "previous_state": { "const": "offer-published" }, + "state": { "enum": ["offer-published", "timed-out", "partitioned"] }, + "terminal": { "enum": [false, true] }, + "authoritative_role": { "const": "origin" } + }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "offer-published" } }, "required": ["state"] }, + "then": { + "properties": { "terminal": { "const": false } }, + "not": { "required": ["previous_state"] } + } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "enum": ["offer.ack", "offer.reject"] } }, "required": ["message_type"] }, + "then": { + "required": ["previous_state"], + "properties": { + "subject": { "properties": { "type": { "const": "offer" } }, "required": ["type"] }, + "previous_state": { "enum": ["offer-published", "offer-acked", "offer-rejected"] }, + "terminal": { "const": true }, + "authoritative_role": { "const": "counterparty" } + }, + "allOf": [ + { + "if": { "properties": { "message_type": { "const": "offer.ack" }, "state": { "enum": ["offer-acked", "timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "state": { "enum": ["offer-acked", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.ack" }, "state": { "const": "offer-acked" } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "offer-published" } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.ack" }, "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "offer-acked" } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.reject" }, "state": { "enum": ["offer-rejected", "timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "state": { "enum": ["offer-rejected", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.reject" }, "state": { "const": "offer-rejected" } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "offer-published" } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.reject" }, "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "offer-rejected" } } } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "const": "match.propose" } }, "required": ["message_type"] }, + "then": { + "properties": { + "subject": { "properties": { "type": { "const": "match" } }, "required": ["type"] }, + "previous_state": { "const": "match-proposed" }, + "state": { "enum": ["match-proposed", "timed-out", "partitioned"] }, + "terminal": { "enum": [false, true] }, + "authoritative_role": { "const": "origin" } + }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "match-proposed" } }, "required": ["state"] }, + "then": { + "properties": { "terminal": { "const": false } }, + "not": { "required": ["previous_state"] } + } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "enum": ["match.accept", "match.reject"] } }, "required": ["message_type"] }, + "then": { + "required": ["previous_state"], + "properties": { + "subject": { "properties": { "type": { "const": "match" } }, "required": ["type"] }, + "previous_state": { "enum": ["match-proposed", "match-accepted", "match-rejected"] }, + "terminal": { "const": true }, + "authoritative_role": { "const": "counterparty" } + }, + "allOf": [ + { + "if": { "properties": { "message_type": { "const": "match.accept" }, "state": { "enum": ["match-accepted", "timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "state": { "enum": ["match-accepted", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.accept" }, "state": { "const": "match-accepted" } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "match-proposed" } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.accept" }, "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "match-accepted" } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.reject" }, "state": { "enum": ["match-rejected", "timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "state": { "enum": ["match-rejected", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.reject" }, "state": { "const": "match-rejected" } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "match-proposed" } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.reject" }, "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["message_type", "state"] }, + "then": { "properties": { "previous_state": { "const": "match-rejected" } } } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.dispatch" } }, "required": ["message_type"] }, + "then": { + "properties": { + "subject": { "properties": { "type": { "const": "transfer" } }, "required": ["type"] }, + "previous_state": { "const": "transfer-dispatched" }, + "state": { "enum": ["transfer-dispatched", "timed-out", "partitioned"] }, + "terminal": { "enum": [false, true] }, + "authoritative_role": { "const": "origin" } + }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "transfer-dispatched" } }, "required": ["state"] }, + "then": { + "properties": { "terminal": { "const": false } }, + "not": { "required": ["previous_state"] } + } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.receive" } }, "required": ["message_type"] }, + "then": { + "required": ["previous_state"], + "properties": { + "subject": { "properties": { "type": { "const": "transfer" } }, "required": ["type"] }, + "previous_state": { "enum": ["transfer-dispatched", "transfer-received"] }, + "state": { "enum": ["transfer-received", "timed-out", "partitioned"] }, + "authoritative_role": { "const": "counterparty" } + }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "transfer-received" } }, "required": ["state"] }, + "then": { + "properties": { + "previous_state": { "const": "transfer-dispatched" }, + "terminal": { "const": false } + } + } + }, + { + "if": { "properties": { "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["state"] }, + "then": { "properties": { "previous_state": { "const": "transfer-received" } } } + }, + { "$ref": "#/$defs/recoveryOverlay" } + ] + } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.ack" } }, "required": ["message_type"] }, + "then": { + "required": ["previous_state", "acknowledges_message_id", "final_ack_role"], + "properties": { + "subject": { "properties": { "type": { "const": "transfer" } }, "required": ["type"] }, + "previous_state": { "const": "transfer-received" }, + "state": { "const": "transfer-acked" }, + "terminal": { "const": true }, + "authoritative_role": { "const": "origin" }, + "final_ack_role": { "const": "origin-confirms-receiver-receipt" } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "offer.ack" } }, "required": ["message_type"] }, + "then": { "properties": { "state": { "enum": ["offer-acked", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.reject" } }, "required": ["message_type"] }, + "then": { "properties": { "state": { "enum": ["offer-rejected", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.accept" } }, "required": ["message_type"] }, + "then": { "properties": { "state": { "enum": ["match-accepted", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.reject" } }, "required": ["message_type"] }, + "then": { "properties": { "state": { "enum": ["match-rejected", "timed-out", "partitioned"] } } } + }, + { + "if": { "properties": { "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["state"] }, + "then": { + "required": ["previous_state", "last_accepted_message_id"], + "properties": { "terminal": { "const": true } }, + "not": { + "anyOf": [ + { "required": ["duplicate_of"] }, + { "required": ["duplicate_behavior"] }, + { "required": ["original_fingerprint_sha256"] }, + { "required": ["idempotency_fingerprint_sha256"] } + ] + } + } + } + ], + "$defs": { + "recoveryOverlay": { + "if": { "properties": { "state": { "enum": ["timed-out", "partitioned"] } }, "required": ["state"] }, + "then": { + "required": ["previous_state", "last_accepted_message_id"], + "properties": { "terminal": { "const": true } }, + "not": { + "anyOf": [ + { "required": ["duplicate_of"] }, + { "required": ["duplicate_behavior"] }, + { "required": ["original_fingerprint_sha256"] }, + { "required": ["idempotency_fingerprint_sha256"] } + ] + } + } + } + } +} diff --git a/profiles/core-dp/schemas/dna-operation.schema.json b/profiles/core-dp/schemas/dna-operation.schema.json new file mode 100644 index 0000000..10a5ca0 --- /dev/null +++ b/profiles/core-dp/schemas/dna-operation.schema.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json", + "title": "Core-DP DNA Register/Read Operation", + "description": "Minimal Core-DP MaterialDNA/ProductDNA register/read request and result contract. Register records compose the pinned LOOP v0.2.0 DNA schemas; DPP/VC/DID legal profile validation remains outside this profile.", + "type": "object", + "additionalProperties": false, + "required": ["message_type", "operation", "entity_type", "request_id"], + "properties": { + "message_type": { + "type": "string", + "enum": ["material.register", "material.read", "product.register", "product.read"] + }, + "operation": { + "type": "string", + "enum": ["register_request", "register_result", "read_request", "read_result"] + }, + "entity_type": { + "type": "string", + "enum": ["material", "product"] + }, + "request_id": { + "type": "string", + "pattern": "^req_[A-Za-z0-9_-]{16,80}$" + }, + "record_id": { + "type": "string", + "minLength": 1 + }, + "record": { + "type": "object" + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["status", "provenance"], + "properties": { + "status": { + "type": "string", + "enum": ["registered", "found", "not_found", "invalid", "conflict", "error"] + }, + "provenance": { "$ref": "#/$defs/resultProvenance" }, + "errors": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json" + } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "message_type": { "const": "material.register" } }, "required": ["message_type"] }, + "then": { + "properties": { + "operation": { "enum": ["register_request", "register_result"] }, + "entity_type": { "const": "material" }, + "record_id": { "$ref": "#/$defs/materialId" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/material-dna.schema.json" + } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "material.read" } }, "required": ["message_type"] }, + "then": { + "properties": { + "operation": { "enum": ["read_request", "read_result"] }, + "entity_type": { "const": "material" }, + "record_id": { "$ref": "#/$defs/materialId" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/material-dna.schema.json" + } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "product.register" } }, "required": ["message_type"] }, + "then": { + "properties": { + "operation": { "enum": ["register_request", "register_result"] }, + "entity_type": { "const": "product" }, + "record_id": { "$ref": "#/$defs/productId" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/product-dna.schema.json" + } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "product.read" } }, "required": ["message_type"] }, + "then": { + "properties": { + "operation": { "enum": ["read_request", "read_result"] }, + "entity_type": { "const": "product" }, + "record_id": { "$ref": "#/$defs/productId" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/product-dna.schema.json" + } + } + } + } + ], + "oneOf": [ + { "$ref": "#/$defs/materialRegisterRequest" }, + { "$ref": "#/$defs/productRegisterRequest" }, + { "$ref": "#/$defs/readRequest" }, + { "$ref": "#/$defs/registerResult" }, + { "$ref": "#/$defs/readResult" } + ], + "$defs": { + "materialId": { + "type": "string", + "pattern": "^MAT-[A-Z]{2}-[A-Z]{3}-\\d{4}-[A-Z]+-[A-Z0-9]{6,}$" + }, + "productId": { + "type": "string", + "pattern": "^PRD-[A-Z0-9-]{8,}$" + }, + "resultProvenance": { + "type": "object", + "additionalProperties": false, + "required": ["source_node", "record_hash_sha256", "recorded_at"], + "properties": { + "source_node": { + "type": "string", + "minLength": 3 + }, + "record_hash_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "evidence_event_id": { + "type": "string", + "pattern": "^evt_[A-Za-z0-9_-]{16,80}$" + }, + "envelope_message_id": { + "type": "string", + "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" + }, + "recorded_at": { + "type": "string", + "format": "date-time" + } + } + }, + "materialRegisterRequest": { + "type": "object", + "required": ["message_type", "operation", "entity_type", "request_id", "record"], + "properties": { + "message_type": { "const": "material.register" }, + "operation": { "const": "register_request" }, + "entity_type": { "const": "material" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/material-dna.schema.json" + } + } + }, + "productRegisterRequest": { + "type": "object", + "required": ["message_type", "operation", "entity_type", "request_id", "record"], + "properties": { + "message_type": { "const": "product.register" }, + "operation": { "const": "register_request" }, + "entity_type": { "const": "product" }, + "record": { + "$ref": "https://localloop.urbnia.com/projects/loop-protocol/schemas/v0.2.0/product-dna.schema.json" + } + } + }, + "readRequest": { + "type": "object", + "required": ["message_type", "operation", "entity_type", "request_id", "record_id"], + "properties": { + "operation": { "const": "read_request" }, + "record_id": { + "type": "string", + "pattern": "^(MAT-[A-Z]{2}-[A-Z]{3}-\\d{4}-[A-Z]+-[A-Z0-9]{6,}|PRD-[A-Z0-9-]{8,})$" + } + }, + "not": { "required": ["record"] } + }, + "registerResult": { + "type": "object", + "required": ["message_type", "operation", "entity_type", "request_id", "record_id", "result"], + "properties": { + "operation": { "const": "register_result" }, + "result": { + "properties": { + "status": { + "enum": ["registered", "invalid", "conflict", "error"] + } + } + } + } + }, + "readResult": { + "type": "object", + "required": ["message_type", "operation", "entity_type", "request_id", "record_id", "result"], + "properties": { + "operation": { "const": "read_result" }, + "result": { + "properties": { + "status": { + "enum": ["found", "not_found", "error"] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "result": { + "properties": { "status": { "const": "found" } }, + "required": ["status"] + } + }, + "required": ["result"] + }, + "then": { "required": ["record"] } + }, + { + "if": { + "properties": { + "result": { + "properties": { "status": { "enum": ["not_found", "error"] } }, + "required": ["status"] + } + }, + "required": ["result"] + }, + "then": { "not": { "required": ["record"] } } + } + ] + } + } +} diff --git a/profiles/core-dp/schemas/envelope.schema.json b/profiles/core-dp/schemas/envelope.schema.json new file mode 100644 index 0000000..8cadf79 --- /dev/null +++ b/profiles/core-dp/schemas/envelope.schema.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/envelope.schema.json", + "title": "Core-DP Signed Message Envelope", + "description": "LAB-ONLY Core-DP envelope for deterministic two-node delivery. signature.value is a detached Ed25519 signature over the documented canonical UTF-8 JSON signing input. This is not a production security claim.", + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "profile_version", + "message_id", + "message_type", + "created_at", + "expires_at", + "sender", + "receiver", + "idempotency_key", + "replay_window_seconds", + "body_schema", + "body", + "signature" + ], + "properties": { + "profile": { "const": "core-dp" }, + "profile_version": { "const": "0.1.0-lab" }, + "message_id": { + "type": "string", + "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" + }, + "message_type": { + "type": "string", + "enum": [ + "capabilities.announce", + "capabilities.response", + "material.register", + "material.read", + "material.search", + "product.register", + "product.read", + "product.search", + "offer.publish", + "offer.ack", + "offer.reject", + "match.propose", + "match.accept", + "match.reject", + "transfer.dispatch", + "transfer.receive", + "transfer.ack", + "error" + ] + }, + "created_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" }, + "sender": { "$ref": "#/$defs/nodeRef" }, + "receiver": { "$ref": "#/$defs/nodeRef" }, + "idempotency_key": { + "type": "string", + "pattern": "^idem_[A-Za-z0-9_-]{16,120}$" + }, + "replay_window_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 900, + "default": 300 + }, + "version_negotiation": { + "type": "object", + "additionalProperties": false, + "required": ["requested", "supported"], + "properties": { + "requested": { "const": "0.1.0-lab" }, + "supported": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^0\\.\\d+\\.\\d+(-lab)?$" }, + "contains": { "const": "0.1.0-lab" } + } + } + }, + "body_schema": { + "type": "string", + "enum": [ + "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json", + "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json", + "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json", + "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json" + ] + }, + "body": { "type": "object" }, + "signature": { + "type": "object", + "additionalProperties": false, + "required": [ + "alg", + "key_id", + "signing_input_sha256", + "value" + ], + "properties": { + "alg": { + "const": "Ed25519-lab-detached-v1", + "description": "Detached Ed25519 signature over the canonical UTF-8 JSON signing input." + }, + "key_id": { + "type": "string", + "description": "MUST match sender.key_id.", + "pattern": "^key_[A-Za-z0-9_.:-]{8,120}$" + }, + "signing_input_sha256": { + "type": "string", + "description": "Diagnostic SHA-256 of the canonical UTF-8 JSON signing input; implementations verify this and the detached signature.", + "pattern": "^[a-f0-9]{64}$" + }, + "value": { + "type": "string", + "description": "Base64url-encoded 64-byte detached Ed25519 signature.", + "pattern": "^[A-Za-z0-9_-]{86}$" + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "message_type": { "enum": ["capabilities.announce", "capabilities.response", "offer.publish", "offer.ack", "offer.reject", "match.propose", "match.accept", "match.reject", "transfer.dispatch", "transfer.receive", "transfer.ack"] } }, "required": ["message_type"] }, + "then": { + "properties": { + "body_schema": { "const": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json" }, + "body": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json" } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "capabilities.announce" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "capabilities.announce" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "capabilities.response" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "capabilities.response" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.publish" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "offer.publish" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.ack" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "offer.ack" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "offer.reject" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "offer.reject" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.propose" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "match.propose" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.accept" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "match.accept" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "match.reject" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "match.reject" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.dispatch" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "transfer.dispatch" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.receive" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "transfer.receive" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "transfer.ack" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "transfer.ack" } }, "required": ["message_type"] } } } + }, + { + "if": { "properties": { "message_type": { "enum": ["material.register", "material.read", "product.register", "product.read"] } }, "required": ["message_type"] }, + "then": { + "properties": { + "body_schema": { "const": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json" }, + "body": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json" } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "material.register" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "material.register" }, "entity_type": { "const": "material" } }, "required": ["message_type", "entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "material.read" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "material.read" }, "entity_type": { "const": "material" } }, "required": ["message_type", "entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "product.register" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "product.register" }, "entity_type": { "const": "product" } }, "required": ["message_type", "entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "product.read" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "message_type": { "const": "product.read" }, "entity_type": { "const": "product" } }, "required": ["message_type", "entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "enum": ["material.search", "product.search"] } }, "required": ["message_type"] }, + "then": { + "properties": { + "body_schema": { "const": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json" }, + "body": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json" } + } + } + }, + { + "if": { "properties": { "message_type": { "const": "material.search" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "entity_type": { "const": "material" } }, "required": ["entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "product.search" } }, "required": ["message_type"] }, + "then": { "properties": { "body": { "properties": { "entity_type": { "const": "product" } }, "required": ["entity_type"] } } } + }, + { + "if": { "properties": { "message_type": { "const": "error" } }, "required": ["message_type"] }, + "then": { + "properties": { + "body_schema": { "const": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json" }, + "body": { "$ref": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json" } + } + } + } + ], + "$defs": { + "nodeRef": { + "type": "object", + "additionalProperties": false, + "required": ["node_id", "endpoint", "key_id"], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]{2,120}$" + }, + "endpoint": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "key_id": { + "type": "string", + "pattern": "^key_[A-Za-z0-9_.:-]{8,120}$" + } + } + } + } +} \ No newline at end of file diff --git a/profiles/core-dp/schemas/epcis-mapping.schema.json b/profiles/core-dp/schemas/epcis-mapping.schema.json new file mode 100644 index 0000000..281974f --- /dev/null +++ b/profiles/core-dp/schemas/epcis-mapping.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/epcis-mapping.schema.json", + "title": "Core-DP EPCIS/CBV Mapping Metadata", + "description": "Profile-local metadata for conservative EPCIS 2.0.1 and CBV 2.0 fixture mapping. This is not full EPCIS conformance.", + "oneOf": [ + { "$ref": "#/$defs/supportedFixtureMapping" }, + { "$ref": "#/$defs/unsupportedFeatureMapping" } + ], + "$defs": { + "pinnedVersions": { + "type": "object", + "additionalProperties": false, + "required": ["epcis", "cbv"], + "properties": { + "epcis": { "const": "2.0.1" }, + "cbv": { "const": "2.0" } + } + }, + "supportedFixtureMapping": { + "type": "object", + "additionalProperties": false, + "required": ["fixture_id", "pinned_versions", "core_dp_mapping", "epcis_subset_event"], + "properties": { + "fixture_id": { "type": "string", "minLength": 1 }, + "pinned_versions": { "$ref": "#/$defs/pinnedVersions" }, + "core_dp_mapping": { + "type": "object", + "additionalProperties": false, + "required": ["evidence_event_type", "subject_type", "subject_id"], + "properties": { + "evidence_event_type": { + "type": "string", + "enum": ["registered", "offer-published", "match-accepted", "transfer-dispatched", "transfer-received", "transfer-acked"] + }, + "subject_type": { + "type": "string", + "enum": ["material", "product", "offer", "match", "transfer"] + }, + "subject_id": { "type": "string", "minLength": 1 } + } + }, + "epcis_subset_event": { + "type": "object", + "additionalProperties": true, + "required": ["type", "eventID", "eventTime", "action", "bizStep", "epcList"], + "properties": { + "type": { "const": "ObjectEvent" }, + "eventID": { "type": "string", "pattern": "^ni:///sha-256;[a-f0-9]{64}$" }, + "eventTime": { "type": "string", "format": "date-time" }, + "action": { "type": "string", "enum": ["ADD", "OBSERVE"] }, + "bizStep": { "type": "string", "enum": ["commissioning", "shipping", "receiving", "accepting"] }, + "epcList": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^urn:loop:(material|product|offer|match|transfer):" } + } + } + } + } + }, + "unsupportedFeatureMapping": { + "type": "object", + "additionalProperties": false, + "required": ["pinned_versions", "full_epcis_conformance_claimed", "unsupported_features"], + "properties": { + "pinned_versions": { "$ref": "#/$defs/pinnedVersions" }, + "full_epcis_conformance_claimed": { "const": false }, + "unsupported_features": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["feature", "rationale"], + "properties": { + "feature": { "type": "string", "minLength": 1 }, + "rationale": { "type": "string", "minLength": 1 } + } + } + } + } + } + } +} diff --git a/profiles/core-dp/schemas/error.schema.json b/profiles/core-dp/schemas/error.schema.json new file mode 100644 index 0000000..e37a113 --- /dev/null +++ b/profiles/core-dp/schemas/error.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json", + "title": "Core-DP Error", + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "retryable", "correlation_id"], + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_request", + "unauthorized", + "forbidden", + "not_found", + "conflict", + "duplicate", + "stale_message", + "unsupported_profile_version", + "signature_invalid", + "schema_invalid", + "timeout", + "partition", + "internal_error" + ] + }, + "message": { "type": "string", "minLength": 1, "maxLength": 500 }, + "retryable": { "type": "boolean" }, + "correlation_id": { "type": "string", "pattern": "^(msg|conv)_[A-Za-z0-9_-]{16,80}$" }, + "details": { "type": "object", "additionalProperties": true } + } +} diff --git a/profiles/core-dp/schemas/evidence-entry.schema.json b/profiles/core-dp/schemas/evidence-entry.schema.json new file mode 100644 index 0000000..8ee2af3 --- /dev/null +++ b/profiles/core-dp/schemas/evidence-entry.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/evidence-entry.schema.json", + "title": "Core-DP Append-Only Evidence Entry", + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "sequence", + "recorded_at", + "node_id", + "subject", + "event_type", + "immutable", + "payload_hash_sha256", + "retention" + ], + "properties": { + "event_id": { "type": "string", "pattern": "^evt_[A-Za-z0-9_-]{16,80}$" }, + "sequence": { "type": "integer", "minimum": 1 }, + "predecessor_event_id": { "type": "string", "pattern": "^evt_[A-Za-z0-9_-]{16,80}$" }, + "recorded_at": { "type": "string", "format": "date-time" }, + "observed_at": { "type": "string", "format": "date-time" }, + "node_id": { "type": "string", "minLength": 3 }, + "actor_node_id": { "type": "string", "minLength": 3 }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["type", "id"], + "properties": { + "type": { + "type": "string", + "enum": ["material", "product", "offer", "match", "transfer", "envelope"] + }, + "id": { "type": "string", "minLength": 1 } + } + }, + "event_type": { + "type": "string", + "enum": [ + "registered", + "read", + "searched", + "offer-published", + "offer-acked", + "offer-rejected", + "match-proposed", + "match-accepted", + "match-rejected", + "transfer-dispatched", + "transfer-received", + "transfer-acked", + "error-recorded", + "key-rotated" + ] + }, + "immutable": { + "type": "object", + "additionalProperties": false, + "required": ["event_id", "sequence", "subject", "event_type", "payload_hash_sha256"], + "properties": { + "event_id": { "type": "string" }, + "sequence": { "type": "integer" }, + "subject": { "type": "object" }, + "event_type": { "type": "string" }, + "payload_hash_sha256": { "type": "string" } + } + }, + "payload_hash_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "envelope_message_id": { "type": "string", "pattern": "^msg_[A-Za-z0-9_-]{16,80}$" }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": ["retain_until", "exportable", "redaction_status"], + "properties": { + "retain_until": { "type": "string", "format": "date-time" }, + "exportable": { "type": "boolean" }, + "redaction_status": { "type": "string", "enum": ["none", "redacted", "tombstoned"] }, + "redaction_reason": { "type": "string", "maxLength": 500 } + } + }, + "redactions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field", "method"], + "properties": { + "field": { "type": "string" }, + "method": { "type": "string", "enum": ["remove", "hash", "generalize"] } + } + } + } + } +} diff --git a/profiles/core-dp/schemas/peer-key-trust.schema.json b/profiles/core-dp/schemas/peer-key-trust.schema.json new file mode 100644 index 0000000..dd5d67a --- /dev/null +++ b/profiles/core-dp/schemas/peer-key-trust.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/peer-key-trust.schema.json", + "title": "Core-DP Peer Key Trust Entry", + "description": "LAB-ONLY Receiver-side accepted-peer key trust fixture for deterministic Ed25519 verification with active/revoked lifecycle checks.", + "type": "object", + "additionalProperties": false, + "required": ["node_id", "key_id", "public_key_jwk", "lifecycle_status"], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]{2,120}$" + }, + "key_id": { + "type": "string", + "pattern": "^key_[A-Za-z0-9_.:-]{8,120}$" + }, + "public_key_jwk": { + "type": "object", + "additionalProperties": false, + "required": ["kty", "crv", "x"], + "properties": { + "kty": { "const": "OKP" }, + "crv": { "const": "Ed25519" }, + "x": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + } + }, + "lifecycle_status": { + "type": "string", + "enum": ["active", "revoked"] + }, + "revoked_at": { + "type": "string", + "format": "date-time" + }, + "trusted_since": { + "type": "string", + "format": "date-time" + } + }, + "allOf": [ + { + "if": { "properties": { "lifecycle_status": { "const": "revoked" } }, "required": ["lifecycle_status"] }, + "then": { "required": ["revoked_at"] } + } + ] +} diff --git a/profiles/core-dp/schemas/search-contract.schema.json b/profiles/core-dp/schemas/search-contract.schema.json new file mode 100644 index 0000000..11196bf --- /dev/null +++ b/profiles/core-dp/schemas/search-contract.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json", + "title": "Core-DP Local and Cross-Node Search Contract", + "oneOf": [ + { "$ref": "#/$defs/searchRequest" }, + { "$ref": "#/$defs/searchResponse" } + ], + "$defs": { + "searchRequest": { + "type": "object", + "additionalProperties": false, + "required": ["entity_type", "filters", "limit"], + "properties": { + "entity_type": { "type": "string", "enum": ["material", "product"] }, + "scope": { "type": "string", "enum": ["local", "cross-node"], "default": "local" }, + "filters": { + "type": "object", + "additionalProperties": false, + "properties": { + "category_prefix": { "type": "string", "minLength": 1 }, + "id_prefix": { "type": "string", "minLength": 3 }, + "origin_city": { "type": "string", "minLength": 1 }, + "current_city": { "type": "string", "minLength": 1 }, + "available_from_gte": { "type": "string", "format": "date-time" }, + "available_from_lt": { "type": "string", "format": "date-time" }, + "quantity_min": { "type": "number", "minimum": 0 }, + "condition": { "type": "string", "minLength": 1 }, + "updated_since": { "type": "string", "format": "date-time" } + } + }, + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { "type": "string", "enum": ["public-lab", "bearer", "node-signature"] }, + "subject_node": { "type": "string" } + } + }, + "strict_filtering": { + "type": "boolean", + "default": false + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, + "cursor": { + "type": "string", + "pattern": "^cur_[A-Za-z0-9_-]{16,200}$" + }, + "consistency": { + "type": "string", + "enum": ["snapshot", "eventual"], + "default": "snapshot" + } + }, + "allOf": [ + { + "if": { "properties": { "scope": { "const": "cross-node" } }, "required": ["scope"] }, + "then": { + "required": ["auth", "consistency"], + "properties": { + "auth": { + "required": ["mode"], + "properties": { + "mode": { "const": "node-signature" } + } + }, + "consistency": { "const": "eventual" } + } + } + }, + { + "if": { "properties": { "scope": { "const": "local" } }, "required": ["scope"] }, + "then": { + "properties": { + "auth": { + "properties": { + "mode": { "enum": ["public-lab", "bearer", "node-signature"] } + } + } + } + } + } + ] + }, + "searchResponse": { + "type": "object", + "additionalProperties": false, + "required": ["entity_type", "results", "ordering", "consistency", "provenance"], + "properties": { + "entity_type": { "type": "string", "enum": ["material", "product"] }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["id", "source_node", "record_hash_sha256", "updated_at"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source_node": { "type": "string", "minLength": 3 }, + "record_hash_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "updated_at": { "type": "string", "format": "date-time" } + } + } + }, + "ordering": { + "type": "object", + "additionalProperties": false, + "required": ["primary", "tie_break"], + "properties": { + "primary": { "const": "updated_at_asc" }, + "tie_break": { "const": "id_asc" } + } + }, + "next_cursor": { + "type": "string", + "pattern": "^cur_[A-Za-z0-9_-]{16,200}$" + }, + "consistency": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { "type": "string", "enum": ["snapshot", "eventual"] }, + "snapshot_id": { "type": "string", "pattern": "^snap_[A-Za-z0-9_-]{8,80}$" }, + "as_of": { "type": "string", "format": "date-time" } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["queried_nodes"], + "properties": { + "queried_nodes": { + "type": "array", + "items": { "type": "string", "minLength": 3 } + }, + "omitted_nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["node_id", "reason"], + "properties": { + "node_id": { "type": "string" }, + "reason": { "type": "string" } + } + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "consistency": { + "properties": { "mode": { "const": "snapshot" } }, + "required": ["mode"] + } + }, + "required": ["consistency"] + }, + "then": { + "properties": { + "consistency": { + "required": ["snapshot_id"] + } + } + } + } + ] + } + } +} diff --git a/profiles/core-dp/schemas/trust-store.schema.json b/profiles/core-dp/schemas/trust-store.schema.json new file mode 100644 index 0000000..d8550bc --- /dev/null +++ b/profiles/core-dp/schemas/trust-store.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/trust-store.schema.json", + "title": "Core-DP Accepted Peer Keys Trust Store", + "description": "External receiver-owned trust store containing public keys with lifecycle intervals and status for active, rotated, and revoked entries. Public keys only; no private key material.", + "type": "object", + "additionalProperties": false, + "required": ["version", "updated_at", "keys"], + "properties": { + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "updated_at": { "type": "string", "format": "date-time" }, + "keys": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["node_id", "key_id", "public_key_jwk", "lifecycle_status", "valid_from", "valid_until"], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]{2,120}$" + }, + "key_id": { + "type": "string", + "pattern": "^key_[A-Za-z0-9_.:-]{8,120}$" + }, + "public_key_jwk": { + "type": "object", + "additionalProperties": false, + "required": ["kty", "crv", "x"], + "properties": { + "kty": { "const": "OKP" }, + "crv": { "const": "Ed25519" }, + "x": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" } + } + }, + "lifecycle_status": { + "type": "string", + "enum": ["active", "rotated", "revoked"] + }, + "valid_from": { + "type": "string", + "format": "date-time", + "description": "Key becomes valid at this time (inclusive)." + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Key expires at this time (exclusive)." + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp when key was revoked; required if lifecycle_status is revoked." + }, + "rotated_to_key_id": { + "type": "string", + "pattern": "^key_[A-Za-z0-9_.:-]{8,120}$", + "description": "Key ID of the rotation successor; required if lifecycle_status is rotated." + } + }, + "allOf": [ + { + "if": { "properties": { "lifecycle_status": { "const": "revoked" } } }, + "then": { "required": ["revoked_at"] } + }, + { + "if": { "properties": { "lifecycle_status": { "const": "rotated" } } }, + "then": { "required": ["rotated_to_key_id"] } + } + ] + } + } + } +} \ No newline at end of file diff --git a/scripts/validate-schemas.js b/scripts/validate-schemas.js index bf1b0d1..33d1c54 100644 --- a/scripts/validate-schemas.js +++ b/scripts/validate-schemas.js @@ -1,12 +1,295 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const fg = require('fast-glob'); const Ajv = require('ajv'); +const Ajv2020 = require('ajv/dist/2020'); +const draft7MetaSchema = require('ajv/dist/refs/json-schema-draft-07.json'); const addFormats = require('ajv-formats'); +const CORE_DP_PROFILE_VERSION = '0.1.0-lab'; +const NORMATIVE_KEYWORDS = [ + 'MUST NOT', + 'SHALL NOT', + 'SHOULD NOT', + 'REQUIRED', + 'RECOMMENDED', + 'OPTIONAL', + 'MUST', + 'SHALL', + 'SHOULD', + 'MAY', +]; +const TERMINAL_CORE_DP_STATES = new Set([ + 'capabilities-confirmed', + 'offer-acked', + 'offer-rejected', + 'match-accepted', + 'match-rejected', + 'transfer-acked', + 'timed-out', + 'partitioned', +]); +const CORE_DP_SEARCH_CONTRACT_SCHEMA_ID = + 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/search-contract.schema.json'; +const CORE_DP_SEARCH_REQUEST_REF = `${CORE_DP_SEARCH_CONTRACT_SCHEMA_ID}#/$defs/searchRequest`; +const CORE_DP_SEARCH_RESPONSE_REF = `${CORE_DP_SEARCH_CONTRACT_SCHEMA_ID}#/$defs/searchResponse`; +const CORE_DP_BODY_SCHEMA_BY_MESSAGE_TYPE = { + 'capabilities.announce': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'capabilities.response': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'material.register': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json', + 'material.read': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json', + 'material.search': CORE_DP_SEARCH_CONTRACT_SCHEMA_ID, + 'product.register': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json', + 'product.read': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/dna-operation.schema.json', + 'product.search': CORE_DP_SEARCH_CONTRACT_SCHEMA_ID, + 'offer.publish': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'offer.ack': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'offer.reject': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'match.propose': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'match.accept': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'match.reject': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'transfer.dispatch': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'transfer.receive': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + 'transfer.ack': 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/choreography-message.schema.json', + error: 'https://localloop.urbnia.com/projects/loop-protocol/profiles/core-dp/0.1.0-lab/schemas/error.schema.json', +}; +const CORE_DP_CHOREOGRAPHY_RULES = { + 'capabilities.announce': { + subjectType: 'capabilities', + state: 'announced', + terminal: false, + authorRole: 'origin', + authoritativeRole: 'origin', + }, + 'capabilities.response': { + subjectType: 'capabilities', + state: 'capabilities-confirmed', + terminal: true, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'offer.publish': { + subjectType: 'offer', + state: 'offer-published', + terminal: false, + authorRole: 'origin', + authoritativeRole: 'origin', + }, + 'offer.ack': { + subjectType: 'offer', + state: 'offer-acked', + previousState: 'offer-published', + terminal: true, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'offer.reject': { + subjectType: 'offer', + state: 'offer-rejected', + previousState: 'offer-published', + terminal: true, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'match.propose': { + subjectType: 'match', + state: 'match-proposed', + terminal: false, + authorRole: 'origin', + authoritativeRole: 'origin', + }, + 'match.accept': { + subjectType: 'match', + state: 'match-accepted', + previousState: 'match-proposed', + terminal: true, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'match.reject': { + subjectType: 'match', + state: 'match-rejected', + previousState: 'match-proposed', + terminal: true, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'transfer.dispatch': { + subjectType: 'transfer', + state: 'transfer-dispatched', + terminal: false, + authorRole: 'origin', + authoritativeRole: 'origin', + }, + 'transfer.receive': { + subjectType: 'transfer', + state: 'transfer-received', + previousState: 'transfer-dispatched', + terminal: false, + authorRole: 'counterparty', + authoritativeRole: 'counterparty', + }, + 'transfer.ack': { + subjectType: 'transfer', + state: 'transfer-acked', + previousState: 'transfer-received', + terminal: true, + authorRole: 'origin', + authoritativeRole: 'origin', + }, +}; +const CORE_DP_SEMANTIC_CHECKS = new Set([ + 'core-dp-envelope-signing-input-sha256', + 'core-dp-envelope-ed25519-signature', + 'core-dp-envelope-trusted-key-resolution', + 'core-dp-envelope-body-contract', + 'core-dp-envelope-replay-window', + 'core-dp-dna-operation-contract', + 'core-dp-search-contract-semantics', + 'core-dp-error-contract-semantics', + 'core-dp-epcis-mapping-semantics', + 'core-dp-evidence-immutable-subset', + 'core-dp-choreography-message-contract', + 'core-dp-choreography-terminal-regression', + 'core-dp-choreography-idempotency-retry', + 'core-dp-choreography-stale-reordered', + 'core-dp-choreography-transfer-convergence', +]); +const CORE_DP_REQUIRED_POSITIVE_COVERAGE_TAGS = new Set([ + 'positive:material-dna-register-request', + 'positive:product-dna-read-result', + 'positive:material-search-local', + 'positive:product-search-cross-node-request', + 'positive:product-search-cross-node-response', + 'positive:offer-authority', + 'positive:match-authority', + 'positive:transfer-authority', + 'positive:retry-duplicate-stored-result', + 'positive:transfer-final-convergence', +]); + +function assertNoDuplicateJsonKeys(raw, filePath) { + const stack = []; + let index = 0; + let expectingKey = false; + + const fail = (message) => { + throw new Error(`Invalid JSON in ${filePath}: ${message}`); + }; + + const skipWhitespace = () => { + while (/\s/.test(raw[index] || '')) { + index += 1; + } + }; + + const parseString = () => { + let value = ''; + index += 1; + while (index < raw.length) { + const char = raw[index]; + if (char === '"') { + index += 1; + return value; + } + if (char === '\\') { + const escape = raw[index + 1]; + if (escape === 'u') { + const hex = raw.slice(index + 2, index + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + fail(`invalid unicode escape near offset ${index}`); + } + value += String.fromCharCode(parseInt(hex, 16)); + index += 6; + continue; + } + const escapes = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; + if (!Object.prototype.hasOwnProperty.call(escapes, escape)) { + fail(`invalid string escape near offset ${index}`); + } + value += escapes[escape]; + index += 2; + continue; + } + value += char; + index += 1; + } + fail(`unterminated string near offset ${index}`); + }; + + while (index < raw.length) { + skipWhitespace(); + const char = raw[index]; + if (char === undefined) { + break; + } + + if (char === '"') { + const value = parseString(); + if (expectingKey) { + const current = stack[stack.length - 1]; + skipWhitespace(); + if (raw[index] === ':') { + if (current.keys.has(value)) { + fail(`duplicate key "${value}"`); + } + current.keys.add(value); + index += 1; + expectingKey = false; + } + } + continue; + } + + if (char === '{') { + stack.push({ type: 'object', keys: new Set() }); + index += 1; + skipWhitespace(); + expectingKey = raw[index] !== '}'; + continue; + } + + if (char === '}') { + if (stack.pop()?.type !== 'object') { + fail(`unexpected } near offset ${index}`); + } + index += 1; + expectingKey = false; + continue; + } + + if (char === '[') { + stack.push({ type: 'array' }); + index += 1; + expectingKey = false; + continue; + } + + if (char === ']') { + if (stack.pop()?.type !== 'array') { + fail(`unexpected ] near offset ${index}`); + } + index += 1; + expectingKey = false; + continue; + } + + if (char === ',') { + index += 1; + skipWhitespace(); + expectingKey = stack[stack.length - 1]?.type === 'object' && raw[index] !== '}'; + continue; + } + + index += 1; + } +} + function loadJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); try { + assertNoDuplicateJsonKeys(raw, filePath); return JSON.parse(raw); } catch (error) { throw new Error(`Invalid JSON in ${filePath}: ${error.message}`); @@ -27,6 +310,124 @@ function formatErrors(errors) { .join('\n'); } +function makeAjv(AjvCtor = Ajv) { + const ajv = new AjvCtor({ allErrors: true, strict: false, allowUnionTypes: true }); + addFormats(ajv); + return ajv; +} + +function canonicalJson(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function sha256Hex(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function coreDpEnvelopeSigningInput(payload) { + return { + profile: payload.profile, + profile_version: payload.profile_version, + message_id: payload.message_id, + message_type: payload.message_type, + created_at: payload.created_at, + expires_at: payload.expires_at, + sender: payload.sender, + receiver: payload.receiver, + idempotency_key: payload.idempotency_key, + body_schema: payload.body_schema, + body: payload.body, + }; +} + +function normalizeNormativeStatement(line) { + return line + .trim() + .replace(/^>\s+/, '') + .replace(/^-\s+/, '') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/\u2014/g, '-') + .replace(/\u2192/g, '->') + .replace(/\s+/g, ' '); +} + +function slugifyHeading(heading) { + return heading + .replace(/^#+\s+/, '') + .trim() + .toLowerCase() + .replace(/[.]/g, '') + .replace(/\//g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function countKeywordOccurrences(statement) { + const counts = new Map(); + let masked = statement; + + for (const keyword of ['MUST NOT', 'SHALL NOT', 'SHOULD NOT']) { + const pattern = new RegExp(`\\b${keyword.replace(' ', '\\s+')}\\b`, 'g'); + const matches = masked.match(pattern) || []; + if (matches.length > 0) { + counts.set(keyword, matches.length); + masked = masked.replace(pattern, ' '); + } + } + + for (const keyword of ['REQUIRED', 'RECOMMENDED', 'OPTIONAL', 'MUST', 'SHALL', 'SHOULD', 'MAY']) { + const pattern = new RegExp(`\\b${keyword}\\b`, 'g'); + const matches = masked.match(pattern) || []; + if (matches.length > 0) { + counts.set(keyword, matches.length); + } + } + + return NORMATIVE_KEYWORDS.flatMap((keyword) => + Array.from({ length: counts.get(keyword) || 0 }, () => keyword), + ); +} + +function extractNormativeEntries(specPath) { + const lines = fs.readFileSync(specPath, 'utf8').split('\n'); + const entries = []; + let currentAnchor = ''; + + lines.forEach((line, index) => { + if (/^#{2,6}\s+/.test(line)) { + currentAnchor = slugifyHeading(line); + } + + const statement = normalizeNormativeStatement(line); + if (!statement) { + return; + } + + for (const keyword of countKeywordOccurrences(statement)) { + entries.push({ + keyword, + source_path: specPath, + source_line: index + 1, + source_anchor: currentAnchor, + statement, + }); + } + }); + + return entries; +} + function inferSchemaName(payload) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { return null; @@ -91,7 +492,89 @@ function validatePayload(payload, validators, label, failures) { } } -function assertOpenApiRefsResolve(openApiPath, schemaIds) { +function compileSchemas(schemaPaths, validators, schemaIds, schemaById, failures) { + const draft07Ajv = makeAjv(Ajv); + const draft2020Ajv = makeAjv(Ajv2020); + draft2020Ajv.addMetaSchema(draft7MetaSchema); + const schemaRecords = schemaPaths.map((schemaPath) => { + const schema = loadJson(schemaPath); + return { + schemaPath, + schema, + schemaId: schema.$id || schemaPath, + isDraft2020: Boolean(schema.$schema && schema.$schema.includes('2020-12')), + }; + }); + + for (const { schemaPath, schema, schemaId, isDraft2020 } of schemaRecords) { + try { + if (!isDraft2020) { + draft07Ajv.addSchema(schema, schemaId); + } + draft2020Ajv.addSchema(schema, schemaId); + schemaIds.add(schemaId); + schemaById.set(schemaId, schema); + } catch (error) { + failures.push(`${schemaPath}: failed to register schema: ${error.message}`); + } + } + + for (const { schemaPath, schemaId, isDraft2020 } of schemaRecords) { + const ajv = isDraft2020 ? draft2020Ajv : draft07Ajv; + try { + const validator = ajv.getSchema(schemaId); + if (!validator) { + failures.push(`Failed to load schema validator: ${schemaPath}`); + continue; + } + validators.set(schemaPath, validator); + validators.set(path.basename(schemaPath), validator); + validators.set(schemaId, validator); + } catch (error) { + failures.push(`${schemaPath}: failed to compile schema: ${error.message}`); + } + } +} + +function decodeJsonPointerSegment(segment) { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +function resolveJsonPointer(root, pointer) { + if (pointer === '' || pointer === '/') { + return root; + } + if (!pointer.startsWith('/')) { + return undefined; + } + + return pointer + .slice(1) + .split('/') + .map(decodeJsonPointerSegment) + .reduce((value, segment) => { + if (value === undefined || value === null || typeof value !== 'object') { + return undefined; + } + return value[segment]; + }, root); +} + +function localSchemaRefResolves(ref, schemaIds, schemaById) { + const [baseId, fragment] = ref.split('#'); + if (!fragment && schemaIds.has(ref)) { + return true; + } + if (!schemaIds.has(baseId)) { + return false; + } + if (!fragment) { + return true; + } + return resolveJsonPointer(schemaById.get(baseId), fragment) !== undefined; +} + +function assertOpenApiRefsResolve(openApiPath, schemaIds, schemaById) { const openApi = loadJson(openApiPath); const failures = []; @@ -105,8 +588,8 @@ function assertOpenApiRefsResolve(openApiPath, schemaIds) { } if ( typeof value.$ref === 'string' && - value.$ref.startsWith('https://localloop.urbnia.com/projects/loop-protocol/schemas/') && - !schemaIds.has(value.$ref) + value.$ref.startsWith('https://localloop.urbnia.com/projects/loop-protocol/') && + !localSchemaRefResolves(value.$ref, schemaIds, schemaById) ) { failures.push(`Unresolved OpenAPI schema ref: ${value.$ref}`); } @@ -114,11 +597,1026 @@ function assertOpenApiRefsResolve(openApiPath, schemaIds) { }; visit(openApi); + + const productSearchOperation = openApi.paths?.['/api/v1/product/search']?.post; + const productSearchRequestRef = + productSearchOperation?.requestBody?.content?.['application/json']?.schema?.$ref; + const productSearchResponseRef = + productSearchOperation?.responses?.['200']?.content?.['application/json']?.schema?.$ref; + + if (productSearchRequestRef !== CORE_DP_SEARCH_REQUEST_REF) { + failures.push(`Product search request must ref exact Core-DP searchRequest definition: ${CORE_DP_SEARCH_REQUEST_REF}`); + } + if (productSearchResponseRef !== CORE_DP_SEARCH_RESPONSE_REF) { + failures.push(`Product search response must ref exact Core-DP searchResponse definition: ${CORE_DP_SEARCH_RESPONSE_REF}`); + } + const securitySchemes = openApi.components?.securitySchemes || {}; + const expectedNodeSignatureSchemes = { + NodeSignature: 'X-Node-Signature', + NodeKeyId: 'X-Node-Key-Id', + NodeSignatureInputSHA256: 'X-Node-Signature-Input-SHA256', + }; + for (const [schemeName, headerName] of Object.entries(expectedNodeSignatureSchemes)) { + const scheme = securitySchemes[schemeName]; + if (scheme?.type !== 'apiKey' || scheme.in !== 'header' || scheme.name !== headerName) { + failures.push(`OpenAPI must define ${schemeName} apiKey security scheme on ${headerName}.`); + } + } + + const productSearchSecurity = JSON.stringify(productSearchOperation?.security || []); + const expectedProductSearchSecurity = JSON.stringify([ + {}, + { BearerAuth: [] }, + { NodeSignature: [], NodeKeyId: [], NodeSignatureInputSHA256: [] }, + ]); + if (productSearchSecurity !== expectedProductSearchSecurity) { + failures.push('Product search security must be exactly public-lab, BearerAuth, or all node-signature headers as one AND alternative.'); + } + + const productSearchPartialHeaderParameters = (productSearchOperation?.parameters || []) + .filter((parameter) => parameter.in === 'header' && /^X-Node-/.test(parameter.name || '')); + if (productSearchPartialHeaderParameters.length > 0) { + failures.push('Product search must not declare optional X-Node-* header parameters; node-signature headers are modeled as an AND security requirement.'); + } + + return failures; +} + +function assertProfileContractLocationsExist(profileRoot, manifest, coreRequirements) { + const failures = []; + const locations = [ + ...manifest.entries.map((entry) => entry.contract_location), + ...coreRequirements.requirements.map((requirement) => requirement.contract_location), + ]; + + for (const location of locations) { + if (!location || typeof location !== 'string') { + failures.push('Core-DP requirement has an empty contract_location.'); + continue; + } + + const [contractPath, fragment] = location.split('#'); + if (contractPath && !fs.existsSync(contractPath)) { + failures.push(`Core-DP contract_location path does not exist: ${location}`); + continue; + } + + if (fragment && fragment.length > 0 && contractPath.endsWith('.md')) { + const markdown = fs.readFileSync(contractPath, 'utf8'); + if (!markdown.split('\n').some((line) => /^#{1,6}\s+/.test(line) && slugifyHeading(line) === fragment)) { + failures.push(`Core-DP contract_location anchor does not exist: ${location}`); + } + } + } + + if (!fs.existsSync(profileRoot)) { + failures.push(`Core-DP profile root is missing: ${profileRoot}`); + } + + return failures; +} + +function manifestConceptKeys(entry) { + const statement = normalizeNormativeStatement(entry.statement || '').toLowerCase(); + const concepts = []; + + if ( + statement.includes('productdna') && + statement.includes('material_ids') && + ( + statement.includes('composition link') || + statement.includes('composition') || + statement.includes('constituent materials') + ) + ) { + concepts.push('productdna-material-composition'); + } + if (statement.includes('productdna') && statement.includes('offer') && statement.includes('match') && statement.includes('transfer')) { + concepts.push('productdna-offer-match-transfer'); + } + if ( + ( + statement.includes('vc') || + statement.includes('did') || + statement.includes('digital product passport') || + statement.includes('dpp') || + ( + statement.includes('passport') && + statement.includes('classification') && + statement.includes('traceability') + ) + ) && + !statement.includes('material_ids') && + !statement.includes('offer -> match -> transfer') + ) { + concepts.push('external-dpp-vc-did-profile-validation'); + } + + return concepts; +} + +function assertManifestClassificationConsistency(manifestPath, manifest) { + const failures = []; + const byStatement = new Map(); + const byConcept = new Map(); + const requiredConceptClassifications = { + 'productdna-material-composition': 'IN', + 'productdna-offer-match-transfer': 'IN', + 'external-dpp-vc-did-profile-validation': 'DEFERRED', + }; + + for (const entry of manifest.entries || []) { + const normalizedStatement = normalizeNormativeStatement(entry.statement || '').toLowerCase(); + if (!byStatement.has(normalizedStatement)) { + byStatement.set(normalizedStatement, new Map()); + } + const statementClassifications = byStatement.get(normalizedStatement); + statementClassifications.set(entry.classification, [ + ...(statementClassifications.get(entry.classification) || []), + entry.id, + ]); + + for (const concept of manifestConceptKeys(entry)) { + if (!byConcept.has(concept)) { + byConcept.set(concept, new Map()); + } + const conceptClassifications = byConcept.get(concept); + conceptClassifications.set(entry.classification, [ + ...(conceptClassifications.get(entry.classification) || []), + entry.id, + ]); + + const requiredClassification = requiredConceptClassifications[concept]; + if (requiredClassification && entry.classification !== requiredClassification) { + failures.push( + `${manifestPath}: ${entry.id} classifies ${concept} as ${entry.classification}; expected ${requiredClassification}.`, + ); + } + } + } + + for (const [normalizedStatement, classifications] of byStatement.entries()) { + if (classifications.size > 1) { + const entries = Array.from(classifications.entries()) + .map(([classification, ids]) => `${classification}: ${ids.join(', ')}`) + .join('; '); + failures.push(`${manifestPath}: contradictory classification for normalized statement "${normalizedStatement}": ${entries}`); + } + } + + for (const [concept, classifications] of byConcept.entries()) { + if (concept === 'external-dpp-vc-did-profile-validation') { + continue; + } + if (classifications.size > 1) { + const entries = Array.from(classifications.entries()) + .map(([classification, ids]) => `${classification}: ${ids.join(', ')}`) + .join('; '); + failures.push(`${manifestPath}: contradictory classification for normalized concept ${concept}: ${entries}`); + } + } + + return failures; +} + +function assertNormativeManifestMatchesSpec(specPath, manifestPath) { + const manifest = loadJson(manifestPath); + const extracted = extractNormativeEntries(specPath); + const failures = []; + + if (manifest.pinned_specification?.path !== specPath) { + failures.push( + `${manifestPath}: pinned specification path must be ${specPath}, got ${manifest.pinned_specification?.path}`, + ); + } + + if (manifest.normative_statement_count !== extracted.length) { + failures.push( + `${manifestPath}: normative_statement_count ${manifest.normative_statement_count} does not match extracted count ${extracted.length}.`, + ); + } + + if (!Array.isArray(manifest.entries) || manifest.entries.length !== extracted.length) { + failures.push( + `${manifestPath}: manifest entries length ${manifest.entries?.length || 0} does not match extracted count ${extracted.length}.`, + ); + } + + const ids = new Set(); + (manifest.entries || []).forEach((entry, index) => { + const expected = extracted[index]; + if (!expected) { + return; + } + + for (const field of [ + 'id', + 'keyword', + 'source_path', + 'source_line', + 'source_anchor', + 'statement_hash', + 'statement', + 'classification', + 'rationale', + 'contract_location', + 'implementation_status', + 'test_status', + ]) { + if (entry[field] === undefined || entry[field] === '') { + failures.push(`${manifestPath}: entry ${index} is missing ${field}.`); + } + } + + const tupleFailures = []; + for (const [field, value] of Object.entries(expected)) { + if (entry[field] !== value) { + tupleFailures.push(`${field} expected ${JSON.stringify(value)} got ${JSON.stringify(entry[field])}`); + } + } + if (tupleFailures.length > 0) { + failures.push(`${manifestPath}: entry ${index} does not match extracted SPECIFICATION.md clause: ${tupleFailures.join('; ')}`); + } + + if (ids.has(entry.id)) { + failures.push(`${manifestPath}: duplicate requirement id ${entry.id}.`); + } + ids.add(entry.id); + + if (!/^[a-f0-9]{64}$/.test(entry.statement_hash || '')) { + failures.push(`${manifestPath}: ${entry.id} has invalid statement_hash format.`); + } else if ( + !entry.id.endsWith(entry.statement_hash.slice(0, 12).toUpperCase()) && + !entry.id.endsWith(`${entry.statement_hash.slice(0, 12).toUpperCase()}-2`) + ) { + failures.push(`${manifestPath}: ${entry.id} does not carry the statement_hash prefix.`); + } + }); + + failures.push(...assertManifestClassificationConsistency(manifestPath, manifest)); + + return { failures, manifest }; +} + +function assertCoreDpRequirements(coreRequirementsPath, manifest, profileReadmePath) { + const coreRequirements = loadJson(coreRequirementsPath); + const failures = []; + const requirementIds = new Set(); + const allowedClassifications = new Set(['IN', 'OUT', 'DEFERRED']); + + if (coreRequirements.profile !== 'core-dp') { + failures.push(`${coreRequirementsPath}: profile must be core-dp.`); + } + if (coreRequirements.profile_version !== CORE_DP_PROFILE_VERSION) { + failures.push(`${coreRequirementsPath}: profile_version must be ${CORE_DP_PROFILE_VERSION}.`); + } + if (coreRequirements.base_specification?.path !== manifest.pinned_specification?.path) { + failures.push(`${coreRequirementsPath}: base specification must match normative manifest pin.`); + } + + for (const requirement of coreRequirements.requirements || []) { + if (requirementIds.has(requirement.id)) { + failures.push(`${coreRequirementsPath}: duplicate requirement id ${requirement.id}.`); + } + requirementIds.add(requirement.id); + + if (!allowedClassifications.has(requirement.classification)) { + failures.push(`${coreRequirementsPath}: ${requirement.id} has invalid classification ${requirement.classification}.`); + } + + if (!requirement.summary || !requirement.contract_location) { + failures.push(`${coreRequirementsPath}: ${requirement.id} needs summary and contract_location.`); + } + } + + const profileReadme = fs.readFileSync(profileReadmePath, 'utf8'); + if (!profileReadme.includes(`**Profile version:** \`${CORE_DP_PROFILE_VERSION}\``)) { + failures.push(`${profileReadmePath}: profile version marker is missing or stale.`); + } + if (!normalizeNormativeStatement(profileReadme).includes('not full LOOP conformance')) { + failures.push(`${profileReadmePath}: lab-only non-full-conformance disclaimer is missing.`); + } + + return { failures, coreRequirements, requirementIds }; +} + +function expectedNodeForRole(payload, role) { + if (role === 'origin') { + return payload.origin_node; + } + if (role === 'counterparty') { + return payload.counterparty_node; + } + return undefined; +} + +function validateDnaOperationContract(payload) { + const failures = []; + const messageEntityType = payload.message_type?.startsWith('material.') ? 'material' : 'product'; + const expectedOperationKind = payload.message_type?.endsWith('.register') ? 'register' : 'read'; + const actualOperationKind = payload.operation?.startsWith('register_') ? 'register' : 'read'; + const recordIdPattern = messageEntityType === 'material' + ? /^MAT-[A-Z]{2}-[A-Z]{3}-\d{4}-[A-Z]+-[A-Z0-9]{6,}$/ + : /^PRD-[A-Z0-9-]{8,}$/; + + if (actualOperationKind !== expectedOperationKind) { + failures.push(`DNA operation ${payload.operation} does not match message_type ${payload.message_type}.`); + } + if (payload.entity_type !== messageEntityType) { + failures.push(`DNA operation entity_type ${payload.entity_type} does not match message_type ${payload.message_type}.`); + } + if (payload.record_id && !recordIdPattern.test(payload.record_id)) { + failures.push(`DNA operation record_id ${payload.record_id} does not match ${payload.entity_type} id pattern.`); + } + if (payload.record?.id && payload.record_id && payload.record.id !== payload.record_id) { + failures.push('DNA operation record_id must match record.id.'); + } + if (payload.record?.['@type']) { + const expectedType = payload.entity_type === 'material' ? 'MaterialDNA' : 'ProductDNA'; + if (payload.record['@type'] !== expectedType) { + failures.push(`DNA operation record @type must be ${expectedType}.`); + } + } + if (payload.operation === 'read_result' && payload.result?.status === 'found' && !payload.record) { + failures.push('DNA operation read_result with found status must include the record.'); + } + if (payload.operation === 'read_result' && ['not_found', 'error'].includes(payload.result?.status) && payload.record) { + failures.push(`DNA operation read_result status ${payload.result.status} must not include a record.`); + } + if (payload.result?.status && ['invalid', 'conflict', 'error', 'not_found'].includes(payload.result.status)) { + if (!Array.isArray(payload.result.errors) || payload.result.errors.length === 0) { + failures.push(`DNA operation result status ${payload.result.status} must include Core-DP error details.`); + } + } + if (payload.result?.status && ['registered', 'found'].includes(payload.result.status) && payload.result.errors) { + failures.push(`DNA operation result status ${payload.result.status} must not include errors.`); + } + + return failures; +} + +function validateSearchContractSemantics(payload) { + const failures = []; + + if (payload.scope === 'cross-node' && payload.auth?.mode !== 'node-signature') { + failures.push('cross-node search must use node-signature auth.'); + } + if (payload.entity_type === 'material' && payload.filters?.condition !== undefined && payload.strict_filtering === true) { + failures.push('strict material search must reject condition filter.'); + } + if (payload.results) { + for (const [index, result] of payload.results.entries()) { + if (payload.entity_type === 'material' && !/^MAT-/.test(result.id)) { + failures.push(`material search result ${index} id must use MAT- prefix.`); + } + if (payload.entity_type === 'product' && !/^PRD-/.test(result.id)) { + failures.push(`product search result ${index} id must use PRD- prefix.`); + } + } + if (payload.consistency?.mode === 'snapshot' && !payload.consistency.snapshot_id) { + failures.push('snapshot search response must include snapshot_id.'); + } + } + + return failures; +} + +function validateEnvelopeReplayWindow(payload, evaluationTimeOverride) { + const failures = []; + const createdAt = Date.parse(payload.created_at || ''); + const expiresAt = Date.parse(payload.expires_at || ''); + const evaluationTime = evaluationTimeOverride !== undefined + ? Date.parse(evaluationTimeOverride) + : Date.parse(payload.evaluation_time || payload.created_at || ''); + const replayWindowSeconds = payload.replay_window_seconds; + + if (!Number.isInteger(replayWindowSeconds)) { + failures.push('signed envelope replay_window_seconds is required.'); + return failures; + } + if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt)) { + failures.push('signed envelope created_at and expires_at must be valid date-time values.'); + return failures; + } + if (createdAt >= expiresAt) { + failures.push('signed envelope created_at must be earlier than expires_at.'); + } + if (!Number.isFinite(evaluationTime)) { + failures.push('signed envelope evaluation_time must be a valid date-time value.'); + return failures; + } + if (evaluationTime < createdAt) { + failures.push('evaluation_time must be >= created_at.'); + } + if (evaluationTime >= expiresAt) { + failures.push('message has expired (evaluation_time must be < expires_at).'); + } + + const maxExpiresAt = createdAt + replayWindowSeconds * 1000; + if (expiresAt > maxExpiresAt) { + failures.push('signed envelope expires_at must be within replay_window_seconds of created_at.'); + } + + return failures; +} + +function validateErrorContractSemantics(payload) { + const failures = []; + + if (payload.code === 'unsupported_profile_version' && payload.retryable !== false) { + failures.push('unsupported_profile_version errors are not retryable without a new profile negotiation.'); + } + if (payload.code === 'signature_invalid' && payload.retryable !== false) { + failures.push('signature_invalid errors are not retryable without changing the signature/key.'); + } + if (payload.code === 'timeout' && payload.retryable !== true) { + failures.push('timeout errors must be retryable.'); + } + + return failures; +} + +function validateEpcisMappingSemantics(payload) { + const failures = []; + + if (payload.full_epcis_conformance_claimed === true) { + failures.push('Core-DP EPCIS metadata must not claim full EPCIS conformance.'); + } + if (payload.epcis_subset_event) { + const expectedEpc = `urn:loop:${payload.core_dp_mapping?.subject_type}:${payload.core_dp_mapping?.subject_id}`; + if (!payload.epcis_subset_event.epcList?.includes(expectedEpc)) { + failures.push(`EPCIS epcList must include mapped Core-DP subject ${expectedEpc}.`); + } + } + + return failures; +} + +function validateChoreographyMessageContract(payload, envelope) { + const failures = []; + const rule = CORE_DP_CHOREOGRAPHY_RULES[payload.message_type]; + const isRecoveryObservation = ['timed-out', 'partitioned'].includes(payload.state); + + if (!rule) { + failures.push(`unknown choreography message_type ${payload.message_type}.`); + return failures; + } + + if (payload.subject?.type !== rule.subjectType) { + failures.push(`${payload.message_type} subject.type must be ${rule.subjectType}.`); + } + if (payload.state !== rule.state && !isRecoveryObservation) { + failures.push(`${payload.message_type} state must be ${rule.state}.`); + } + if (!isRecoveryObservation && payload.terminal !== rule.terminal) { + failures.push(`${payload.message_type} terminal must be ${rule.terminal}.`); + } + const terminalForState = TERMINAL_CORE_DP_STATES.has(payload.state); + if (payload.terminal !== terminalForState) { + failures.push(`state ${payload.state} terminal must be ${terminalForState}.`); + } + if (!isRecoveryObservation && rule.previousState && payload.previous_state !== rule.previousState) { + failures.push(`${payload.message_type} previous_state must be ${rule.previousState}.`); + } + if (isRecoveryObservation) { + if (payload.previous_state !== rule.state) { + failures.push(`${payload.state} recovery observation for ${payload.message_type} previous_state must be ${rule.state}.`); + } + if (!payload.last_accepted_message_id) { + failures.push(`${payload.state} recovery observation must include last_accepted_message_id.`); + } + if (payload.duplicate_of || payload.duplicate_behavior) { + failures.push(`${payload.state} recovery observation must not be modeled as a duplicate retry result.`); + } + } + + if (payload.authoritative_role !== rule.authoritativeRole) { + failures.push(`${payload.message_type} authoritative_role must be ${rule.authoritativeRole}.`); + } + const expectedAuthority = expectedNodeForRole(payload, rule.authoritativeRole); + if (envelope) { + const envelopeAuthority = envelope.sender?.node_id === expectedAuthority + ? envelope.sender.node_id + : envelope.receiver?.node_id === expectedAuthority + ? envelope.receiver.node_id + : undefined; + if (!envelopeAuthority) { + failures.push(`${payload.message_type} authoritative node ${expectedAuthority} is neither envelope sender nor receiver.`); + } + } + + if (envelope) { + const expectedSender = expectedNodeForRole(payload, rule.authorRole); + if (envelope.sender?.node_id !== expectedSender) { + failures.push(`${payload.message_type} sender.node_id must be ${expectedSender}.`); + } + if (envelope.receiver?.node_id === envelope.sender?.node_id) { + failures.push('envelope sender and receiver must be different nodes.'); + } + } + + if (payload.message_type === 'transfer.ack') { + if (isRecoveryObservation) { + failures.push('transfer.ack must not be modeled as a local recovery observation.'); + } + if (payload.final_ack_role !== 'origin-confirms-receiver-receipt') { + failures.push('transfer.ack final_ack_role must explicitly be origin-confirms-receiver-receipt.'); + } + if (!payload.acknowledges_message_id) { + failures.push('transfer.ack must identify the transfer.receive message it acknowledges.'); + } + } + + if (payload.message_type === 'capabilities.response' && payload.state === 'capabilities-confirmed') { + if (payload.accepted_profile_version !== CORE_DP_PROFILE_VERSION) { + failures.push(`capabilities.response accepted_profile_version must be ${CORE_DP_PROFILE_VERSION}.`); + } + } + + return failures; +} + +function validateSemanticCheck(checkName, payload, context = {}) { + const failures = []; + const envelopePayload = payload?.body && payload?.body_schema ? payload : null; + const body = envelopePayload?.body || payload; + const { evaluationTime, trustStore } = context; + + if (checkName === 'core-dp-envelope-signing-input-sha256') { + const signingInput = coreDpEnvelopeSigningInput(payload); + const actual = sha256Hex(canonicalJson(signingInput)); + if (payload.signature?.signing_input_sha256 !== actual) { + failures.push(`expected signing_input_sha256 ${actual}, got ${payload.signature?.signing_input_sha256}`); + } + return failures; + } + + if (checkName === 'core-dp-envelope-ed25519-signature') { + const signature = payload.signature || {}; + const signingInput = canonicalJson(coreDpEnvelopeSigningInput(payload)); + const actualSigningInputHash = sha256Hex(signingInput); + + if (signature.signing_input_sha256 !== actualSigningInputHash) { + failures.push(`expected signing_input_sha256 ${actualSigningInputHash}, got ${signature.signing_input_sha256}`); + } + if (signature.key_id !== payload.sender?.key_id) { + failures.push('signature.key_id must match sender.key_id.'); + } + + if (!trustStore || !Array.isArray(trustStore.keys)) { + failures.push('trustStore with keys array is required for signature verification.'); + return failures; + } + const trustedEntry = trustStore.keys.find( + (key) => key.node_id === payload.sender?.node_id && key.key_id === signature.key_id, + ); + if (!trustedEntry) { + failures.push(`sender key ${signature.key_id} not found in trust store for node ${payload.sender?.node_id}.`); + return failures; + } + if (trustedEntry.lifecycle_status === 'revoked') { + failures.push(`sender key ${signature.key_id} for node ${payload.sender?.node_id} has been revoked.`); + return failures; + } + const createdAt = Date.parse(payload.created_at || ''); + if (createdAt < Date.parse(trustedEntry.valid_from) || createdAt >= Date.parse(trustedEntry.valid_until)) { + failures.push(`sender key ${signature.key_id} for node ${payload.sender?.node_id} not valid for message created_at ${payload.created_at}.`); + return failures; + } + + try { + const publicKey = crypto.createPublicKey({ key: trustedEntry.public_key_jwk, format: 'jwk' }); + const verified = crypto.verify( + null, + Buffer.from(signingInput, 'utf8'), + publicKey, + Buffer.from(signature.value || '', 'base64url'), + ); + if (!verified) { + failures.push('detached Ed25519 signature verification failed.'); + } + } catch (error) { + failures.push(`detached Ed25519 signature verification failed: ${error.message}`); + } + return failures; + } + + if (checkName === 'core-dp-envelope-trusted-key-resolution') { + const signature = payload.signature || {}; + if (!trustStore || !Array.isArray(trustStore.keys)) { + failures.push('trustStore with keys array is required.'); + return failures; + } + const trustedEntry = trustStore.keys.find( + (key) => key.node_id === payload.sender?.node_id && key.key_id === signature.key_id, + ); + if (!trustedEntry) { + failures.push(`sender key ${signature.key_id} not found in trust store for node ${payload.sender?.node_id}.`); + return failures; + } + if (trustedEntry.lifecycle_status === 'revoked') { + failures.push(`sender key ${signature.key_id} for node ${payload.sender?.node_id} has been revoked.`); + return failures; + } + const createdAt = Date.parse(payload.created_at || ''); + if (createdAt < Date.parse(trustedEntry.valid_from) || createdAt >= Date.parse(trustedEntry.valid_until)) { + failures.push(`sender key ${signature.key_id} for node ${payload.sender?.node_id} not valid for message created_at ${payload.created_at}.`); + return failures; + } + if (trustedEntry.lifecycle_status === 'rotated') { + const msgCreatedAt = Date.parse(payload.created_at || ''); + const validUntil = Date.parse(trustedEntry.valid_until); + if (msgCreatedAt >= validUntil) { + failures.push(`sender key ${signature.key_id} for node ${payload.sender?.node_id} rotated and not valid for message created_at ${payload.created_at}.`); + return failures; + } + } + return failures; + } + + if (checkName === 'core-dp-envelope-body-contract') { + const expectedBodySchema = CORE_DP_BODY_SCHEMA_BY_MESSAGE_TYPE[payload.message_type]; + if (!expectedBodySchema) { + failures.push(`message_type ${payload.message_type} has no registered body_schema contract.`); + } else if (payload.body_schema !== expectedBodySchema) { + failures.push(`message_type ${payload.message_type} must use body_schema ${expectedBodySchema}.`); + } + if (payload.body?.message_type && payload.body.message_type !== payload.message_type) { + failures.push(`envelope message_type ${payload.message_type} must match body.message_type ${payload.body.message_type}.`); + } + if (payload.message_type === 'material.search' && payload.body?.entity_type !== 'material') { + failures.push('material.search envelope body entity_type must be material.'); + } + if (payload.message_type === 'product.search' && payload.body?.entity_type !== 'product') { + failures.push('product.search envelope body entity_type must be product.'); + } + if (payload.message_type === 'error' && payload.body?.correlation_id === payload.message_id) { + failures.push('error correlation_id must identify the failed message or conversation, not the error envelope itself.'); + } + return failures; + } + + if (checkName === 'core-dp-envelope-replay-window') { + return validateEnvelopeReplayWindow(payload, evaluationTime); + } + + if (checkName === 'core-dp-dna-operation-contract') { + return validateDnaOperationContract(body); + } + + if (checkName === 'core-dp-search-contract-semantics') { + return validateSearchContractSemantics(body); + } + + if (checkName === 'core-dp-error-contract-semantics') { + return validateErrorContractSemantics(body); + } + + if (checkName === 'core-dp-epcis-mapping-semantics') { + return validateEpcisMappingSemantics(body); + } + + if (checkName === 'core-dp-evidence-immutable-subset') { + for (const field of ['event_id', 'sequence', 'event_type', 'payload_hash_sha256']) { + if (body.immutable?.[field] !== body[field]) { + failures.push(`immutable.${field} must match top-level ${field}.`); + } + } + if (canonicalJson(body.immutable?.subject) !== canonicalJson(body.subject)) { + failures.push('immutable.subject must match top-level subject.'); + } + return failures; + } + + if (checkName === 'core-dp-choreography-message-contract') { + return validateChoreographyMessageContract(body, envelopePayload); + } + + if (checkName === 'core-dp-choreography-terminal-regression') { + if (TERMINAL_CORE_DP_STATES.has(body.previous_state) && !TERMINAL_CORE_DP_STATES.has(body.state)) { + failures.push(`terminal state ${body.previous_state} cannot regress to ${body.state}.`); + } + return failures; + } + + if (checkName === 'core-dp-choreography-idempotency-retry') { + if (body.attempt > 1 && !body.duplicate_of) { + failures.push('retry attempts above 1 must identify duplicate_of.'); + } + if (body.duplicate_of && !body.idempotency_key) { + failures.push('duplicate messages must include the original idempotency_key.'); + } + if (body.duplicate_behavior === 'stored_result' && body.original_fingerprint_sha256 !== body.idempotency_fingerprint_sha256) { + failures.push('stored_result duplicates must have identical idempotency fingerprints.'); + } + if (body.duplicate_behavior === 'conflict' && body.original_fingerprint_sha256 === body.idempotency_fingerprint_sha256) { + failures.push('idempotency conflict duplicates must have different idempotency fingerprints.'); + } + return failures; + } + + if (checkName === 'core-dp-choreography-stale-reordered') { + if (body.local_state && body.previous_state !== body.local_state) { + failures.push(`reordered message previous_state ${body.previous_state} does not match local_state ${body.local_state}.`); + } + return failures; + } + + if (checkName === 'core-dp-choreography-transfer-convergence') { + if (['timed-out', 'partitioned'].includes(body.state) && !body.last_accepted_message_id) { + failures.push(`${body.state} reconciliation must include last_accepted_message_id.`); + } + if (body.state === 'transfer-acked') { + if (!body.local_evidence_hash_sha256 || !body.peer_evidence_hash_sha256 || !body.convergence_hash_sha256) { + failures.push('transfer-acked convergence requires local, peer, and convergence evidence hashes.'); + } else if (body.local_evidence_hash_sha256 !== body.peer_evidence_hash_sha256) { + failures.push('transfer convergence evidence hashes must match.'); + } else { + const expected = sha256Hex(`${body.subject.id}:${body.local_evidence_hash_sha256}:${body.peer_evidence_hash_sha256}`); + if (body.convergence_hash_sha256 !== expected) { + failures.push(`transfer convergence_hash_sha256 must be ${expected}.`); + } + } + } + return failures; + } + + failures.push(`unknown semantic check ${checkName}.`); + return failures; +} + +function assertCoreDpVectors(vectorsPath, validators, requirementIds, manifestRequirementIds) { + const suite = loadJson(vectorsPath); + const failures = []; + const vectorIds = new Set(); + const semanticChecksUsed = new Set(); + const positiveCoverageTags = new Set(); + + const trustStorePath = 'profiles/core-dp/conformance/trust/accepted-peer-keys.json'; + let trustStore; + try { + trustStore = loadJson(trustStorePath); + } catch (error) { + failures.push(`Failed to load trust store from ${trustStorePath}: ${error.message}`); + return failures; + } + + if (suite.suite !== 'core-dp-0.1.0-lab') { + failures.push(`${vectorsPath}: suite must be core-dp-0.1.0-lab.`); + } + + for (const vector of suite.vectors || []) { + if (vectorIds.has(vector.id)) { + failures.push(`${vectorsPath}: duplicate vector id ${vector.id}.`); + } + vectorIds.add(vector.id); + + if (!['valid', 'invalid'].includes(vector.expected)) { + failures.push(`${vectorsPath}: ${vector.id} expected must be valid or invalid.`); + } + + if (!Array.isArray(vector.requirement_ids) || vector.requirement_ids.length === 0) { + failures.push(`${vectorsPath}: ${vector.id} must link at least one requirement_id.`); + } + + for (const requirementId of vector.requirement_ids || []) { + if (!requirementIds.has(requirementId) && !manifestRequirementIds.has(requirementId)) { + failures.push(`${vectorsPath}: ${vector.id} references unknown requirement ${requirementId}.`); + } + } + + for (const coverageTag of vector.coverage_tags || []) { + if (!CORE_DP_REQUIRED_POSITIVE_COVERAGE_TAGS.has(coverageTag)) { + failures.push(`${vectorsPath}: ${vector.id} declares unknown coverage tag ${coverageTag}.`); + } + if (vector.expected !== 'valid') { + failures.push(`${vectorsPath}: ${vector.id} coverage tag ${coverageTag} must be on a valid positive vector.`); + } + if (!Array.isArray(vector.semantic_checks) || vector.semantic_checks.length === 0) { + failures.push(`${vectorsPath}: ${vector.id} coverage tag ${coverageTag} requires semantic_checks.`); + } + positiveCoverageTags.add(coverageTag); + } + + const validator = validators.get(vector.payload_schema); + if (!validator) { + failures.push(`${vectorsPath}: ${vector.id} references unknown payload_schema ${vector.payload_schema}.`); + continue; + } + + const schemaValid = validator(vector.payload); + const vectorFailures = []; + if (!schemaValid) { + vectorFailures.push(`schema failed:\n${formatErrors(validator.errors || [])}`); + } + + if (schemaValid && vector.payload_schema.endsWith('/envelope.schema.json')) { + const bodyValidator = validators.get(vector.payload.body_schema); + if (!bodyValidator) { + vectorFailures.push(`envelope body_schema is not registered: ${vector.payload.body_schema}`); + } else if (!bodyValidator(vector.payload.body)) { + vectorFailures.push(`envelope body failed ${vector.payload.body_schema}:\n${formatErrors(bodyValidator.errors || [])}`); + } + } + + if ( + vector.expected === 'valid' && + vector.payload_schema.endsWith('/envelope.schema.json') && + (vector.requirement_ids || []).includes('CORE-DP-REQ-SIGNED-ENVELOPE') + ) { + const semanticChecks = new Set(vector.semantic_checks || []); + for (const requiredCheck of [ + 'core-dp-envelope-signing-input-sha256', + 'core-dp-envelope-ed25519-signature', + 'core-dp-envelope-trusted-key-resolution', + 'core-dp-envelope-body-contract', + 'core-dp-envelope-replay-window', + ]) { + if (!semanticChecks.has(requiredCheck)) { + vectorFailures.push(`valid signed envelope vectors must run ${requiredCheck}.`); + } + } + } + + const context = { + evaluationTime: vector.evaluation_time, + trustStore, + }; + + for (const checkName of vector.semantic_checks || []) { + semanticChecksUsed.add(checkName); + if (!CORE_DP_SEMANTIC_CHECKS.has(checkName)) { + vectorFailures.push(`unknown semantic check ${checkName}.`); + continue; + } + vectorFailures.push(...validateSemanticCheck(checkName, vector.payload, context)); + } + + if (vector.expected === 'valid' && vectorFailures.length > 0) { + failures.push(`${vectorsPath}: ${vector.id} expected valid but failed:\n${vectorFailures.join('\n')}`); + } + if (vector.expected === 'invalid' && vectorFailures.length === 0) { + failures.push(`${vectorsPath}: ${vector.id} expected invalid but schema and semantic checks passed.`); + } + if ( + vector.expected === 'invalid' && + typeof vector.expected_failure_contains === 'string' && + vector.expected_failure_contains.length > 0 && + !vectorFailures.some((failure) => failure.includes(vector.expected_failure_contains)) + ) { + failures.push( + `${vectorsPath}: ${vector.id} did not fail for intended reason containing "${vector.expected_failure_contains}". Actual failures:\n${vectorFailures.join('\n') || 'none'}`, + ); + } + } + + for (const checkName of CORE_DP_SEMANTIC_CHECKS) { + if (!semanticChecksUsed.has(checkName)) { + failures.push(`${vectorsPath}: registered semantic check is not covered by any vector: ${checkName}.`); + } + } + + for (const coverageTag of CORE_DP_REQUIRED_POSITIVE_COVERAGE_TAGS) { + if (!positiveCoverageTags.has(coverageTag)) { + failures.push(`${vectorsPath}: missing required positive coverage tag: ${coverageTag}.`); + } + } + + return failures; +} + +function assertCoreDpEpcisFixtures(fixturesPath, unsupportedPath, validators) { + const fixture = loadJson(fixturesPath); + const unsupported = loadJson(unsupportedPath); + const failures = []; + const validator = validators.get('profiles/core-dp/schemas/epcis-mapping.schema.json'); + + if (!validator) { + failures.push('EPCIS mapping schema validator is not registered.'); + } else { + for (const [artifactPath, artifact] of [ + [fixturesPath, fixture], + [unsupportedPath, unsupported], + ]) { + if (!validator(artifact)) { + failures.push(`${artifactPath}: did not match epcis-mapping.schema.json.\n${formatErrors(validator.errors || [])}`); + } + } + } + + for (const [label, value] of [ + ['fixture EPCIS version', fixture.pinned_versions?.epcis], + ['unsupported EPCIS version', unsupported.pinned_versions?.epcis], + ]) { + if (value !== '2.0.1') { + failures.push(`${label} must be 2.0.1.`); + } + } + for (const [label, value] of [ + ['fixture CBV version', fixture.pinned_versions?.cbv], + ['unsupported CBV version', unsupported.pinned_versions?.cbv], + ]) { + if (value !== '2.0') { + failures.push(`${label} must be 2.0.`); + } + } + + if (unsupported.full_epcis_conformance_claimed !== false) { + failures.push(`${unsupportedPath}: full_epcis_conformance_claimed must remain false.`); + } + + const event = fixture.epcis_subset_event || {}; + if (event.type !== 'ObjectEvent') { + failures.push(`${fixturesPath}: fixture must use ObjectEvent.`); + } + if (!['commissioning', 'shipping', 'receiving', 'accepting'].includes(event.bizStep)) { + failures.push(`${fixturesPath}: fixture bizStep ${event.bizStep} is outside Core-DP CBV subset.`); + } + if (!event.epcList?.includes(`urn:loop:${fixture.core_dp_mapping?.subject_type}:${fixture.core_dp_mapping?.subject_id}`)) { + failures.push(`${fixturesPath}: epcList must include the mapped Core-DP subject.`); + } + + return failures; +} + +function assertCoreDpTrustStore(validators) { + const trustStorePath = 'profiles/core-dp/conformance/trust/accepted-peer-keys.json'; + const trustStoreSchemaPath = 'profiles/core-dp/schemas/trust-store.schema.json'; + const failures = []; + + const validator = validators.get(trustStoreSchemaPath); + if (!validator) { + failures.push(`Trust store schema not registered: ${trustStoreSchemaPath}`); + return failures; + } + + try { + const trustStore = loadJson(trustStorePath); + if (!validator(trustStore)) { + failures.push(`${trustStorePath}: does not match trust-store.schema.json.\n${formatErrors(validator.errors || [])}`); + } + } catch (error) { + failures.push(`Failed to validate trust store: ${error.message}`); + } + + // Validate peer-key-trust schema file is loadable + const peerKeyTrustPath = 'profiles/core-dp/schemas/peer-key-trust.schema.json'; + const peerKeyValidator = validators.get(peerKeyTrustPath); + if (!peerKeyValidator) { + failures.push(`Peer key trust schema not registered: ${peerKeyTrustPath}`); + } + + return failures; +} + +function assertCoreDpProfile(validators) { + const manifestPath = 'profiles/core-dp/requirements/spec-v0.2.0-normative-manifest.json'; + const coreRequirementsPath = 'profiles/core-dp/requirements/core-dp-requirements.json'; + const profileReadmePath = 'profiles/core-dp/README.md'; + const failures = []; + + const manifestResult = assertNormativeManifestMatchesSpec('SPECIFICATION.md', manifestPath); + failures.push(...manifestResult.failures); + + const requirementsResult = assertCoreDpRequirements( + coreRequirementsPath, + manifestResult.manifest, + profileReadmePath, + ); + failures.push(...requirementsResult.failures); + failures.push( + ...assertProfileContractLocationsExist( + 'profiles/core-dp', + manifestResult.manifest, + requirementsResult.coreRequirements, + ), + ); + + failures.push(...assertCoreDpTrustStore(validators)); + + const manifestRequirementIds = new Set((manifestResult.manifest.entries || []).map((entry) => entry.id)); + failures.push( + ...assertCoreDpVectors( + 'profiles/core-dp/conformance/vectors/core-dp-vectors.json', + validators, + requirementsResult.requirementIds, + manifestRequirementIds, + ), + ); + failures.push( + ...assertCoreDpEpcisFixtures( + 'profiles/core-dp/epcis/fixtures/core-dp-transfer-object-event.json', + 'profiles/core-dp/epcis/unsupported-features.json', + validators, + ), + ); + return failures; } function assertProtocolContractMatchesDocs(specPath, openApiPath) { const spec = fs.readFileSync(specPath, 'utf8'); + const profileReadme = fs.readFileSync('profiles/core-dp/README.md', 'utf8'); const openApi = loadJson(openApiPath); const failures = []; @@ -138,6 +1636,9 @@ function assertProtocolContractMatchesDocs(specPath, openApiPath) { const documentedLabEndpoints = [ 'POST /api/v1/material-status', ]; + const additiveProfileEndpoints = [ + 'POST /api/v1/product/search', + ]; for (const endpoint of [...expectedProtocolEndpoints, ...documentedLabEndpoints]) { if (!spec.includes(`**${endpoint}**`)) { @@ -162,8 +1663,17 @@ function assertProtocolContractMatchesDocs(specPath, openApiPath) { } } + for (const endpoint of additiveProfileEndpoints) { + if (!openApiEndpoints.has(endpoint)) { + failures.push(`OpenAPI is missing additive Core-DP endpoint: ${endpoint}`); + } + if (!profileReadme.includes(`\`${endpoint}\``)) { + failures.push(`profiles/core-dp/README.md is missing additive endpoint marker: ${endpoint}`); + } + } + for (const endpoint of openApiEndpoints) { - if (!expectedProtocolEndpoints.includes(endpoint)) { + if (!expectedProtocolEndpoints.includes(endpoint) && !additiveProfileEndpoints.includes(endpoint)) { failures.push(`OpenAPI contains unexpected protocol endpoint: ${endpoint}`); } } @@ -187,7 +1697,9 @@ function assertExampleReadmeMatchesFiles(readmePath, examplePaths) { function main() { const schemaPaths = fg.sync(['schemas/*.schema.json']); + const profileSchemaPaths = fg.sync(['profiles/core-dp/schemas/*.schema.json']); const examplePaths = fg.sync(['examples/**/*.json']); + const jsonPaths = fg.sync(['*.json', 'contexts/**/*.jsonld', 'examples/**/*.json', 'profiles/**/*.json', 'schemas/**/*.json']); if (schemaPaths.length === 0) { console.error('No schemas found in schemas/*.schema.json'); @@ -199,36 +1711,31 @@ function main() { process.exit(1); } - const ajv = new Ajv({ allErrors: true, strict: false, allowUnionTypes: true }); - addFormats(ajv); - const validators = new Map(); const schemaIds = new Set(); - for (const schemaPath of schemaPaths) { - const schema = loadJson(schemaPath); - const schemaId = schema.$id || path.basename(schemaPath); - ajv.addSchema(schema, schemaId); - const validator = ajv.getSchema(schemaId); - if (validator) { - validators.set(path.basename(schemaPath), validator); - schemaIds.add(schemaId); - } + const schemaById = new Map(); + const failures = []; + + for (const jsonPath of jsonPaths) { + loadJson(jsonPath); } + compileSchemas([...schemaPaths, ...profileSchemaPaths], validators, schemaIds, schemaById, failures); + if (validators.size === 0) { console.error('Failed to load any schema validators.'); process.exit(1); } - const failures = []; for (const examplePath of examplePaths) { const payload = loadJson(examplePath); validatePayload(payload, validators, examplePath, failures); } - failures.push(...assertOpenApiRefsResolve('openapi.json', schemaIds)); + failures.push(...assertOpenApiRefsResolve('openapi.json', schemaIds, schemaById)); failures.push(...assertProtocolContractMatchesDocs('SPECIFICATION.md', 'openapi.json')); failures.push(...assertExampleReadmeMatchesFiles('examples/README.md', examplePaths)); + failures.push(...assertCoreDpProfile(validators)); if (failures.length > 0) { console.error('Schema validation failed:\n'); @@ -238,7 +1745,33 @@ function main() { process.exit(1); } - console.log(`Validated ${examplePaths.length} example file(s) against ${validators.size} schema(s).`); + console.log( + `Validated ${examplePaths.length} example file(s), ${jsonPaths.length} JSON artifact(s), and Core-DP profile vectors against ${schemaPaths.length + profileSchemaPaths.length} schema(s).`, + ); +} + +if (require.main === module) { + main(); } -main(); +// Exported so other scripts (e.g. profiles/core-dp/conformance/run-conformance.js) can reuse +// the shared AJV setup and Core-DP semantic-check implementations instead of re-implementing +// them. Exporting here is side-effect free: main() only runs when this file is the entry point +// (node scripts/validate-schemas.js / npm run validate:schemas), so `npm test` behavior is +// unchanged. +module.exports = { + loadJson, + formatErrors, + makeAjv, + compileSchemas, + canonicalJson, + sha256Hex, + coreDpEnvelopeSigningInput, + validateSemanticCheck, + validateEnvelopeReplayWindow, + CORE_DP_SEMANTIC_CHECKS, + CORE_DP_BODY_SCHEMA_BY_MESSAGE_TYPE, + CORE_DP_CHOREOGRAPHY_RULES, + TERMINAL_CORE_DP_STATES, + CORE_DP_PROFILE_VERSION, +};