Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit
# The default below is the project's own demo resolver and is not wired up yet.
# Every operator serves their own: set this to your resolver deployment's public
# origin before publishing anything real. It cannot be corrected afterwards β€”
# publishing starts the ESPR retention window and `odal passport archive`
# publishing starts the ESPR retention window and `odal passport retire`
# refuses inside it, so the labels would have to be reissued.
RESOLVER_BASE_URL=https://id.odal-node.io
# OPERATOR_DID_URL= # default: derived from VAULT_BASE_URL host
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ jobs:
run: mkdir -p dpp-engine/results
# One MinIO for the whole job, rather than one per S3 test β€” the same
# arrangement as the Postgres service above, for the same reason. The four
# `s3_archive` tests booted their own and spent about ten seconds each
# `s3_backup` tests booted their own and spent about ten seconds each
# doing it, which put them on the wrong side of the slow-test budget
# whenever the runner was busy. `ODAL_TEST_S3_ENDPOINT` points them here;
# unset, they still start their own container, so a bare `cargo test`
Expand All @@ -204,7 +204,7 @@ jobs:
# `volumes` and `options` but not a command. The alternative β€” an image
# that runs the server by default β€” would have CI and a local run testing
# two different MinIO builds, which is a worse trade than a longer step.
# The image and tag are the ones `s3_archive.rs` pins for its own container;
# The image and tag are the ones `s3_backup.rs` pins for its own container;
# the two must move together.
#
# `quay.io`, not Docker Hub: `docker.io/minio/minio` was removed. The Hub API
Expand Down
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,51 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):

### Breaking

- **`POST /dpp/{dppId}/archive` is now `POST /dpp/{dppId}/retire`, the status it
sets is `"retired"`, and the event it emits is `dpp.passport.retired`.**
*(Breaking across six surfaces: the route, the `retireDpp` operationId, the
`PassportStatus` wire value, the event subject, `trustMode.archive` β†’
`trustMode.backup` on `GET /node/state`, and `odal passport archive` β†’
`odal passport retire`. `"archived"` is **refused** on input, not aliased.)*

**Migration:** call `/retire` instead of `/archive`; expect `"retired"`
wherever you matched `"archived"`; resubscribe webhooks and NATS consumers
filtering `dpp.passport.archived`; rename `ARCHIVE_S3_*` to `BACKUP_S3_*`.
Migration `0041_retired_status.sql` rewrites stored statuses on upgrade.

**Why.** The word named three different things here. **EN 18221:2026 clause
4.2** β€” one of the six standards cited by Commission Implementing Decision (EU)
2026/1736 β€” uses "archiving" for the retention of historical versions of a
passport that is **still live**, which this node does in `passport_version`
and serves at `GET /dpp/{dppId}/versions`. A terminal lifecycle status is not
that, and neither is the **ESPR Art. 10(4)** back-up copy held by an
**Art. 2(32)** independent provider. While all three wore the word, anyone
mapping this system onto EN 18221 by name ticked a box that was not ticked β€”
which is how the clause 4.2 gap survived unnoticed: the name looked taken.

*Different*, not unrelated, and the difference matters when reading the
back-up: clause 4.2 expects archived versions to be held by the back-up
provider **as well as** by this node, so a provider is not exempt from the
clause. What separates the two here is shape β€” `BackupCopyPort` carries one
copy per passport and no series at all β€” so no arrangement with a provider
wires `passport_version` for us, and nothing here should be read as saying a
provider owes no history.

**Nothing was removed.** Archiving keeps the word and now means only what the
standard means by it. The status is `retired`; the Art. 10(4) copy is the
back-up copy. `scripts/vocabulary-check.sh`, in `just check`, refuses any new
route path, `api/paths/` file or event subject containing "archiv".

**The audit trail is not rewritten.** `action`, `prevStatus` and `newStatus`
are inside the hash chain, so `0041` only *widens* `passport_audit`'s CHECK β€”
`retired` is added and `archived` stays legal. An entry saying `archived`
records a transition performed while that was the word, and editing it would
make every later entry read as tampered.

Pins dpp-core **0.21.0**, which carries the status rename and renames the
back-up port with it (`ports::archive::ArchivePort` β†’ `ports::backup::BackupCopyPort`,
plus `ArchiveReceipt`/`ArchiveStatus`/`ArchiveVerification`/`GhostArchive`).

