diff --git a/site/dpp-docs/openapi-source.json b/site/dpp-docs/openapi-source.json index be36a2b..2b0a214 100644 --- a/site/dpp-docs/openapi-source.json +++ b/site/dpp-docs/openapi-source.json @@ -3,5 +3,5 @@ "_path_comment": "MUST be the bundled single-file spec, never api/openapi.yaml. The engine authors its description as a tree (a root of $refs plus paths/ and components/) and commits api/openapi.bundled.yaml as the single-file form every consumer reads — committed precisely so this vendoring step can read one blob out of git history. This pin still named the root after that split, so a sync would have copied 278 lines of relative `$ref: paths/...` pointers over the vendored spec; nothing here resolves them, and the rendered docs would have lost every endpoint and schema.", "repository": "odal-node/dpp-engine", "path": "api/openapi.bundled.yaml", - "commit": "db2d8317b98b1c04e41ff4179c8b1237528eda49" + "commit": "3391439b344205eff36caa802ea73cbe3ae32ee6" } diff --git a/site/dpp-docs/public/openapi.yaml b/site/dpp-docs/public/openapi.yaml index b02baf7..bfb7094 100644 --- a/site/dpp-docs/public/openapi.yaml +++ b/site/dpp-docs/public/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Odal Node API - version: 0.12.0 + version: 0.13.0 description: | **Sovereign Digital Product Passport Infrastructure** @@ -43,43 +43,45 @@ tags: - name: DPP Management description: Create, read, update, list, and audit Digital Product Passports. - name: DPP Lifecycle - description: Lifecycle transitions — publish, suspend, archive. - - name: Scan Telemetry - description: Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups. - - name: Registry - description: EU-registry registration state — per-passport and operator-wide, plus the operator's verified-registry standing. + description: Lifecycle transitions — publish, suspend, archive, end-of-life, and transfer of responsibility. - name: Evidence Dossiers description: Signed, self-contained evidence dossiers — generate, fetch, and verify (stored or uploaded) offline, with zero trust in the issuing node. + - name: Registry + description: EU-registry registration state — per-passport and operator-wide, plus the operator's verified-registry standing. + - name: Scan Telemetry + description: Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups. - name: Operator description: Operator configuration (branding, legal info, retention policy). - - name: API Keys - description: API key management — create, list, revoke. - - name: Plugins - description: Signed product group-plugin hot-install — verify, persist, hot-swap (admin-only). - - name: Webhooks - description: Signed outbound event delivery — subscribe, list, remove, test. - name: Facilities description: Manufacturing/processing facilities (ESPR Annex III) stamped onto new passports. - name: Operator Identifiers description: Economic-operator identifiers (ESPR Art. 13) stamped onto new passports. + - name: API Keys + description: API key management — create, list, revoke. + - name: Webhooks + description: Signed outbound event delivery — subscribe, list, remove, test. + - name: Plugins + description: Signed product-group plugin hot-install — verify, persist, hot-swap (admin-only). + - name: Ruleset + description: Compliance Current — the signed, versioned ruleset channel. Re-read it and hot-swap a verified bundle without a node restart (admin-only). - name: Node - description: Node setup/readiness state. - - name: Identity - description: Public did:web identity — DID document, health, readiness. On the fused node these are served under `/identity/*`. The standalone identity service (:8002) serves them at the root — `/health`, `/ready`, `/.well-known/did.json`. - - name: Identity (internal) - description: mTLS service-to-service signing/rotation. **Not exposed by the fused node**, which signs in-process — reachable only on the standalone identity service, and only from a client presenting `CN=odal-vault`. - - name: Integrator - description: CSV/XLSX bulk import — templates, upload, async job polling. + description: Node setup state, and what credential the caller presented. `/whoami` reports only what the request already carried; it is not a lookup. - name: Public (Vault) description: Unauthenticated vault endpoints for inter-service communication. - name: Credentialed Access description: Audience-scoped passport reads authenticated by a verifiable credential rather than an API key — repairers, market-surveillance authorities. Deliberately outside both `/public` (a public URL whose body varies by caller breaks caching and the meaning of `publicJwsSignature`) and `/api/v1` (API keys are the operator's own machine access; a repairer or authority holds a credential and no key). - name: Vault (internal) description: mTLS service-to-service telemetry ingestion (resolver → vault only). + - name: Integrator + description: CSV/XLSX bulk import — product groups, their versioned JSON Schemas, templates, upload, and async job polling. + - name: Identity + description: Public did:web identity — DID document, health, readiness. On the fused node these are served under `/identity/*`. The standalone identity service (:8002) serves them at the root — `/health`, `/ready`, `/.well-known/did.json`. + - name: Identity (internal) + description: mTLS service-to-service signing/rotation. **Not exposed by the fused node**, which signs in-process — reachable only on the standalone identity service, and only from a client presenting `CN=odal-vault`. - name: Public Resolver description: Unauthenticated public endpoints for QR scan resolution. - name: Health - description: Health and readiness probes. + description: Health and readiness probes, plus the vault's build metadata — every deployable's unauthenticated liveness surface in one place. paths: /vault/api/v1/dpp: post: @@ -97,12 +99,14 @@ paths: security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateRequest' + $ref: '#/components/schemas/CreatePassportRequest' example: productName: EcoCell Pro 48V productGroup: battery @@ -130,6 +134,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' '422': $ref: '#/components/responses/ValidationError' /vault/api/v1/dpp/validate: @@ -176,7 +182,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateRequest' + $ref: '#/components/schemas/CreatePassportRequest' responses: '200': description: | @@ -363,26 +369,16 @@ paths: $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' - /vault/api/v1/dpp/{dppId}/publish: - post: - operationId: publishDpp - summary: Publish a DPP + /vault/api/v1/dpp/{dppId}/history: + get: + operationId: getDppHistory + summary: Get DPP audit history description: | - Transition a DPP from `draft` to `active`. - - On success: - 1. `qrCodeUrl` is set to the passport's GS1 Digital Link carrier — - `{resolverBase}/01/{gtin}/21/{serial}` for a trade item, else - `{resolverBase}/dpp/{id}` - 2. The identity service signs the VC payload with Ed25519 (best-effort; - signing failure does not block publish — `jwsSignature` may be null) - 3. `retentionLocked` is set to `true` permanently - 4. `status` = `active`, `publishedAt` = now - 5. A `dpp.published` NATS event is emitted (fire-after-commit) - - No request body is required. + Returns the chronological audit trail for a passport: creation, + status transitions, field updates, etc. Unbounded — returns the full + trail with no pagination or limit. tags: - - DPP Lifecycle + - DPP Management security: - BearerApiKey: [] - BasicAuth: [] @@ -394,35 +390,51 @@ paths: $ref: '#/components/schemas/DppId' responses: '200': - description: DPP published successfully. Returns the full passport record. + description: List of audit entries. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + type: array + items: + $ref: '#/components/schemas/PassportAuditEntry' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/dpp/{dppId}/suspend: + /vault/api/v1/dpp/{dppId}/lint: post: - operationId: suspendDpp - summary: Suspend a DPP + operationId: relintDpp + summary: Re-check plausibility-lint findings description: | - Transition an `active` DPP to `suspended`. - The DPP becomes non-resolvable. The JWS signature is preserved. - Emits a `dpp.suspended` NATS event. + Recomputes the `dpp-rules` plausibility lint pack against the DPP's + current product group data and persists the refreshed `lintResult` (pack + version, findings, assessed-at timestamp). Findings are non-binding — + arithmetic and physical-plausibility checks distinct from binding + compliance rules — and never gate publish or any other transition. - An optional request body with a `reason` field can be provided. + Works regardless of DPP status, including `active` (published): + re-checking does not retroactively affect the passport's JWS + signature, which is frozen over whatever `lintResult` looked like at + publish time. No request body is required. + + The response also reports **`publishReadiness`**: whether this passport + would clear the category mandatory-content gate and the product-group + data/schema gates. That is the gate that most often refuses a battery, and + asking it here is the only way to learn the answer without attempting the + publish. + + `publishReadiness.passportScope` answers a different question from the + blockers: whether Art. 77(1) requires a battery passport for this record at + all. The Regulation defines five battery categories and that article reaches + three — LMT, electric-vehicle, and industrial above 2 kWh — so a portable or + SLI passport is voluntary. This node applies the content gate either way, + which for a voluntary passport is stricter than the article requires; the + `note` says so rather than leaving the caller to wonder why they are being + asked. tags: - - DPP Lifecycle + - DPP Management security: - BearerApiKey: [] - BasicAuth: [] @@ -432,19 +444,23 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/SuspendRequest' responses: '200': - description: DPP suspended. Returns the full passport record. + description: | + Lint findings refreshed. Returns the full passport record, plus + `publishReadiness` — whether this passport would clear the publish gates + that can be answered without attempting the transition. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + allOf: + - $ref: '#/components/schemas/PassportResponse' + - type: object + required: + - publishReadiness + properties: + publishReadiness: + $ref: '#/components/schemas/PublishReadiness' '400': $ref: '#/components/responses/BadRequest' '401': @@ -453,18 +469,20 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - /vault/api/v1/dpp/{dppId}/archive: - post: - operationId: archiveDpp - summary: Archive a DPP + /vault/api/v1/dpp/{dppId}/verify-tree: + get: + operationId: verifyDppTree + summary: Recursively verify a passport's component tree (BOM) description: | - Transition a DPP to `archived`. This is irreversible. - Archived DPPs are immutable and retained for regulatory record-keeping. - No request body is required. + Walks the passport's `componentRefs` breadth-first, fetching each node + and checking its public JWS against the pinned hash. Fails closed on + every ambiguity, bounded by a depth cap and a total-node cap; the report + names the path from the root to any broken node. + + Integrity only: this proves each node's signed public view is unchanged + (hash pin), not the cryptographic validity of the signature. tags: - - DPP Lifecycle + - DPP Management security: - BearerApiKey: [] - BasicAuth: [] @@ -476,35 +494,49 @@ paths: $ref: '#/components/schemas/DppId' responses: '200': - description: DPP archived. Returns the full passport record. + description: The component-tree verification report. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + $ref: '#/components/schemas/TreeReport' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/dpp/{dppId}/eol: - post: - operationId: declareDppEol - summary: Declare a DPP end-of-life + /vault/api/v1/dpp/{dppId}/seal: + get: + operationId: getDppSeal + summary: Fetch the passport's eIDAS qualified electronic seal description: | - Transition a `published` or `suspended` DPP to `deactivated` - (terminal). The record is retained, never deleted — the passport - outlives the product. Destruction (`reason.kind: destroyed`) is only - lawful with a recognised derogation from the unsold-goods destruction - ban (ESPR Art. 25 delegated act). + Returns the qualified seal a QTSP applied to this passport, together with + the compact JWS it was taken over and that JWS's SHA-256 digest. + + The seal has its own route because it is stripped from every audience + view, public included: it covers the **full**-payload `jwsSignature`, so + attaching it to a redacted body would hand the reader a proof that + verifies against nothing they received. + + **This node does not validate the seal.** A detached CAdES must be + checked by an independent AdES validator against the EU Trusted List. A + verdict from the node that bought the seal would attest nothing, so none + is offered. + + `coverage` answers a narrower question that the node *can* answer, from + its own records: `sealedPayloadHash` is the digest it asked the backend + to seal, so a passport re-published after sealing shows as `superseded` + without any AdES tooling. That is a record of what was requested, not + proof of what the CAdES covers — the validator's extracted message + digest is the cross-check. A `superseded` seal remains valid for the + signature it does cover; a seal over the new signature has not landed + yet. + + `404` when the passport has no seal — it may be unpublished, its seal may + still be queued, or the node may have no QTSP configured. An unsealed + passport has no seal resource rather than an empty one. tags: - - DPP Lifecycle + - DPP Management security: - BearerApiKey: [] - BasicAuth: [] @@ -514,38 +546,76 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EolRequest' responses: '200': - description: DPP deactivated. Returns the full passport record. + description: The qualified seal and the signature it attests to. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + $ref: '#/components/schemas/SealResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - $ref: '#/components/responses/Conflict' - /vault/api/v1/dpp/{dppId}/transfer/initiate: + /vault/api/v1/seal: + get: + operationId: getSealSummary + summary: Operator-wide sealing state + description: | + How many published passports carry no seal, plus the outbox totals + behind that number. + + `unsealedPublished` is the headline; the three row counts are context. + They answer different questions and can legitimately disagree: the + counts describe outbox **rows**, while the obligation is about + **passports**. Enqueueing happens after the publish commits, so a crash + in that window publishes a passport that no row will ever cover — + `pending: 0, exhausted: 0` is therefore consistent with any number of + unsealed passports, and a summary built on rows alone would report all + clear. A repair sweep queues those passports on its next pass. + + A passport whose seal covers a *superseded* signature is not counted + here — it carries a seal, and that seal remains a valid attestation of + the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal` + reports that case per passport as `coverage`. + + When `sealingConfigured` is `false` no seal provider is selected, so + every count is `0` because this node has no outbox — not because it has + nothing outstanding. + tags: + - DPP Management + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + '200': + description: Operator-wide sealing state. + content: + application/json: + schema: + $ref: '#/components/schemas/SealSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + /vault/api/v1/dpp/{dppId}/publish: post: - operationId: initiateDppTransfer - summary: Initiate a transfer of responsibility + operationId: publishDpp + summary: Publish a DPP description: | - The outgoing operator signs a pending handover onto the passport's - transfer chain. Only a `published` DPP can be transferred. In the - managed single-node model the caller supplies both the outgoing and - incoming operator; the node signs on the outgoing operator's behalf. + Transition a DPP from `draft` to `active`. + + On success: + 1. `qrCodeUrl` is set to the passport's GS1 Digital Link carrier — + `{resolverBase}/01/{gtin}/21/{serial}` for a trade item, else + `{resolverBase}/dpp/{id}` + 2. The identity service signs the VC payload with Ed25519 (best-effort; + signing failure does not block publish — `jwsSignature` may be null) + 3. `retentionLocked` is set to `true` permanently + 4. `status` = `active`, `publishedAt` = now + 5. A `dpp.published` NATS event is emitted (fire-after-commit) + + No request body is required. tags: - DPP Lifecycle security: @@ -557,19 +627,321 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TransferInitiateRequest' responses: '200': - description: Transfer initiated (pending acceptance). + description: DPP published successfully. Returns the full passport record. content: application/json: schema: - $ref: '#/components/schemas/TransferRecord' + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/amend: + post: + operationId: amendDpp + summary: Amend a published DPP by issuing a successor + description: | + Correct a published DPP. A published passport cannot be edited in place — + its signatures commit to its bytes and the retention guard refuses the + write — so this issues a **new** passport instead. + + The successor is a copy of `dppId` with the `patch` applied, carrying + `supersedesId` back to `dppId` and `version` incremented. It goes through + the same gates as any first publish: schema validation, product-group + validation, mandatory-content checks, signing, and registry sync. When it + publishes successfully, `dppId` moves to the terminal `superseded` state. + + **The response body is a different passport from the one in the path.** It + is the successor, with its own `id`. Clients holding the old id keep a valid + reference: a superseded passport stays resolvable, keeps its signatures and + its seal, and reports `superseded` as its status. + + Only an `active` DPP can be amended. A draft is edited in place with + `PUT /vault/api/v1/dpp/{dppId}`; a suspended, archived, superseded or + deactivated DPP cannot be superseded. + + The schema version is **inherited**, not advanced to the product group's + current one. An amendment corrects content; migrating a passport to a newer + schema is a separate act. This also keeps the successor's disclosure classes + identical to the predecessor's, since those are read from the schema + version's own annotations. + + Emits `dpp.passport.published` for the successor and + `dpp.passport.superseded` for the predecessor. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AmendRequest' + responses: + '201': + description: | + Successor published and predecessor superseded. Returns the **successor** + — a different record from the one named in the path, with its own `id`, + `version` incremented, and `supersedesId` set to `dppId`. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/supersede: + post: + operationId: supersedeDpp + summary: Retire a DPP in favour of a newer one + description: | + Marks the DPP in the path `superseded`, retiring it in favour of the + replacement named in `supersededBy`. + + **The link is checked here, not written.** The successor must already carry + `supersedesId` pointing back at this passport, set when it was created — + `supersedesId` is a protected field and no field patch can add it later. A + successor that does not declare the link is refused with `422`. + + That ordering is deliberate. Writing the link during this transition would + leave, on a failure between the two writes, a retired passport with nothing + pointing at its replacement — the one state a reader cannot recover from. + Requiring the link to exist first makes that unreachable. + + **Both must already be published.** A draft successor retiring a live + passport would leave the product with no servable record, and a + not-yet-published successor may still fail its own gates. A predecessor that + is not published has nothing to retire. + + Terminal and irreversible: a superseded passport accepts no further + transitions, and the successor is what a reader follows forward. + + It keeps serving on the public tier. Superseding replaces the *record*, not + the goods: products made under the old specification are still in the field + carrying data carriers that resolve to it, and ESPR Art. 10(4)(i) asks the + passport to remain available for at least the product's expected lifetime. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SupersedeRequest' + responses: + '200': + description: DPP superseded. Returns the retired passport's record. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/suspend: + post: + operationId: suspendDpp + summary: Suspend a DPP + description: | + Transition an `active` DPP to `suspended`. + The DPP becomes non-resolvable. The JWS signature is preserved. + Emits a `dpp.suspended` NATS event. + + An optional request body with a `reason` field can be provided. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SuspendRequest' + responses: + '200': + description: DPP suspended. Returns the full passport record. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + /vault/api/v1/dpp/{dppId}/archive: + post: + operationId: archiveDpp + summary: Archive a DPP + description: | + Transition a DPP to `archived`. This is irreversible. + Archived DPPs are immutable and retained for regulatory record-keeping. + No request body is required. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + responses: + '200': + description: DPP archived. Returns the full passport record. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/dpp/{dppId}/eol: + post: + operationId: declareDppEol + summary: Declare a DPP end-of-life + description: | + Transition a `published` or `suspended` DPP to `deactivated` + (terminal). The record is retained, never deleted — the passport + outlives the product. Destruction (`reason.kind: destroyed`) is only + lawful with a recognised derogation from the unsold-goods destruction + ban (ESPR Art. 25 delegated act). + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EolRequest' + responses: + '200': + description: DPP deactivated. Returns the full passport record. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + /vault/api/v1/dpp/{dppId}/transfer/initiate: + post: + operationId: initiateDppTransfer + summary: Initiate a transfer of responsibility + description: | + The outgoing operator signs a pending handover onto the passport's + transfer chain. Only a `published` DPP can be transferred. In the + managed single-node model the caller supplies both the outgoing and + incoming operator; the node signs on the outgoing operator's behalf. + tags: + - DPP Lifecycle + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TransferInitiateRequest' + responses: + '200': + description: Transfer initiated (pending acceptance). + content: + application/json: + schema: + $ref: '#/components/schemas/TransferRecord' '400': $ref: '#/components/responses/BadRequest' '401': @@ -752,16 +1124,23 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /vault/api/v1/dpp/{dppId}/history: - get: - operationId: getDppHistory - summary: Get DPP audit history + /vault/api/v1/dpp/{dppId}/evidence: + post: + operationId: generateDppEvidence + summary: Generate and store a signed evidence dossier description: | - Returns the chronological audit trail for a passport: creation, - status transitions, field updates, etc. Unbounded — returns the full - trail with no pagination or limit. + Assembles a self-contained, signed dossier proving a passport's full + proof chain — both JWS signatures, DID document snapshots, the + hash-chained audit trail, and (when present) the transfer chain and + end-of-life record — and persists it. See + `docs/architecture/EVIDENCE-DOSSIER.md` for the complete format + specification. + + Authenticated tier only — the dossier's `fullView` member carries + full-view (non-redacted) passport data. Requires the passport to have + been published at least once; a draft has no signature to export. tags: - - DPP Management + - Evidence Dossiers security: - BearerApiKey: [] - BasicAuth: [] @@ -771,34 +1150,45 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' + - $ref: '#/components/parameters/IdempotencyKey' responses: - '200': - description: List of audit entries. + '201': + description: The stored dossier record. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/AuditEntry' + $ref: '#/components/schemas/EvidenceDossierRecord' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - /vault/api/v1/dpp/{dppId}/stats: + '409': + $ref: '#/components/responses/Conflict' get: - operationId: getDppScanStats - summary: Per-passport scan telemetry + operationId: listDppEvidence + summary: List stored evidence dossiers for a passport description: | - Aggregate, privacy-safe resolution counts for one passport over a - trailing window. Scans and QR-image renders are reported as separate - fields and are never summed — a render is label production, not a - resolution. Nothing about the scanner (IP, agent, session) is collected - or returned; the counters carry no such fields. Returns zeros for a - passport that has never been scanned. + Every evidence dossier generated for this passport, newest first. + + Summaries only — each row carries the dossier's id, when it was generated + and what it covers, but not the document itself. Dossiers embed full-view + passport data and both signature chains, so they are large and are fetched + one at a time through `GET /api/v1/evidence/{id}`. + + A passport can have several: a dossier is a snapshot of the proof chain at + the moment it was generated, so one taken before a transfer and one taken + after are both valid and describe different states. Nothing here is + superseded by anything else here, which is why the list is not filtered + down to a latest. + + Empty for a passport that has never been published — there is no signature + to attest to before then. tags: - - Scan Telemetry + - Evidence Dossiers security: - BearerApiKey: [] - BasicAuth: [] @@ -808,59 +1198,107 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' - - name: days - in: query - required: false - description: Trailing window in days (default 30, clamped to 1..=730). - schema: - type: integer - minimum: 1 - maximum: 730 - default: 30 responses: '200': - description: Aggregate scan counts for the passport. + description: Stored dossier summaries. content: application/json: schema: - $ref: '#/components/schemas/PassportScanStats' + type: array + items: + $ref: '#/components/schemas/EvidenceDossierSummary' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - /vault/api/v1/stats: + '404': + $ref: '#/components/responses/NotFound' + /vault/api/v1/evidence/{id}: get: - operationId: getOperatorScanStats - summary: Operator-wide scan telemetry rollup + operationId: getEvidenceDossier + summary: Fetch one stored dossier's document + description: Returns the dossier document itself — the same shape `POST .../evidence` returns on generation, not the summary wrapper the list endpoint shows. + tags: + - Evidence Dossiers + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: The evidence dossier document. + content: + application/json: + schema: + $ref: '#/components/schemas/EvidenceDossier' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + /vault/api/v1/evidence/{id}/verify: + post: + operationId: verifyStoredEvidenceDossier + summary: Verify a stored dossier description: | - Aggregate resolution counts across all of the operator's passports over - a trailing window — the "your passports were resolved N times" figure. - Scans and QR-image renders are separate; nothing about the scanner is - collected. + An integrity check against the stored dossier's own signatures and + hash chains. A tamper is still a `200` response — the report's + `checks` name which check failed. tags: - - Scan Telemetry + - Evidence Dossiers security: - BearerApiKey: [] - BasicAuth: [] parameters: - - name: days - in: query - required: false - description: Trailing window in days (default 30, clamped to 1..=730). + - name: id + in: path + required: true schema: - type: integer - minimum: 1 - maximum: 730 - default: 30 + type: string + format: uuid responses: '200': - description: Operator-wide aggregate scan counts. + description: The verification report. content: application/json: schema: - $ref: '#/components/schemas/OperatorScanStats' + $ref: '#/components/schemas/VerificationReport' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + /vault/api/v1/evidence/verify: + post: + operationId: verifyUploadedEvidenceDossier + summary: Verify an uploaded dossier document + description: Same checks as the stored-dossier verify endpoint, run against an uploaded document instead. + tags: + - Evidence Dossiers + security: + - BearerApiKey: [] + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EvidenceDossier' + responses: + '200': + description: The verification report. + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationReport' '401': $ref: '#/components/responses/Unauthorized' + '422': + description: Not a valid dossier — malformed JSON or an unrecognised field. /vault/api/v1/dpp/{dppId}/registry: get: operationId: getDppRegistryStatus @@ -928,20 +1366,19 @@ paths: $ref: '#/components/schemas/RegistryRollupView' '401': $ref: '#/components/responses/Unauthorized' - /vault/api/v1/dpp/{dppId}/verify-tree: + /vault/api/v1/dpp/{dppId}/stats: get: - operationId: verifyDppTree - summary: Recursively verify a passport's component tree (BOM) + operationId: getDppScanStats + summary: Per-passport scan telemetry description: | - Walks the passport's `componentRefs` breadth-first, fetching each node - and checking its public JWS against the pinned hash. Fails closed on - every ambiguity, bounded by a depth cap and a total-node cap; the report - names the path from the root to any broken node. - - Integrity only: this proves each node's signed public view is unchanged - (hash pin), not the cryptographic validity of the signature. + Aggregate, privacy-safe resolution counts for one passport over a + trailing window. Scans and QR-image renders are reported as separate + fields and are never summed — a render is label production, not a + resolution. Nothing about the scanner (IP, agent, session) is collected + or returned; the counters carry no such fields. Returns zeros for a + passport that has never been scanned. tags: - - DPP Management + - Scan Telemetry security: - BearerApiKey: [] - BasicAuth: [] @@ -951,233 +1388,214 @@ paths: required: true schema: $ref: '#/components/schemas/DppId' + - name: days + in: query + required: false + description: Trailing window in days (default 30, clamped to 1..=730). + schema: + type: integer + minimum: 1 + maximum: 730 + default: 30 responses: '200': - description: The component-tree verification report. + description: Aggregate scan counts for the passport. content: application/json: schema: - $ref: '#/components/schemas/TreeReport' + $ref: '#/components/schemas/PassportScanStats' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /vault/api/v1/seal: + /vault/api/v1/stats: get: - operationId: getSealSummary - summary: Operator-wide sealing state + operationId: getOperatorScanStats + summary: Operator-wide scan telemetry rollup description: | - How many published passports carry no seal, plus the outbox totals - behind that number. - - `unsealedPublished` is the headline; the three row counts are context. - They answer different questions and can legitimately disagree: the - counts describe outbox **rows**, while the obligation is about - **passports**. Enqueueing happens after the publish commits, so a crash - in that window publishes a passport that no row will ever cover — - `pending: 0, exhausted: 0` is therefore consistent with any number of - unsealed passports, and a summary built on rows alone would report all - clear. A repair sweep queues those passports on its next pass. - - A passport whose seal covers a *superseded* signature is not counted - here — it carries a seal, and that seal remains a valid attestation of - the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal` - reports that case per passport as `coverage`. - - When `sealingConfigured` is `false` no seal provider is selected, so - every count is `0` because this node has no outbox — not because it has - nothing outstanding. + Aggregate resolution counts across all of the operator's passports over + a trailing window — the "your passports were resolved N times" figure. + Scans and QR-image renders are separate; nothing about the scanner is + collected. tags: - - DPP Management + - Scan Telemetry security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - name: days + in: query + required: false + description: Trailing window in days (default 30, clamped to 1..=730). + schema: + type: integer + minimum: 1 + maximum: 730 + default: 30 responses: '200': - description: Operator-wide sealing state. + description: Operator-wide aggregate scan counts. content: application/json: schema: - $ref: '#/components/schemas/SealSummaryResponse' + $ref: '#/components/schemas/OperatorScanStats' '401': $ref: '#/components/responses/Unauthorized' - /vault/api/v1/dpp/{dppId}/seal: + /vault/api/v1/operator: get: - operationId: getDppSeal - summary: Fetch the passport's eIDAS qualified electronic seal + operationId: getOperatorConfig + summary: Get operator configuration description: | - Returns the qualified seal a QTSP applied to this passport, together with - the compact JWS it was taken over and that JWS's SHA-256 digest. - - The seal has its own route because it is stripped from every audience - view, public included: it covers the **full**-payload `jwsSignature`, so - attaching it to a redacted body would hand the reader a proof that - verifies against nothing they received. - - **This node does not validate the seal.** A detached CAdES must be - checked by an independent AdES validator against the EU Trusted List. A - verdict from the node that bought the seal would attest nothing, so none - is offered. - - `coverage` answers a narrower question that the node *can* answer, from - its own records: `sealedPayloadHash` is the digest it asked the backend - to seal, so a passport re-published after sealing shows as `superseded` - without any AdES tooling. That is a record of what was requested, not - proof of what the CAdES covers — the validator's extracted message - digest is the cross-check. A `superseded` seal remains valid for the - signature it does cover; a seal over the new signature has not landed - yet. - - `404` when the passport has no seal — it may be unpublished, its seal may - still be queued, or the node may have no QTSP configured. An unsealed - passport has no seal resource rather than an empty one. + Returns the calling operator's configuration (branding, legal info, + retention settings). If no config has been saved yet, returns an + empty default config rather than 404. tags: - - DPP Management + - Operator security: - BearerApiKey: [] - BasicAuth: [] - parameters: - - name: dppId - in: path - required: true - schema: - $ref: '#/components/schemas/DppId' responses: '200': - description: The qualified seal and the signature it attests to. + description: Operator configuration. content: application/json: schema: - $ref: '#/components/schemas/SealResponse' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/OperatorConfig' '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /vault/api/v1/dpp/{dppId}/lint: - post: - operationId: relintDpp - summary: Re-check plausibility-lint findings + patch: + operationId: updateOperatorConfig + summary: Update operator configuration description: | - Recomputes the `dpp-rules` plausibility lint pack against the DPP's - current product group data and persists the refreshed `lintResult` (pack - version, findings, assessed-at timestamp). Findings are non-binding — - arithmetic and physical-plausibility checks distinct from binding - compliance rules — and never gate publish or any other transition. + Merge-patch the operator's configuration. Only supplied fields + are changed. - Works regardless of DPP status, including `active` (published): - re-checking does not retroactively affect the passport's JWS - signature, which is frozen over whatever `lintResult` looked like at - publish time. No request body is required. + Requires an admin-scoped key. Reading the configuration back does not — + `GET` is open to any authenticated caller, since a passport's issuer is + public information the moment one is published. tags: - - DPP Management + - Operator security: - BearerApiKey: [] - BasicAuth: [] - parameters: - - name: dppId - in: path - required: true - schema: - $ref: '#/components/schemas/DppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOperatorConfig' responses: '200': - description: Lint findings refreshed. Returns the full passport record. + description: Updated operator configuration. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + $ref: '#/components/schemas/OperatorConfig' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /vault/api/v1/dpp/{dppId}/evidence: + /vault/api/v1/facilities: + get: + operationId: listFacilities + summary: List facilities + description: | + Lists the operator's facilities (ESPR Annex III). The `isDefault` + facility is stamped onto new passports. Requires an admin-scoped key. + tags: + - Facilities + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + '200': + description: List of facilities. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Facility' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' post: - operationId: generateDppEvidence - summary: Generate and store a signed evidence dossier + operationId: addFacility + summary: Add a facility description: | - Assembles a self-contained, signed dossier proving a passport's full - proof chain — both JWS signatures, DID document snapshots, the - hash-chained audit trail, and (when present) the transfer chain and - end-of-life record — and persists it. See - `docs/architecture/EVIDENCE-DOSSIER.md` for the complete format - specification. + Add a facility. The identifier is validated by scheme — a `gln` must + pass the GS1 mod-10 check digit. Requires an admin-scoped key. - Authenticated tier only — the dossier's `fullView` member carries - full-view (non-redacted) passport data. Requires the passport to have - been published at least once; a draft has no signature to export. + Facilities are retired, never deleted, so a duplicate created by a retried + request cannot be cleaned up afterwards. Send an `Idempotency-Key`. tags: - - Evidence Dossiers + - Facilities security: - BearerApiKey: [] - BasicAuth: [] parameters: - - name: dppId - in: path - required: true - schema: - $ref: '#/components/schemas/DppId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFacilityRequest' responses: '201': - description: The stored dossier record. + description: Facility created. content: application/json: schema: - $ref: '#/components/schemas/EvidenceDossierRecord' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/Facility' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' '409': - $ref: '#/components/responses/Conflict' - get: - operationId: listDppEvidence - summary: List stored evidence dossiers for a passport - description: Summaries only (no document body), newest first. + $ref: '#/components/responses/IdempotentRequestInFlight' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/facilities/{id}: + delete: + operationId: removeFacility + summary: Remove a facility + description: 'Retires the facility (soft-delete): the row is kept as Annex III provenance for passports that already stamped its identifier — never hard-deleted. Requires an admin-scoped key.' tags: - - Evidence Dossiers + - Facilities security: - BearerApiKey: [] - BasicAuth: [] parameters: - - name: dppId + - name: id in: path required: true schema: - $ref: '#/components/schemas/DppId' + type: string + format: uuid responses: - '200': - description: Stored dossier summaries. - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/EvidenceDossierSummary' + '204': + description: Facility removed. '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - /vault/api/v1/evidence/{id}: - get: - operationId: getEvidenceDossier - summary: Fetch one stored dossier's document - description: Returns the dossier document itself — the same shape `POST .../evidence` returns on generation, not the summary wrapper the list endpoint shows. + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/facilities/{id}/default: + post: + operationId: setDefaultFacility + summary: Set the default facility + description: Makes this facility the sole default, stamped onto new passports. Requires an admin-scoped key. tags: - - Evidence Dossiers + - Facilities security: - BearerApiKey: [] - BasicAuth: [] @@ -1189,26 +1607,25 @@ paths: type: string format: uuid responses: - '200': - description: The evidence dossier document. - content: - application/json: - schema: - $ref: '#/components/schemas/EvidenceDossier' + '204': + description: Default facility set. + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - /vault/api/v1/evidence/{id}/verify: - post: - operationId: verifyStoredEvidenceDossier - summary: Verify a stored dossier + /vault/api/v1/facilities/{id}/audit: + get: + operationId: getFacilityAudit + summary: Facility audit trail description: | - An integrity check against the stored dossier's own signatures and - hash chains. A tamper is still a `200` response — the report's - `checks` name which check failed. + Append-only mutation history for one facility (added, retired, + set-default), oldest first. Requires an admin-scoped key. tags: - - Evidence Dossiers + - Facilities security: - BearerApiKey: [] - BasicAuth: [] @@ -1221,88 +1638,169 @@ paths: format: uuid responses: '200': - description: The verification report. + description: List of audit entries for this facility. content: application/json: schema: - $ref: '#/components/schemas/VerificationReport' + type: array + items: + $ref: '#/components/schemas/RegistryIdentityAuditEntry' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /vault/api/v1/evidence/verify: - post: - operationId: verifyUploadedEvidenceDossier - summary: Verify an uploaded dossier document - description: Same checks as the stored-dossier verify endpoint, run against an uploaded document instead. + '403': + $ref: '#/components/responses/Forbidden' + /vault/api/v1/operator-identifiers: + get: + operationId: listOperatorIdentifiers + summary: List operator identifiers + description: | + Lists the operator's economic-operator identifiers (ESPR Art. 13). The + `isPrimary` identifier is stamped onto new passports. Admin scope required. tags: - - Evidence Dossiers + - Operator Identifiers security: - BearerApiKey: [] - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EvidenceDossier' responses: '200': - description: The verification report. + description: List of operator identifiers. content: application/json: schema: - $ref: '#/components/schemas/VerificationReport' + type: array + items: + $ref: '#/components/schemas/OperatorIdentifier' '401': $ref: '#/components/responses/Unauthorized' - '422': - description: Not a valid dossier — malformed JSON or an unrecognised field. - /vault/api/v1/operator: - get: - operationId: getOperatorConfig - summary: Get operator configuration + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: addOperatorIdentifier + summary: Add an operator identifier description: | - Returns the calling operator's configuration (branding, legal info, - retention settings). If no config has been saved yet, returns an - empty default config rather than 404. + Add an economic-operator identifier. Validated by scheme — LEI uses + ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a country prefix. + Requires an admin-scoped key. + + Operator identifiers are retired, never deleted, so a duplicate created by + a retried request cannot be cleaned up afterwards. Send an + `Idempotency-Key`. tags: - - Operator + - Operator Identifiers security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOperatorIdentifierRequest' responses: - '200': - description: Operator configuration. + '201': + description: Operator identifier created. content: application/json: schema: - $ref: '#/components/schemas/OperatorConfig' + $ref: '#/components/schemas/OperatorIdentifier' '401': $ref: '#/components/responses/Unauthorized' - patch: - operationId: updateOperatorConfig - summary: Update operator configuration + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/operator-identifiers/{id}: + delete: + operationId: removeOperatorIdentifier + summary: Remove an operator identifier + description: 'Retires the identifier (soft-delete): the row is kept as Art. 13 provenance for passports that already stamped its value — never hard-deleted. Requires an admin-scoped key.' + tags: + - Operator Identifiers + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Operator identifier removed. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + /vault/api/v1/operator-identifiers/{id}/primary: + post: + operationId: setPrimaryOperatorIdentifier + summary: Set the primary operator identifier + description: Makes this identifier the sole primary, stamped onto new passports. Requires an admin-scoped key. + tags: + - Operator Identifiers + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Primary operator identifier set. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + /vault/api/v1/operator-identifiers/{id}/audit: + get: + operationId: getOperatorIdentifierAudit + summary: Operator-identifier audit trail description: | - Merge-patch the operator's configuration. Only supplied fields - are changed. + Append-only mutation history for one operator identifier (added, + retired, set-primary), oldest first. Requires an admin-scoped key. tags: - - Operator + - Operator Identifiers security: - BearerApiKey: [] - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateOperatorConfig' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid responses: '200': - description: Updated operator configuration. + description: List of audit entries for this operator identifier. content: application/json: schema: - $ref: '#/components/schemas/OperatorConfig' + type: array + items: + $ref: '#/components/schemas/RegistryIdentityAuditEntry' '400': $ref: '#/components/responses/BadRequest' '401': @@ -1316,6 +1814,8 @@ paths: description: | Lists all active API keys for this deployment. Only the key prefix is returned — the full secret is shown once at creation time. + + Requires an admin-scoped key. tags: - API Keys security: @@ -1340,11 +1840,23 @@ paths: description: | Generate a new API key. The response includes the full plain-text `secret` — this is the only time it will be shown. Store it securely. + + **A replayed request does not return the secret.** This route accepts an + `Idempotency-Key`, but the secret is never stored, so a retry that finds a + completed key answers with the key record and + `"secretAlreadyDelivered": true` in place of `secret`. That is the honest + answer: the credential exists and was handed to the first attempt. If that + response was lost, revoke the key and create another. + + Requires an admin-scoped key — including the first one, which is minted + with the operator's Basic credential. tags: - API Keys security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: @@ -1357,11 +1869,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/NewApiKey' + $ref: '#/components/schemas/CreatedApiKeyResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' '422': $ref: '#/components/responses/ValidationError' /vault/api/v1/api-keys/{id}: @@ -1371,6 +1885,14 @@ paths: description: | Soft-revokes a key. The record remains for audit but `isActive` is set to false. Subsequent auth attempts with this key will fail. + + **A key cannot revoke itself.** Revoking the credential this very request + authenticated with would lock the caller — and everything sharing that key + — out of every authenticated route, so it is refused with `409`. + Authenticate with a different key, or with the operator's Basic credential, + which carries no key id and is therefore the lockout-recovery path. + + Requires an admin-scoped key. tags: - API Keys security: @@ -1398,66 +1920,211 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - /vault/api/v1/plugins: + '409': + description: | + The key named in the path is the one this request authenticated with. + Revoking it would lock the caller out of every authenticated route, so + it is refused rather than performed. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/conflict + title: Conflict + status: 409 + detail: Cannot revoke the API key you are currently authenticating with. Authenticate with a different key (or admin credentials), then revoke this one. + /vault/api/v1/credentials: post: - operationId: installPlugin - summary: Install a signed product group plugin + operationId: issueCredential + summary: Issue an access credential description: | - Verify, persist, and hot-swap a signed product group plugin — no node restart. + Mints a DPP access credential signed with this node's own key, and returns + it as a compact VC-JWT the holder presents in the `X-DPP-Credential` header + on `GET /vault/credential/dpp/{dppId}`. - The node verifies the uploaded artifact's detached signature against its - pinned publisher key, gates the plugin's declared ABI, instantiate-smokes - the module, persists it (so a restart re-loads it), and atomically swaps - it into service. Any rejection is fail-closed — the previously installed - plugin keeps serving. Admin-scoped. + **Only a legitimate interest, never an authority.** An operator naming its + own authorised repairer attests something no one else can — membership of + that network is a fact the operator alone holds, and no EU register of + authorised repairers exists to hold it instead. An operator naming itself a + market surveillance authority attests nothing, because the standing being + claimed is conferred by a member state. The three authority roles are + refused with `422`. - Both a portable `.wasm` (compiled on the node) and a precompiled `.cwasm` - (loaded only if it matches this node's engine) are accepted. + **Issuing is not trusting.** A credential minted here is honoured by *this* + node only if the node also trusts its own DID (`CREDENTIAL_ISSUERS_SELF`). + An operator running more than one node ordinarily mints on one and honours + it on another, so issuance is available regardless of that switch. + + **Revocation is expiry.** This node fetches W3C status lists but publishes + none, so a credential it mints carries no `credentialStatus` and cannot be + withdrawn before it lapses. `validForDays` is therefore capped rather than + open-ended, and defaults to less than the cap. + + **Retry with an `Idempotency-Key`.** Issuance is the sharpest case in the + keyed set: the node stores no record of what it signed and publishes no + status list, so a duplicate cannot be found afterwards and could not be + withdrawn if it were. A retry under the same key replays the original + response — including `credentialJws`, which is what stops the retry from + minting a *second* live credential. + + Requires an admin-scoped key. tags: - - Plugins + - Access security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: - multipart/form-data: + application/json: schema: - type: object - required: - - wasm - - sig - properties: - wasm: - type: string - format: binary - description: | - The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the product group when `productGroup` is omitted (`product-group-.wasm`) and whether it is treated as precompiled (`.cwasm`). - sig: - type: string - format: binary - description: Detached Ed25519 signature over SHA-256 of the artifact bytes. - productGroup: - type: string - description: Product group key; derived from the filename if omitted. - example: battery + $ref: '#/components/schemas/IssueCredentialRequest' responses: '201': - description: Plugin verified, persisted, and now serving. + description: Credential issued. content: application/json: schema: - $ref: '#/components/schemas/InstalledPlugin' + $ref: '#/components/schemas/IssuedCredential' '400': - description: Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined). + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': - description: A non-admin credential attempted to install a plugin. + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' '422': - description: The artifact was rejected — bad signature, incompatible ABI, or a non-instantiable/incompatible module. + description: | + The request cannot produce a usable credential — an authority role, a + `holderDid` that is not a DID, a `country` that is not alpha-2, or a + `validForDays` outside 1–90. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' '501': - description: This node has no plugin host configured; runtime install is unavailable. + description: | + This deployment reaches no key store, so it cannot sign. The standalone + vault binary answers this; the fused node does not. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + /vault/api/v1/unsold-goods: + get: + operationId: listUnsoldGoods + summary: List unsold-goods disclosure lines + description: | + Every recorded ESPR Art. 24 disclosure line, newest first, optionally + narrowed to one financial year. + + Admin rather than write: these are the operator's own annual figures, and + reading them back is an administrative act rather than part of producing + passports. + tags: + - Unsold goods + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: reportingPeriod + in: query + required: false + description: Financial year as `YYYY`. Omit for every period. + schema: + type: string + pattern: ^[0-9]{4}$ + responses: + '200': + description: The disclosure lines. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UnsoldGoodsEntry' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + description: '`reportingPeriod` is not a four-digit year.' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + post: + operationId: recordUnsoldGoods + summary: Record an unsold-goods disclosure line + description: | + Records one line of the ESPR Art. 24 disclosure of unsold consumer products + discarded in a financial year. + + **This is not a passport.** There is no digital product passport anywhere in + Art. 24 or Art. 25: the subject is an *operator over a financial year*, the + medium is the operator's own website, and the trigger is discarding unsold + stock — none of which is a product placed on the market. These routes + therefore sit beside the other operator-scoped ones, and no `unsold-goods` + product group carries passports. + + **Destruction must be justified.** Art. 25 prohibits destroying unsold + consumer products listed in Annex VII from 19 July 2026, so + `destination: exemptDestruction` requires `destructionJustification` and is + refused with `422` without it. The converse is refused too: a justification + on any other destination describes nothing. + + **Annex VII scope is not decided here.** That is a CN-code prefix test, and + the goods in a disclosure line do not arrive carrying CN codes. + `productCategory` is the operator's own categorisation for Art. 24(1)(a). + + **Retry with an `Idempotency-Key`.** This route serves no `DELETE`, so a + duplicate line is permanent — and Art. 24 is disclosed publicly for a + financial year, so a discard counted twice overstates what the operator + actually did. + + Requires a write-scoped key; reading the lines back requires admin. + tags: + - Unsold goods + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUnsoldGoodsEntry' + responses: + '201': + description: The recorded line. + content: + application/json: + schema: + $ref: '#/components/schemas/UnsoldGoodsEntry' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' + '422': + description: | + The line cannot be recorded — a `reportingPeriod` that is not a + four-digit year, a negative count or weight, a `countryOfDisposal` that + is not alpha-2, a destruction with no justification, or a justification + on something that was not destroyed. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' /vault/api/v1/webhooks: get: operationId: listWebhooks @@ -1491,17 +2158,25 @@ paths: unless the node sets WEBHOOK_ALLOW_PRIVATE_TARGETS). The response includes the `secret` — shown ONCE — used to verify the `X-Odal-Signature` header on every delivery. Admin-scoped. + + **A replayed request does not return the secret.** This route accepts an + `Idempotency-Key`, but the signing secret is never stored, so a retry that + finds a completed key answers with the subscription and + `"secretAlreadyDelivered": true` in place of `secret`. If the first + response was lost, delete the subscription and create another. tags: - Webhooks security: - BearerApiKey: [] - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/NewWebhookSubscription' + $ref: '#/components/schemas/CreateWebhookRequest' responses: '201': description: Subscription created. The `secret` field is shown ONCE. @@ -1513,6 +2188,8 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' '422': $ref: '#/components/responses/ValidationError' /vault/api/v1/webhooks/{id}: @@ -1579,13 +2256,119 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - /vault/api/v1/node/state: - get: - operationId: getNodeState - summary: Node setup state + /vault/api/v1/plugins: + post: + operationId: installPlugin + summary: Install a signed product group plugin description: | - Reports whether the node has been claimed (at least one active API key) - and whether the operator identity is complete enough to publish. Used by + Verify, persist, and hot-swap a signed product group plugin — no node restart. + + The node verifies the uploaded artifact's detached signature against its + pinned publisher key, gates the plugin's declared ABI, instantiate-smokes + the module, persists it (so a restart re-loads it), and atomically swaps + it into service. Any rejection is fail-closed — the previously installed + plugin keeps serving. Admin-scoped. + + Both a portable `.wasm` (compiled on the node) and a precompiled `.cwasm` + (loaded only if it matches this node's engine) are accepted. + tags: + - Plugins + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - wasm + - sig + properties: + wasm: + type: string + format: binary + description: | + The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the product group when `productGroup` is omitted (`product-group-.wasm`) and whether it is treated as precompiled (`.cwasm`). + sig: + type: string + format: binary + description: Detached Ed25519 signature over SHA-256 of the artifact bytes. + productGroup: + type: string + description: Product group key; derived from the filename if omitted. + example: battery + responses: + '201': + description: Plugin verified, persisted, and now serving. + content: + application/json: + schema: + $ref: '#/components/schemas/InstalledPlugin' + '400': + description: Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined). + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: A non-admin credential attempted to install a plugin. + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' + '422': + description: The artifact was rejected — bad signature, incompatible ABI, or a non-instantiable/incompatible module. + '501': + description: This node has no plugin host configured; runtime install is unavailable. + /vault/api/v1/ruleset/reload: + post: + operationId: reloadRuleset + summary: Re-read the signed ruleset channel and hot-swap a verified bundle + description: | + Adopt a newly published compliance ruleset — no node restart. + + The node re-reads its configured channel, verifies the bundle's manifest + signature against the pinned publisher key, checks the content hash, refuses + a bundle whose rules do not take effect yet or that is older than the one + already in force, and only then swaps it in atomically. Requests in flight + keep serving throughout. Any rejection is fail-closed — the ruleset already + in force keeps validating. Admin-scoped. + + The node also polls the channel on its own (`RULESET_POLL_INTERVAL_SECS`), + so this route is how an operator says "take it now", not the only way a swap + happens. + + Re-reading a channel that has not changed is a **success** with + `changed: false`, not an error. + tags: + - Ruleset + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + '200': + description: The channel was read. `changed` says whether the ruleset in force moved. + content: + application/json: + schema: + $ref: '#/components/schemas/RulesetReload' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: A non-admin credential attempted to reload the ruleset. + '422': + description: 'The bundle was refused and the ruleset in force is unchanged. The problem `code` says which refusal: `RULESET_REJECTED` (bad signature, content-hash mismatch, or a malformed manifest — distrust the bytes), `RULESET_NOT_YET_EFFECTIVE` (authentic but its rules start later — leave it staged and re-offer it once the date arrives), or `RULESET_SUPERSEDED` (authentic but older than what is running — the rollback refusal; something served a stale bundle).' + '501': + description: This node has no signed ruleset channel configured; it is running its compiled-in baseline and there is nothing to re-read. + '503': + description: The channel could not be read at all — the bundle file is missing or unreadable, or what arrived was not a bundle. Transport-level; retry once the drop is fixed. + /vault/api/v1/node/state: + get: + operationId: getNodeState + summary: Node setup state + description: | + Reports whether the node has been claimed (at least one active API key) + and whether the operator identity is complete enough to publish. Used by `odal bootstrap` to stay idempotent. tags: - Node @@ -1629,505 +2412,591 @@ paths: $ref: '#/components/schemas/WhoamiResponse' '401': $ref: '#/components/responses/Unauthorized' - /vault/api/v1/facilities: + /vault/public/dpp/{dppId}: get: - operationId: listFacilities - summary: List facilities + operationId: publicGetDpp + summary: Public read of a published DPP description: | - Lists the operator's facilities (ESPR Annex III). The `isDefault` - facility is stamped onto new passports. Requires an admin-scoped key. + Unauthenticated read of a published passport. Used by the resolver + service internally. Returns 404 for drafts, 410 for suspended. tags: - - Facilities - security: - - BearerApiKey: [] - - BasicAuth: [] + - Public (Vault) + security: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + - name: schema_view + in: query + required: false + description: Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed. + schema: + type: string + example: 2.0.0 responses: '200': - description: List of facilities. + description: Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Facility' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - post: - operationId: addFacility - summary: Add a facility - description: | - Add a facility. The identifier is validated by scheme — a `gln` must - pass the GS1 mod-10 check digit. Requires an admin-scoped key. - tags: - - Facilities - security: - - BearerApiKey: [] - - BasicAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateFacilityRequest' - responses: - '201': - description: Facility created. + $ref: '#/components/schemas/PassportResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + description: Not found or not published. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/Facility' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/schemas/Problem' + '410': + description: Passport has been suspended. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/facilities/{id}: - delete: - operationId: removeFacility - summary: Remove a facility - description: 'Retires the facility (soft-delete): the row is kept as Annex III provenance for passports that already stamped its identifier — never hard-deleted. Requires an admin-scoped key.' + description: The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no product group data). + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + /vault/public/dpp/by-gtin/{gtin}: + get: + operationId: publicGetDppByGtin + summary: Public read of a published DPP by GTIN + description: | + Unauthenticated read of the published passport for a GS1 GTIN-14 — the + same signed public view the by-id route serves, found by trade item + instead of by passport id. + + **A withdrawn passport answers `410`, not `404`.** That distinction is the + point of this route rather than a detail of it: `404` means no passport for + this GTIN was ever published here, while `410` means one was and has since + been suspended. Only the second is a recall signal, and a consumer who + scanned a product needs to be able to tell them apart. `422` is a + structurally invalid GTIN-14 — a bad check digit or wrong length — which is + a malformed request rather than an answer about any product. + + Only the passport's `public` audience view is returned, carrying the + publish-time `publicJwsSignature` that covers exactly those bytes. + Restricted fields are not present at all. tags: - - Facilities - security: - - BearerApiKey: [] - - BasicAuth: [] + - Public (Vault) + security: [] parameters: - - name: id + - name: gtin in: path required: true schema: type: string - format: uuid + description: GS1 GTIN-14 (14 digits, mod-10 check digit). + example: '09506000134352' + - name: schema_view + in: query + required: false + description: Request a read-time schema-upcast view (see the by-id read). The response becomes `{ passport, schemaView }`. + schema: + type: string + example: 2.0.0 responses: - '204': - description: Facility removed. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + '200': + description: Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportResponse' '404': $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/facilities/{id}/audit: + description: The requested `schema_view` is unavailable. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + /vault/credential/dpp/{dppId}: get: - operationId: getFacilityAudit - summary: Facility audit trail + operationId: readDppByCredential + summary: Audience-scoped read of a published DPP description: | - Append-only mutation history for one facility (added, retired, - set-default), oldest first. Requires an admin-scoped key. + Reads a published passport filtered to the caller's audience. No + `X-DPP-Credential` header returns the same signed public view as + `/public/dpp/{dppId}`. A verified credential returns the passport + filtered to that audience's disclosure classes (ESPR Art. 77(2)), + carrying the proof computed over that view. Credentialed reads are + recorded to the passport's audit trail; anonymous reads are not. + + Returns the public view (not an error) when credential verification + is not configured on this node. tags: - - Facilities - security: - - BearerApiKey: [] - - BasicAuth: [] + - Credentialed Access + security: [] parameters: - - name: id + - name: dppId in: path required: true + schema: + $ref: '#/components/schemas/DppId' + - name: X-DPP-Credential + in: header + required: false + description: A verifiable access credential. Absent means public access. schema: type: string - format: uuid responses: '200': - description: List of audit entries for this facility. + description: Passport filtered to the resolved audience. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/RegistryIdentityAudit' + $ref: '#/components/schemas/PassportResponse' '400': $ref: '#/components/responses/BadRequest' '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - /vault/api/v1/facilities/{id}/default: - post: - operationId: setDefaultFacility - summary: Set the default facility - description: Makes this facility the sole default, stamped onto new passports. Requires an admin-scoped key. - tags: - - Facilities - security: - - BearerApiKey: [] - - BasicAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '204': - description: Default facility set. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + description: The presented credential failed verification. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' '404': - $ref: '#/components/responses/NotFound' - /vault/api/v1/operator-identifiers: - get: - operationId: listOperatorIdentifiers - summary: List operator identifiers - description: | - Lists the operator's economic-operator identifiers (ESPR Art. 13). The - `isPrimary` identifier is stamped onto new passports. Admin scope required. - tags: - - Operator Identifiers - security: - - BearerApiKey: [] - - BasicAuth: [] - responses: - '200': - description: List of operator identifiers. + description: Not found or not published. content: - application/json: + application/problem+json: schema: - type: array - items: - $ref: '#/components/schemas/OperatorIdentifier' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/schemas/Problem' + '410': + description: This passport has been suspended. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + /vault/internal/scan-batch: post: - operationId: addOperatorIdentifier - summary: Add an operator identifier + operationId: ingestScanBatch + summary: Flush a scan-telemetry batch (internal, mTLS) description: | - Add an economic-operator identifier. Validated by scheme — LEI uses - ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a country prefix. - Requires an admin-scoped key. + The mTLS-gated sink the public resolver flushes its in-memory + aggregate scan/QR-render counters to (`CN=odal-resolver` only). The + resolver holds no operator API key and no database of its own. + + **Send an `Idempotency-Key`.** The ingest is additive + (`count = count + delta`), so a window re-sent after a lost acknowledgement + is counted twice. The resolver holds a failed batch and re-sends it + byte-for-byte under a stable key; without one, a read timeout on a request + the node already committed silently inflates an operator's resolution + counts. tags: - - Operator Identifiers + - Vault (internal) security: - - BearerApiKey: [] - - BasicAuth: [] + - MutualTLS: [] + parameters: + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateOperatorIdentifierRequest' - responses: - '201': - description: Operator identifier created. - content: - application/json: - schema: - $ref: '#/components/schemas/OperatorIdentifier' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/operator-identifiers/{id}: - delete: - operationId: removeOperatorIdentifier - summary: Remove an operator identifier - description: 'Retires the identifier (soft-delete): the row is kept as Art. 13 provenance for passports that already stamped its value — never hard-deleted. Requires an admin-scoped key.' - tags: - - Operator Identifiers - security: - - BearerApiKey: [] - - BasicAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid + $ref: '#/components/schemas/ScanBatch' responses: '204': - description: Operator identifier removed. - '400': - $ref: '#/components/responses/BadRequest' + description: Batch ingested. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - '422': - $ref: '#/components/responses/ValidationError' - /vault/api/v1/operator-identifiers/{id}/audit: + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' + /integrator/api/v1/product-groups: get: - operationId: getOperatorIdentifierAudit - summary: Operator-identifier audit trail + operationId: listProductGroups + summary: List product groups and their passport obligations description: | - Append-only mutation history for one operator identifier (added, - retired, set-primary), oldest first. Requires an admin-scoped key. + Whether a digital product passport is required for each product group this + node knows of, from what date, under which acts, and whether this build can + actually make a binding determination. Unauthenticated. + + "Knows of" is wider than "has a schema for". A product group reached by an + act while carrying no catalog descriptor is listed too, with a `null` + `title` — that group has no schema, plugin or template to be discovered from, + so this is the only place it can be asked about. + + **Every date is served with its `basis`.** Most of the catalog is undated, + and of the dates that exist some trace to an adopted text (`sourced`) and + some are a reading (`assumed`). A date without its basis would present a + qualified reading as an unqualified claim, so `basis` is always present + wherever a date or a retention period is. + + `required` and `determinable` are different questions and are reported + separately. An obligation can exist while the implementing acts that define + the technical requirements do not, in which case nothing is bindingly + determinable yet however clearly the duty is written. + + Schema versions are not restated here — see `/integrator/api/v1/schemas`, + which is their one home. tags: - - Operator Identifiers - security: - - BearerApiKey: [] - - BasicAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid + - Integrator + security: [] responses: '200': - description: List of audit entries for this operator identifier. + description: Every product group this build knows of. content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/RegistryIdentityAudit' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - /vault/api/v1/operator-identifiers/{id}/primary: - post: - operationId: setPrimaryOperatorIdentifier - summary: Set the primary operator identifier - description: Makes this identifier the sole primary, stamped onto new passports. Requires an admin-scoped key. + $ref: '#/components/schemas/ProductGroupObligationList' + /integrator/api/v1/product-groups/{productGroup}: + get: + operationId: getProductGroupObligation + summary: One product group's passport obligation + description: | + The passport obligation for a single product group. Unauthenticated. + + A key neither catalog knows is a `404` rather than an entry with an empty + obligation — "this node knows nothing about it" and "no passport is + required" are different answers, and the second would be a compliance claim + the node is not entitled to make. + + A key an act reaches while no product group descriptor exists for it is + **not** a `404`. The node holds a recorded binding for such a group, and + refusing it would deny knowledge the node has. It is served with a `null` + `title` and its obligation in full. tags: - - Operator Identifiers - security: - - BearerApiKey: [] - - BasicAuth: [] + - Integrator + security: [] parameters: - - name: id + - name: productGroup in: path required: true + description: The product group catalog key. schema: type: string - format: uuid + example: toy responses: - '204': - description: Primary operator identifier set. - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + '200': + description: The product group's obligation. + content: + application/json: + schema: + $ref: '#/components/schemas/ProductGroupObligation' '404': $ref: '#/components/responses/NotFound' - /vault/public/dpp/{dppId}: + /integrator/api/v1/schemas: get: - operationId: publicGetDpp - summary: Public read of a published DPP + operationId: listProductGroupSchemas + summary: List product group schemas and their versions description: | - Unauthenticated read of a published passport. Used by the resolver - service internally. Returns 404 for drafts, 410 for suspended. + Every product group with a JSON Schema, the version a new passport is validated + against (`current`), and every version a stored passport may legitimately + record (`versions`). Unauthenticated. tags: - - Public (Vault) - parameters: - - name: dppId - in: path - required: true - schema: - $ref: '#/components/schemas/DppId' - - name: schema_view - in: query - required: false - description: Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed. - schema: - type: string - example: 2.0.0 + - Integrator + security: [] responses: '200': - description: Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`. + description: The available product group schemas. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' - '400': - $ref: '#/components/responses/BadRequest' - '404': - description: Not found or not published. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - '410': - description: Passport has been suspended. + type: object + properties: + schemas: + type: array + items: + type: object + properties: + productGroup: + type: string + example: battery + current: + type: + - string + - 'null' + example: 2.6.0 + versions: + type: array + items: + type: string + example: + - 1.0.0 + - 2.6.0 + /integrator/api/v1/schemas/{productGroup}: + get: + operationId: getCurrentProductGroupSchema + summary: Fetch a product group's current JSON Schema + description: | + The schema a passport created today is validated against, resolved through + the same registry the publish gate uses — never a copy, which would drift in + the direction where a body passes here and fails at publish. Unauthenticated. + + Every `description` is omitted from the served document. Those fields make + regulatory assertions that have not been verified against primary text, and + serving them would turn developer-facing comments into a product surface. + Everything that decides accept or reject — types, `enum`, `required`, + `pattern`, bounds, `additionalProperties` — is served in full, so a client + can pre-validate a body and get the verdict the create route would give. + tags: + - Integrator + security: [] + parameters: + - name: productGroup + in: path + required: true + schema: + type: string + example: battery + responses: + '200': + description: The product group's current JSON Schema. content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/Problem' - '422': - description: The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no product group data). + type: object + '404': + description: No schema for this product group; the body names the known product groups. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /vault/public/dpp/by-gtin/{gtin}: + /integrator/api/v1/schemas/{productGroup}/{version}: get: - operationId: publicGetDppByGtin - summary: Public read of a published DPP by GTIN + operationId: getPinnedProductGroupSchema + summary: Fetch a pinned version of a product group's JSON Schema description: | - Unauthenticated read of the published passport for a GS1 GTIN-14. - Returns 404 if no published passport matches the GTIN. + A stored passport records the `schemaVersion` it was written under, so a + client holding one needs that exact schema rather than whatever is current. + The version may be given with or without a leading `v`. Unauthenticated. + + Descriptions are omitted, as on the current-schema route. tags: - - Public (Vault) + - Integrator + security: [] parameters: - - name: gtin + - name: productGroup in: path required: true schema: type: string - description: GS1 GTIN-14 (14 digits, mod-10 check digit). - example: '09506000134352' - - name: schema_view - in: query - required: false - description: Request a read-time schema-upcast view (see the by-id read). The response becomes `{ passport, schemaView }`. + example: battery + - name: version + in: path + required: true schema: type: string - example: 2.0.0 + example: 2.6.0 responses: '200': - description: Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`. + description: The product group's JSON Schema at that version. content: application/json: schema: - $ref: '#/components/schemas/PassportResponse' + type: object + '400': + description: The version is not a semver string. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' '404': - $ref: '#/components/responses/NotFound' - '410': - $ref: '#/components/responses/Gone' - '422': - description: The requested `schema_view` is unavailable. + description: No schema at that version; the body names what is available. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /vault/credential/dpp/{dppId}: + /integrator/api/v1/templates/{productGroup}: get: - operationId: readDppByCredential - summary: Audience-scoped read of a published DPP + operationId: getImportTemplate + summary: Download a CSV import template description: | - Reads a published passport filtered to the caller's audience. No - `X-DPP-Credential` header returns the same signed public view as - `/public/dpp/{dppId}`. A verified credential returns the passport - filtered to that audience's disclosure classes (ESPR Art. 77(2)), - carrying the proof computed over that view. Credentialed reads are - recorded to the passport's audit trail; anonymous reads are not. + Returns the canonical CSV import template for a product group. Most product + groups are served under their own key; batteries are served **per category** + as `battery-ev`, `battery-lmt` and `battery-industrial`. - Returns the public view (not an error) when credential verification - is not configured on this node. + The served set is deliberately **not enumerated here.** It is derived from + one table in the handler, and the `404` body lists it — so a template added + there is named by the refusal without anyone remembering to edit this + sentence. That table exists because this list previously had three homes + that disagreed, and this description was one of them. + + **There is no bare `battery` template.** What a battery passport must carry + is decided per category, and the state-of-health parameters are two disjoint + sets under Annex VII Part A — an EV battery reports state of certified + energy alone, while stationary and LMT batteries report a five-parameter + list. One file cannot carry three obligations without offering columns some + categories must not fill. + + **Three categories, not five.** Reg. (EU) 2023/1542 defines five battery + categories, but Art. 77(1) gives a battery passport only to "each LMT + battery, each industrial battery with a capacity greater than 2 kWh and each + electric vehicle battery". Portable and SLI batteries bear no passport + obligation, so there is no template for them. The industrial template is for + batteries above the 2 kWh threshold. + + All three battery templates are **generated** from the same rules table the + publish-time content gate reads, so a template cannot fall behind the + obligation it exists to satisfy. Their rows import under the `battery` + product group — the category is carried by the row's own `batteryType` + column, not by a product group of its own. + + Structured data points use four flat-file conventions: repeating groups + (`cathode_1_name`, `cathode_1_weightPct`, …), nested blocks + (`dynamicPerformance_ratedCapacityAh`, …), a two-column range + (`notInUseTemperatureMinC`/`MaxC`), and a semicolon-delimited list + (`componentPartNumbers`). + + `?format=xlsx` returns 501 (download the CSV and open it in a spreadsheet + app). Unauthenticated. tags: - - Credentialed Access + - Integrator + security: [] parameters: - - name: dppId + - name: productGroup in: path required: true + description: | + The **template key**, which since the battery split is no longer always + a product group: `battery-ev`, `battery-lmt` and `battery-industrial` + are keys served here, while `battery` is a product group served by + nothing. Rows from all three import under the `battery` product group. + + The parameter keeps its name because renaming a path parameter renames + the method argument in every generated client, and for the eight + non-battery templates the two are still the same string. schema: - $ref: '#/components/schemas/DppId' - - name: X-DPP-Credential - in: header - required: false - description: A verifiable access credential. Absent means public access. + type: string + example: battery-ev + - name: format + in: query schema: type: string + enum: + - csv + - xlsx + default: csv responses: '200': - description: Passport filtered to the resolved audience. - content: - application/json: - schema: - $ref: '#/components/schemas/PassportResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - description: The presented credential failed verification. + description: CSV template (Content-Disposition attachment). content: - application/problem+json: + text/csv: schema: - $ref: '#/components/schemas/Problem' + type: string '404': - description: Not found or not published. + description: No template for this product group. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - '410': - description: This passport has been suspended. + '501': + description: XLSX export not yet implemented. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /vault/internal/scan-batch: + /integrator/api/v1/import/{productGroup}: post: - operationId: ingestScanBatch - summary: Flush a scan-telemetry batch (internal, mTLS) + operationId: importFile + summary: Bulk-import passports from a file description: | - The mTLS-gated sink the public resolver flushes its in-memory - aggregate scan/QR-render counters to (`CN=odal-resolver` only). The - resolver holds no operator API key and no database of its own. + Upload a CSV or XLSX file to create draft passports for a product group + (`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's + `Authorization: Bearer` token is validated and forwarded to the vault. + + A battery **template** key is accepted here too — `battery-ev`, + `battery-lmt` and `battery-industrial` all import as `battery`, since the + category is carried by the row's own `batteryType` column rather than being + a product group of its own. An operator who downloads `battery-ev` can post + it back to `battery-ev` without translating the name. + + - ≤ 100 valid rows → processed synchronously, `200` with results. + - `> 100` valid rows → an async job is queued, `202` with a `jobId`. + - `mode=dry_run` → validate only, `200` with the would-be results. + + Every import — dry-run or apply, sync or async — mints a job id and + persists a row-addressed report retrievable via the job-status + endpoint, even when this endpoint's own response is synchronous. tags: - - Vault (internal) + - Integrator security: - - MutualTLS: [] + - BearerApiKey: [] + parameters: + - name: productGroup + in: path + required: true + schema: + type: string + example: battery + - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/ScanBatch' + type: object + required: + - file + properties: + file: + type: string + format: binary + description: CSV or XLSX file. + mode: + type: string + description: '"dry_run" to validate without creating records; any other value (or omitted) means apply.' responses: - '204': - description: Batch ingested. + '200': + description: Synchronous import (or dry-run) results. + content: + application/json: + schema: + $ref: '#/components/schemas/ImportSyncResponse' + '202': + description: Async import job accepted — poll the job-status endpoint. + content: + application/json: + schema: + $ref: '#/components/schemas/ImportAsyncResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - /vault/health: + '404': + description: Unknown product group. + '409': + $ref: '#/components/responses/IdempotentRequestInFlight' + '422': + $ref: '#/components/responses/ValidationError' + /integrator/api/v1/imports/{job_id}: get: - operationId: vaultHealth - summary: Vault health check + operationId: getImportJobStatus + summary: Poll an async import job + description: | + Returns the status and progress of an async import job. Requires the + same bearer auth as the import endpoint (validated against the vault). tags: - - Health + - Integrator + security: + - BearerApiKey: [] + parameters: + - name: job_id + in: path + required: true + schema: + type: string + format: uuid responses: '200': - description: Service is up. - /vault/ready: - get: - operationId: vaultReady - summary: Vault readiness check - tags: - - Health - responses: - '200': - description: Service is ready (DB connection verified). - '503': - $ref: '#/components/responses/ServiceUnavailable' - /vault/api/v1/info: - get: - operationId: vaultInfo - summary: Service info - description: Returns version and build information. - tags: - - Health - responses: - '200': - description: Service info. + description: Job status and progress. content: application/json: schema: - $ref: '#/components/schemas/VaultInfo' + $ref: '#/components/schemas/JobStatusResponse' + '400': + description: Invalid job ID format. + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' /identity/.well-known/did.json: get: operationId: getDidDocument @@ -2136,6 +3005,7 @@ paths: Serves the operator's `did:web` DID document. Public, unauthenticated. tags: - Identity + security: [] responses: '200': description: The DID document. @@ -2143,24 +3013,6 @@ paths: application/json: schema: $ref: '#/components/schemas/DidDocument' - /identity/health: - get: - operationId: identityHealth - summary: Identity health check - tags: - - Health - responses: - '200': - description: Service is up. - /identity/ready: - get: - operationId: identityReady - summary: Identity readiness check - tags: - - Health - responses: - '200': - description: Service is ready. /internal/sign: servers: - url: http://localhost:8002 @@ -2181,14 +3033,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SignRequest' + $ref: '#/components/schemas/InternalSignRequest' responses: '200': description: Signed payload. content: application/json: schema: - $ref: '#/components/schemas/SignResponse' + $ref: '#/components/schemas/InternalSignResponse' '400': description: Payload is not valid base64 or not valid JSON. '422': @@ -2215,14 +3067,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/VerifyRequest' + $ref: '#/components/schemas/InternalVerifyRequest' responses: '200': description: 'Verification result. Always `200` — an unverifiable signature is `{ "valid": false }`, not an error status.' content: application/json: schema: - $ref: '#/components/schemas/VerifyResponse' + $ref: '#/components/schemas/InternalVerifyResponse' '422': $ref: '#/components/responses/ValidationError' /internal/keys/rotate: @@ -2245,398 +3097,80 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RotateRequest' + $ref: '#/components/schemas/InternalRotateKeyRequest' responses: '200': description: Key rotated; returns the new fingerprint and DID document. content: application/json: schema: - $ref: '#/components/schemas/RotateResponse' + $ref: '#/components/schemas/InternalRotateKeyResponse' '422': $ref: '#/components/responses/ValidationError' - /integrator/health: - get: - operationId: integratorHealth - summary: Integrator health check - tags: - - Health - responses: - '200': - description: Service is up. - /integrator/api/v1/templates/{productGroup}: - get: - operationId: getImportTemplate - summary: Download a CSV import template - description: | - Returns the canonical CSV import template for a product group (`battery`, - `textile`). `?format=xlsx` returns 501 (download the CSV and open it in - a spreadsheet app). Unauthenticated. - tags: - - Integrator - parameters: - - name: productGroup - in: path - required: true - schema: - type: string - example: battery - - name: format - in: query - schema: - type: string - enum: - - csv - - xlsx - default: csv - responses: - '200': - description: CSV template (Content-Disposition attachment). - content: - text/csv: - schema: - type: string - '404': - description: No template for this product group. - '501': - description: XLSX export not yet implemented. - /integrator/api/v1/product-groups: + /dpp/{dppId}: get: - operationId: listProductGroups - summary: List product groups and their passport obligations + operationId: resolveDpp + summary: Resolve a public DPP description: | - Whether a digital product passport is required for each product group this - node knows of, from what date, under which acts, and whether this build can - actually make a binding determination. Unauthenticated. + Resolve a published Digital Product Passport by ID. This endpoint + is the target of QR code scans. No authentication required. - "Knows of" is wider than "has a schema for". A product group reached by an - act while carrying no catalog descriptor is listed too, with a `null` - `title` — that group has no schema, plugin or template to be discovered from, - so this is the only place it can be asked about. + Every representation is built from the **signed** public payload, not + the live database row, so the body and the proof it carries agree by + construction. - **Every date is served with its `basis`.** Most of the catalog is undated, - and of the dates that exist some trace to an adopted text (`sourced`) and - some are a reading (`assumed`). A date without its basis would present a - qualified reading as an unqualified claim, so `basis` is always present - wherever a date or a retention period is. + Content negotiation via `Accept` header: + - `application/json` / `application/ld+json` (default): JSON-LD + passport data + - `text/html`: the consumer-facing HTML passport page with operator + branding (logo, colours) + - `application/aas+json`: an IDTA Asset Administration Shell + Environment (see below) - `required` and `determinable` are different questions and are reported - separately. An obligation can exist while the implementing acts that define - the technical requirements do not, in which case nothing is bindingly - determinable yet however clearly the duty is written. + An absent, empty, `*/*`, `application/*`, `application/json` or + `application/ld+json` header all reach the JSON-LD default. Only a + header naming something this route cannot produce gets `406`. - Schema versions are not restated here — see `/integrator/api/v1/schemas`, - which is their one home. - tags: - - Integrator - security: [] - responses: - '200': - description: Every product group this build knows of. - content: - application/json: - schema: - $ref: '#/components/schemas/ProductGroupObligationList' - /integrator/api/v1/product-groups/{productGroup}: - get: - operationId: getProductGroupObligation - summary: One product group's passport obligation - description: | - The passport obligation for a single product group. Unauthenticated. + Responses carry `Vary: Accept`. - A key neither catalog knows is a `404` rather than an entry with an empty - obligation — "this node knows nothing about it" and "no passport is - required" are different answers, and the second would be a compliance claim - the node is not entitled to make. + **Errors follow the door.** A request that reached the HTML view gets an + HTML error page carrying the same status; every other view answers + `application/problem+json`. So each error below has two representations, + and which one arrives depends on the same `Accept` header that chose the + success representation. - A key an act reaches while no product group descriptor exists for it is - **not** a `404`. The node holds a recorded binding for such a group, and - refusing it would deny knowledge the node has. It is served with a `null` - `title` and its obligation in full. + **Serving is conditional on verification, so verification failures are + statuses of this route.** The resolver holds no passport data — it fetches + from the vault's public tier and verifies the public signature against the + operator's DID before building any representation. A passport it cannot + verify is never served unmarked: `409` when the signature does not check + out, `503` when the DID document could not be reached to try. tags: - - Integrator + - Public Resolver security: [] parameters: - - name: productGroup + - name: dppId in: path required: true - description: The product group catalog key. schema: - type: string - example: toy + $ref: '#/components/schemas/DppId' responses: '200': - description: The product group's obligation. + description: DPP resolved. content: application/json: schema: - $ref: '#/components/schemas/ProductGroupObligation' - '404': - $ref: '#/components/responses/NotFound' - /integrator/api/v1/schemas: - get: - operationId: listSectorSchemas - summary: List product group schemas and their versions - description: | - Every product group with a JSON Schema, the version a new passport is validated - against (`current`), and every version a stored passport may legitimately - record (`versions`). Unauthenticated. - tags: - - Integrator - responses: - '200': - description: The available product group schemas. - content: - application/json: + $ref: '#/components/schemas/PassportResponse' + text/html: schema: - type: object - properties: - schemas: - type: array - items: - type: object - properties: - productGroup: - type: string - example: battery - current: - type: - - string - - 'null' - example: 2.6.0 - versions: - type: array - items: - type: string - example: - - 1.0.0 - - 2.6.0 - /integrator/api/v1/schemas/{productGroup}: - get: - operationId: getCurrentSectorSchema - summary: Fetch a product group's current JSON Schema - description: | - The schema a passport created today is validated against, resolved through - the same registry the publish gate uses — never a copy, which would drift in - the direction where a body passes here and fails at publish. Unauthenticated. - - Every `description` is omitted from the served document. Those fields make - regulatory assertions that have not been verified against primary text, and - serving them would turn developer-facing comments into a product surface. - Everything that decides accept or reject — types, `enum`, `required`, - `pattern`, bounds, `additionalProperties` — is served in full, so a client - can pre-validate a body and get the verdict the create route would give. - tags: - - Integrator - parameters: - - name: productGroup - in: path - required: true - schema: - type: string - example: battery - responses: - '200': - description: The product group's current JSON Schema. - content: - application/json: + type: string + description: Consumer-facing HTML passport page. + application/aas+json: schema: type: object - '404': - description: No schema for this product group; the body names the known product groups. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - /integrator/api/v1/schemas/{productGroup}/{version}: - get: - operationId: getPinnedSectorSchema - summary: Fetch a pinned version of a product group's JSON Schema - description: | - A stored passport records the `schemaVersion` it was written under, so a - client holding one needs that exact schema rather than whatever is current. - The version may be given with or without a leading `v`. Unauthenticated. - - Descriptions are omitted, as on the current-schema route. - tags: - - Integrator - parameters: - - name: productGroup - in: path - required: true - schema: - type: string - example: battery - - name: version - in: path - required: true - schema: - type: string - example: 2.6.0 - responses: - '200': - description: The product group's JSON Schema at that version. - content: - application/json: - schema: - type: object - '400': - description: The version is not a semver string. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - '404': - description: No schema at that version; the body names what is available. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - /integrator/api/v1/import/{productGroup}: - post: - operationId: importFile - summary: Bulk-import passports from a file - description: | - Upload a CSV or XLSX file to create draft passports for a product group - (`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's - `Authorization: Bearer` token is validated and forwarded to the vault. - - - ≤ 100 valid rows → processed synchronously, `200` with results. - - `> 100` valid rows → an async job is queued, `202` with a `jobId`. - - `mode=dry_run` → validate only, `200` with the would-be results. - - Every import — dry-run or apply, sync or async — mints a job id and - persists a row-addressed report retrievable via the job-status - endpoint, even when this endpoint's own response is synchronous. - tags: - - Integrator - security: - - BearerApiKey: [] - parameters: - - name: productGroup - in: path - required: true - schema: - type: string - example: battery - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: - - file - properties: - file: - type: string - format: binary - description: CSV or XLSX file. - mode: - type: string - description: '"dry_run" to validate without creating records; any other value (or omitted) means apply.' - responses: - '200': - description: Synchronous import (or dry-run) results. - content: - application/json: - schema: - $ref: '#/components/schemas/ImportSyncResponse' - '202': - description: Async import job accepted — poll the job-status endpoint. - content: - application/json: - schema: - $ref: '#/components/schemas/ImportAsyncResponse' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': - description: Unknown product group. - '422': - $ref: '#/components/responses/ValidationError' - /integrator/api/v1/imports/{job_id}: - get: - operationId: getImportJobStatus - summary: Poll an async import job - description: | - Returns the status and progress of an async import job. Requires the - same bearer auth as the import endpoint (validated against the vault). - tags: - - Integrator - security: - - BearerApiKey: [] - parameters: - - name: job_id - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Job status and progress. - content: - application/json: - schema: - $ref: '#/components/schemas/JobStatusResponse' - '400': - description: Invalid job ID format. - '401': - $ref: '#/components/responses/Unauthorized' - '404': - $ref: '#/components/responses/NotFound' - /dpp/{dppId}: - get: - operationId: resolveDpp - summary: Resolve a public DPP - description: | - Resolve a published Digital Product Passport by ID. This endpoint - is the target of QR code scans. No authentication required. - - Every representation is built from the **signed** public payload, not - the live database row, so the body and the proof it carries agree by - construction. - - Content negotiation via `Accept` header: - - `application/json` / `application/ld+json` (default): JSON-LD - passport data - - `text/html`: the consumer-facing HTML passport page with operator - branding (logo, colours) - - `application/aas+json`: an IDTA Asset Administration Shell - Environment (see below) - - An absent, empty, `*/*`, `application/*`, `application/json` or - `application/ld+json` header all reach the JSON-LD default. Only a - header naming something this route cannot produce gets `406`. - - Responses carry `Vary: Accept`. - tags: - - Public Resolver - parameters: - - name: dppId - in: path - required: true - schema: - $ref: '#/components/schemas/DppId' - responses: - '200': - description: DPP resolved. - content: - application/json: - schema: - $ref: '#/components/schemas/PassportResponse' - text/html: - schema: - type: string - description: Consumer-facing HTML passport page. - application/aas+json: - schema: - type: object - description: | - An IDTA Asset Administration Shell `Environment` — shells - and submodels in one self-contained document. + description: | + An IDTA Asset Administration Shell `Environment` — shells + and submodels in one self-contained document. `conceptDescriptions` is **absent**, not empty. This node coins no concept descriptions, and the metamodel constrains @@ -2687,9 +3221,83 @@ paths: default `https://id.odal-node.io`). Error responses carry no `Link` — an error is not a representation of the passport. '404': - $ref: '#/components/responses/NotFound' + description: | + No published passport with this identifier, or the identifier is not a + valid passport id — the resolver reports a malformed id as "not found" + rather than as a bad request, since to a consumer holding a data + carrier the two are the same answer. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + text/html: + schema: + type: string + description: Consumer-facing "not found" page. '406': $ref: '#/components/responses/NotAcceptable' + '409': + description: | + The passport's public signature did not verify against the operator's + DID, so it is not served. See the operation description: verification + runs before any representation is built, and fails closed. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + text/html: + schema: + type: string + description: Consumer-facing error page. + '410': + description: | + The passport exists and has been withdrawn from public view — it is + suspended. Distinct from `404`: the identifier is confirmed real and + service is being declined, which is the signal a consumer scanning a + recalled product needs. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + text/html: + schema: + type: string + description: Consumer-facing withdrawal page. + '500': + description: | + The HTML view could not be rendered. Only the HTML door produces this; + the data doors have no rendering step to fail. + content: + text/html: + schema: + type: string + '502': + description: | + The vault this resolver fronts could not be read. Deliberately not + `404` — the identifier may be perfectly good, so a consumer should + retry rather than conclude the product has no passport. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + text/html: + schema: + type: string + description: Consumer-facing error page. + '503': + description: | + Verification could not be attempted: the operator's DID document was + unreachable, unparseable, or carried no key matching the signature. + Nothing has been established about the passport either way, which is + why this is temporary where `409` is not. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + text/html: + schema: + type: string + description: Consumer-facing error page. /dpp/{dppId}/qr: get: operationId: getDppQrCode @@ -2699,8 +3307,19 @@ paths: GS1 Digital Link on the resolver's configured base (e.g. `https://id.odal-node.io/01/{gtin}/21/{serial}`). This endpoint is on the **resolver** service (port 8003). + + **Every response is `image/png`, including the failures.** This route is + addressed by ``, so an error carries no problem document — the + body is empty and the status line is the whole of the answer. A client that + needs a readable reason should ask `GET /dpp/{dppId}`, which answers the + same failures as `application/problem+json`. + + Fails closed on verification, like every other resolver route: the + passport's public signature is checked against the operator's DID before a + QR is drawn, so an unverifiable passport yields no image. tags: - Public Resolver + security: [] parameters: - name: dppId in: path @@ -2716,38 +3335,84 @@ paths: type: string format: binary '404': - $ref: '#/components/responses/NotFound' - '422': - $ref: '#/components/responses/ValidationError' - /01/{gtin}: - get: - operationId: resolveByGtin - summary: GS1 Digital Link resolver - description: | - GS1-conformant resolver (GS1-CRSV1) keyed by GTIN-14. Behaviour depends - on the `linkType` query / `Accept` header: - - default → `307` redirect to the HTML DPP page - - `?linkType=gs1:pip` / `gs1:dpp` (and related) → `307` redirect to the DPP - - `?linkType=linkset` or `Accept: application/linkset+json` → `200` - RFC 9264 linkset - tags: - - Public Resolver - parameters: - - name: gtin - in: path - required: true - schema: - type: string - example: '09506000134352' - - name: linkType - in: query - required: false - schema: - type: string - description: e.g. linkset, gs1:pip, gs1:dpp - responses: - '200': - description: RFC 9264 linkset (when a linkset is requested). + description: No published passport with this identifier. Empty body. + content: + image/png: + schema: + type: string + format: binary + '409': + description: The passport's public signature did not verify against the operator's DID, so no QR was drawn. Empty body. + content: + image/png: + schema: + type: string + format: binary + '410': + description: The passport has been withdrawn from public view — it is suspended. Empty body. + content: + image/png: + schema: + type: string + format: binary + '422': + description: The passport carries no data carrier to encode — no GTIN, so no GS1 Digital Link exists to put in a QR code. Empty body. + content: + image/png: + schema: + type: string + format: binary + '500': + description: QR encoding failed. Empty body. + content: + image/png: + schema: + type: string + format: binary + '502': + description: The vault this resolver fronts could not be read. Empty body. + content: + image/png: + schema: + type: string + format: binary + '503': + description: Verification could not be attempted — the operator's DID document was unreachable. Empty body. + content: + image/png: + schema: + type: string + format: binary + /01/{gtin}: + get: + operationId: resolveByGtin + summary: GS1 Digital Link resolver + description: | + GS1-conformant resolver (GS1-CRSV1) keyed by GTIN-14. Behaviour depends + on the `linkType` query / `Accept` header: + - default → `307` redirect to the HTML DPP page + - `?linkType=gs1:pip` / `gs1:dpp` (and related) → `307` redirect to the DPP + - `?linkType=linkset` or `Accept: application/linkset+json` → `200` + RFC 9264 linkset + tags: + - Public Resolver + security: [] + parameters: + - name: gtin + in: path + required: true + schema: + type: string + example: '09506000134352' + - name: linkType + in: query + required: false + schema: + type: string + description: e.g. linkset, gs1:pip, gs1:dpp + responses: + '200': + description: RFC 9264 linkset (when a linkset is requested). content: application/linkset+json: schema: @@ -2756,6 +3421,14 @@ paths: description: Redirect to the DPP page (`Location` header). '404': description: No published DPP for this GTIN, or unknown link type. + '409': + $ref: '#/components/responses/PassportSignatureUnverified' + '410': + $ref: '#/components/responses/Gone' + '502': + $ref: '#/components/responses/ResolverUpstreamFailure' + '503': + $ref: '#/components/responses/PassportVerificationUnavailable' /01/{gtin}/21/{serial}: get: operationId: resolveByGtinSerial @@ -2769,6 +3442,7 @@ paths: otherwise (`linkType` / `Accept` negotiation). tags: - Public Resolver + security: [] parameters: - name: gtin in: path @@ -2799,6 +3473,14 @@ paths: description: Redirect to the DPP page (`Location` header). '404': description: No published DPP for this GTIN, or unknown link type. + '409': + $ref: '#/components/responses/PassportSignatureUnverified' + '410': + $ref: '#/components/responses/Gone' + '502': + $ref: '#/components/responses/ResolverUpstreamFailure' + '503': + $ref: '#/components/responses/PassportVerificationUnavailable' /01/{gtin}/10/{batch}: get: operationId: resolveByGtinBatch @@ -2810,6 +3492,7 @@ paths: (`linkType` / `Accept` negotiation). tags: - Public Resolver + security: [] parameters: - name: gtin in: path @@ -2840,6 +3523,14 @@ paths: description: Redirect to the DPP page (`Location` header). '404': description: No published DPP for this GTIN, or unknown link type. + '409': + $ref: '#/components/responses/PassportSignatureUnverified' + '410': + $ref: '#/components/responses/Gone' + '502': + $ref: '#/components/responses/ResolverUpstreamFailure' + '503': + $ref: '#/components/responses/PassportVerificationUnavailable' /01/{gtin}/10/{batch}/21/{serial}: get: operationId: resolveByGtinBatchSerial @@ -2851,6 +3542,7 @@ paths: `/01/{gtin}` otherwise (`linkType` / `Accept` negotiation). tags: - Public Resolver + security: [] parameters: - name: gtin in: path @@ -2887,24 +3579,203 @@ paths: description: Redirect to the DPP page (`Location` header). '404': description: No published DPP for this GTIN, or unknown link type. + '409': + $ref: '#/components/responses/PassportSignatureUnverified' + '410': + $ref: '#/components/responses/Gone' + '502': + $ref: '#/components/responses/ResolverUpstreamFailure' + '503': + $ref: '#/components/responses/PassportVerificationUnavailable' + /vault/health: + get: + operationId: vaultHealth + summary: Vault health check + description: | + Liveness: the vault process is running and serving. On the node (port 8001) + this is mounted at `/vault/health`. + + It checks nothing beyond itself — no database, no object storage, no + upstream. A `200` here means the process is up, not that it can do useful + work; use `/vault/ready` for that. Wiring a restart policy to this endpoint + and expecting it to catch a lost database will never restart anything. + + The body carries the service name, its version, and the `dpp-core` version + it was built against, which is the quickest way to tell two deployments + apart when their behaviour differs. + tags: + - Health + security: [] + responses: + '200': + description: | + The process is serving. Body carries `status`, `service`, `version` and + `coreVersion`. + /vault/ready: + get: + operationId: vaultReady + summary: Vault readiness check + description: | + Readiness: the vault can reach the database it needs to answer requests. On + the node (port 8001) this is mounted at `/vault/ready`. + + **This is the only readiness probe in the API that checks a dependency.** + It pings PostgreSQL and answers `503` when that fails, so it is the one to + put behind a load balancer or a deployment gate. The identity and resolver + readiness probes answer `200` unconditionally, because neither has an + external dependency to check. + + Every call records the ping latency and its outcome as metrics, so a + scrape of this endpoint doubles as a continuous database-latency signal + rather than only a yes/no. + tags: + - Health + security: [] + responses: + '200': + description: The database answered; the vault can serve requests. + '503': + $ref: '#/components/responses/ServiceUnavailable' + /vault/api/v1/info: + get: + operationId: vaultInfo + summary: Service info + description: | + What this build is and what it supports — for a client deciding which + features to offer before it has authenticated. + + Carries four things: the vault's own `version`, the `dpp-core` version it + was compiled against, the authentication methods it accepts, and the + feature flags it reports. A dashboard reads this to know whether an + endpoint is worth calling, rather than calling it and interpreting a `404`. + + Unauthenticated on purpose: a client needs to know how to authenticate + before it can, and the values here are properties of the build rather than + of the operator or their data. + + `coreVersion` is the useful one when two deployments disagree — the vault + version alone does not tell you which regulatory rules were compiled in. + tags: + - Health + security: [] + responses: + '200': + description: Build metadata, authentication methods and feature flags. + content: + application/json: + schema: + $ref: '#/components/schemas/VaultInfo' + /identity/health: + get: + operationId: identityHealth + summary: Identity health check + description: | + Liveness: the identity service is running and serving. Mounted at + `/identity/health` on the node (port 8001), and at `/health` when identity + runs as its own deployable (port 8002). + + Checks nothing beyond itself. The signing key store is opened at startup, so + a process that is serving has already loaded it — which is why the readiness + probe beside this one has nothing further to verify. + + The body carries the service name and version. + tags: + - Health + security: [] + responses: + '200': + description: The process is serving. Body carries `status`, `service` and `version`. + /identity/ready: + get: + operationId: identityReady + summary: Identity readiness check + description: | + Readiness: **always `200`**, and that is the honest answer rather than a + stub. + + The identity service has no external dependency to check. Its signing key + store is opened at startup, so a process that is serving at all has already + loaded everything it needs — there is no state in which it is live but not + ready. Reporting anything conditional here would invent a distinction the + service does not have. + + So this is useful as a probe target that answers, not as a signal that + anything was verified. `/vault/ready` is the probe that actually checks a + dependency. + tags: + - Health + security: [] + responses: + '200': + description: Serving. Nothing is checked, because there is nothing to check. + /integrator/health: + get: + operationId: integratorHealth + summary: Integrator health check + description: | + Liveness: the bulk-import service is running and serving. On the node + (port 8001) this is mounted at `/integrator/health`. + + Checks nothing beyond itself, and in particular says nothing about whether + an import currently in flight is progressing — an import is a job, and + `GET /integrator/api/v1/imports/{job_id}` is what reports on one. + + The body carries the service name and version. + tags: + - Health + security: [] + responses: + '200': + description: The process is serving. Body carries `status`, `service` and `version`. /health: get: operationId: resolverHealth summary: Resolver health check + description: | + Liveness: the public resolver is running and serving. This endpoint is on + the **resolver** service (port 8003), which is a separate deployable from + the node. + + Checks nothing beyond itself — not Redis, not the vault it reads passports + from. A `200` means the process answers; it does not mean a scan will + resolve. + + The node has its own `/health` on port 8001, and it is deliberately bare: + it returns `{"status":"ok"}` and nothing else. The node's profile, per-port + trust modes and ruleset version are **not** there — they are on the + authenticated `GET /vault/api/v1/node/state`, because an unauthenticated + endpoint that publishes a node's trust posture tells an unauthenticated + reader more than it should. An external uptime probe pointed at either + `/health` can assert liveness and nothing more. tags: - Health + security: [] responses: '200': - description: Resolver is up. + description: The process is serving. Body carries `status`, `service` and `version`. /ready: get: operationId: resolverReady summary: Resolver readiness check + description: | + Readiness: **always `200`**, on the **resolver** service (port 8003). + + Like the identity service's readiness probe, this checks nothing, and that + is the honest answer rather than a stub. The resolver's dependencies — the + vault it reads passports from, and its Redis cache — are consulted per + request and degrade per request: a cache miss falls through to the vault, + and a vault it cannot reach produces a `502` on that read rather than a + process that should be taken out of rotation. + + So there is no state in which the resolver is live but categorically not + ready, and reporting one would invent a distinction it does not have. + `/vault/ready` is the probe in this API that actually verifies a dependency. tags: - Health + security: [] responses: '200': - description: Resolver is ready. + description: Serving. Nothing is checked, because there is nothing to check. components: securitySchemes: BearerApiKey: @@ -2940,116 +3811,120 @@ components: endpoints only from a client presenting `CN=odal-vault`. The fused `dpp-node` never exposes these over the network — it signs in-process. schemas: - ManufacturerInfo: + Problem: type: object + description: | + RFC 7807 / RFC 9457 problem details. The shape + `dpp-common::http_problem::Problem` produces, served as + `application/problem+json`. + + `type` is derived from `title`, so each distinct `title` used across the + codebase is a stable catalogue key that clients may depend on. required: - - name - - address + - type + - title + - status properties: - name: + type: type: string - example: EcoTech GmbH - address: + format: uri + description: Absolute URI identifying the problem type. + example: https://problems.odal-node.io/not-found + title: type: string - example: Hauptstraße 1, 10115 Berlin - didWebUrl: + description: Short human-readable summary of the problem type. + example: Not Found + status: + type: integer + description: The HTTP status code, mirroring the status line. + example: 404 + detail: type: string - format: uri - description: did:web URL for the manufacturer - example: https://ecotech.example.com/.well-known/did.json - MaterialEntry: + description: Human-readable explanation for this specific occurrence. + example: 'No schema for product group ''nosuchgroup''. Known product groups: aluminium, battery.' + instance: + type: string + format: uri-reference + description: URI reference identifying this specific occurrence. + errors: + type: array + description: | + The individual field failures behind this problem, when there are any. + + An RFC 7807 §3.2 extension member. `detail` carries the same failures + joined with `"; "` and is unaffected, so a client written before this + member existed keeps working; `errors` is what lets a newer one address a + single field. Publishing a battery that is missing its Annex XIII content + produces upwards of thirty entries here. + + Omitted entirely — never `[]` — on problems that are not about fields. + items: + $ref: '#/components/schemas/ProblemFieldError' + ProblemFieldError: type: object + description: | + One field's failure inside a `Problem`. + + Present only on problems that are about specific members of the request + document — a validation rejection, most often. `field` is a JSON Pointer into + that document, so a client can address the offending member directly instead + of parsing it back out of `detail`. required: - - name - - weightKg + - field + - message properties: - name: + field: type: string - example: Lithium carbonate - weightKg: - type: number - description: Weight in kilograms - example: 12.5 - recycledPct: - type: number - minimum: 0 - maximum: 100 - description: Percentage of recycled content (0.0–100.0) - example: 35 - countryOfOrigin: + description: | + RFC 6901 JSON Pointer to the offending member. Empty when the failure is + about the document as a whole rather than one of its fields. + example: /productGroupData/batteryModelId + message: type: string - minLength: 2 - maxLength: 2 - description: ISO 3166-1 alpha-2 country code - example: DE - ProductGroupData: - type: object - description: | - Product group-specific data, **internally tagged** by a `productGroup` - discriminator — e.g. - `{ "productGroup": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. - The remaining fields are product group-specific and validated against the - product group's versioned JSON schema. Sectors include `battery`, `textile`, - `steel`, `aluminium`, `tyre`, `electronics`, and others. - - The tag is **open**. A product group this build does not model round-trips its - tag and payload verbatim instead of failing to parse, so a group added to the - catalog does not require a client release. That is why the server does not - derive this shape with `#[serde(tag = …)]`, which would close the set at - compile time. - required: - - productGroup - discriminator: - propertyName: productGroup - properties: - productGroup: - type: string - example: battery - additionalProperties: true - example: - productGroup: battery - gtin: '09506000134352' - batteryChemistry: LFP - nominalVoltageV: 48 - nominalCapacityAh: 100 - expectedLifetimeCycles: 3000 - co2ePerUnitKg: 45.2 - PassportRef: - type: object - required: - - uri - - publicJwsHash - description: 'A cross-operator reference to another passport: where to fetch it, and the lowercase-hex SHA-256 of that passport''s public JWS, which pins the exact signed public view expected there.' - properties: - uri: - type: string - format: uri - description: Resolvable https URI of the referenced passport. - example: https://id.other-operator.example/dpp/0191b2c3-d4e5-7f80 - publicJwsHash: - type: string - pattern: ^[0-9a-f]{64}$ - description: Lowercase hex SHA-256 of the referenced passport's public JWS. - CreateRequest: + description: What is wrong with that member, in one sentence. + example: '''batteryModelId'' is mandatory for a ''industrial'' battery and is absent' + PassportResponse: type: object + description: The canonical Digital Product Passport record. Fields marked required are emitted on every read; the rest are omitted when unset rather than sent as null, so a consumer must treat absence and null as the same thing. required: + - id - productName + - productGroup - manufacturer - description: | - Request body for creating a new DPP. Only `productName` and - `manufacturer` are required. All other fields are optional and - can be filled in later via `PUT` before publishing. + - materials + - status + - schemaVersion + - createdAt + - updatedAt + - retentionLocked + - version properties: + id: + $ref: '#/components/schemas/DppId' + batchId: + type: + - string + - 'null' productName: type: string - description: Human-readable product name. Must not be blank. example: EcoCell Pro 48V productGroup: type: string - description: | - EU ESPR product group (the dispatch key), e.g. `battery`, `textile`, - `electronics`. Optional — derived from `productGroupData` when omitted. + description: 'EU ESPR product group — the delegated-act bucket selecting the applicable schema and plugin. Deliberately an open string, not a closed enum: adding a product group is a catalog manifest plus a schema, not a release, and a product group this build does not know still round-trips its wire tag verbatim.' example: battery + applicableInstruments: + type: array + description: |- + The legal instruments this passport was issued under, fixed when the product was placed on the market and never recomputed afterwards. + + A set rather than a single value because acts accumulate: ESPR Art. 5(7) lets a group-specific delegated act supplement a horizontal one and the Regulation states no precedence rule, so the governing law is the union of the members' requirements. + + Not derivable from `productGroup`. A horizontal act can reach a product whose product group is not one the catalog models, so an entry may be asserted by the economic operator rather than resolved from the catalog — which `recorded` distinguishes. Read it as who asserted the entry, not as how much to trust it. + items: + $ref: '#/components/schemas/InstrumentRef' + granularity: + $ref: '#/components/schemas/Granularity' + description: The level this passport describes. Omitted entirely while no adopted act has fixed one — see `Granularity` for why omitted is not `item`. manufacturer: $ref: '#/components/schemas/ManufacturerInfo' materials: @@ -3057,122 +3932,155 @@ components: items: $ref: '#/components/schemas/MaterialEntry' co2ePerUnit: - type: number - minimum: 0 - description: |- - CO₂ equivalent per unit, in kg. Must be finite and non-negative; anything else is rejected with `422`. - Supplied here as a scalar and stored as a `CarbonFootprint` object, so the value echoed back on `PassportResponse` is `{ "valueKg": … }` rather than the bare number sent. The lifecycle stage and system boundary that make two figures comparable cannot be set through this route. - example: 4.2 + description: 'CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not a bare number: a figure without its lifecycle stage and system boundary is not comparable to another product''s.' + anyOf: + - $ref: '#/components/schemas/CarbonFootprint' + - type: 'null' repairabilityScore: - type: number - minimum: 0 - maximum: 10 - description: |- - Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU 2023/1669 — those have prescribed methodologies this does not implement. Anything outside 0–10 is rejected with `422`. - Supplied here as a scalar and stored as a `RepairabilityScore` object, so the value echoed back on `PassportResponse` is `{ "overall": … }` rather than the bare number sent. - example: 7.5 + description: Non-regulatory repairability heuristic — see the schema's own note. + anyOf: + - $ref: '#/components/schemas/RepairabilityScore' + - type: 'null' + complianceResult: + allOf: + - $ref: '#/components/schemas/ComplianceResult' + description: The computed compliance determination, attached at create/update. Part of the signed payload and immutable after retention lock. Absent until a determination has been computed. + lintResult: + allOf: + - $ref: '#/components/schemas/LintResult' + description: Advisory plausibility findings. Absent until a lint pass has run, and recomputable after publish — unlike `complianceResult`. productGroupData: - description: Optional at create; an explicit `null` is accepted and equivalent to omitting it. Publish validates it only when present. + description: Explicitly `null` — not omitted — for a passport whose product group data has not been supplied yet, which is every draft created without it. The field has no `skip_serializing_if`, so it is always present on the wire. anyOf: - $ref: '#/components/schemas/ProductGroupData' - type: 'null' - batchId: + status: + $ref: '#/components/schemas/PassportStatus' + qrCodeUrl: + type: + - string + - 'null' + format: uri + description: 'GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io).' + jwsSignature: + type: + - string + - 'null' + description: Compact JWS (Ed25519) over the **full** canonical payload. Null until published. + publicJwsSignature: type: string - description: Optional batch or lot identifier - example: BATCH-2026-04-001 + description: Compact JWS over the **public (redacted) view**, so anyone can verify the public passport independently — the resolver checks this on the unauthenticated public route. Set at publish; absent for drafts. + disclosureSignatures: + type: object + additionalProperties: + type: string + description: 'Compact JWS signatures over the **non-public** redacted views, keyed by disclosure set (e.g. `public+restricted+individual`), never by audience name. Every audience receiving more than the public view needs a proof over *its* view: `publicJwsSignature` covers only the public payload and `jwsSignature` only the full one, so a reader handed a filtered body and either of those holds a signature that cannot verify against the bytes it received. Empty for drafts.' + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + publishedAt: + type: + - string + - 'null' + format: date-time placedOnMarketDate: type: string format: date - description: | - The date this product was placed on the EU market — the regulated - triggering event that fixes which law governs it. - - Optional, and omitting it is not neutral. A compliance determination - whose rule is phased by date has no answer without it: the node - reports the missing fact rather than substituting today's date, which - would produce a determination that silently changes its own answer - when a phase begins. For batteries this decides which EU 2023/1542 - Art. 8 minimum recycled shares apply. - example: '2026-03-14' + description: The date the product was placed on the EU market — the regulated triggering event fixing **which law governs it**, distinct from the three lifecycle dates above, none of which selects a rule. Staged EU obligations attach at placing on the market and do not move afterwards. Absence means the date was not declared; it is **not** licence to substitute the current date, and a determination depending on it has no answer. schemaVersion: type: string + description: Semantic version of the **product group** schema `productGroupData` was validated against. Scoped to `productGroupData` only — the envelope fields have no equivalent version and never will, because they are shared by every product group's stored documents. The envelope's compatibility rule is additive only, permanently. + example: 1.0.0 + retentionLocked: + type: boolean description: | - Product group schema version. Optional, and the only accepted value is the - product group's **current** version — omitting it is equivalent. Any other - value is rejected with `422`. - - It is not the caller's to choose: the stored version selects the - disclosure table the passport's public view is filtered through and - signed under, and an older table classifies fewer fields, defaulting - the rest to public. The body is validated against the current schema - in either case, so a differing declaration is already false about the - body it accompanies. - example: 2.6.0 + Set to `true` permanently on first publish. Retention-locked + passports must remain accessible for the EU ESPR retention period. + version: + type: integer + minimum: 1 + description: Monotonic version counter. `1` on first publish; incremented on the successor each time a new version supersedes this record. + supersedesId: + allOf: + - $ref: '#/components/schemas/DppId' + description: The passport this record supersedes. Absent for first versions. parentPassportRef: - $ref: '#/components/schemas/PassportRef' + allOf: + - $ref: '#/components/schemas/PassportRef' + description: Cross-operator reference to the predecessor this passport derives from (second-life successor linkage). componentRefs: type: array + description: 'Cross-operator references to the constituent passports this product is assembled from — its bill of materials. The inverse edge of `parentPassportRef`: these point down to many constituents, that points up to one predecessor.' items: $ref: '#/components/schemas/PassportRef' - description: Cross-operator references to this product's constituent passports (its bill of materials). Local cycles / over-depth are refused at create/update; immutable after publish. + retentionUntil: + type: string + format: date-time + description: Deadline by which this record must remain accessible, computed at publish from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) requires the delegated act to specify a period corresponding to at least the product's expected lifetime; Art. 11(e) restates it as an essential requirement, available including after the responsible operator's insolvency, liquidation or cessation of activity. + productId: + type: string + format: uuid + description: Opaque link to an internal product-template record. Not a legal identifier. commodityCode: type: string - description: Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, no separators). Registration data the EU registry verifies against the ranges its product group permits. + description: Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the product group does not call for one; this node will not invent a classification it cannot derive. example: '85076000' + operatorIdentifier: + type: string + description: 'EORI or national economic-operator identifier for the responsible party (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). **Frozen at publish — this is the operator that published the passport, not necessarily the one responsible for it now.** A transfer of responsibility does not rewrite it and cannot: published content is immutable and covered by the signature over it. For current responsibility, read the transfer chain.' + example: DE123456789 + facility: + allOf: + - $ref: '#/components/schemas/FacilitySnapshot' + description: Snapshot of the Annex III facility where this product was manufactured or processed, copied by value at create time. + seal: + allOf: + - $ref: '#/components/schemas/SealedEnvelope' + description: The eIDAS electronic seal applied to this passport. Absent until a seal has been applied; check its `placeholder` flag rather than inferring validity from presence. DppId: type: string format: uuid description: UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs. example: 019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f - RecordedBasis: + PassportStatus: type: string enum: - - catalog - - operator + - draft + - active + - suspended + - archived + - superseded + - deactivated description: | - Who asserted that an act applies. Read it as provenance, not as how much to - trust the entry. + DPP lifecycle state. The domain model uses `Published` internally; + the wire format uses `active`. Deserialization accepts both. + - `draft`: under construction; not publicly resolvable + - `active`: published and signed; publicly resolvable via QR + - `suspended`: temporarily hidden (recall, dispute); JWS preserved + - `archived`: end-of-life; immutable; retained for regulatory record-keeping + - `superseded`: replaced by a newer passport version; terminal + - `deactivated`: end-of-life declared (recycled, destroyed under a + derogation, exported, or lost); terminal. The typed reason is carried by + the EOL event, not this field - `catalog` — resolved from the instrument catalog. + Valid transitions: + draft → active, draft → archived, + active → suspended, active → archived, active → superseded, + active → deactivated, + suspended → active, suspended → archived, suspended → deactivated. - `operator` — asserted by the economic operator placing the product on the - market. **Not a fallback.** The catalog cannot be exhaustive: horizontal - ecodesign requirements cover sets of products that were never shortlisted as - product groups, so an act may apply to a product while reaching no product - group the catalog models. An operator who knows an act applies must be able to - say so. - example: catalog - InstrumentRef: - type: object + `archived`, `superseded` and `deactivated` are terminal. + Granularity: + type: string + enum: + - model + - batch + - item description: | - One legal instrument recorded on a passport as applicable to it, and who - asserted that it applies. - - Fixed when the product was placed on the market and never recomputed. Carries - no status, deliberately: a passport freezes its applicable set at issuance, and - re-deriving how far along an act is would misstate what governed the product at - that moment. The obligation endpoint's `ReachingInstrument` answers the live - question and does carry status. - properties: - instrument: - type: string - description: The instrument's catalog id. - example: battery-reg-2023-1542 - recorded: - $ref: '#/components/schemas/RecordedBasis' - description: Who asserted this entry, fixed at issuance along with the rest of the applicable set. - required: - - instrument - - recorded - additionalProperties: false - Granularity: - type: string - enum: - - model - - batch - - item - description: | - The level a passport is issued at. + The level a passport is issued at. Set by the applicable delegated act (ESPR Art. 9(2)(d)), so it is a property of the law rather than a choice made here, and unset while no adopted act has @@ -3186,6 +4094,124 @@ components: out twice, which made them two things to drift and left neither checked against the code that emits them. example: item + ManufacturerInfo: + type: object + required: + - name + - address + properties: + name: + type: string + example: EcoTech GmbH + address: + type: string + example: Hauptstraße 1, 10115 Berlin + didWebUrl: + type: string + format: uri + description: did:web URL for the manufacturer + example: https://ecotech.example.com/.well-known/did.json + FacilitySnapshot: + type: object + description: 'Annex III facility details copied by value into the passport at create time. Self-contained on purpose: the signed passport stays a complete record even after the operator retires the facility from their mutable registry.' + required: + - scheme + - value + - name + - country + properties: + scheme: + type: string + example: gln + value: + type: string + example: '4012345000009' + name: + type: string + country: + type: string + pattern: ^[A-Z]{2}$ + description: | + ISO 3166-1 alpha-2 country code, upper case. + + Copied from a facility whose country was validated against the ISO + 3166-1 list when it was registered, so this is what the server emits + rather than merely what it hopes for. + address: + type: string + MaterialEntry: + type: object + required: + - name + - weightKg + properties: + name: + type: string + example: Lithium carbonate + weightKg: + type: number + description: Weight in kilograms + example: 12.5 + recycledPct: + type: number + minimum: 0 + maximum: 100 + description: Percentage of recycled content (0.0–100.0) + example: 35 + countryOfOrigin: + type: string + minLength: 2 + maxLength: 2 + description: ISO 3166-1 alpha-2 country code + example: DE + ProductGroupData: + type: object + description: | + Product group-specific data, **internally tagged** by a `productGroup` + discriminator — e.g. + `{ "productGroup": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. + The remaining fields are product group-specific and validated against the + product group's versioned JSON schema. Product groups include `battery`, `textile`, + `steel`, `aluminium`, `tyre`, `electronics`, and others. + + The tag is **open**. A product group this build does not model round-trips its + tag and payload verbatim instead of failing to parse, so a group added to the + catalog does not require a client release. That is why the server does not + derive this shape with `#[serde(tag = …)]`, which would close the set at + compile time. + required: + - productGroup + discriminator: + propertyName: productGroup + properties: + productGroup: + type: string + example: battery + additionalProperties: true + example: + productGroup: battery + gtin: '09506000134352' + batteryChemistry: LFP + nominalVoltageV: 48 + nominalCapacityAh: 100 + expectedLifetimeCycles: 3000 + co2ePerUnitKg: 45.2 + PassportRef: + type: object + required: + - uri + - publicJwsHash + description: 'A cross-operator reference to another passport: where to fetch it, and the lowercase-hex SHA-256 of that passport''s public JWS, which pins the exact signed public view expected there.' + properties: + uri: + type: string + format: uri + description: Resolvable https URI of the referenced passport. + example: https://id.other-operator.example/dpp/0191b2c3-d4e5-7f80 + publicJwsHash: + type: string + pattern: ^[0-9a-f]{64}$ + description: Lowercase hex SHA-256 of the referenced passport's public JWS. CarbonFootprint: type: object description: A CO₂-equivalent figure with the LCA context needed to read it. A bare number is not comparable across products — the lifecycle stage and system boundary are what make two figures mean the same thing. @@ -3221,21 +4247,6 @@ components: maxLength: 8 description: Performance class label as defined by the applicable delegated act (e.g. an A–G band). Free text because the banding is per product group. example: B - RepairCriterion: - type: object - description: One weighted criterion contributing to a repairability score. - required: - - name - - score - - weight - properties: - name: - type: string - example: disassembly_depth - score: - type: number - weight: - type: number RepairabilityScore: type: object description: Repairability as a **non-regulatory heuristic**. Deliberately not an EN 45554 or Regulation (EU) 2023/1669 index — those have prescribed methodologies this does not implement, and presenting a heuristic as either would misstate it. @@ -3252,32 +4263,21 @@ components: description: Per-criterion breakdown. Empty when only the overall score is known. items: $ref: '#/components/schemas/RepairCriterion' - ComplianceStatus: - type: string - description: Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group plugin was loaded and nothing was assessed — it is not a pass. - enum: - - PASSTHROUGH_NO_VALIDATION - - COMPLIANT - - NON_COMPLIANT - - NOT_ASSESSED - - NOT_IMPLEMENTED - ComplianceFinding: + RepairCriterion: type: object - description: A single compliance finding. Severity is encoded by which array it lands in on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no separate severity field. + description: One weighted criterion contributing to a repairability score. required: - - code - - message + - name + - score + - weight properties: - code: - type: string - description: Stable machine-readable code. - example: battery.recycled_content.cobalt_below_2031 - field: - type: string - description: JSON-pointer-style locator, or absent when the finding is not tied to a single field. - example: /recycledContentCobaltPct - message: + name: type: string + example: disassembly_depth + score: + type: number + weight: + type: number ComplianceResult: type: object description: The computed compliance determination. Part of the signed passport payload and immutable after retention lock. Absent until a determination has been computed — for example on a product group with no plugin loaded. @@ -3323,28 +4323,30 @@ components: type: object additionalProperties: true description: Calculation receipt (input hash, ruleset id and version, factor dataset version and table hash) for notified-body audit. Present only when a calculation actually ran. - LintSeverity: + ComplianceStatus: type: string - description: Severity of a plausibility finding. Neither value blocks publish — lint is advisory by construction, unlike a compliance violation. + description: Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group plugin was loaded and nothing was assessed — it is not a pass. enum: - - WARNING - - NOTICE - LintFinding: + - PASSTHROUGH_NO_VALIDATION + - COMPLIANT + - NON_COMPLIANT + - NOT_ASSESSED + - NOT_IMPLEMENTED + ComplianceFinding: type: object + description: A single compliance finding. Severity is encoded by which array it lands in on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no separate severity field. required: - code - - field - - severity - message properties: code: type: string - example: mass.balance + description: Stable machine-readable code. + example: battery.recycled_content.cobalt_below_2031 field: type: string - example: /materials - severity: - $ref: '#/components/schemas/LintSeverity' + description: JSON-pointer-style locator, or absent when the finding is not tied to a single field. + example: /recycledContentCobaltPct message: type: string LintResult: @@ -3365,66 +4367,71 @@ components: assessedAt: type: string format: date-time - PassportStatus: - type: string - enum: - - draft - - active - - suspended - - archived - - superseded - - deactivated - description: | - DPP lifecycle state. The domain model uses `Published` internally; - the wire format uses `active`. Deserialization accepts both. - - `draft`: under construction; not publicly resolvable - - `active`: published and signed; publicly resolvable via QR - - `suspended`: temporarily hidden (recall, dispute); JWS preserved - - `archived`: end-of-life; immutable; retained for regulatory record-keeping - - `superseded`: replaced by a newer passport version; terminal - - `deactivated`: end-of-life declared (recycled, destroyed under a - derogation, exported, or lost); terminal. The typed reason is carried by - the EOL event, not this field - - Valid transitions: - draft → active, draft → archived, - active → suspended, active → archived, active → superseded, - active → deactivated, - suspended → active, suspended → archived, suspended → deactivated. - - `archived`, `superseded` and `deactivated` are terminal. - FacilitySnapshot: + LintFinding: type: object - description: 'Annex III facility details copied by value into the passport at create time. Self-contained on purpose: the signed passport stays a complete record even after the operator retires the facility from their mutable registry.' required: - - scheme - - value - - name - - country + - code + - field + - severity + - message properties: - scheme: + code: type: string - example: gln - value: + example: mass.balance + field: type: string - example: '4012345000009' - name: - type: string - country: + example: /materials + severity: + $ref: '#/components/schemas/LintSeverity' + message: type: string - minLength: 2 - maxLength: 2 - description: ISO 3166-1 alpha-2 country code. - address: + LintSeverity: + type: string + description: Severity of a plausibility finding. Neither value blocks publish — lint is advisory by construction, unlike a compliance violation. + enum: + - WARNING + - NOTICE + InstrumentRef: + type: object + description: | + One legal instrument recorded on a passport as applicable to it, and who + asserted that it applies. + + Fixed when the product was placed on the market and never recomputed. Carries + no status, deliberately: a passport freezes its applicable set at issuance, and + re-deriving how far along an act is would misstate what governed the product at + that moment. The obligation endpoint's `ReachingInstrument` answers the live + question and does carry status. + properties: + instrument: type: string - SealFormat: + description: The instrument's catalog id. + example: battery-reg-2023-1542 + recorded: + $ref: '#/components/schemas/RecordedBasis' + description: Who asserted this entry, fixed at issuance along with the rest of the applicable set. + required: + - instrument + - recorded + additionalProperties: false + RecordedBasis: type: string - description: AdES format of a seal value. enum: - - JADES - - PADES - - CADES - - XADES + - catalog + - operator + description: | + Who asserted that an act applies. Read it as provenance, not as how much to + trust the entry. + + `catalog` — resolved from the instrument catalog. + + `operator` — asserted by the economic operator placing the product on the + market. **Not a fallback.** The catalog cannot be exhaustive: horizontal + ecodesign requirements cover sets of products that were never shortlisted as + product groups, so an act may apply to a product while reaching no product + group the catalog models. An operator who knows an act applies must be able to + say so. + example: catalog SealedEnvelope: type: object description: An eIDAS electronic seal over the passport's full-payload signature. Check `placeholder` before treating it as evidence — a placeholder envelope is produced when no QTSP is configured and carries no legal validity. @@ -3448,48 +4455,34 @@ components: placeholder: type: boolean description: '`true` when this envelope has **no** legal validity. Consumers must check this flag rather than inferring validity from the envelope''s presence.' - PassportResponse: + SealFormat: + type: string + description: AdES format of a seal value. + enum: + - JADES + - PADES + - CADES + - XADES + CreatePassportRequest: type: object - description: The canonical Digital Product Passport record. Fields marked required are emitted on every read; the rest are omitted when unset rather than sent as null, so a consumer must treat absence and null as the same thing. required: - - id - productName - - productGroup - manufacturer - - materials - - status - - schemaVersion - - createdAt - - updatedAt - - retentionLocked - - version + description: | + Request body for creating a new DPP. Only `productName` and + `manufacturer` are required. All other fields are optional and + can be filled in later via `PUT` before publishing. properties: - id: - $ref: '#/components/schemas/DppId' - batchId: - type: - - string - - 'null' productName: type: string + description: Human-readable product name. Must not be blank. example: EcoCell Pro 48V productGroup: type: string - description: 'EU ESPR product group — the delegated-act bucket selecting the applicable schema and plugin. Deliberately an open string, not a closed enum: adding a product group is a catalog manifest plus a schema, not a release, and a product group this build does not know still round-trips its wire tag verbatim.' + description: | + EU ESPR product group (the dispatch key), e.g. `battery`, `textile`, + `electronics`. Optional — derived from `productGroupData` when omitted. example: battery - applicableInstruments: - type: array - description: |- - The legal instruments this passport was issued under, fixed when the product was placed on the market and never recomputed afterwards. - - A set rather than a single value because acts accumulate: ESPR Art. 5(7) lets a group-specific delegated act supplement a horizontal one and the Regulation states no precedence rule, so the governing law is the union of the members' requirements. - - Not derivable from `productGroup`. A horizontal act can reach a product whose product group is not one the catalog models, so an entry may be asserted by the economic operator rather than resolved from the catalog — which `recorded` distinguishes. Read it as who asserted the entry, not as how much to trust it. - items: - $ref: '#/components/schemas/InstrumentRef' - granularity: - $ref: '#/components/schemas/Granularity' - description: The level this passport describes. Omitted entirely while no adopted act has fixed one — see `Granularity` for why omitted is not `item`. manufacturer: $ref: '#/components/schemas/ManufacturerInfo' materials: @@ -3497,233 +4490,119 @@ components: items: $ref: '#/components/schemas/MaterialEntry' co2ePerUnit: - description: 'CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not a bare number: a figure without its lifecycle stage and system boundary is not comparable to another product''s.' - anyOf: - - $ref: '#/components/schemas/CarbonFootprint' - - type: 'null' + type: number + minimum: 0 + description: |- + CO₂ equivalent per unit, in kg. Must be finite and non-negative; anything else is rejected with `422`. + Supplied here as a scalar and stored as a `CarbonFootprint` object, so the value echoed back on `PassportResponse` is `{ "valueKg": … }` rather than the bare number sent. The lifecycle stage and system boundary that make two figures comparable cannot be set through this route. + example: 4.2 repairabilityScore: - description: Non-regulatory repairability heuristic — see the schema's own note. - anyOf: - - $ref: '#/components/schemas/RepairabilityScore' - - type: 'null' - complianceResult: - allOf: - - $ref: '#/components/schemas/ComplianceResult' - description: The computed compliance determination, attached at create/update. Part of the signed payload and immutable after retention lock. Absent until a determination has been computed. - lintResult: - allOf: - - $ref: '#/components/schemas/LintResult' - description: Advisory plausibility findings. Absent until a lint pass has run, and recomputable after publish — unlike `complianceResult`. + type: number + minimum: 0 + maximum: 10 + description: |- + Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU 2023/1669 — those have prescribed methodologies this does not implement. Anything outside 0–10 is rejected with `422`. + Supplied here as a scalar and stored as a `RepairabilityScore` object, so the value echoed back on `PassportResponse` is `{ "overall": … }` rather than the bare number sent. + example: 7.5 productGroupData: - description: Explicitly `null` — not omitted — for a passport whose product group data has not been supplied yet, which is every draft created without it. The field has no `skip_serializing_if`, so it is always present on the wire. + description: Optional at create; an explicit `null` is accepted and equivalent to omitting it. Publish validates it only when present. anyOf: - $ref: '#/components/schemas/ProductGroupData' - type: 'null' - status: - $ref: '#/components/schemas/PassportStatus' - qrCodeUrl: - type: - - string - - 'null' - format: uri - description: 'GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io).' - jwsSignature: - type: - - string - - 'null' - description: Compact JWS (Ed25519) over the **full** canonical payload. Null until published. - publicJwsSignature: - type: string - description: Compact JWS over the **public (redacted) view**, so anyone can verify the public passport independently — the resolver checks this on the unauthenticated public route. Set at publish; absent for drafts. - disclosureSignatures: - type: object - additionalProperties: - type: string - description: 'Compact JWS signatures over the **non-public** redacted views, keyed by disclosure set (e.g. `public+restricted+individual`), never by audience name. Every audience receiving more than the public view needs a proof over *its* view: `publicJwsSignature` covers only the public payload and `jwsSignature` only the full one, so a reader handed a filtered body and either of those holds a signature that cannot verify against the bytes it received. Empty for drafts.' - createdAt: - type: string - format: date-time - updatedAt: + batchId: type: string - format: date-time - publishedAt: - type: - - string - - 'null' - format: date-time + description: Optional batch or lot identifier + example: BATCH-2026-04-001 placedOnMarketDate: type: string format: date - description: The date the product was placed on the EU market — the regulated triggering event fixing **which law governs it**, distinct from the three lifecycle dates above, none of which selects a rule. Staged EU obligations attach at placing on the market and do not move afterwards. Absence means the date was not declared; it is **not** licence to substitute the current date, and a determination depending on it has no answer. + description: | + The date this product was placed on the EU market — the regulated + triggering event that fixes which law governs it. + + Optional, and omitting it is not neutral. A compliance determination + whose rule is phased by date has no answer without it: the node + reports the missing fact rather than substituting today's date, which + would produce a determination that silently changes its own answer + when a phase begins. For batteries this decides which EU 2023/1542 + Art. 8 minimum recycled shares apply. + example: '2026-03-14' schemaVersion: type: string - description: Semantic version of the **product group** schema `productGroupData` was validated against. Scoped to `productGroupData` only — the envelope fields have no equivalent version and never will, because they are shared by every product group's stored documents. The envelope's compatibility rule is additive only, permanently. - example: 1.0.0 - retentionLocked: - type: boolean description: | - Set to `true` permanently on first publish. Retention-locked - passports must remain accessible for the EU ESPR retention period. - version: - type: integer - minimum: 1 - description: Monotonic version counter. `1` on first publish; incremented on the successor each time a new version supersedes this record. - supersedesId: - allOf: - - $ref: '#/components/schemas/DppId' - description: The passport this record supersedes. Absent for first versions. + Product group schema version. Optional, and the only accepted value is the + product group's **current** version — omitting it is equivalent. Any other + value is rejected with `422`. + + It is not the caller's to choose: the stored version selects the + disclosure table the passport's public view is filtered through and + signed under, and an older table classifies fewer fields, defaulting + the rest to public. The body is validated against the current schema + in either case, so a differing declaration is already false about the + body it accompanies. + example: 2.6.0 parentPassportRef: - allOf: - - $ref: '#/components/schemas/PassportRef' - description: Cross-operator reference to the predecessor this passport derives from (second-life successor linkage). + $ref: '#/components/schemas/PassportRef' componentRefs: type: array - description: 'Cross-operator references to the constituent passports this product is assembled from — its bill of materials. The inverse edge of `parentPassportRef`: these point down to many constituents, that points up to one predecessor.' items: $ref: '#/components/schemas/PassportRef' - retentionUntil: - type: string - format: date-time - description: Deadline by which this record must remain accessible, computed at publish from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) requires the delegated act to specify a period corresponding to at least the product's expected lifetime; Art. 11(e) restates it as an essential requirement, available including after the responsible operator's insolvency, liquidation or cessation of activity. - productId: - type: string - format: uuid - description: Opaque link to an internal product-template record. Not a legal identifier. + description: Cross-operator references to this product's constituent passports (its bill of materials). Local cycles / over-depth are refused at create/update; immutable after publish. commodityCode: type: string - description: Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the product group does not call for one; this node will not invent a classification it cannot derive. + description: Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, no separators). Registration data the EU registry verifies against the ranges its product group permits. example: '85076000' - operatorIdentifier: - type: string - description: 'EORI or national economic-operator identifier for the responsible party (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). **Frozen at publish — this is the operator that published the passport, not necessarily the one responsible for it now.** A transfer of responsibility does not rewrite it and cannot: published content is immutable and covered by the signature over it. For current responsibility, read the transfer chain.' - example: DE123456789 - facility: - allOf: - - $ref: '#/components/schemas/FacilitySnapshot' - description: Snapshot of the Annex III facility where this product was manufactured or processed, copied by value at create time. - seal: + supersedesId: allOf: - - $ref: '#/components/schemas/SealedEnvelope' - description: The eIDAS electronic seal applied to this passport. Absent until a seal has been applied; check its `placeholder` flag rather than inferring validity from presence. - Problem: + - $ref: '#/components/schemas/DppId' + description: | + The passport this one replaces, if it is a new version of an existing + record. + + Declared here because `supersedesId` is protected: it is set by the write + that creates the whole record, never by a field patch. Recording the link + retires nothing — the predecessor is retired by + `POST /dpp/{dppId}/supersede` once this successor is published, and that + route checks the two agree. + + Only for a successor created independently. `POST /dpp/{dppId}/amend` + mints its own successor and sets this field itself; it is not settable + through that route and does not need to be. + SuspendRequest: type: object description: | - RFC 7807 / RFC 9457 problem details. The shape - `dpp-common::http_problem::Problem` produces, served as - `application/problem+json`. + Optional body for suspending a published passport. - `type` is derived from `title`, so each distinct `title` used across the - codebase is a stable catalogue key that clients may depend on. - required: - - type - - title - - status - properties: - type: - type: string - format: uri - description: Absolute URI identifying the problem type. - example: https://problems.odal-node.io/not-found - title: - type: string - description: Short human-readable summary of the problem type. - example: Not Found - status: - type: integer - description: The HTTP status code, mirroring the status line. - example: 404 - detail: - type: string - description: Human-readable explanation for this specific occurrence. - example: 'No schema for product group ''nosuchsector''. Known product groups: aluminium, battery.' - instance: - type: string - format: uri-reference - description: URI reference identifying this specific occurrence. - ValidateResponse: - type: object - description: 'The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.' - required: - - createValid - - productGroupDataValid - properties: - createValid: - type: boolean - description: Always `true` on a 200 — a body create would reject gets back the identical 422 create would have returned, not a paraphrase of it. - example: true - productGroupDataValid: - type: boolean - description: | - Whether the product group data would clear the publish-time schema gate. `true` - when no product group data is supplied, since publish only validates it when - present. - - **Not a publish verdict.** It reports one of publish's preconditions, and - publish applies others this route does not run: the registry-identity - requirement (a default facility and a primary operator identifier), which - needs operator state this route never reads; the category-mandatory - content gate, reachable only by attempting the lifecycle transition; and - the compliance gate, which needs a `placedOnMarketDate` and a stored - passport. `true` means "this body clears the schema gate", never "publish - will succeed" — the field was called `publishValid` and was renamed - because that name promised the latter. - example: false - detail: - type: - - string - - 'null' - description: Why the product group data would be refused. Null when `productGroupDataValid` is true. - example: 'cannot publish: no registered JSON Schema for product group ''furniture'' — publish requires a resolvable schema when product group data is present' - PassportListResponse: - type: object - description: One page of passports, with the totals needed to page through them. - required: - - dpps - - total - - limit - - skip - properties: - dpps: - type: array - items: - $ref: '#/components/schemas/PassportResponse' - total: - type: integer - description: Total matching the filter across every page — deliberately not the length of `dpps`. - limit: - type: integer - description: The page size actually applied, after clamping. - skip: - type: integer - description: The offset this page starts at. - SuspendRequest: - type: object - description: | - Optional body for suspending a published passport. - - The body itself is optional — `POST` with no body suspends without a recorded - reason. Where a reason is supplied it is appended to the audit entry, not - stored on the passport, so it is not part of the signed document. + The body itself is optional — `POST` with no body suspends without a recorded + reason. Where a reason is supplied it is appended to the audit entry, not + stored on the passport, so it is not part of the signed document. properties: reason: type: string description: Human-readable reason for the suspension, appended to the audit trail. example: Product recall — safety investigation pending - DerogationRef: + EolRequest: type: object required: - - category - description: A recognised derogation from the ESPR Art. 25 destruction ban. The category list is fixed by the applicable delegated act; validated against that list at the engine boundary, not by this schema. + - reason + description: Request body for declaring a passport end-of-life. properties: - category: - type: string - description: The derogation category as named by the delegated act. - example: health-and-safety - actCitation: + reason: + $ref: '#/components/schemas/DeactivationReason' + declaredBy: + type: + - string + - 'null' + description: DID of the declaring operator; defaults to the authenticated actor. + materialRecovery: + type: + - object + - 'null' + description: Optional recovered-material summary (Battery Annex XIII circularity). + notes: type: - string - 'null' - description: The act/article this derogation is grounded in (e.g. an OJ/CELEX ref). DeactivationReason: description: Why a passport reached end-of-life, internally tagged by `kind`. Destruction alone requires a `derogation` citing the lawful basis. oneOf: @@ -3764,72 +4643,42 @@ components: - lost example: kind: recycled - EolRequest: + DerogationRef: type: object required: - - reason - description: Request body for declaring a passport end-of-life. + - category + description: A recognised derogation from the ESPR Art. 25 destruction ban. The category list is fixed by the applicable delegated act; validated against that list at the engine boundary, not by this schema. properties: - reason: - $ref: '#/components/schemas/DeactivationReason' - declaredBy: - type: - - string - - 'null' - description: DID of the declaring operator; defaults to the authenticated actor. - materialRecovery: - type: - - object - - 'null' - description: Optional recovered-material summary (Battery Annex XIII circularity). - notes: + category: + type: string + description: The derogation category as named by the delegated act. + example: health-and-safety + actCitation: type: - string - 'null' - OperatorRole: - type: string - description: The role of an economic operator in the DPP supply chain. - enum: - - manufacturer - - importer - - distributor - - authorisedRepresentative - - remanufacturer - - repurposer - - preparerForReuse - - repairer - - recycler - ResponsibleOperator: + description: The act/article this derogation is grounded in (e.g. an OJ/CELEX ref). + TransferInitiateRequest: type: object required: - - did - - name - - role - - country - description: An economic operator responsible for a DPP (ESPR "responsible economic operator"). + - fromOperator + - toOperator + - reason properties: - did: - type: string - example: did:web:acme.example.com - name: - type: string - role: - $ref: '#/components/schemas/OperatorRole' - euOperatorId: - type: - - string - - 'null' - description: EU-assigned economic operator identifier, if available. - euOperatorIdScheme: + fromOperator: + allOf: + - $ref: '#/components/schemas/ResponsibleOperator' + description: The current (outgoing) responsible operator — must match the chain head. + toOperator: + allOf: + - $ref: '#/components/schemas/ResponsibleOperator' + description: The incoming responsible operator taking over the DPP. + reason: + $ref: '#/components/schemas/TransferReason' + notes: type: - string - 'null' - description: Scheme euOperatorId is expressed in — "vat", "lei", "eori", "duns". - country: - type: string - minLength: 2 - maxLength: 2 - description: ISO 3166-1 alpha-2 country code of the operator's establishment. TransferReason: type: string description: The reason for a transfer of DPP responsibility. @@ -3841,34 +4690,162 @@ components: - preparationForReuse - import - insolvencySuccession - TransferInitiateRequest: + PassportListResponse: type: object + description: One page of passports, with the totals needed to page through them. required: - - fromOperator - - toOperator - - reason + - dpps + - total + - limit + - skip properties: - fromOperator: - allOf: - - $ref: '#/components/schemas/ResponsibleOperator' - description: The current (outgoing) responsible operator — must match the chain head. - toOperator: - allOf: - - $ref: '#/components/schemas/ResponsibleOperator' - description: The incoming responsible operator taking over the DPP. - reason: - $ref: '#/components/schemas/TransferReason' - notes: + dpps: + type: array + items: + $ref: '#/components/schemas/PassportResponse' + total: + type: integer + description: Total matching the filter across every page — deliberately not the length of `dpps`. + limit: + type: integer + description: The page size actually applied, after clamping. + skip: + type: integer + description: The offset this page starts at. + ValidateResponse: + type: object + description: 'The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.' + required: + - createValid + - productGroupDataValid + properties: + createValid: + type: boolean + description: Always `true` on a 200 — a body create would reject gets back the identical 422 create would have returned, not a paraphrase of it. + example: true + productGroupDataValid: + type: boolean + description: | + Whether the product group data would clear the publish-time schema gate. `true` + when no product group data is supplied, since publish only validates it when + present. + + **Not a publish verdict.** It reports one of publish's preconditions, and + publish applies others this route does not run: the registry-identity + requirement (a default facility and a primary operator identifier), which + needs operator state this route never reads; the category-mandatory + content gate, reachable only by attempting the lifecycle transition; and + the compliance gate, which needs a `placedOnMarketDate` and a stored + passport. `true` means "this body clears the schema gate", never "publish + will succeed" — the field was called `publishValid` and was renamed + because that name promised the latter. + example: false + detail: type: - string - 'null' - TransferRecord: + description: Why the product group data would be refused. Null when `productGroupDataValid` is true. + example: 'cannot publish: no registered JSON Schema for product group ''furniture'' — publish requires a resolvable schema when product group data is present' + PassportAuditEntry: type: object required: - - transferId + - id - passportId - - fromOperator - - toOperator + - actor + - action + - timestamp + properties: + id: + type: string + format: uuid + passportId: + type: string + actor: + type: string + example: admin@example.com + action: + type: string + example: published + previousStatus: + type: + - string + - 'null' + newStatus: + type: + - string + - 'null' + metadata: + type: + - object + - 'null' + timestamp: + type: string + format: date-time + requestId: + type: + - string + - 'null' + description: |- + The `x-request-id` of the HTTP request that produced this entry — the same value returned in that request's response header, so a support conversation can be traced from a client's log to the trail. Null for an entry written outside a request, and for every entry written before this field was populated. + + Deliberately **not** covered by `entryHash`: it describes the transport that carried the change, not the change, and folding it in would invalidate the chain of every entry already stored. + prevHash: + type: + - string + - 'null' + description: 'Hex SHA-256 of the previous entry''s `entryHash`, or null for the first entry in a passport''s chain. Together with `entryHash` this makes the trail append-only and tamper-evident: recomputing the chain detects any inserted, removed or edited entry.' + entryHash: + type: + - string + - 'null' + description: Hex SHA-256 over this entry's canonical (RFC 8785) bytes including `prevHash`. Null only for entries written before the chain was introduced. + TreeReport: + type: object + required: + - verified + - nodes + description: Result of recursively verifying a passport's component tree. + properties: + verified: + type: boolean + description: True iff every visited node verified. + nodes: + type: array + items: + $ref: '#/components/schemas/TreeNodeReport' + TreeNodeReport: + type: object + required: + - path + - verified + properties: + path: + type: array + items: + type: string + description: Component-ref URIs from the root down to this node. + verified: + type: boolean + reason: + type: + - string + - 'null' + enum: + - unreachable + - notPublished + - hashMismatch + - cycle + - depthExceeded + - nodeCapExceeded + - malformedRef + description: The failure reason when `verified` is false. + TransferRecord: + type: object + required: + - transferId + - passportId + - fromOperator + - toOperator - reason - initiatedAt description: A single transfer-of-responsibility event, dual-signed by the outgoing and incoming operators. @@ -3917,179 +4894,426 @@ components: type: - string - 'null' - AuditEntry: + ResponsibleOperator: type: object required: - - id - - passportId - - actor - - action - - timestamp + - did + - name + - role + - country + description: An economic operator responsible for a DPP (ESPR "responsible economic operator"). properties: - id: - type: string - format: uuid - passportId: - type: string - actor: + did: type: string - example: admin@example.com - action: + example: did:web:acme.example.com + name: type: string - example: published - previousStatus: + role: + $ref: '#/components/schemas/OperatorRole' + euOperatorId: type: - string - 'null' - newStatus: + description: EU-assigned economic operator identifier, if available. + euOperatorIdScheme: type: - string - 'null' - metadata: - type: - - object - - 'null' - timestamp: + description: Scheme euOperatorId is expressed in — "vat", "lei", "eori", "duns". + country: + type: string + pattern: ^[A-Z]{2}$ + description: | + ISO 3166-1 alpha-2 country code of the operator's establishment, upper + case. + + Checked against the assigned ISO 3166-1 list on + `POST /dpp/{dppId}/transfer/initiate`, not merely shaped — `de` and `XX` + are both refused with `422`. Canonical form is required rather than + normalised because both operators are inside the payload this node signs, + and upper-casing a caller's value before signing would sign something they + did not send. + example: DE + OperatorRole: + type: string + description: The role of an economic operator in the DPP supply chain. + enum: + - manufacturer + - importer + - distributor + - authorisedRepresentative + - remanufacturer + - repurposer + - preparerForReuse + - repairer + - recycler + SealResponse: + type: object + description: The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check. + required: + - declaredBy + - format + - sealValue + - sealedAt + - placeholder + - currentJws + - currentPayloadHash + - coverage + - verification + properties: + declaredBy: + $ref: '#/components/schemas/SealDeclarer' + format: + type: string + description: AdES format of `sealValue`. + example: CADES + sealValue: + type: string + description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. + sealedAt: type: string format: date-time - prevHash: + description: '**This node''s clock when the backend answered — not a trusted timestamp.** Not necessarily when the signature was formed. A seal carries an independently established signing time only from baseline level `T` upward, where a timestamp authority attests it; at `B` there is no such token anywhere in the envelope, so this is an unattested claim by the party that bought the seal. Anything resting on *when* the seal was made must read the timestamp token out of `sealValue`.' + signingCertRef: type: - string - 'null' - description: 'Hex SHA-256 of the previous entry''s `entryHash`, or null for the first entry in a passport''s chain. Together with `entryHash` this makes the trail append-only and tamper-evident: recomputing the chain detects any inserted, removed or edited entry.' - entryHash: + description: Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed. + placeholder: + type: boolean + description: True when this is a placeholder with no legal validity. + currentJws: + type: string + description: The passport's **current** compact JWS. + currentPayloadHash: + type: string + description: Hex SHA-256 of `currentJws`. + sealedPayloadHash: type: - string - 'null' - description: Hex SHA-256 over this entry's canonical (RFC 8785) bytes including `prevHash`. Null only for entries written before the chain was introduced. - DailyScanCount: - type: object - required: - - day - - count - properties: - day: + description: 'Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator''s extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere.' + coverage: + $ref: '#/components/schemas/SealCoverage' + verification: type: string - format: date - count: - type: integer - PassportScanStats: + description: 'Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List.' + SealSummaryResponse: type: object - description: Per-passport scan aggregates over a trailing window. `totalScans` and `qrRenders` sit side by side and are never combined — a QR render is label production, not a resolution. Nothing about the scanner (IP, agent, session) is collected or returned; the counters carry no such fields. + description: 'Operator-wide sealing state. Read `sealingConfigured` first: when it is false every count is `0` because this node has no outbox, **not** because nothing is outstanding.' required: - - windowDays - - totalScans - - scansHtml - - scansJson - - daily - - qrRenders + - unsealedPublished + - pending + - sealed + - exhausted + - sealingConfigured properties: - windowDays: - type: integer - totalScans: + unsealedPublished: type: integer - scansHtml: + description: Published passports carrying no seal at all. `0` is the healthy state. + pending: type: integer - scansJson: + sealed: type: integer - daily: - type: array - description: Per-day scan totals, oldest first. - items: - $ref: '#/components/schemas/DailyScanCount' - qrRenders: + exhausted: type: integer - OperatorScanStats: + description: Rows that gave up after exhausting their retries. + sealingConfigured: + type: boolean + SealDeclarer: type: object - description: Operator-wide scan rollup over a trailing window. + description: | + Who declared the content a seal covers, which is not who sealed it. + + A seal proves a document came from whoever holds the certificate. It carries no + statement about *scope*: "we vouch for this content" and "we transmitted this + intact" look identical. A response that serves a seal and names no declaring + party invites the reader to collapse the two, whatever anyone intended. + + Every audience view strips the seal, so this is the only surface where that + collapse is reachable — and its readers being authenticated and technical makes + them more likely to build on the assumption, not less. required: - - windowDays - - totalScans - - totalQrRenders - - distinctPassportsScanned + - manufacturer + - operatorIdentifier + - responsibilityMayHaveTransferred + - note properties: - windowDays: - type: integer - totalScans: - type: integer - totalQrRenders: - type: integer - distinctPassportsScanned: - type: integer - RegistrationView: + manufacturer: + type: string + description: The manufacturer named in the sealed passport, frozen at publish. + example: TestCorp GmbH + operatorIdentifier: + type: + - string + - 'null' + description: The Annex III(k) unique operator identifier recorded at publish. `null` means none was recorded — never that none applies. + responsibilityMayHaveTransferred: + type: boolean + description: | + True when the passport's transfer chain records a **completed** handover, + so the party responsible now is not the one named above. An initiated + handover nobody accepted has moved nothing and does not set this. + + The names above are frozen into the sealed bytes and cannot be rewritten — + a published passport's content is immutable and the seal covers it — so + this flag is the only honest way to say the answer above is historical + rather than current. + note: + type: string + description: States the sealing/authorship distinction outright rather than leaving it to be inferred from field names, in the same spirit as `verification`. + SealCoverage: + type: string + description: 'Whether the stored seal covers the passport''s current signature. Answered from this node''s record of what it *asked* to be sealed — weaker than a validator''s verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.' + enum: + - current + - superseded + - unknown + EvidenceDossier: type: object - description: One passport's registration, as the EU-registry queue holds it. + description: | + A self-contained, signed snapshot of a passport's full proof chain, + persisted by the node at generation time. Verification + (`POST /evidence/{id}/verify`) is an integrity check of the stored + dossier against its own signatures and hash chains. See + `docs/architecture/EVIDENCE-DOSSIER.md` for the full specification, + including why `calcReceipts`/`checkpoint` are always empty/`null` in + format v1. required: - - status - - attempts - - stalled + - manifest + - manifestJws + - fullView + - publicView + - didDocuments + - auditEntries properties: - status: + manifest: + $ref: '#/components/schemas/DossierManifest' + manifestJws: type: string - enum: - - pending - - submitted - - registered - - rejected - - deactivated - registryId: + fullView: + $ref: '#/components/schemas/SignedLayer' + publicView: + $ref: '#/components/schemas/SignedLayer' + didDocuments: + type: object + description: DID document snapshots, keyed by DID. + additionalProperties: + type: object + auditEntries: + type: array + items: + $ref: '#/components/schemas/PassportAuditEntry' + transferChain: + type: + - object + - 'null' + description: Present iff the passport has ever changed responsible operator. + eolEvent: + type: + - object + - 'null' + description: Present iff the passport was declared end-of-life. + checkpoint: + type: + - object + - 'null' + description: Always `null` in format v1 — the signed-checkpoint layer is not yet built. + calcReceipts: + type: array + description: Always empty in format v1 — `dpp-calc` invocation is not yet wired end to end. + items: + type: object + componentGraph: + anyOf: + - $ref: '#/components/schemas/TreeReport' + - type: 'null' + description: | + The recursive component-tree (bill-of-materials) verification report, + present iff the passport declares `componentRefs`. `null` for a unit + with no modelled sub-assemblies. + + Generated at dossier-assembly time by walking the tree and pin-checking + each node, then bound into `contentHashes` like every other member — so + a tampered report fails the dossier's `content_integrity` check rather + than passing as an unverifiable attachment. + + Integrity only, the same caveat as the standalone `verify-tree` route: + it proves each node's signed public view is unchanged against its + pinned hash, not the cryptographic validity of that node's signature. + qualifiedSeal: + anyOf: + - $ref: '#/components/schemas/QualifiedSealMember' + - type: 'null' + description: The passport's eIDAS qualified seal, present iff one has been applied. `null` when the seal is still queued. + DossierManifest: + type: object + description: | + Signed metadata binding every dossier member into one atomic, + tamper-evident unit. + required: + - formatVersion + - passportId + - issuerDid + - createdAt + - nodeVersion + - coreVersion + - contentHashes + properties: + formatVersion: type: string - description: The registry's own record id, once it has issued one. - message: + example: '1' + passportId: type: string - description: The last thing the registry (or the drain) said about it. - attempts: - type: integer - stalled: - type: boolean - description: True once `attempts` reaches the drain threshold — the row is not going to succeed without someone looking at it. - statusIntent: + issuerDid: type: string - description: 'A status change owed to the registry, independent of the queue state. Nothing drains these: the registry publishes no status-push API, so they are held durably and reported rather than accumulating out of sight.' - TransferView: + example: did:web:node.example.com + createdAt: + type: string + format: date-time + nodeVersion: + type: string + rulesetVersion: + type: + - string + - 'null' + contentHashes: + type: object + description: member name -> hex SHA-256 of that member's JCS-canonical bytes. + additionalProperties: + type: string + coreVersion: + type: string + description: The `dpp-core` version this node was built against. Recorded alongside `nodeVersion` because the two move independently — the regulatory logic, schemas and disclosure policy behind a determination live in core, so a dossier naming only the node version cannot be traced back to the code that produced its verdict. + example: 0.18.0 + SignedLayer: type: object - description: One transfer-of-responsibility notification owed to the registry. + description: | + A JWS alongside the exact JSON payload it was signed over — embedded + directly rather than left for a verifier to reconstruct. required: - - transferId - - status - - attempts - - stalled + - payload + - jws properties: - transferId: + payload: + type: object + jws: + type: string + description: Compact EdDSA JWS. + QualifiedSealMember: + type: object + description: | + The passport's eIDAS qualified seal as carried inside an evidence dossier: + the seal envelope plus the JWS it was computed over and that payload's hash, + so a verifier holding only the dossier has both the CAdES and the preimage to + check it against. + + Included because a dossier is what an authority is handed, and the seal is its + one member carrying an Art. 35(2) presumption — and because it is unreachable + otherwise: the seal is stripped from `fullView` and `publicView` alike, since + it covers the full-payload signature rather than any redaction. Bound into + `contentHashes` like every other member. + + ⚠️ **This shape is described here but not enforced anywhere.** The field is + held as untyped JSON on the dossier record, so no Rust type declares these + three members and the OpenAPI contract test has nothing to compare them + against. Treat the property list as documentation of intent, not as a + guarantee. Giving the dossier a real type for this member would close that, + and is the only thing that would. + properties: + seal: + type: object + description: The `SealedEnvelope` as persisted on the passport. + signedOverJws: + type: string + description: The JWS the seal was computed over. + payloadHash: + type: string + pattern: ^[0-9a-f]{64}$ + description: SHA-256 of the sealed payload, lowercase hex. + EvidenceDossierRecord: + type: object + description: A stored dossier snapshot — the dossier plus its persistence envelope. + required: + - id + - passportId + - actor + - createdAt + - docHash + - dossier + properties: + id: type: string format: uuid - status: + passportId: type: string - enum: - - pending - - notified - - rejected - registryId: + format: uuid + actor: type: string - message: + description: Who requested generation. + createdAt: type: string - attempts: - type: integer - stalled: - type: boolean - CurrentOperatorView: + format: date-time + docHash: + type: string + description: Hex SHA-256 of the JCS-canonicalised stored dossier document. + dossier: + $ref: '#/components/schemas/EvidenceDossier' + EvidenceDossierSummary: type: object - description: The operator responsible for a passport **today**, derived from its transfer chain. Reported separately from the passport's own `operatorIdentifier`, which is the operator that *published* it — frozen at publish and covered by the signature, so a transfer does not rewrite it. For a passport that has changed hands the two differ, and that difference is a fact about the product. + description: Listing projection of a stored dossier — everything but the document. required: - - did - - name - - country - - transferCount + - id + - passportId + - actor + - createdAt + - docHash properties: - did: + id: + type: string + format: uuid + passportId: + type: string + format: uuid + actor: type: string + createdAt: + type: string + format: date-time + docHash: + type: string + VerificationReport: + type: object + required: + - trustAnchorNote + - checks + properties: + trustAnchorNote: + type: string + example: trust anchored to the dossier's embedded DID-document snapshot dated 2026-07-10T00:00:00Z + checks: + type: array + items: + $ref: '#/components/schemas/CheckResult' + CheckResult: + type: object + description: Outcome of a single named verification check. + required: + - name + - status + properties: name: type: string - country: + example: audit_chain + status: type: string - transferCount: - type: integer - description: Completed handovers this passport has been through. + enum: + - pass + - fail + - absent + detail: + type: string + description: Present when status is `fail` or `absent`. PassportRegistryView: type: object description: 'EU-registry state for one passport. `configured: false` means this deployment has no registry queues at all — reported instead of a row of zeros, which would read as "everything is registered".' @@ -4109,78 +5333,11 @@ components: type: array description: Handover notifications recorded for this passport, newest first. items: - $ref: '#/components/schemas/TransferView' + $ref: '#/components/schemas/TransferNotificationView' currentOperator: allOf: - $ref: '#/components/schemas/CurrentOperatorView' description: Absent when the passport has never been transferred, in which case its own `operatorIdentifier` is current. - VerificationView: - type: object - description: The operator's verified-registry standing. Verified status ends when the electronic identification means used expire, and at the latest three years after verification; an operator that lets it lapse cannot register or amend anything until it verifies again. Reported whether or not the queues are configured. - required: - - current - properties: - current: - type: boolean - description: False both when never verified and when lapsed — the registry refuses either way, though they are different situations to act on. - verifiedAt: - type: string - format: date-time - description: Absent when never verified. - expiresAt: - type: string - format: date-time - description: The three-year cap. The eID means may expire sooner, which this cannot see, so it is an upper bound rather than a promise. - daysRemaining: - type: integer - description: Negative once lapsed. Absent when never verified. - RegistrationCounts: - type: object - required: - - pending - - submitted - - registered - - rejected - - deactivated - - statusIntents - - stalled - - unregisteredPublished - properties: - pending: - type: integer - submitted: - type: integer - registered: - type: integer - rejected: - type: integer - deactivated: - type: integer - statusIntents: - type: integer - description: Status changes owed to the registry that nothing drains. - stalled: - type: integer - description: Rows that have retried past the point of self-recovery. - unregisteredPublished: - type: integer - description: 'Published passports with **no** outbox row at all — they owe a registration nobody is tracking (published before the outbox existed, or lost to an older write path). Reported, not repaired: the queued payload is what a drain replays and there is none to rebuild, so fabricating a row would create an entry that can never drain.' - TransferCounts: - type: object - required: - - pending - - notified - - rejected - - stalled - properties: - pending: - type: integer - notified: - type: integer - rejected: - type: integer - stalled: - type: integer RegistryRollupView: type: object description: 'Operator-wide EU-registry state. `configured: false` means this deployment has no registry queues; the counts are then omitted rather than reported as zero.' @@ -4191,417 +5348,343 @@ components: configured: type: boolean verification: - $ref: '#/components/schemas/VerificationView' + $ref: '#/components/schemas/RegistryVerificationView' registrations: $ref: '#/components/schemas/RegistrationCounts' transfers: - $ref: '#/components/schemas/TransferCounts' - TreeNodeReport: - type: object - required: - - path - - verified - properties: - path: - type: array - items: - type: string - description: Component-ref URIs from the root down to this node. - verified: - type: boolean - reason: - type: - - string - - 'null' - enum: - - unreachable - - notPublished - - hashMismatch - - cycle - - depthExceeded - - nodeCapExceeded - - malformedRef - description: The failure reason when `verified` is false. - TreeReport: - type: object - required: - - verified - - nodes - description: Result of recursively verifying a passport's component tree. - properties: - verified: - type: boolean - description: True iff every visited node verified. - nodes: - type: array - items: - $ref: '#/components/schemas/TreeNodeReport' - SealSummaryResponse: - type: object - description: 'Operator-wide sealing state. Read `sealingConfigured` first: when it is false every count is `0` because this node has no outbox, **not** because nothing is outstanding.' - required: - - unsealedPublished - - pending - - sealed - - exhausted - - sealingConfigured - properties: - unsealedPublished: - type: integer - description: Published passports carrying no seal at all. `0` is the healthy state. - pending: - type: integer - sealed: - type: integer - exhausted: - type: integer - description: Rows that gave up after exhausting their retries. - sealingConfigured: - type: boolean - SealDeclarer: - type: object - description: | - Who declared the content a seal covers, which is not who sealed it. - - A seal proves a document came from whoever holds the certificate. It carries no - statement about *scope*: "we vouch for this content" and "we transmitted this - intact" look identical. A response that serves a seal and names no declaring - party invites the reader to collapse the two, whatever anyone intended. - - Every audience view strips the seal, so this is the only surface where that - collapse is reachable — and its readers being authenticated and technical makes - them more likely to build on the assumption, not less. - required: - - manufacturer - - operatorIdentifier - - responsibilityMayHaveTransferred - - note - properties: - manufacturer: - type: string - description: The manufacturer named in the sealed passport, frozen at publish. - example: TestCorp GmbH - operatorIdentifier: - type: - - string - - 'null' - description: The Annex III(k) unique operator identifier recorded at publish. `null` means none was recorded — never that none applies. - responsibilityMayHaveTransferred: - type: boolean - description: | - True when the passport's transfer chain records a **completed** handover, - so the party responsible now is not the one named above. An initiated - handover nobody accepted has moved nothing and does not set this. - - The names above are frozen into the sealed bytes and cannot be rewritten — - a published passport's content is immutable and the seal covers it — so - this flag is the only honest way to say the answer above is historical - rather than current. - note: - type: string - description: States the sealing/authorship distinction outright rather than leaving it to be inferred from field names, in the same spirit as `verification`. - Coverage: - type: string - description: 'Whether the stored seal covers the passport''s current signature. Answered from this node''s record of what it *asked* to be sealed — weaker than a validator''s verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.' - enum: - - current - - superseded - - unknown - SealResponse: + $ref: '#/components/schemas/TransferNotificationCounts' + RegistrationView: type: object - description: The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check. + description: One passport's registration, as the EU-registry queue holds it. required: - - declaredBy - - format - - sealValue - - sealedAt - - placeholder - - currentJws - - currentPayloadHash - - coverage - - verification + - status + - attempts + - stalled properties: - declaredBy: - $ref: '#/components/schemas/SealDeclarer' - format: + status: type: string - description: AdES format of `sealValue`. - example: CADES - sealValue: + enum: + - pending + - submitted + - registered + - rejected + - deactivated + registryId: type: string - description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. - sealedAt: + description: The registry's own record id, once it has issued one. + message: type: string - format: date-time - signingCertRef: - type: - - string - - 'null' - description: Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed. - placeholder: + description: The last thing the registry (or the drain) said about it. + attempts: + type: integer + stalled: type: boolean - description: True when this is a placeholder with no legal validity. - currentJws: - type: string - description: The passport's **current** compact JWS. - currentPayloadHash: - type: string - description: Hex SHA-256 of `currentJws`. - sealedPayloadHash: - type: - - string - - 'null' - description: 'Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator''s extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere.' - coverage: - $ref: '#/components/schemas/Coverage' - verification: + description: True once `attempts` reaches the drain threshold — the row is not going to succeed without someone looking at it. + statusIntent: type: string - description: 'Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List.' - EvidenceDossierSummary: + description: 'A status change owed to the registry, independent of the queue state. Nothing drains these: the registry publishes no status-push API, so they are held durably and reported rather than accumulating out of sight.' + RegistrationCounts: type: object - description: Listing projection of a stored dossier — everything but the document. required: - - id - - passportId - - actor - - createdAt - - docHash + - pending + - submitted + - registered + - rejected + - deactivated + - statusIntents + - stalled + - unregisteredPublished properties: - id: - type: string - format: uuid - passportId: + pending: + type: integer + submitted: + type: integer + registered: + type: integer + rejected: + type: integer + deactivated: + type: integer + statusIntents: + type: integer + description: Status changes owed to the registry that nothing drains. + stalled: + type: integer + description: Rows that have retried past the point of self-recovery. + unregisteredPublished: + type: integer + description: 'Published passports with **no** outbox row at all — they owe a registration nobody is tracking (published before the outbox existed, or lost to an older write path). Reported, not repaired: the queued payload is what a drain replays and there is none to rebuild, so fabricating a row would create an entry that can never drain.' + TransferNotificationView: + type: object + description: One transfer-of-responsibility notification owed to the registry. + required: + - transferId + - status + - attempts + - stalled + properties: + transferId: type: string format: uuid - actor: + status: type: string - createdAt: + enum: + - pending + - notified + - rejected + registryId: type: string - format: date-time - docHash: + message: type: string - DossierManifest: + attempts: + type: integer + stalled: + type: boolean + TransferNotificationCounts: type: object - description: | - Signed metadata binding every dossier member into one atomic, - tamper-evident unit. required: - - formatVersion - - passportId - - issuerDid - - createdAt - - nodeVersion - - coreVersion - - contentHashes + - pending + - notified + - rejected + - stalled properties: - formatVersion: - type: string - example: '1' - passportId: - type: string - issuerDid: - type: string - example: did:web:node.example.com - createdAt: + pending: + type: integer + notified: + type: integer + rejected: + type: integer + stalled: + type: integer + RegistryVerificationView: + type: object + description: The operator's verified-registry standing. Verified status ends when the electronic identification means used expire, and at the latest three years after verification; an operator that lets it lapse cannot register or amend anything until it verifies again. Reported whether or not the queues are configured. + required: + - current + properties: + current: + type: boolean + description: False both when never verified and when lapsed — the registry refuses either way, though they are different situations to act on. + verifiedAt: type: string format: date-time - nodeVersion: - type: string - rulesetVersion: - type: - - string - - 'null' - contentHashes: - type: object - description: member name -> hex SHA-256 of that member's JCS-canonical bytes. - additionalProperties: - type: string - coreVersion: + description: Absent when never verified. + expiresAt: type: string - description: The `dpp-core` version this node was built against. Recorded alongside `nodeVersion` because the two move independently — the regulatory logic, schemas and disclosure policy behind a determination live in core, so a dossier naming only the node version cannot be traced back to the code that produced its verdict. - example: 0.18.0 - SignedLayer: + format: date-time + description: The three-year cap. The eID means may expire sooner, which this cannot see, so it is an upper bound rather than a promise. + daysRemaining: + type: integer + description: Negative once lapsed. Absent when never verified. + CurrentOperatorView: type: object - description: | - A JWS alongside the exact JSON payload it was signed over — embedded - directly rather than left for a verifier to reconstruct. + description: The operator responsible for a passport **today**, derived from its transfer chain. Reported separately from the passport's own `operatorIdentifier`, which is the operator that *published* it — frozen at publish and covered by the signature, so a transfer does not rewrite it. For a passport that has changed hands the two differ, and that difference is a fact about the product. required: - - payload - - jws + - did + - name + - country + - transferCount properties: - payload: - type: object - jws: + did: type: string - description: Compact EdDSA JWS. - QualifiedSealMember: + name: + type: string + country: + type: string + transferCount: + type: integer + description: Completed handovers this passport has been through. + PassportScanStats: type: object - description: | - The passport's eIDAS qualified seal as carried inside an evidence dossier: - the seal envelope plus the JWS it was computed over and that payload's hash, - so a verifier holding only the dossier has both the CAdES and the preimage to - check it against. + description: Per-passport scan aggregates over a trailing window. `totalScans` and `qrRenders` sit side by side and are never combined — a QR render is label production, not a resolution. Nothing about the scanner (IP, agent, session) is collected or returned; the counters carry no such fields. + required: + - windowDays + - totalScans + - scansHtml + - scansJson + - daily + - qrRenders + - ingesting + properties: + windowDays: + type: integer + totalScans: + type: integer + scansHtml: + type: integer + scansJson: + type: integer + daily: + type: array + description: Per-day scan totals, oldest first. + items: + $ref: '#/components/schemas/DailyScanCount' + qrRenders: + type: integer + ingesting: + type: boolean + description: | + Whether scan telemetry is actually reaching this node. - Included because a dossier is what an authority is handed, and the seal is its - one member carrying an Art. 35(2) presumption — and because it is unreachable - otherwise: the seal is stripped from `fullView` and `publicView` alike, since - it covers the full-payload signature rather than any redaction. Bound into - `contentHashes` like every other member. + `false` alongside `totalScans: 0` means the number is **unmeasured**, not + zero — and that is the shipped default, since `SCAN_INGEST_URL` is unset + unless an operator configures it. - ⚠️ **This shape is described here but not enforced anywhere.** The field is - held as untyped JSON on the dossier record, so no Rust type declares these - three members and the OpenAPI contract test has nothing to compare them - against. Treat the property list as documentation of intent, not as a - guarantee. Giving the dossier a real type for this member would close that, - and is the only thing that would. - properties: - seal: - type: object - description: The `SealedEnvelope` as persisted on the passport. - signedOverJws: + The node cannot answer this from its own configuration: `SCAN_INGEST_URL` + belongs to the resolver, a separate deployable. What it reports is whether + a resolver has flushed to it, which the resolver makes possible by sending + an empty batch each interval even with nothing to count. + + It goes false again when a resolver stops reporting: the resolver declares + its own flush cadence in every batch, and telemetry is called stale after + three of those intervals — enough to absorb the dropped heartbeats the + sender never retries, since holding an empty batch would starve its + counter. A resolver too old to declare a cadence leaves no window to be + outside of, and this then reports only that one ever flushed; `lastIngestAt` + is on the wire either way, so a caller who knows their own deployment can + judge for themselves. + + Resets on restart — this is liveness, not history — so a freshly booted + node reports `false` until the next flush, at most one interval. + lastIngestAt: type: string - description: The JWS the seal was computed over. - payloadHash: + format: date-time + description: When a resolver last flushed to this node. Absent if none has. + OperatorScanStats: + type: object + description: Operator-wide scan rollup over a trailing window. + required: + - windowDays + - totalScans + - totalQrRenders + - distinctPassportsScanned + - ingesting + properties: + windowDays: + type: integer + totalScans: + type: integer + totalQrRenders: + type: integer + distinctPassportsScanned: + type: integer + ingesting: + type: boolean + description: | + Whether scan telemetry is actually reaching this node. + + `false` alongside `totalScans: 0` means the number is **unmeasured**, not + zero — and that is the shipped default, since `SCAN_INGEST_URL` is unset + unless an operator configures it. + + The node cannot answer this from its own configuration: `SCAN_INGEST_URL` + belongs to the resolver, a separate deployable. What it reports is whether + a resolver has flushed to it, which the resolver makes possible by sending + an empty batch each interval even with nothing to count. + + It goes false again when a resolver stops reporting: the resolver declares + its own flush cadence in every batch, and telemetry is called stale after + three of those intervals — enough to absorb the dropped heartbeats the + sender never retries, since holding an empty batch would starve its + counter. A resolver too old to declare a cadence leaves no window to be + outside of, and this then reports only that one ever flushed; `lastIngestAt` + is on the wire either way, so a caller who knows their own deployment can + judge for themselves. + + Resets on restart — this is liveness, not history — so a freshly booted + node reports `false` until the next flush, at most one interval. + lastIngestAt: type: string - pattern: ^[0-9a-f]{64}$ - description: SHA-256 of the sealed payload, lowercase hex. - EvidenceDossier: + format: date-time + description: When a resolver last flushed to this node. Absent if none has. + DailyScanCount: type: object - description: | - A self-contained, signed snapshot of a passport's full proof chain, - persisted by the node at generation time. Verification - (`POST /evidence/{id}/verify`) is an integrity check of the stored - dossier against its own signatures and hash chains. See - `docs/architecture/EVIDENCE-DOSSIER.md` for the full specification, - including why `calcReceipts`/`checkpoint` are always empty/`null` in - format v1. required: - - manifest - - manifestJws - - fullView - - publicView - - didDocuments - - auditEntries + - day + - count properties: - manifest: - $ref: '#/components/schemas/DossierManifest' - manifestJws: + day: type: string - fullView: - $ref: '#/components/schemas/SignedLayer' - publicView: - $ref: '#/components/schemas/SignedLayer' - didDocuments: - type: object - description: DID document snapshots, keyed by DID. - additionalProperties: - type: object - auditEntries: + format: date + count: + type: integer + ScanBatch: + type: object + required: + - scans + - qrRenders + description: The full flush payload the resolver sends to the vault. + properties: + scans: type: array items: - $ref: '#/components/schemas/AuditEntry' - transferChain: - type: - - object - - 'null' - description: Present iff the passport has ever changed responsible operator. - eolEvent: - type: - - object - - 'null' - description: Present iff the passport was declared end-of-life. - checkpoint: - type: - - object - - 'null' - description: Always `null` in format v1 — the signed-checkpoint layer is not yet built. - calcReceipts: + $ref: '#/components/schemas/ScanBatchEntry' + qrRenders: type: array - description: Always empty in format v1 — `dpp-calc` invocation is not yet wired end to end. items: - type: object - componentGraph: - anyOf: - - $ref: '#/components/schemas/TreeReport' - - type: 'null' + $ref: '#/components/schemas/QrRenderBatchEntry' + flushIntervalSecs: + type: integer + format: int64 + minimum: 1 description: | - The recursive component-tree (bill-of-materials) verification report, - present iff the passport declares `componentRefs`. `null` for a unit - with no modelled sub-assemblies. + How often the sending resolver flushes, in seconds. - Generated at dossier-assembly time by walking the tree and pin-checking - each node, then bound into `contentHashes` like every other member — so - a tampered report fails the dossier's `content_integrity` check rather - than passing as an unverifiable attachment. + Declared rather than assumed. The node reports `ingesting` on + `GET /stats` by asking whether the last flush is recent, and "recent" is + only meaningful relative to how often the sender promised to call — + `SCAN_FLUSH_INTERVAL_SECS` belongs to the resolver, a separate deployable + whose environment the node cannot read. - Integrity only, the same caveat as the standalone `verify-tree` route: - it proves each node's signed public view is unchanged against its - pinned hash, not the cryptographic validity of that node's signature. - qualifiedSeal: - anyOf: - - $ref: '#/components/schemas/QualifiedSealMember' - - type: 'null' - description: The passport's eIDAS qualified seal, present iff one has been applied. `null` when the seal is still queued. - EvidenceDossierRecord: + Stamped when the window is drained, not when it is sent, so a batch held + after a failed flush is re-sent byte-identical under its original + `Idempotency-Key`. + + Optional: a resolver predating this field omits it, and the node then + reports whether telemetry has *ever* arrived rather than applying a + threshold to a cadence it does not know. + example: 300 + ScanBatchEntry: type: object - description: A stored dossier snapshot — the dossier plus its persistence envelope. required: - - id - - passportId - - actor - - createdAt - - docHash - - dossier + - dppId + - day + - variant + - count + description: One aggregated scan increment since the resolver's last flush. properties: - id: - type: string - format: uuid - passportId: - type: string - format: uuid - actor: - type: string - description: Who requested generation. - createdAt: + dppId: type: string - format: date-time - docHash: + description: The resolved passport id, as an opaque string (validated at ingest). + day: type: string - description: Hex SHA-256 of the JCS-canonicalised stored dossier document. - dossier: - $ref: '#/components/schemas/EvidenceDossier' - CheckResult: + format: date + variant: + $ref: '#/components/schemas/ScanVariant' + count: + type: integer + minimum: 0 + QrRenderBatchEntry: type: object - description: Outcome of a single named verification check. required: - - name - - status + - dppId + - day + - count + description: One aggregated QR-render increment since the resolver's last flush. properties: - name: - type: string - example: audit_chain - status: - type: string - enum: - - pass - - fail - - absent - detail: + dppId: type: string - description: Present when status is `fail` or `absent`. - VerificationReport: - type: object - required: - - trustAnchorNote - - checks - properties: - trustAnchorNote: + day: type: string - example: trust anchored to the dossier's embedded DID-document snapshot dated 2026-07-10T00:00:00Z - checks: - type: array - items: - $ref: '#/components/schemas/CheckResult' + format: date + count: + type: integer + minimum: 0 + ScanVariant: + type: string + enum: + - html + - json OperatorConfig: type: object required: @@ -4678,64 +5761,231 @@ components: - string - 'null' format: date-time - updatedAt: + updatedAt: + type: + - string + - 'null' + format: date-time + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. + UpdateOperatorConfig: + type: object + description: Merge-patch update — only supply fields being changed. + properties: + legalName: + type: string + tradeName: + type: string + address: + type: string + country: + type: string + contactEmail: + type: string + didWebUrl: + type: string + productCategories: + type: array + items: + type: string + brandPrimary: + type: string + brandSecondary: + type: string + brandLogoUrl: + type: string + customDomain: + type: string + dataResidency: + type: string + retentionPolicyDays: + type: integer + featureFlags: + type: object + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. + Facility: + type: object + required: + - id + - name + - identifierScheme + - identifierValue + - country + - isDefault + - createdAt + description: A manufacturing/processing facility (ESPR Annex III). + properties: + id: + type: string + format: uuid + name: + type: string + example: München Cell Plant + identifierScheme: + type: string + description: Identifier scheme, e.g. "gln" or "national". + example: gln + identifierValue: + type: string + example: '4012345000009' + country: + type: string + minLength: 2 + maxLength: 2 + example: DE + address: + type: + - string + - 'null' + isDefault: + type: boolean + description: The default facility is stamped onto new passports. + createdAt: + type: string + format: date-time + CreateFacilityRequest: + type: object + required: + - name + - identifierScheme + - identifierValue + - country + properties: + name: + type: string + identifierScheme: + type: string + example: gln + identifierValue: + type: string + description: Validated by scheme — a "gln" must pass the GS1 mod-10 check digit. + example: '4012345000009' + country: + type: string + pattern: ^[A-Z]{2}$ + description: | + ISO 3166-1 alpha-2 country the facility is located in. + + **Upper case, and an assigned code.** Unlike the other country fields on + this API, this one is checked against the ISO 3166-1 list rather than + merely shaped — `de` and `XX` are both refused. The pattern is what makes + the published contract as strict as the handler: a length bound alone + admits `1?`, which satisfies the contract and is then refused with `422`, + and a contract looser than the implementation is the kind a generated + client trusts. + example: DE + address: type: - string - 'null' - format: date-time - registryVerifiedAt: + isDefault: + type: boolean + default: false + description: Make this the default facility on creation (unsets any previous default). + OperatorIdentifier: + type: object + required: + - id + - scheme + - value + - isPrimary + - createdAt + description: An economic-operator identifier (ESPR Art. 13). + properties: + id: + type: string + format: uuid + scheme: + type: string + description: Identifier scheme, e.g. "vat", "lei", "eori", "duns". + example: lei + value: + type: string + example: 5493001KJTIIGC8Y1R12 + label: type: - string - 'null' + isPrimary: + type: boolean + description: The primary identifier is stamped onto new passports. + createdAt: + type: string format: date-time - description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. - UpdateOperatorConfig: + CreateOperatorIdentifierRequest: type: object - description: Merge-patch update — only supply fields being changed. + required: + - scheme + - value properties: - legalName: - type: string - tradeName: - type: string - address: - type: string - country: + scheme: type: string - contactEmail: + description: Identifier scheme, e.g. "vat", "lei", "eori", "duns". + example: lei + value: type: string - didWebUrl: + description: Validated by scheme — LEI uses ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a 2-letter country prefix. Unknown schemes are accepted without structural verification. + example: 5493001KJTIIGC8Y1R12 + label: + type: + - string + - 'null' + isPrimary: + type: boolean + default: false + description: Make this the primary identifier on creation (unsets any previous primary). + RegistryIdentityAuditEntry: + type: object + required: + - id + - operatorId + - entityType + - entityId + - action + - actor + - ts + description: An immutable audit record for a registry-identity mutation (a facility per Annex III or an operator identifier per Art. 13). Append-only. + properties: + id: type: string - productCategories: - type: array - items: - type: string - brandPrimary: + format: uuid + operatorId: type: string - brandSecondary: + entityType: type: string - brandLogoUrl: + enum: + - facility + - operator_identifier + entityId: type: string - customDomain: + format: uuid + action: type: string - dataResidency: + enum: + - added + - retired + - set_default + - set_primary + actor: type: string - retentionPolicyDays: - type: integer - featureFlags: - type: object - registryVerifiedAt: + description: user_id of the actor who performed the change. + snapshot: type: - - string + - object - 'null' + description: The full record at the time of the action, for reconstruction. + ts: + type: string format: date-time - description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. - ApiKeyScope: - type: string - description: What an API key authorises. `admin` is the default when a key is minted without an explicit scope, so a key issued to an integration should name `read` or `write` deliberately. - enum: - - read - - write - - admin ApiKey: type: object required: @@ -4773,6 +6023,13 @@ components: format: date-time scope: $ref: '#/components/schemas/ApiKeyScope' + ApiKeyScope: + type: string + description: What an API key authorises. `admin` is the default when a key is minted without an explicit scope, so a key issued to an integration should name `read` or `write` deliberately. + enum: + - read + - write + - admin CreateApiKeyRequest: type: object required: @@ -4792,7 +6049,292 @@ components: allOf: - $ref: '#/components/schemas/ApiKeyScope' description: Defaults to `admin` when omitted. Name `read` or `write` explicitly for a key issued to an integration. - NewApiKey: + CredentialRole: + description: | + The access role granted. + + Only roles carrying a **legitimate interest** may be issued here. + `market_surveillance_authority`, `customs_authority` and `notified_body` map + to the authority audience under Art. 77(2)(b) and are refused with `422`: + authority status is conferred by a member state, so an operator signing itself + one has attested nothing. An authority presents a credential from its own + issuer, named in `CREDENTIAL_ISSUERS_AUTHORITY`. + + A product-group-specific role that none of the named ones fit uses the object + form — the variant is externally tagged, so it is an object where the rest are + strings. It carries a legitimate interest like the others. + oneOf: + - type: string + title: Named role + enum: + - authorised_repairer + - recycler + - remanufacturer + - preparer_for_reuse + - distributor + - type: object + title: Custom role + required: + - custom + additionalProperties: false + properties: + custom: + type: string + description: Free-form role label, e.g. a product-group-specific one. + example: authorised_repairer + IssueCredentialRequest: + type: object + description: | + Ask this node to vouch for a holder. + + The node signs with its own key, so the credential says "this operator + attests that this party holds this role". That is a claim an operator is + uniquely placed to make about its own authorised network, and one it has no + standing to make about authority status — see `role`. + required: + - holderDid + - holderName + - role + - country + properties: + holderDid: + type: string + description: | + DID of the party being vouched for. A verifier matches it exactly, so a + name or a URL here produces a credential nothing can present. + example: did:web:repairs.example + holderName: + type: string + description: Legal name of the holder, carried in the credential for an auditor. + example: Nord Repair GmbH + role: + $ref: '#/components/schemas/CredentialRole' + country: + type: string + pattern: ^[A-Za-z]{2}$ + description: | + ISO 3166-1 alpha-2 country of the holder's registration. + + Either case is accepted and the credential carries the upper-cased form, + which is what a verifier compares against. Same reason for the pattern as + on `countryOfDisposal`: a length bound alone admits `1?`. + example: DE + productGroups: + type: array + items: + type: string + description: | + Product groups the credential covers. Omit or leave empty to cover every + product group this operator publishes. + example: + - battery + validForDays: + type: integer + minimum: 1 + maximum: 90 + default: 30 + description: | + Lifetime in days. + + Nothing can withdraw a credential once issued — this node fetches W3C + status lists but publishes none, so a credential it mints carries no + `credentialStatus` — which makes the expiry the only control there is. + That is why the ceiling is 90 rather than open-ended, and why the default + is shorter still. Re-issuing is cheap. + example: 30 + IssuedCredential: + type: object + description: | + The minted credential, in both the forms a caller needs: the wire value the + holder presents, and the same claims readable without decoding it. + required: + - credentialJws + - credential + properties: + credentialJws: + type: string + description: | + Compact VC-JWT — the value the holder sends as the `X-DPP-Credential` + header. This is the credential; `credential` below is the same claims in + readable form, not a second artefact. + credential: + type: object + description: | + The credential document, so a caller can show the holder what it says + without decoding the JWS. Shape follows W3C VC Data Model v2.0. + additionalProperties: true + CreateUnsoldGoodsEntry: + type: object + description: | + One line of an ESPR Art. 24 disclosure: a category of unsold consumer products + discarded in a financial year, with the reason and where it went. + + A disclosure is many of these, not one row. Art. 24(1) asks for the figures + "differentiated per type or category of products", and separately per reason + and destination — which a single aggregate cannot express. + + The operator is **not** part of the request. The report is about this node's + own operator, taken from its operator config; there is no second operator on a + single-tenant node for it to legitimately be. + required: + - reportingPeriod + - unitCount + - volumeKg + - productCategory + - reason + - destination + - countryOfDisposal + properties: + reportingPeriod: + type: string + pattern: ^[0-9]{4}$ + description: | + The financial year the goods were discarded in. Art. 24(1) discloses "the + preceding financial year", annually, so this is a year and not a month or + a quarter. + example: '2026' + unitCount: + type: integer + format: int64 + minimum: 0 + description: | + How many products. Art. 24(1)(a) asks for the number **and** the weight; + both are required here because a disclosure carrying only one cannot + satisfy it. + example: 1240 + volumeKg: + type: number + format: double + minimum: 0 + description: Their total weight in kilograms. + example: 860.5 + productCategory: + $ref: '#/components/schemas/UnsoldProductCategory' + reason: + $ref: '#/components/schemas/UnsoldDiscardReason' + destination: + $ref: '#/components/schemas/UnsoldDestination' + destructionJustification: + type: string + description: | + Which Art. 25 exemption the destruction relies on. + + **Required** when `destination` is `exemptDestruction`, and **refused** + otherwise. ESPR Art. 25 prohibits destroying unsold consumer products + listed in Annex VII from 19 July 2026, so a recorded destruction has to say + why it was permitted; a justification attached to a donation describes + nothing. + example: Contaminated stock unfit for use under Art. 25(5) + countryOfDisposal: + type: string + pattern: ^[A-Za-z]{2}$ + description: | + ISO 3166-1 alpha-2 country the goods were disposed of in. + + Either case is accepted and the stored value is upper-cased. The pattern + is what makes the description enforceable: a length bound alone admits + `1?`, which satisfies the published contract and is then refused by the + handler — a contract looser than the implementation is the kind a + generated client trusts. + example: DE + UnsoldGoodsEntry: + type: object + description: A stored ESPR Art. 24 disclosure line. + required: + - id + - reportingPeriod + - volumeKg + - productCategory + - reason + - destination + - countryOfDisposal + - createdAt + properties: + id: + type: string + format: uuid + reportingPeriod: + type: string + example: '2026' + unitCount: + type: + - integer + - 'null' + format: int64 + description: | + Null only for a row written before the count had a column — the write path + requires it, so anything recorded through this API carries one. + volumeKg: + type: number + format: double + productCategory: + $ref: '#/components/schemas/UnsoldProductCategory' + reason: + $ref: '#/components/schemas/UnsoldDiscardReason' + destination: + $ref: '#/components/schemas/UnsoldDestination' + destructionJustification: + type: string + description: Present only for `exemptDestruction`. + countryOfDisposal: + type: string + pattern: ^[A-Z]{2}$ + description: ISO 3166-1 alpha-2 country the goods were disposed of in. + operatorName: + type: + - string + - 'null' + description: | + The operator the disclosure is about — this node's own legal name at the + time the line was recorded. Report content, not a tenant key. + createdAt: + type: string + format: date-time + UnsoldProductCategory: + type: string + description: | + The operator's own categorisation of the discarded goods, for Art. 24(1)(a)'s + "differentiated per type or category of products" — which prescribes no + vocabulary. + + This is **not** Annex VII ban scope. Annex VII gives two *code-valued* + headings — apparel and clothing accessories (`4203`, `61`, `62`, `6504`, + `6505`) and footwear (`6401`–`6405`) — matched by CN **prefix**, with clothing + accessories inside the first heading rather than beside it. Reading this list + as those headings gets both their count and their shape wrong. + enum: + - apparel + - footwear + - homeTextile + - accessories + - other + example: apparel + UnsoldDiscardReason: + type: string + description: Why the goods went unsold, for Art. 24(1)(b). + enum: + - endOfSeason + - qualityDefect + - packagingDefect + - overProduction + - customerReturn + - other + example: endOfSeason + UnsoldDestination: + type: string + description: | + Where the goods went, for Art. 24(1)(c). + + `exemptDestruction` is the only value recording an act that is otherwise + prohibited, and the only one that requires `destructionJustification`. + enum: + - donation + - recycling + - repurposing + - supplierReturn + - exemptDestruction + example: recycling + CreatedApiKeyResponse: type: object required: - key @@ -4806,21 +6348,6 @@ components: The full plain-text API key. Shown ONCE at creation time. Store securely — it cannot be retrieved again. example: odal_sk_abc123def456ghi789jkl012mno345pqr678 - InstalledPlugin: - type: object - description: What the node installed, reported back after a successful hot-swap. - required: - - productGroup - - abiVersion - properties: - productGroup: - type: string - description: Product group catalog key the plugin is bound to. - example: battery - abiVersion: - type: string - description: ABI version the plugin declared, formatted `major.minor`. - example: '1.0' WebhookSubscription: type: object description: A receiver subscription, redacted. The signing secret is never carried here — it is returned exactly once from the create call and otherwise stays server-side. @@ -4857,7 +6384,7 @@ components: updatedAt: type: string format: date-time - NewWebhookSubscription: + CreateWebhookRequest: type: object description: Input for creating a subscription. The signing secret is generated server-side and is never client-supplied, so it is not part of this body. required: @@ -4885,6 +6412,21 @@ components: secret: type: string description: Signing secret. Store it now; it is never shown again. + InstalledPlugin: + type: object + description: What the node installed, reported back after a successful hot-swap. + required: + - productGroup + - abiVersion + properties: + productGroup: + type: string + description: Product group catalog key the plugin is bound to. + example: battery + abiVersion: + type: string + description: ABI version the plugin declared, formatted `major.minor`. + example: '1.0' NodeState: type: object required: @@ -4906,272 +6448,51 @@ components: description: | The node's deployment profile. Absent on a standalone vault, which has no composition root to resolve one. - trustMode: - type: object - additionalProperties: - type: string - enum: - - ghost - - sandbox - - live - description: | - Each resolved trust port and the tier it operates at — `ghost` is a - placeholder with no real authority behind it, `sandbox` a real but - non-production service, `live` a production one. - - This is the ghost-honesty signal: no surface may present a placeholder as - real. It is reported here rather than on the unauthenticated `/health` - because which ports are degraded, and how, is a targeting signal — the - same reasoning that keeps `/metrics` off the public router. - - Absent on a standalone vault, which resolves no trust ports. - example: - seal: ghost - registry_sync: sandbox - rulesetVersion: - type: string - description: | - Version of the Compliance Current ruleset this node validates against, so - the rules a passport was checked under are observable. Absent on a - standalone vault. - example: baseline - WhoamiResponse: - type: object - description: What the presented credential is. Reports only what the caller already sent — it reveals nothing about any other key, and the key's secret is never stored in a recoverable form. - required: - - userId - - scope - properties: - userId: - type: string - description: The caller's identity, as authenticated. - scope: - $ref: '#/components/schemas/ApiKeyScope' - keyId: - type: string - format: uuid - description: The key's row id — never the token. Absent for local-admin Basic auth, which has no key row. - Facility: - type: object - required: - - id - - name - - identifierScheme - - identifierValue - - country - - isDefault - - createdAt - description: A manufacturing/processing facility (ESPR Annex III). - properties: - id: - type: string - format: uuid - name: - type: string - example: München Cell Plant - identifierScheme: - type: string - description: Identifier scheme, e.g. "gln" or "national". - example: gln - identifierValue: - type: string - example: '4012345000009' - country: - type: string - minLength: 2 - maxLength: 2 - example: DE - address: - type: - - string - - 'null' - isDefault: - type: boolean - description: The default facility is stamped onto new passports. - createdAt: - type: string - format: date-time - CreateFacilityRequest: - type: object - required: - - name - - identifierScheme - - identifierValue - - country - properties: - name: - type: string - identifierScheme: - type: string - example: gln - identifierValue: - type: string - description: Validated by scheme — a "gln" must pass the GS1 mod-10 check digit. - example: '4012345000009' - country: - type: string - minLength: 2 - maxLength: 2 - example: DE - address: - type: - - string - - 'null' - isDefault: - type: boolean - default: false - description: Make this the default facility on creation (unsets any previous default). - RegistryIdentityAudit: - type: object - required: - - id - - operatorId - - entityType - - entityId - - action - - actor - - ts - description: An immutable audit record for a registry-identity mutation (a facility per Annex III or an operator identifier per Art. 13). Append-only. - properties: - id: - type: string - format: uuid - operatorId: - type: string - entityType: - type: string - enum: - - facility - - operator_identifier - entityId: - type: string - format: uuid - action: - type: string - enum: - - added - - retired - - set_default - - set_primary - actor: - type: string - description: user_id of the actor who performed the change. - snapshot: - type: - - object - - 'null' - description: The full record at the time of the action, for reconstruction. - ts: - type: string - format: date-time - OperatorIdentifier: - type: object - required: - - id - - scheme - - value - - isPrimary - - createdAt - description: An economic-operator identifier (ESPR Art. 13). - properties: - id: - type: string - format: uuid - scheme: - type: string - description: Identifier scheme, e.g. "vat", "lei", "eori", "duns". - example: lei - value: - type: string - example: 5493001KJTIIGC8Y1R12 - label: - type: - - string - - 'null' - isPrimary: - type: boolean - description: The primary identifier is stamped onto new passports. - createdAt: - type: string - format: date-time - CreateOperatorIdentifierRequest: - type: object - required: - - scheme - - value - properties: - scheme: - type: string - description: Identifier scheme, e.g. "vat", "lei", "eori", "duns". - example: lei - value: - type: string - description: Validated by scheme — LEI uses ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a 2-letter country prefix. Unknown schemes are accepted without structural verification. - example: 5493001KJTIIGC8Y1R12 - label: - type: - - string - - 'null' - isPrimary: - type: boolean - default: false - description: Make this the primary identifier on creation (unsets any previous primary). - ScanVariant: - type: string - enum: - - html - - json - ScanCount: - type: object - required: - - dppId - - day - - variant - - count - description: One aggregated scan increment since the resolver's last flush. - properties: - dppId: - type: string - description: The resolved passport id, as an opaque string (validated at ingest). - day: + trustMode: + type: object + additionalProperties: + type: string + enum: + - ghost + - sandbox + - live + description: | + Each resolved trust port and the tier it operates at — `ghost` is a + placeholder with no real authority behind it, `sandbox` a real but + non-production service, `live` a production one. + + This is the ghost-honesty signal: no surface may present a placeholder as + real. It is reported here rather than on the unauthenticated `/health` + because which ports are degraded, and how, is a targeting signal — the + same reasoning that keeps `/metrics` off the public router. + + Absent on a standalone vault, which resolves no trust ports. + example: + seal: ghost + registry_sync: sandbox + rulesetVersion: type: string - format: date - variant: - $ref: '#/components/schemas/ScanVariant' - count: - type: integer - minimum: 0 - QrRenderCount: + description: | + Version of the Compliance Current ruleset this node validates against, so + the rules a passport was checked under are observable. Absent on a + standalone vault. + example: baseline + WhoamiResponse: type: object + description: What the presented credential is. Reports only what the caller already sent — it reveals nothing about any other key, and the key's secret is never stored in a recoverable form. required: - - dppId - - day - - count - description: One aggregated QR-render increment since the resolver's last flush. + - userId + - scope properties: - dppId: + userId: type: string - day: + description: The caller's identity, as authenticated. + scope: + $ref: '#/components/schemas/ApiKeyScope' + keyId: type: string - format: date - count: - type: integer - minimum: 0 - ScanBatch: - type: object - required: - - scans - - qrRenders - description: The full flush payload the resolver sends to the vault. - properties: - scans: - type: array - items: - $ref: '#/components/schemas/ScanCount' - qrRenders: - type: array - items: - $ref: '#/components/schemas/QrRenderCount' + format: uuid + description: The key's row id — never the token. Absent for local-admin Basic auth, which has no key row. VaultInfo: type: object required: @@ -5226,7 +6547,7 @@ components: - did:web:id.odal-node.io#key-1 assertionMethod: - did:web:id.odal-node.io#key-1 - SignRequest: + InternalSignRequest: type: object required: - operator_id @@ -5244,7 +6565,7 @@ components: payload: type: string description: Base64-encoded canonical JSON of the payload to sign. - SignResponse: + InternalSignResponse: type: object required: - jws_signature @@ -5252,7 +6573,7 @@ components: jws_signature: type: string description: Compact JWS (EdDSA over RFC 8785 canonical bytes). - VerifyRequest: + InternalVerifyRequest: type: object required: - operator_id @@ -5269,7 +6590,7 @@ components: description: The compact JWS to verify. payload: description: The payload the caller expects the JWS to have been signed over. - VerifyResponse: + InternalVerifyResponse: type: object required: - valid @@ -5277,7 +6598,7 @@ components: valid: type: boolean description: True iff the signature verifies against the named operator's key AND was signed over exactly this payload. - RotateRequest: + InternalRotateKeyRequest: type: object required: - operator_id @@ -5285,7 +6606,7 @@ components: operator_id: type: string example: self_hosted - RotateResponse: + InternalRotateKeyResponse: type: object required: - operator_id @@ -5306,45 +6627,229 @@ components: type: boolean did_document: $ref: '#/components/schemas/DidDocument' - DateBasis: - type: string - enum: - - sourced - - assumed + ImportSyncResponse: + type: object + required: + - jobId + - totalRows + - successCount + - errorCount + - created + - updated + - errors + description: Returned for synchronous imports (≤ 100 valid rows) and dry runs. + properties: + jobId: + type: string + format: uuid + description: The job this response's report was persisted under — retrievable later via the job-status endpoint. + totalRows: + type: integer + description: Rows read from the uploaded file, excluding the header. + successCount: + type: integer + description: 'Rows **not rejected**, counted once each. Deliberately not the size of `created` plus `updated`: a row whose record was already up to date, or which conflicted, writes nothing and is still not a failure. Always `0` for a dry run, which writes nothing at all.' + errorCount: + type: integer + description: Rows **rejected**, counted once each. A row that fails several checks contributes one to this count and several entries to `errors`, so `errorCount` and `errors.length` differ and are not interchangeable. + created: + type: array + items: + $ref: '#/components/schemas/ImportCreatedEntry' + errors: + type: array + items: + $ref: '#/components/schemas/ImportErrorEntry' + updated: + type: array + description: Rows that matched an existing passport and updated it. Counted in `successCount`, which is deliberately not `created.length + updated.length` — see that field. + items: + $ref: '#/components/schemas/ImportUpdatedEntry' + ImportAsyncResponse: + type: object + required: + - jobId + - status + - totalRows + description: Returned for async imports (> 100 valid rows). Poll the job-status endpoint. + properties: + jobId: + type: string + format: uuid + status: + type: string + example: queued + totalRows: + type: integer + ImportCreatedEntry: + type: object + required: + - row + - passportId + - status + properties: + row: + type: integer + description: 1-based row number from the uploaded file. + passportId: + type: string + status: + type: string + example: draft + ImportUpdatedEntry: + type: object + description: One row that matched an existing passport and updated it. + required: + - row + - passportId + properties: + row: + type: integer + description: 1-based row number in the uploaded file, excluding the header. + passportId: + type: string + format: uuid + ImportErrorEntry: + type: object + required: + - row + - field + - message + properties: + row: + type: integer + field: + type: string + description: Column name, or "vault" / "auth" / "internal". + message: + type: string + JobStatusResponse: + type: object + required: + - jobId + - status + - progress + properties: + jobId: + type: string + format: uuid + status: + type: string + enum: + - queued + - processing + - completed + - failed + progress: + $ref: '#/components/schemas/JobProgress' + result: + type: + - object + - 'null' + description: Populated on completion (created/errors) or failure (reason). + report: + type: + - object + - 'null' + description: The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`. + JobProgress: + type: object + description: How far through its rows an import job has got. + properties: + processed: + type: integer + minimum: 0 + description: Rows handled so far. + example: 120 + total: + type: integer + minimum: 0 + description: Rows the job was created with. + example: 500 + required: + - processed + - total + ProductGroupObligationList: + type: object + description: Every product group this build models, with its passport obligation. + properties: + productGroups: + type: array + items: + $ref: '#/components/schemas/ProductGroupObligation' + required: + - productGroups + ProductGroupObligation: + type: object description: | - Whether an obligation date traces to an adopted text (`sourced`) or is a - reading that has not been confirmed against one (`assumed`). + What this node knows about one product group's digital product passport + obligation, drawn from the embedded instrument catalog. + properties: + productGroup: + type: string + description: The catalog key. + example: toy + title: + type: + - string + - 'null' + description: | + Human-readable name from the product group catalog. - Always served with the date it qualifies. A date without its basis presents a - qualified reading as an unqualified claim, which on a public endpoint from a - compliance vendor is a materially different statement. + `null` where an act reaches this key but no product group descriptor + exists for it — a real case, and the one this endpoint is most needed for: + no schema, plugin or template names such a group, so nothing else in the + API can be asked about it. A `null` title is not an error and says nothing + about the obligation, which is reported in full beside it. + example: Toys + passport: + $ref: '#/components/schemas/PassportObligation' + description: Whether a passport is required for this product group, and from when. + determinable: + type: boolean + description: | + Whether this build can make a binding compliance determination for this + product group. False where the duty exists but the implementing acts that + define its technical requirements are unpublished. + example: false + granularity: + anyOf: + - $ref: '#/components/schemas/Granularity' + - type: 'null' + description: | + The level a passport is issued at for this product group, where a + delegated act fixes one. `null` where none does — which is every product + group today. - Deliberately a separate schema from `RetentionBasis`, which it currently - matches variant for variant. They are two enumerations in the code answering - two questions, and collapsing them here would let one drift silently behind - the other. - example: sourced - ObligationDate: - type: object - description: | - The date a passport obligation applies from, with the provenance of that date. + Nullable here rather than omitted, unlike the same field on a passport: + this endpoint answers a question about every product group it lists, and a + missing key would read as "not asked" rather than "no act has fixed one". + retention: + anyOf: + - $ref: '#/components/schemas/RetentionPeriod' + - type: 'null' + description: How long records must be kept, with the basis of that figure. `null` where no reaching act fixes one. + instruments: + type: array + description: | + The acts the catalog knows reach this product group, each with how far + along the act itself is and whether it binds *this* group. - One object rather than two loose fields so a date cannot be served without its - basis. Most of the catalog is undated, and of the dates that exist some trace - to an adopted text and some are a reading — a date presented bare turns a - qualified reading into an unqualified claim. - properties: - date: - type: string - format: date - description: ISO-8601 date the obligation applies from. - example: '2030-08-01' - basis: - $ref: '#/components/schemas/DateBasis' - description: Whether this date traces to an adopted text. + **These statuses qualify `passport.required`.** That field folds across + every act here, so it reports that an act imposes a passport — not that + any act binds this group today. Where every entry is `provisional` or + `watch`, a `required: true` is a reading of instruments that have not yet + bound anything, and must not be presented as a determination. + items: + $ref: '#/components/schemas/ReachingInstrument' required: - - date - - basis + - productGroup + - title + - passport + - determinable + - granularity + - retention + - instruments PassportObligation: type: object description: | @@ -5374,16 +6879,44 @@ components: required: - required - from - RetentionBasis: + ObligationDate: + type: object + description: | + The date a passport obligation applies from, with the provenance of that date. + + One object rather than two loose fields so a date cannot be served without its + basis. Most of the catalog is undated, and of the dates that exist some trace + to an adopted text and some are a reading — a date presented bare turns a + qualified reading into an unqualified claim. + properties: + date: + type: string + format: date + description: ISO-8601 date the obligation applies from. + example: '2030-08-01' + basis: + $ref: '#/components/schemas/DateBasis' + description: Whether this date traces to an adopted text. + required: + - date + - basis + DateBasis: type: string enum: - sourced - assumed description: | - Whether a retention period traces to an adopted text (`sourced`) or is a + Whether an obligation date traces to an adopted text (`sourced`) or is a reading that has not been confirmed against one (`assumed`). - See `DateBasis` for why these are two schemas rather than one. + Always served with the date it qualifies. A date without its basis presents a + qualified reading as an unqualified claim, which on a public endpoint from a + compliance vendor is a materially different statement. + + Deliberately a separate schema from `RetentionBasis`, which it currently + matches variant for variant. They are two enumerations in the code answering + two questions, and collapsing them here would let one drift silently behind + the other. example: sourced RetentionPeriod: type: object @@ -5407,57 +6940,17 @@ components: required: - years - basis - InstrumentStatus: - type: string - enum: - - adopted - - proposed - - anticipated - description: | - How far through the legislative process an act is — whether the **act - exists**, not whether it binds anything. - - `adopted` — adopted and published in the Official Journal, citable by CELEX. - - `proposed` — formally proposed but not adopted. Its text can be read and cited - as a *proposal*, never as law. - - `anticipated` — announced in a working plan or otherwise expected, with no - text to read. Such an act has no CELEX, and nothing derived from it may be - presented as sourced. - - Distinct from `RegulatoryStatus`, and the distinction is easy to lose: an - adopted act binds nothing until its own dates arrive, and routinely binds one - product group while another waits. The practical case is that ESPR has been - `adopted` since 2024 while every product group under it is still `provisional` - — both statements are true and neither implies the other. - example: adopted - RegulatoryStatus: + RetentionBasis: type: string enum: - - in_force - - provisional - - watch + - sourced + - assumed description: | - Whether an act's obligations bind **a given product group** — a different - question from whether the act exists, which is `InstrumentStatus`. - - `in_force` — the act creates binding obligations for this product group that - can be determined now. A future applicability date is *not* this status. - - `provisional` — the act exists or is anticipated, but nothing is bindingly - determinable for this product group yet. Any obligation reported alongside - this status is a reading, not a determination. - - `watch` — tracked, but this act imposes nothing on this product group. A - watching brief rather than a duty, and never determinable. + Whether a retention period traces to an adopted text (`sourced`) or is a + reading that has not been confirmed against one (`assumed`). - **Read this before acting on `passport.required`.** The obligation is a fold - across every act reaching the product group, so `required: true` says an act - imposes a passport — not that the act binds this group today. Where every - reaching act is `provisional` or `watch`, the requirement is a reading of - instruments that have not yet bound anything. - example: provisional + See `DateBasis` for why these are two schemas rather than one. + example: sourced ReachingInstrument: type: object description: | @@ -5488,229 +6981,278 @@ components: - recorded - instrumentStatus - bindingStatus - ProductGroupObligation: - type: object + InstrumentStatus: + type: string + enum: + - adopted + - proposed + - anticipated description: | - What this node knows about one product group's digital product passport - obligation, drawn from the embedded instrument catalog. - properties: - productGroup: - type: string - description: The catalog key. - example: toy - title: - type: - - string - - 'null' - description: | - Human-readable name from the product group catalog. - - `null` where an act reaches this key but no product group descriptor - exists for it — a real case, and the one this endpoint is most needed for: - no schema, plugin or template names such a group, so nothing else in the - API can be asked about it. A `null` title is not an error and says nothing - about the obligation, which is reported in full beside it. - example: Toys - passport: - $ref: '#/components/schemas/PassportObligation' - description: Whether a passport is required for this product group, and from when. - determinable: - type: boolean - description: | - Whether this build can make a binding compliance determination for this - product group. False where the duty exists but the implementing acts that - define its technical requirements are unpublished. - example: false - granularity: - anyOf: - - $ref: '#/components/schemas/Granularity' - - type: 'null' - description: | - The level a passport is issued at for this product group, where a - delegated act fixes one. `null` where none does — which is every product - group today. - - Nullable here rather than omitted, unlike the same field on a passport: - this endpoint answers a question about every product group it lists, and a - missing key would read as "not asked" rather than "no act has fixed one". - retention: - anyOf: - - $ref: '#/components/schemas/RetentionPeriod' - - type: 'null' - description: How long records must be kept, with the basis of that figure. `null` where no reaching act fixes one. - instruments: - type: array - description: | - The acts the catalog knows reach this product group, each with how far - along the act itself is and whether it binds *this* group. + How far through the legislative process an act is — whether the **act + exists**, not whether it binds anything. - **These statuses qualify `passport.required`.** That field folds across - every act here, so it reports that an act imposes a passport — not that - any act binds this group today. Where every entry is `provisional` or - `watch`, a `required: true` is a reading of instruments that have not yet - bound anything, and must not be presented as a determination. - items: - $ref: '#/components/schemas/ReachingInstrument' - required: - - productGroup - - title - - passport - - determinable - - granularity - - retention - - instruments - ProductGroupObligationList: - type: object - description: Every product group this build models, with its passport obligation. - properties: - productGroups: - type: array - items: - $ref: '#/components/schemas/ProductGroupObligation' - required: - - productGroups - ImportCreatedEntry: - type: object - required: - - row - - passportId - - status - properties: - row: - type: integer - description: 1-based row number from the uploaded file. - passportId: - type: string - status: - type: string - example: draft - ImportErrorEntry: + `adopted` — adopted and published in the Official Journal, citable by CELEX. + + `proposed` — formally proposed but not adopted. Its text can be read and cited + as a *proposal*, never as law. + + `anticipated` — announced in a working plan or otherwise expected, with no + text to read. Such an act has no CELEX, and nothing derived from it may be + presented as sourced. + + Distinct from `RegulatoryStatus`, and the distinction is easy to lose: an + adopted act binds nothing until its own dates arrive, and routinely binds one + product group while another waits. The practical case is that ESPR has been + `adopted` since 2024 while every product group under it is still `provisional` + — both statements are true and neither implies the other. + example: adopted + RegulatoryStatus: + type: string + enum: + - in_force + - provisional + - watch + description: | + Whether an act's obligations bind **a given product group** — a different + question from whether the act exists, which is `InstrumentStatus`. + + `in_force` — the act creates binding obligations for this product group that + can be determined now. A future applicability date is *not* this status. + + `provisional` — the act exists or is anticipated, but nothing is bindingly + determinable for this product group yet. Any obligation reported alongside + this status is a reading, not a determination. + + `watch` — tracked, but this act imposes nothing on this product group. A + watching brief rather than a duty, and never determinable. + + **Read this before acting on `passport.required`.** The obligation is a fold + across every act reaching the product group, so `required: true` says an act + imposes a passport — not that the act binds this group today. Where every + reaching act is `provisional` or `watch`, the requirement is a reading of + instruments that have not yet bound anything. + example: provisional + PublishBlocker: type: object + description: | + One reason a publish would be refused, addressed to the field that causes it. + + Addressed rather than prose: the mandatory-content gate names every missing + field at once, and a caller completing a draft wants to attach each message to + the input it belongs to. required: - - row - field - message properties: - row: - type: integer field: type: string - description: Column name, or "vault" / "auth" / "internal". + description: Path of the offending field. + example: /productGroupData/batteryModelId message: type: string - ImportUpdatedEntry: + description: Why this field blocks the publish. + PassportScopeReport: type: object - description: One row that matched an existing passport and updated it. + description: | + Whether Reg. (EU) 2023/1542 Art. 77(1) requires a battery passport for this + record at all. + + > "From 18 February 2027 each LMT battery, each industrial battery with a + > capacity greater than 2 kWh and each electric vehicle battery placed on the + > market or put into service shall have an electronic record ('battery + > passport')." + + The Regulation defines five battery categories; this article reaches three. + Portable and SLI batteries bear no passport obligation, so a passport for one + is the operator's own artefact rather than a discharged duty. + + Reported **beside** the gates, never instead of them. This node applies the + category content gate whatever this says, and for a voluntary passport that + makes it stricter than the article requires — which the `note` admits rather + than hides. + + Two details the article turns on: the threshold is **energy** (kWh), not the + ampere-hour "rated capacity" defined for the purposes of an annex; and unlike + Arts. 7 and 8 it carries no `rechargeable` qualifier. required: - - row - - passportId + - status properties: - row: - type: integer - description: 1-based row number in the uploaded file, excluding the header. - passportId: + status: type: string - format: uuid - ImportSyncResponse: + enum: + - required + - voluntary + - notApplicable + description: | + `required` — Art. 77(1) reaches this record. + `voluntary` — it does not; a passport is still permitted. + `notApplicable` — not a battery, so this article has nothing to say. Other + instruments may still require a passport. + example: required + note: + type: string + description: | + Present when the answer needs justifying — an industrial battery that + declares no `ratedCapacityKwh` (treated as in scope, because an undeclared + capacity is not evidence of a small one), or a voluntary passport this node + nonetheless gates. + PublishReadiness: type: object + description: | + Whether the passport would clear the publish gates that can be answered + without attempting the transition: the category **mandatory-content** gate and + the product-group data/schema gates. + + Deliberately not the whole of publish. The registry-identity requirement is + operator state rather than passport state, and the binding-compliance gate + needs a determination this route does not run — so `ready: true` means "these + gates pass", not "publish will certainly succeed". + + The mandatory-content half is the reason this exists. It is the gate that most + often refuses a battery — 46 data points for an electric-vehicle one, 45 for + an LMT battery, 38 for an industrial one — and it could not previously be + previewed anywhere: it is reachable only from a stored passport, and the only + dry-run the API offered takes an unsaved request body. required: - - jobId - - totalRows - - successCount - - errorCount - - created - - updated - - errors - description: Returned for synchronous imports (≤ 100 valid rows) and dry runs. + - ready + - blockers + - passportScope properties: - jobId: - type: string - format: uuid - description: The job this response's report was persisted under — retrievable later via the job-status endpoint. - totalRows: - type: integer - description: Rows read from the uploaded file, excluding the header. - successCount: - type: integer - description: 'Rows **not rejected**, counted once each. Deliberately not the size of `created` plus `updated`: a row whose record was already up to date, or which conflicted, writes nothing and is still not a failure. Always `0` for a dry run, which writes nothing at all.' - errorCount: - type: integer - description: Rows **rejected**, counted once each. A row that fails several checks contributes one to this count and several entries to `errors`, so `errorCount` and `errors.length` differ and are not interchangeable. - created: - type: array - items: - $ref: '#/components/schemas/ImportCreatedEntry' - errors: - type: array - items: - $ref: '#/components/schemas/ImportErrorEntry' - updated: + ready: + type: boolean + description: True when no blocker below applies. + blockers: type: array - description: Rows that matched an existing passport and updated it. Counted in `successCount`, which is deliberately not `created.length + updated.length` — see that field. + description: Every blocking field, named individually. Empty when `ready`. items: - $ref: '#/components/schemas/ImportUpdatedEntry' - ImportAsyncResponse: + $ref: '#/components/schemas/PublishBlocker' + passportScope: + $ref: '#/components/schemas/PassportScopeReport' + AmendRequest: type: object + description: | + Body for amending a published passport. + + A published passport's content is immutable — its signatures commit to its + bytes and the retention guard refuses the write. An amendment therefore + publishes a **new** passport carrying `supersedesId` back to the one being + corrected, and moves that one to the terminal `superseded` state. The + superseded record keeps its signatures and stays resolvable by its own id. required: - - jobId - - status - - totalRows - description: Returned for async imports (> 100 valid rows). Poll the job-status endpoint. + - patch properties: - jobId: - type: string - format: uuid - status: + patch: + type: object + description: | + The correction, in the same shape the draft-update body takes: only the + patchable content fields (`productName`, `co2ePerUnit`, + `repairabilityScore`, `productGroupData`, `componentRefs`) take effect. + + Other keys are ignored rather than refused, so a client that sends a full + create-shaped body still works and still cannot make a create-time field + take effect. Fields fixed at issuance — the product group, the schema + version, the manufacturer, the lineage edges — are not amendable; a + passport whose product group is wrong is not the same product. + additionalProperties: true + example: + productName: Model X Battery Pack (rev B) + productGroupData: + gtin: '01234567890128' + batteryType: industrial + reason: type: string - example: queued - totalRows: - type: integer - JobProgress: + description: | + Why the passport is being corrected. Recorded on the superseded record's + audit entry alongside the successor's id — the two together are what + answers "why did this change" for a reader who arrives at the old record. + example: Recycled-content share restated after supplier re-declaration + SupersedeRequest: type: object - description: How far through its rows an import job has got. - properties: - processed: - type: integer - minimum: 0 - description: Rows handled so far. - example: 120 - total: - type: integer - minimum: 0 - description: Rows the job was created with. - example: 500 + description: | + Which passport replaces the one being retired. + + Supersession is a **link between two passports that already exist**, not a + create-and-retire in one call: a successor is an ordinary passport with its own + content and its own publish gates, so it goes through the normal create and + publish routes and is only then named here. + + For the other direction — correcting a published passport, where the successor + is derived from the record it replaces — use `POST /dpp/{dppId}/amend`, which + mints the successor itself and cannot name one that already exists. required: - - processed - - total - JobStatusResponse: + - supersededBy + properties: + supersededBy: + allOf: + - $ref: '#/components/schemas/DppId' + description: | + The successor, which must already be **published** and must already carry + `supersedesId` pointing back at the passport named in the path. That link + is set when the successor is created; this call confirms the two agree + before retiring anything, and refuses with `422` if they do not. + reason: + type: string + description: | + Why the passport is being retired. Stored on the predecessor's audit entry + alongside the successor's id — the same entry `amend` writes, so a reader + arriving at a retired record finds what replaced it and why, whichever + route retired it. + example: Reissued on schema 2.6.0 after the product group's lens changed + RulesetReload: type: object + description: The outcome of re-reading the signed compliance-ruleset channel. required: - - jobId - - status - - progress + - rulesetVersion + - changed properties: - jobId: - type: string - format: uuid - status: + rulesetVersion: type: string - enum: - - queued - - processing - - completed - - failed - progress: - $ref: '#/components/schemas/JobProgress' - result: - type: - - object - - 'null' - description: Populated on completion (created/errors) or failure (reason). - report: - type: - - object - - 'null' - description: The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`. + description: The bundle version in force after the reload. `baseline` on a node that has never adopted a signed bundle. + example: 2026-Q3.2 + changed: + type: boolean + description: Whether this call actually replaced the ruleset. `false` means the channel re-offered the bundle already in force — the ordinary answer on a quiet channel, and a success rather than an error. + example: true + parameters: + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: | + An opaque, client-minted string that makes this write safe to retry. + + Send the same key with the same body and the first outcome is returned + instead of a second resource being created. The replayed response carries + `Idempotency-Replayed: true`, so a client can tell a replay from a first + execution without comparing anything. + + Only the routes that document this parameter accept it. Sending it to any + other route is a `400` rather than being ignored — a route that is idempotent + by shape records nothing, and silently accepting the header would suggest a + protection that is not there. + + **A retry must resend byte-identical bytes.** The key is bound to a SHA-256 + of the raw request body, not of a canonicalised form, so re-serialising with + different member order or whitespace counts as a different request and is + refused with `422`. Use a fresh key whenever the body changes. + + On a `multipart/form-data` route this includes the **boundary**: the whole + encoded body is what is hashed. Most HTTP clients generate a fresh boundary + per request, which makes the retry differ in bytes and be refused. Either + pin the boundary across attempts, or omit the key on multipart uploads. + + Keys are scoped to the authenticated caller and to this route, so the same + key may safely be reused across different operations, and one caller can + never observe another's. A key is honoured for **24 hours**; after that the + same key starts a new request. + + While a first attempt is still running, a duplicate receives `409` with + `Retry-After`. + schema: + type: string + minLength: 1 + maxLength: 255 + example: 6f2a1c40-2f6f-4b8a-9a3e-1f4c9b2d7e51 responses: Unauthorized: description: Missing or invalid authentication credentials. @@ -5726,7 +7268,11 @@ components: Forbidden: description: | The credential is authenticated but lacks the required scope — e.g. a - `write`/`read` key attempting an admin-only action (registry identity). + `write`/`read` key attempting an admin-only action. + + Every scope refusal in the service answers this one sentence, naming the scope + required rather than the route. The route is what the caller just addressed; + the scope is the part they have to act on. content: application/problem+json: schema: @@ -5735,7 +7281,32 @@ components: type: https://problems.odal-node.io/forbidden title: Forbidden status: 403 - detail: Registry-identity management requires an admin-scoped credential. + detail: This operation requires an admin-scoped credential. + IdempotentRequestInFlight: + description: | + An earlier attempt carrying this `Idempotency-Key` is still running. + + Returned instead of letting a duplicate execute alongside it. Retry shortly + with the same key and the same body; `Retry-After` carries the suggested + delay. + + A claim left behind by a process that died mid-request is reclaimed + automatically after 60 seconds, so this can never wedge a key permanently. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + minimum: 1 + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/idempotent-request-in-flight + title: Idempotent Request In Flight + status: 409 + detail: An earlier attempt with this `Idempotency-Key` is still running. Retry shortly; do not change the body. ValidationError: description: One or more fields failed validation. content: @@ -5799,20 +7370,6 @@ components: title: Gone status: 410 detail: This passport is suspended and is not publicly available. - ServiceUnavailable: - description: | - A dependency the node needs is not reachable — the readiness probe pings the - primary datastore, and reports this when that ping fails. The node is running; - it is not ready to serve. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - example: - type: https://problems.odal-node.io/service-unavailable - title: Service Unavailable - status: 503 - detail: The primary datastore did not respond. NotAcceptable: description: | No representation matches the request's `Accept` header. The response @@ -5831,3 +7388,81 @@ components: title: Not Acceptable status: 406 detail: No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json. + PassportSignatureUnverified: + description: | + The passport was fetched but its public signature did not verify against the + operator's DID, so the resolver refuses to serve it. + + Every resolver representation is built from the **verified** signed payload, + never from whatever the vault returned, and verification fails closed: a + published passport carrying no public signature, a signature that does not + check out, or a payload that does not match what was signed all end here + rather than being served unmarked. + + Distinct from `503`, which means verification could not be *attempted* + because the DID document was unreachable. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/conflict + title: Conflict + status: 409 + detail: The passport's digital signature could not be verified. + ResolverUpstreamFailure: + description: | + The resolver could not read the passport from the vault it fronts. + + The resolver holds no passport data of its own — it fetches from the vault's + public tier on every request — so an unreachable vault, or one answering + anything other than a success, a `404`/`400` or a `410`, is reported as an + upstream failure rather than as an answer about the passport. + + Deliberately not `404`: the identifier may be perfectly good. A consumer + scanning a carrier should retry rather than conclude the product has no + passport. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/bad-gateway + title: Bad Gateway + status: 502 + detail: The passport could not be read. + PassportVerificationUnavailable: + description: | + The passport could not be verified right now, so it is not served. + + Verification needs the operator's DID document, and this is the answer when + that document cannot be fetched, cannot be parsed, or carries no key matching + the signature's `kid`. The passport itself may be perfectly valid — nothing + has been established about it either way, which is why this is a temporary + failure and not a judgement. + + Distinct from `409`, where verification ran and the signature did not check + out. Retry; a `409` will not fix itself. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/service-unavailable + title: Service Unavailable + status: 503 + detail: The passport could not be verified right now; try again later. + ServiceUnavailable: + description: | + A dependency the node needs is not reachable — the readiness probe pings the + primary datastore, and reports this when that ping fails. The node is running; + it is not ready to serve. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/service-unavailable + title: Service Unavailable + status: 503 + detail: The primary datastore did not respond. diff --git a/site/dpp-docs/src/content/docs/core/overview.mdx b/site/dpp-docs/src/content/docs/core/overview.mdx index 833018f..076fbed 100644 --- a/site/dpp-docs/src/content/docs/core/overview.mdx +++ b/site/dpp-docs/src/content/docs/core/overview.mdx @@ -10,7 +10,7 @@ The core is the regulatory standard expressed in code: the rules a compliant Dig - **A definition of a passport** — its identity, who issued it, what it's made of, the sector-specific data the regulation requires, and the lifecycle it moves through. (The exact shape lives in the [code](https://github.com/odal-node/dpp-core), which is the authoritative reference.) - **Validation** — a passport is checked against its sector's versioned schema. This is a pure, local operation: no network, no services, nothing leaves the machine. - **Signing and verification** — a valid passport is signed with the issuer's own key, binding it to their published identity. Anyone can then verify it on their own, without the issuer's systems and without Odal. -- **A lifecycle** — draft, published, suspended, archived. The transitions are one-directional and recorded, so a passport's history can't be quietly rewritten. +- **A lifecycle** — draft, published, suspended, retired. The transitions are one-directional and recorded, so a passport's history can't be quietly rewritten. ## Why it has no infrastructure diff --git a/site/dpp-docs/src/content/docs/engine/retention.mdx b/site/dpp-docs/src/content/docs/engine/retention.mdx index cd1be88..97b57ff 100644 --- a/site/dpp-docs/src/content/docs/engine/retention.mdx +++ b/site/dpp-docs/src/content/docs/engine/retention.mdx @@ -11,7 +11,7 @@ Retention has two directions that are easy to conflate, so state them plainly: t ## Publishing locks a passport -The moment a passport is published, a retention lock is set on it — and it is never cleared. A locked passport can still change *state*: it can be suspended during a recall, or archived at end-of-life. But its *content* can no longer be edited. Only a draft is mutable; once a passport becomes a signed, published proof, it is fixed. +The moment a passport is published, a retention lock is set on it — and it is never cleared. A locked passport can still change *state*: it can be suspended during a recall, or retired once its retention period has run. But its *content* can no longer be edited. Only a draft is mutable; once a passport becomes a signed, published proof, it is fixed. ## There is no delete @@ -19,11 +19,17 @@ The node's storage interface has no delete operation at all. This is a structura ## History is append-only -Every transition — created, published, suspended, archived — is written to an audit trail as it happens. Entries are only ever added; they are never rewritten or deleted. A passport's provenance can therefore be examined years later and trusted to reflect what actually occurred, in the order it occurred. +Every transition — created, published, suspended, retired — is written to an audit trail as it happens. Entries are only ever added; they are never rewritten or deleted. A passport's provenance can therefore be examined years later and trusted to reflect what actually occurred, in the order it occurred. -## Archival is a state, not an ending +## Retirement is a state, not an ending -When a product reaches end-of-life, its passport moves to *archived* — read-only, but still resolvable. The regulation's record-keeping window outlives the product itself, and archival keeps the passport readable for that entire window instead of removing it. End-of-life is a lifecycle state, never a deletion. +Once the record-keeping window has run, a passport moves to *retired* — read-only, but still resolvable. The regulation's window outlives the product itself, and retirement keeps the passport readable instead of removing it. It is a lifecycle state, never a deletion. + +## Archiving is a different thing, and it runs the whole time + +*Retired* is where a passport's publication life ends. *Archiving* is what happens to a passport that is still very much alive: every time its content changes, the version it replaces is kept, whole, for as long as the passport exists. Anyone entitled to read the current passport can ask what it said at an earlier moment and get that version back, under the same access rules. + +This is EN 18221:2026 clause 4.2, one of the six standards the Commission cited in Implementing Decision (EU) 2026/1736. The two words were once the same word here, which made the difference easy to miss: a system can retire records faithfully and still keep no history at all. Odal does both, and calls them by different names so nobody has to guess which one they are looking at. ## The address stays stable diff --git a/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx b/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx index 1c43ce7..d0044c3 100644 --- a/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx +++ b/site/dpp-docs/src/content/docs/regulatory/central-registry.mdx @@ -15,7 +15,7 @@ This design is the right one because it lets the regulation centralise discovery ## What the registry requires of implementations -The registry went live on **20 July 2026**, meeting the Commission's own Art. 13 deadline, and the implementing act adopted under Art. 13(5) sets out how registration works. So the requirements are no longer expectations: a published passport is registered with its unique identifier, the registration carries the resolver endpoint, and the registering platform keeps it in sync as passports are suspended or archived. +The registry went live on **20 July 2026**, meeting the Commission's own Art. 13 deadline, and the implementing act adopted under Art. 13(5) sets out how registration works. So the requirements are no longer expectations: a published passport is registered with its unique identifier, the registration carries the resolver endpoint, and the registering platform keeps it in sync as passports are suspended or retired. Odal has prepared for this beyond modelling the interface: **every published passport already commits its registration intent to a durable outbox, in the same database transaction as the publish itself.** A background worker drains that outbox with retry and backoff. The practical consequence: publishing never blocks on the registry, a crash never loses a registration, and a backlog accumulated during an outage drains without a single passport falling through.