- **`publishReadiness.passportScope.status` reports six answers where it
reported three.** *(Breaking: `voluntary` is gone. A record the article does
not reach now answers `notCovered`, `belowThreshold` or `notYetBinding`
Expand Down
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ Implementations:
- `NoOpEventBus` (default when `NATS_URL` is absent) β€” discards silently
- `NatsEventBus` (in `dpp-node/src/infra/`) β€” publishes to NATS JetStream stream `DPP_EVENTS` with subject pattern `dpp.>`, 7-day retention, file storage

Subjects: `dpp.passport.{created,updated,published,suspended,superseded,archived,failed}`, `dpp.import.{completed,failed}`. The authoritative set is `event::subjects` in `dpp-common`; this line has been behind it before.
Subjects: `dpp.passport.{created,updated,published,suspended,superseded,retired,failed}`, `dpp.import.{completed,failed}`. The authoritative set is `event::subjects` in `dpp-common`; this line has been behind it before.

### Job Store

Expand Down Expand Up @@ -423,9 +423,9 @@ unprotected one. Read the table in the code.
| PUT | `/vault/api/v1/dpp/{dppId}` | Bearer | Update passport (draft only) |
| POST | `/vault/api/v1/dpp/{dppId}/publish` | Bearer | Publish (signs with Ed25519) |
| POST | `/vault/api/v1/dpp/{dppId}/amend` | Bearer **(write)** | Correct a published passport by publishing a **successor** (`supersedesId` β†’ this id, `version` + 1) and moving this one to the terminal `superseded` state. Returns `201` with the successor β€” **a different record from the one in the path**. The superseded passport keeps its signatures and stays readable here and in the audit trail; its **public** by-id URL keeps serving (the frozen signed view, so `status` reads as it did at publish), while the product's GTIN resolves on past it to the successor |
| POST | `/vault/api/v1/dpp/{dppId}/supersede` | Bearer **(write)** | Retire this passport in favour of an **already-published** successor named in `supersededBy`, which must already carry `supersedesId` back to this id (declared on `POST /dpp`; this route only checks it). Returns `200` with **the retired passport** β€” the opposite subject from `amend`, which mints its successor and returns that. Use this when the replacement was created independently: a newer schema version, an imported record, a successor issued after a transfer |
| POST | `/vault/api/v1/dpp/{dppId}/supersede` | Bearer **(write)** | Supersede this passport in favour of an **already-published** successor named in `supersededBy`, which must already carry `supersedesId` back to this id (declared on `POST /dpp`; this route only checks it). Returns `200` with **the superseded passport** β€” the opposite subject from `amend`, which mints its successor and returns that. Use this when the replacement was created independently: a newer schema version, an imported record, a successor issued after a transfer |
| POST | `/vault/api/v1/dpp/{dppId}/suspend` | Bearer | Suspend |
| POST | `/vault/api/v1/dpp/{dppId}/archive` | Bearer | Archive |
| POST | `/vault/api/v1/dpp/{dppId}/retire` | Bearer | Retire (terminal; **not** EN 18221 archiving β€” see `/versions`) |
| POST | `/vault/api/v1/dpp/{dppId}/lint` | Bearer (write) | Re-run the plausibility lint pack β€” **persists** `lintResult` |
| POST | `/vault/api/v1/dpp/{dppId}/eol` | Bearer (write) | Declare end of life |
| POST | `/vault/api/v1/dpp/{dppId}/transfer/initiate` | Bearer (write) | Sign a pending transfer of responsibility |
Expand All @@ -437,7 +437,7 @@ unprotected one. Read the table in the code.
| GET | `/vault/api/v1/dpp/{dppId}/registry` | Bearer | EU-registry sync status for one passport |
| GET | `/vault/api/v1/registry` | Bearer | EU-registry sync rollup |
| GET | `/vault/api/v1/dpp/{dppId}/history` | Bearer | Audit trail |
| GET | `/vault/api/v1/dpp/{dppId}/versions` | Bearer | Archived historical versions of the passport, oldest first; `?asOf=<RFC 3339>` returns the one current at that instant (as a one-element array), `404` when none covers it. **Not the `archived` lifecycle status** β€” this is EN 18221 clause 4.2's sense of the word, the history of a passport that is still live |
| GET | `/vault/api/v1/dpp/{dppId}/versions` | Bearer | Archived historical versions of the passport, oldest first; `?asOf=<RFC 3339>` returns the one current at that instant (as a one-element array), `404` when none covers it. **Not the `retired` lifecycle status** β€” this is EN 18221 clause 4.2's sense of the word, the history of a passport that is still live |
| GET | `/vault/api/v1/dpp/{dppId}/seal` | Bearer | eIDAS qualified seal + the JWS/digest it covers (`404` when unsealed) |
| POST | `/vault/api/v1/dpp/{dppId}/seal/repair` | Bearer **(admin)** | Queue a replacement for a stored seal that does not verify. **Buys a second seal** for a digest already paid for, so it refuses unless the seal is demonstrably broken β€” checked at request time, never read from the audit's list |
| GET | `/vault/api/v1/seal` | Bearer | Operator-wide sealing state β€” published passports carrying no seal, outbox totals, the configured backend's trust tier, and what the last **completed** pass over every stored seal found (`audit`, `null` when none has) |
Expand Down Expand Up @@ -605,6 +605,15 @@ Two things that bite:

Not debt β€” rules that hold, stated once so they are not re-derived.

- **"Archive" means EN 18221 clause 4.2 and nothing else.** Three things once wore the word and the collision hid a real compliance gap for months, because the name looked already taken. They are told apart by **shape**:
- **archive / archiving** β€” a *history*: every past version of a passport that is still live. `ArchivingPassportRepo`, `passport_version`, `GET /dpp/{id}/versions?asOf=`.
- **retire** β€” a *state*: the terminal lifecycle status, the record has stopped changing. `PassportStatus::Retired`, `POST /dpp/{id}/retire`. No legal anchor; it is our own word.
- **back-up copy** β€” a *copy*, held by an independent third party so the passport survives its operator. ESPR **Art. 10(4)** + **Art. 2(32)** (*not* Art. 13, which is the registry). `BackupCopyPort`, `BACKUP_S3_*`.

🚨 **Shape, never actor.** Clause 4.2 expects archived versions to be held by the back-up provider *as well as* by this node, so a provider is **not** exempt from archiving and no back-up arrangement wires `passport_version` for us. What makes `BackupCopyPort` not-archiving is that it carries no series β€” one copy per passport, `retrieve` answering with one. Getting this backwards is a mistake already made and corrected once.

`scripts/vocabulary-check.sh` (in `just check`) refuses any route path, `api/paths/` file name or event subject containing `archiv`. Compound uses are fine and untouched: the keystore's *archived keys*, a seal's *archival timestamp*, *retiring* a facility or an operator identifier.

- **Errors are RFC 7807.** `dpp-common::http_problem::Problem` is the error shape for every HTTP surface (vault, integrator, identity, resolver). A new error path uses `Problem`, not an ad-hoc body.
- **IDs are UUID v7.** `PassportId`, audit, API-key, event and job IDs all use `now_v7()` so they are time-sortable. Use `now_v7()` for any new identifier; `new_v4()` is acceptable only for throwaway values that are not identifiers (a temp filename).
- **The passport graph lives in `doc`, not in tables.** Component relationships are `componentRefs` inside the passport JSONB, walked by `dpp-vault`'s `verify_tree`. There are no component/material/supplier tables in `ops/pg/*` and none are planned unless a query pattern demands them β€” do not add one to model a relationship the document already carries.
18 changes: 9 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,20 @@ debug = "line-tables-only"
# Core crates β€” consumed from crates.io (dpp-core is published independently;
# see the pins below for the version actually in use, and README.md's sync
# note for the other places that must agree on a bump).
dpp-domain = "0.20.0"
dpp-domain = "0.21.0"

# Engine-wide types (tenant, auth, audit, API keys) β€” engine-internal crate.
dpp-types = { path = "crates/dpp-types" }
dpp-crypto = "0.20.0"
dpp-vc = "0.20.0"
dpp-crypto = "0.21.0"
dpp-vc = "0.21.0"
# KeyStore lives in dpp-core/dpp-crypto; the identity service crate is dpp-identity
# (dir: crates/dpp-identity, pkg: dpp-identity).
dpp-digital-link = "0.20.0"
dpp-aas = "0.20.0"
dpp-plugin-traits = "0.20.0"
dpp-registry = "0.20.0"
dpp-calc = "0.20.0"
dpp-rules = { version = "0.20.0", features = ["bundle"] }
dpp-digital-link = "0.21.0"
dpp-aas = "0.21.0"
dpp-plugin-traits = "0.21.0"
dpp-registry = "0.21.0"
dpp-calc = "0.21.0"
dpp-rules = { version = "0.21.0", features = ["bundle"] }

# Web framework
axum = { version = "0.8", features = ["macros", "multipart"] }
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The engine ships as a **single binary** (`dpp-node`) that fuses all services und
|---|---|---|
| `dpp-types` | lib | Platform-wide types β€” operator config, auth, audit, API keys |
| `dpp-dal` | lib | PostgreSQL DAL β€” passport repo, migrations (single-tenant; no RLS) |
| `dpp-vault` | bin+lib | DPP write engine β€” create, versioned lifecycle (publish / suspend / archive / end-of-life), transfer-of-responsibility handshake, hash-chained audit, evidence-dossier generation + verification |
| `dpp-vault` | bin+lib | DPP write engine β€” create, versioned lifecycle (publish / suspend / retire / end-of-life), transfer-of-responsibility handshake, hash-chained audit, evidence-dossier generation + verification |
| `dpp-identity` | bin+lib | `did:web` identity HTTP service β€” signing, key rotation |
| `dpp-resolver` | bin+lib | Public QR / Digital Link resolver, JWS-verified fail-closed |
| `dpp-render` | lib | Shared HTML rendering of the public passport view. Extracted from `dpp-resolver` so the live read and the continuity tier's pre-rendered snapshot go through **one** renderer β€” a second implementation is precisely how the static tier would drift from what the resolver serves |
Expand Down Expand Up @@ -108,7 +108,7 @@ cargo nextest run --workspace

The node starts on port **8001**. The resolver runs separately on port **8003**.

**Environment**: Copy the single root template β€” `cp .env.example .env`. It documents every backend var, grouped by deployable (node + resolver), including the trust-layer surface: `NODE_PROFILE` (a `production` node refuses to boot on placeholder trust adapters), `RULESET_BUNDLE_PATH` + `RULESET_PUBLISHER_PUBKEY` (signed compliance-ruleset channel), and the optional `EU_REGISTRY_*` / `ARCHIVE_S3_*` / `QTSP_*` adapter blocks.
**Environment**: Copy the single root template β€” `cp .env.example .env`. It documents every backend var, grouped by deployable (node + resolver), including the trust-layer surface: `NODE_PROFILE` (a `production` node refuses to boot on placeholder trust adapters), `RULESET_BUNDLE_PATH` + `RULESET_PUBLISHER_PUBKEY` (signed compliance-ruleset channel), and the optional `EU_REGISTRY_*` / `BACKUP_S3_*` / `QTSP_*` adapter blocks.

### Operate it with the `odal` CLI

Expand Down
2 changes: 1 addition & 1 deletion api/components/responses/Conflict.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
description: |
State conflict β€” e.g. attempting to publish an already-published DPP
or updating an archived DPP.
or updating a retired DPP.
content:
application/problem+json:
schema:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: >-
Commission Implementing Decision (EU) 2026/1736.


🚨 **Not the `archived` lifecycle status**, which is a terminal state reached
🚨 **Not the `retired` lifecycle status**, which is a terminal state reached
after the ESPR retention period and says the record has stopped changing. This
is the standard's sense of the word β€” historical versions of a passport that is
still live β€” and the two are unrelated.
Expand Down
17 changes: 11 additions & 6 deletions api/components/schemas/passport/PassportStatus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ enum:
- draft
- active
- suspended
- archived
- retired
- superseded
- deactivated
description: |
Expand All @@ -12,16 +12,21 @@ description: |
- `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
- `retired`: post-retention; immutable; retained for regulatory record-keeping.
Not archiving β€” see `GET /dpp/{dppId}/versions` for EN 18221 clause 4.2
- `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,
draft β†’ active, draft β†’ retired,
active β†’ suspended, active β†’ retired, active β†’ superseded,
active β†’ deactivated,
suspended β†’ active, suspended β†’ archived, suspended β†’ deactivated.
suspended β†’ active, suspended β†’ retired, suspended β†’ deactivated.

`archived`, `superseded` and `deactivated` are terminal.
`retired`, `superseded` and `deactivated` are terminal.

`archived` is refused on input. It was this status's wire value and now names
only what EN 18221 clause 4.2 means by it β€” the retention of historical
versions of a passport that is still live.
Loading
Loading