From 9675f4cdf9a95e0a2514307a07430bdd60ff84f3 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 17 Sep 2026 04:33:22 +0200 Subject: [PATCH 1/4] =?UTF-8?q?chore(deps)!:=20repin=20core=200.21.0,=20ar?= =?UTF-8?q?chive=E2=86=92retire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 37 +++++ CLAUDE.md | 8 +- Cargo.toml | 18 +-- README.md | 4 +- api/components/responses/Conflict.yaml | 2 +- .../passport-reports/PassportVersion.yaml | 2 +- .../schemas/passport/PassportStatus.yaml | 17 ++- api/openapi.bundled.json | 26 ++-- api/openapi.bundled.yaml | 47 ++++--- api/openapi.yaml | 6 +- api/paths/vault/vault_api_v1_dpp_{dppId}.yaml | 2 +- .../vault/vault_api_v1_dpp_{dppId}_amend.yaml | 2 +- ...l => vault_api_v1_dpp_{dppId}_retire.yaml} | 14 +- .../vault_api_v1_dpp_{dppId}_versions.yaml | 4 +- cli/src/cli_args.rs | 12 +- cli/src/commands/lifecycle.rs | 30 ++-- cli/src/console/menu/passports.rs | 14 +- cli/src/core/passport/lifecycle.rs | 24 ++-- cli/src/core/passport/mod.rs | 2 +- cli/src/core/types.rs | 4 +- cli/src/dispatch.rs | 6 +- cli/src/stateless/render.rs | 2 +- crates/dpp-common/src/event.rs | 2 +- crates/dpp-common/src/event_codes.rs | 6 +- .../dpp-dal/src/pg/repo_passport_version.rs | 2 +- crates/dpp-dal/src/pg/repo_registry_sync.rs | 2 +- crates/dpp-dal/src/pg/repo_snapshot.rs | 2 +- crates/dpp-dal/tests/pg_integration.rs | 2 +- crates/dpp-dal/tests/pg_passport_version.rs | 2 +- crates/dpp-node/Cargo.toml | 6 +- crates/dpp-node/src/boot/trust.rs | 6 +- crates/dpp-node/src/infra/mod.rs | 4 +- .../src/infra/{s3_archive.rs => s3_backup.rs} | 128 +++++++++--------- crates/dpp-node/src/infra/s3_snapshot.rs | 2 +- crates/dpp-node/src/infra/seal.rs | 2 +- crates/dpp-node/src/main.rs | 12 +- crates/dpp-node/src/router.rs | 2 +- crates/dpp-node/tests/nats_event_bus.rs | 8 +- crates/dpp-node/tests/openapi_contract.rs | 4 +- crates/dpp-node/tests/registry_outbox.rs | 4 +- .../tests/{s3_archive.rs => s3_backup.rs} | 18 +-- crates/dpp-node/tests/seal_outbox.rs | 12 +- crates/dpp-node/tests/smoke.rs | 4 +- crates/dpp-node/tests/snapshot_outbox.rs | 4 +- crates/dpp-render/src/page.rs | 28 +++- crates/dpp-resolver/src/config.rs | 2 +- crates/dpp-types/src/api_key.rs | 2 +- crates/dpp-types/src/audit.rs | 6 +- crates/dpp-types/src/registry_sync.rs | 6 +- crates/dpp-types/src/snapshot.rs | 8 +- crates/dpp-types/src/trust.rs | 16 +-- crates/dpp-vault/src/domain/service/amend.rs | 6 +- .../dpp-vault/src/domain/service/evidence.rs | 2 +- .../dpp-vault/src/domain/service/lifecycle.rs | 33 ++--- crates/dpp-vault/src/domain/service/mod.rs | 28 ++-- .../dpp-vault/src/domain/service/publish.rs | 10 +- crates/dpp-vault/src/domain/service/query.rs | 2 +- crates/dpp-vault/src/handlers/mod.rs | 2 +- .../src/handlers/{archive.rs => retire.rs} | 10 +- crates/dpp-vault/src/handlers/versions.rs | 12 +- crates/dpp-vault/src/main.rs | 6 +- crates/dpp-vault/src/public_view.rs | 2 +- crates/dpp-vault/src/router.rs | 4 +- crates/dpp-vault/tests/continuity_snapshot.rs | 4 +- crates/dpp-vault/tests/evidence_dossier.rs | 4 +- crates/dpp-vault/tests/helpers/mod.rs | 4 +- docs/architecture/DATA-MODEL.md | 4 +- docs/architecture/EVENT-BUS.md | 2 +- docs/architecture/EVIDENCE-DOSSIER.md | 2 +- docs/architecture/OVERVIEW.md | 2 +- docs/guides/OPERATOR-SETUP.md | 18 +-- docs/guides/WEBHOOKS.md | 4 +- docs/legal/DPP-RETENTION.md | 2 +- docs/ops/PRODUCTION-RUNBOOK.md | 2 +- docs/project/BLUEPRINT.md | 2 +- docs/project/ENDPOINTS.md | 2 +- justfile | 16 ++- ops/pg/0041_retired_status.sql | 56 ++++++++ scripts/vocabulary-check.sh | 56 ++++++++ scripts/vocabulary-check.test.sh | 84 ++++++++++++ 82 files changed, 631 insertions(+), 341 deletions(-) rename api/paths/vault/{vault_api_v1_dpp_{dppId}_archive.yaml => vault_api_v1_dpp_{dppId}_retire.yaml} (63%) rename crates/dpp-node/src/infra/{s3_archive.rs => s3_backup.rs} (71%) rename crates/dpp-node/tests/{s3_archive.rs => s3_backup.rs} (94%) rename crates/dpp-vault/src/handlers/{archive.rs => retire.rs} (78%) create mode 100644 ops/pg/0041_retired_status.sql create mode 100644 scripts/vocabulary-check.sh create mode 100644 scripts/vocabulary-check.test.sh diff --git a/.env.example b/.env.example index b3eb1e37..a6a0e633 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd464abc..4514f2e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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` @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f6480add..7c4885d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,43 @@ 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 unrelated 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. + + **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` diff --git a/CLAUDE.md b/CLAUDE.md index e8528a9c..535c2018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 | @@ -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=` 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=` 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) | diff --git a/Cargo.toml b/Cargo.toml index c51c2f2f..3a8e24bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 2ae6fc7b..8c9b44e8 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/api/components/responses/Conflict.yaml b/api/components/responses/Conflict.yaml index 2bed68f0..455cee30 100644 --- a/api/components/responses/Conflict.yaml +++ b/api/components/responses/Conflict.yaml @@ -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: diff --git a/api/components/schemas/passport-reports/PassportVersion.yaml b/api/components/schemas/passport-reports/PassportVersion.yaml index f13b655a..98272472 100644 --- a/api/components/schemas/passport-reports/PassportVersion.yaml +++ b/api/components/schemas/passport-reports/PassportVersion.yaml @@ -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. diff --git a/api/components/schemas/passport/PassportStatus.yaml b/api/components/schemas/passport/PassportStatus.yaml index a624ee5f..31bc8d0d 100644 --- a/api/components/schemas/passport/PassportStatus.yaml +++ b/api/components/schemas/passport/PassportStatus.yaml @@ -3,7 +3,7 @@ enum: - draft - active - suspended - - archived + - retired - superseded - deactivated description: | @@ -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. diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index a3de44be..558fb724 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -36,7 +36,7 @@ }, { "name": "DPP Lifecycle", - "description": "Lifecycle transitions — publish, suspend, archive, end-of-life, and transfer of responsibility." + "description": "Lifecycle transitions — publish, suspend, retire, end-of-life, and transfer of responsibility." }, { "name": "Evidence Dossiers", @@ -432,7 +432,7 @@ "put": { "operationId": "updateDpp", "summary": "Update a DPP", - "description": "JSON merge-patch on a `draft` or `active` DPP.\nThe request body is a free-form JSON object; only supplied\nfields are changed. Returns `409 Conflict` if the DPP is\n`suspended` or `archived`.\n", + "description": "JSON merge-patch on a `draft` or `active` DPP.\nThe request body is a free-form JSON object; only supplied\nfields are changed. Returns `409 Conflict` if the DPP is\n`suspended` or `retired`.\n", "tags": [ "DPP Management" ], @@ -553,7 +553,7 @@ "get": { "operationId": "getDppVersions", "summary": "List a DPP's archived versions, or reconstitute one", - "description": "Returns every archived version of a passport, oldest first — or, with\n`asOf`, the single version that was current at that instant.\n\n✅ EN 18221:2026 clause 4.2: *\"the archived version corresponding to a given\npoint in time shall be retrievable by authenticated and authorized actors\"*.\n\n🚨 **Not `POST /dpp/{dppId}/archive`.** That moves a passport to the terminal\n`archived` lifecycle status after its retention period has elapsed. This is\nthe standard's sense of the word — historical versions of a passport that is\nstill live — and the two are unrelated.\n\nArchiving begins at the **first change** to a passport, as the clause\nrequires, so a passport that has never changed has no archived versions and\nreturns an empty list. That is not a gap: the live record is the state at\nevery moment before the first change.\n\nEach version is the complete record, so a reader reconstitutes rather than\nreplays. Versions are append-only and are kept for the passport's lifetime.\n", + "description": "Returns every archived version of a passport, oldest first — or, with\n`asOf`, the single version that was current at that instant.\n\n✅ EN 18221:2026 clause 4.2: *\"the archived version corresponding to a given\npoint in time shall be retrievable by authenticated and authorized actors\"*.\n\n🚨 **Not `POST /dpp/{dppId}/retire`.** That moves a passport to the terminal\n`retired` lifecycle status after its retention period has elapsed. This is\nthe standard's sense of the word — historical versions of a passport that is\nstill live — and the two are unrelated.\n\nArchiving begins at the **first change** to a passport, as the clause\nrequires, so a passport that has never changed has no archived versions and\nreturns an empty list. That is not a gap: the live record is the state at\nevery moment before the first change.\n\nEach version is the complete record, so a reader reconstitutes rather than\nreplays. Versions are append-only and are kept for the passport's lifetime.\n", "tags": [ "DPP Management" ], @@ -950,7 +950,7 @@ "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 —\nits signatures commit to its bytes and the retention guard refuses the\nwrite — so this issues a **new** passport instead.\n\nThe successor is a copy of `dppId` with the `patch` applied, carrying\n`supersedesId` back to `dppId` and `version` incremented. It goes through\nthe same gates as any first publish: schema validation, product-group\nvalidation, mandatory-content checks, signing, and registry sync. When it\npublishes successfully, `dppId` moves to the terminal `superseded` state.\n\n**The response body is a different passport from the one in the path.** It\nis the successor, with its own `id`. Clients holding the old id keep a valid\nreference: a superseded passport stays resolvable, keeps its signatures and\nits seal, and reports `superseded` as its status.\n\nOnly an `active` DPP can be amended. A draft is edited in place with\n`PUT /vault/api/v1/dpp/{dppId}`; a suspended, archived, superseded or\ndeactivated DPP cannot be superseded.\n\nThe schema version is **inherited**, not advanced to the product group's\ncurrent one. An amendment corrects content; migrating a passport to a newer\nschema is a separate act. This also keeps the successor's disclosure classes\nidentical to the predecessor's, since those are read from the schema\nversion's own annotations.\n\nEmits `dpp.passport.published` for the successor and\n`dpp.passport.superseded` for the predecessor.\n", + "description": "Correct a published DPP. A published passport cannot be edited in place —\nits signatures commit to its bytes and the retention guard refuses the\nwrite — so this issues a **new** passport instead.\n\nThe successor is a copy of `dppId` with the `patch` applied, carrying\n`supersedesId` back to `dppId` and `version` incremented. It goes through\nthe same gates as any first publish: schema validation, product-group\nvalidation, mandatory-content checks, signing, and registry sync. When it\npublishes successfully, `dppId` moves to the terminal `superseded` state.\n\n**The response body is a different passport from the one in the path.** It\nis the successor, with its own `id`. Clients holding the old id keep a valid\nreference: a superseded passport stays resolvable, keeps its signatures and\nits seal, and reports `superseded` as its status.\n\nOnly an `active` DPP can be amended. A draft is edited in place with\n`PUT /vault/api/v1/dpp/{dppId}`; a suspended, retired, superseded or\ndeactivated DPP cannot be superseded.\n\nThe schema version is **inherited**, not advanced to the product group's\ncurrent one. An amendment corrects content; migrating a passport to a newer\nschema is a separate act. This also keeps the successor's disclosure classes\nidentical to the predecessor's, since those are read from the schema\nversion's own annotations.\n\nEmits `dpp.passport.published` for the successor and\n`dpp.passport.superseded` for the predecessor.\n", "tags": [ "DPP Lifecycle" ], @@ -1147,11 +1147,11 @@ } } }, - "/vault/api/v1/dpp/{dppId}/archive": { + "/vault/api/v1/dpp/{dppId}/retire": { "post": { - "operationId": "archiveDpp", - "summary": "Archive a DPP", - "description": "Transition a DPP to `archived`. This is irreversible.\nArchived DPPs are immutable and retained for regulatory record-keeping.\nNo request body is required.\n", + "operationId": "retireDpp", + "summary": "Retire a DPP", + "description": "Transition a DPP to `retired`. This is irreversible.\nRetired DPPs are immutable and retained for regulatory record-keeping.\nNo request body is required.\n\n🚨 **Not archiving.** EN 18221 clause 4.2 archiving is the retention of\nhistorical versions of a passport that is still live, and it is served by\n`GET /dpp/{dppId}/versions`. This route ends a record's publication life.\n", "tags": [ "DPP Lifecycle" ], @@ -1175,7 +1175,7 @@ ], "responses": { "200": { - "description": "DPP archived. Returns the full passport record.", + "description": "DPP retired. Returns the full passport record.", "content": { "application/json": { "schema": { @@ -5152,11 +5152,11 @@ "draft", "active", "suspended", - "archived", + "retired", "superseded", "deactivated" ], - "description": "DPP lifecycle state. The domain model uses `Published` internally;\nthe wire format uses `active`. Deserialization accepts both.\n- `draft`: under construction; not publicly resolvable\n- `active`: published and signed; publicly resolvable via QR\n- `suspended`: temporarily hidden (recall, dispute); JWS preserved\n- `archived`: end-of-life; immutable; retained for regulatory record-keeping\n- `superseded`: replaced by a newer passport version; terminal\n- `deactivated`: end-of-life declared (recycled, destroyed under a\n derogation, exported, or lost); terminal. The typed reason is carried by\n the EOL event, not this field\n\nValid transitions:\ndraft → active, draft → archived,\nactive → suspended, active → archived, active → superseded,\nactive → deactivated,\nsuspended → active, suspended → archived, suspended → deactivated.\n\n`archived`, `superseded` and `deactivated` are terminal.\n" + "description": "DPP lifecycle state. The domain model uses `Published` internally;\nthe wire format uses `active`. Deserialization accepts both.\n- `draft`: under construction; not publicly resolvable\n- `active`: published and signed; publicly resolvable via QR\n- `suspended`: temporarily hidden (recall, dispute); JWS preserved\n- `retired`: post-retention; immutable; retained for regulatory record-keeping.\n Not archiving — see `GET /dpp/{dppId}/versions` for EN 18221 clause 4.2\n- `superseded`: replaced by a newer passport version; terminal\n- `deactivated`: end-of-life declared (recycled, destroyed under a\n derogation, exported, or lost); terminal. The typed reason is carried by\n the EOL event, not this field\n\nValid transitions:\ndraft → active, draft → retired,\nactive → suspended, active → retired, active → superseded,\nactive → deactivated,\nsuspended → active, suspended → retired, suspended → deactivated.\n\n`retired`, `superseded` and `deactivated` are terminal.\n\n`archived` is refused on input. It was this status's wire value and now names\nonly what EN 18221 clause 4.2 means by it — the retention of historical\nversions of a passport that is still live.\n" }, "Granularity": { "type": "string", @@ -8996,7 +8996,7 @@ }, "PassportVersion": { "type": "object", - "description": "One archived version of a passport: the record as it stood, and the moment it stopped standing.\n\n✅ EN 18221:2026 clause 4.2 (archiving), one of the six standards cited by Commission Implementing Decision (EU) 2026/1736.\n\n🚨 **Not the `archived` 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.", + "description": "One archived version of a passport: the record as it stood, and the moment it stopped standing.\n\n✅ EN 18221:2026 clause 4.2 (archiving), one of the six standards cited by Commission Implementing Decision (EU) 2026/1736.\n\n🚨 **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.", "required": [ "id", "passportId", @@ -9583,7 +9583,7 @@ } }, "Conflict": { - "description": "State conflict — e.g. attempting to publish an already-published DPP\nor updating an archived DPP.\n", + "description": "State conflict — e.g. attempting to publish an already-published DPP\nor updating a retired DPP.\n", "content": { "application/problem+json": { "schema": { diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index 767f08e5..d696ce9f 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -43,7 +43,7 @@ tags: - name: DPP Management description: Create, read, update, list, and audit Digital Product Passports. - name: DPP Lifecycle - description: Lifecycle transitions — publish, suspend, archive, end-of-life, and transfer of responsibility. + description: Lifecycle transitions — publish, suspend, retire, 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 @@ -331,7 +331,7 @@ paths: JSON merge-patch on a `draft` or `active` DPP. The request body is a free-form JSON object; only supplied fields are changed. Returns `409 Conflict` if the DPP is - `suspended` or `archived`. + `suspended` or `retired`. tags: - DPP Management security: @@ -414,8 +414,8 @@ paths: ✅ EN 18221:2026 clause 4.2: *"the archived version corresponding to a given point in time shall be retrievable by authenticated and authorized actors"*. - 🚨 **Not `POST /dpp/{dppId}/archive`.** That moves a passport to the terminal - `archived` lifecycle status after its retention period has elapsed. This is + 🚨 **Not `POST /dpp/{dppId}/retire`.** That moves a passport to the terminal + `retired` lifecycle status after its retention period has elapsed. This is the standard's sense of the word — historical versions of a passport that is still live — and the two are unrelated. @@ -841,7 +841,7 @@ paths: 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 + `PUT /vault/api/v1/dpp/{dppId}`; a suspended, retired, superseded or deactivated DPP cannot be superseded. The schema version is **inherited**, not advanced to the product group's @@ -1001,14 +1001,18 @@ paths: $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' - /vault/api/v1/dpp/{dppId}/archive: + /vault/api/v1/dpp/{dppId}/retire: post: - operationId: archiveDpp - summary: Archive a DPP + operationId: retireDpp + summary: Retire a DPP description: | - Transition a DPP to `archived`. This is irreversible. - Archived DPPs are immutable and retained for regulatory record-keeping. + Transition a DPP to `retired`. This is irreversible. + Retired DPPs are immutable and retained for regulatory record-keeping. No request body is required. + + 🚨 **Not archiving.** EN 18221 clause 4.2 archiving is the retention of + historical versions of a passport that is still live, and it is served by + `GET /dpp/{dppId}/versions`. This route ends a record's publication life. tags: - DPP Lifecycle security: @@ -1022,7 +1026,7 @@ paths: $ref: '#/components/schemas/DppId' responses: '200': - description: DPP archived. Returns the full passport record. + description: DPP retired. Returns the full passport record. content: application/json: schema: @@ -4245,7 +4249,7 @@ components: - draft - active - suspended - - archived + - retired - superseded - deactivated description: | @@ -4254,19 +4258,24 @@ components: - `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. + + `retired`, `superseded` and `deactivated` are terminal. - `archived`, `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. Granularity: type: string enum: @@ -7546,7 +7555,7 @@ components: ✅ EN 18221:2026 clause 4.2 (archiving), one of the six standards cited by Commission Implementing Decision (EU) 2026/1736. - 🚨 **Not the `archived` 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. + 🚨 **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. required: - id - passportId @@ -8256,7 +8265,7 @@ components: Conflict: 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: diff --git a/api/openapi.yaml b/api/openapi.yaml index 9c2865ae..7d1244ec 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -359,8 +359,8 @@ paths: $ref: paths/vault/vault_api_v1_dpp_{dppId}_supersede.yaml /vault/api/v1/dpp/{dppId}/suspend: $ref: paths/vault/vault_api_v1_dpp_{dppId}_suspend.yaml - /vault/api/v1/dpp/{dppId}/archive: - $ref: paths/vault/vault_api_v1_dpp_{dppId}_archive.yaml + /vault/api/v1/dpp/{dppId}/retire: + $ref: paths/vault/vault_api_v1_dpp_{dppId}_retire.yaml /vault/api/v1/dpp/{dppId}/eol: $ref: paths/vault/vault_api_v1_dpp_{dppId}_eol.yaml /vault/api/v1/dpp/{dppId}/transfer/initiate: @@ -492,7 +492,7 @@ tags: description: Create, read, update, list, and audit Digital Product Passports. - name: DPP Lifecycle description: >- - Lifecycle transitions — publish, suspend, archive, end-of-life, and + Lifecycle transitions — publish, suspend, retire, end-of-life, and transfer of responsibility. - name: Evidence Dossiers description: >- diff --git a/api/paths/vault/vault_api_v1_dpp_{dppId}.yaml b/api/paths/vault/vault_api_v1_dpp_{dppId}.yaml index c717adbe..c7dad716 100644 --- a/api/paths/vault/vault_api_v1_dpp_{dppId}.yaml +++ b/api/paths/vault/vault_api_v1_dpp_{dppId}.yaml @@ -36,7 +36,7 @@ put: JSON merge-patch on a `draft` or `active` DPP. The request body is a free-form JSON object; only supplied fields are changed. Returns `409 Conflict` if the DPP is - `suspended` or `archived`. + `suspended` or `retired`. tags: - DPP Management security: diff --git a/api/paths/vault/vault_api_v1_dpp_{dppId}_amend.yaml b/api/paths/vault/vault_api_v1_dpp_{dppId}_amend.yaml index 498f3a57..ddb982e3 100644 --- a/api/paths/vault/vault_api_v1_dpp_{dppId}_amend.yaml +++ b/api/paths/vault/vault_api_v1_dpp_{dppId}_amend.yaml @@ -18,7 +18,7 @@ post: 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 + `PUT /vault/api/v1/dpp/{dppId}`; a suspended, retired, superseded or deactivated DPP cannot be superseded. The schema version is **inherited**, not advanced to the product group's diff --git a/api/paths/vault/vault_api_v1_dpp_{dppId}_archive.yaml b/api/paths/vault/vault_api_v1_dpp_{dppId}_retire.yaml similarity index 63% rename from api/paths/vault/vault_api_v1_dpp_{dppId}_archive.yaml rename to api/paths/vault/vault_api_v1_dpp_{dppId}_retire.yaml index 1d96b169..aec008c0 100644 --- a/api/paths/vault/vault_api_v1_dpp_{dppId}_archive.yaml +++ b/api/paths/vault/vault_api_v1_dpp_{dppId}_retire.yaml @@ -1,10 +1,14 @@ post: - operationId: archiveDpp - summary: Archive a DPP + operationId: retireDpp + summary: Retire a DPP description: | - Transition a DPP to `archived`. This is irreversible. - Archived DPPs are immutable and retained for regulatory record-keeping. + Transition a DPP to `retired`. This is irreversible. + Retired DPPs are immutable and retained for regulatory record-keeping. No request body is required. + + 🚨 **Not archiving.** EN 18221 clause 4.2 archiving is the retention of + historical versions of a passport that is still live, and it is served by + `GET /dpp/{dppId}/versions`. This route ends a record's publication life. tags: - DPP Lifecycle security: @@ -18,7 +22,7 @@ post: $ref: ../../components/schemas/passport/DppId.yaml responses: '200': - description: DPP archived. Returns the full passport record. + description: DPP retired. Returns the full passport record. content: application/json: schema: diff --git a/api/paths/vault/vault_api_v1_dpp_{dppId}_versions.yaml b/api/paths/vault/vault_api_v1_dpp_{dppId}_versions.yaml index 1b12b8ee..bfbf5949 100644 --- a/api/paths/vault/vault_api_v1_dpp_{dppId}_versions.yaml +++ b/api/paths/vault/vault_api_v1_dpp_{dppId}_versions.yaml @@ -8,8 +8,8 @@ get: ✅ EN 18221:2026 clause 4.2: *"the archived version corresponding to a given point in time shall be retrievable by authenticated and authorized actors"*. - 🚨 **Not `POST /dpp/{dppId}/archive`.** That moves a passport to the terminal - `archived` lifecycle status after its retention period has elapsed. This is + 🚨 **Not `POST /dpp/{dppId}/retire`.** That moves a passport to the terminal + `retired` lifecycle status after its retention period has elapsed. This is the standard's sense of the word — historical versions of a passport that is still live — and the two are unrelated. diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index 8890162b..1b9636fc 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -212,7 +212,7 @@ pub enum Commands { pub enum PassportCommands { /// List or search passports (id, name, status) — no ID needed List { - /// Filter by status (draft, active, suspended, archived) + /// Filter by status (draft, active, suspended, retired) #[arg(long)] status: Option, /// Free-text search across product name, batch, and manufacturer @@ -250,8 +250,8 @@ pub enum PassportCommands { /// Passport ID id: String, }, - /// Archive a passport (terminal state) - Archive { + /// Retire a passport (terminal state) + Retire { /// Passport ID id: String, }, @@ -285,7 +285,7 @@ pub enum PassportCommands { /// Output format #[arg(long, default_value = "json")] format: String, - /// Filter by status (draft, active, suspended, archived) + /// Filter by status (draft, active, suspended, retired) #[arg(long)] status: Option, /// Output file (stdout if omitted) @@ -321,12 +321,12 @@ pub enum PassportCommands { /// instead when the replacement is a correction of this record rather than /// a passport created independently. Supersede { - /// Passport ID to retire + /// Passport ID to supersede id: String, /// The successor's passport ID #[arg(long = "superseded-by")] superseded_by: String, - /// Why the passport is being retired + /// Why the passport is being superseded #[arg(long)] reason: Option, /// Output raw JSON instead of a summary diff --git a/cli/src/commands/lifecycle.rs b/cli/src/commands/lifecycle.rs index 35b72e54..349f3eaf 100644 --- a/cli/src/commands/lifecycle.rs +++ b/cli/src/commands/lifecycle.rs @@ -1,14 +1,14 @@ -//! `odal suspend | archive | history ` — passport lifecycle operations. +//! `odal suspend | retire | history ` — passport lifecycle operations. use anyhow::Result; use crate::{ core::{ passport::{ - Supersession, action_amend, action_archive, action_history, action_supersede, + Supersession, action_amend, action_history, action_retire, action_supersede, action_suspend, }, - types::{ArchiveParams, HistoryParams, SuspendParams}, + types::{HistoryParams, RetireParams, SuspendParams}, }, stateless::render::render_history, }; @@ -16,18 +16,18 @@ use crate::{ /// Report a supersession the same way whichever route produced it. /// /// The two routes return opposite halves of the pair — `amend` answers with the -/// successor it minted, `supersede` with the predecessor it retired — and an +/// successor it minted, `supersede` with the predecessor it superseded — and an /// operator who has to remember which is which will eventually act on the wrong /// id. Naming both, in the same order, every time removes the question. fn render_supersession(result: &Supersession, verb: &str) { - println!("Passport {} {verb}.", result.retired); + println!("Passport {} {verb}.", result.superseded); println!( - " Retired: {} (terminal — it accepts no further transitions)", - result.retired + " Superseded: {} (terminal — it accepts no further transitions)", + result.superseded ); println!(" Successor: {}", result.successor); println!(); - println!("The retired record keeps its signatures and stays readable, and its public"); + println!("The superseded record keeps its signatures and stays readable, and its public"); println!("URL keeps serving — a carrier already in the field still resolves."); } @@ -51,7 +51,7 @@ pub async fn run_amend(id: &str, patch_file: &str, reason: Option<&str>, json: b if json { println!( "{}", - serde_json::json!({ "retired": result.retired, "successor": result.successor }) + serde_json::json!({ "superseded": result.superseded, "successor": result.successor }) ); return Ok(()); } @@ -59,12 +59,12 @@ pub async fn run_amend(id: &str, patch_file: &str, reason: Option<&str>, json: b Ok(()) } -/// `odal passport supersede` — retire a passport in favour of one that already +/// `odal passport supersede` — replace a passport with one that already /// exists. /// /// The sibling of `run_amend` and not a variant of it: amend *mints* the /// successor, this one *names* an existing passport that must already carry -/// `supersedesId` back to the id being retired. Use it when the replacement was +/// `supersedesId` back to the id being superseded. Use it when the replacement was /// created independently — a newer schema version, an imported record, or a /// successor issued after a transfer. pub async fn run_supersede( @@ -78,7 +78,7 @@ pub async fn run_supersede( if json { println!( "{}", - serde_json::json!({ "retired": result.retired, "successor": result.successor }) + serde_json::json!({ "superseded": result.superseded, "successor": result.successor }) ); return Ok(()); } @@ -93,10 +93,10 @@ pub async fn run_suspend(id: &str) -> Result<()> { Ok(()) } -pub async fn run_archive(id: &str) -> Result<()> { +pub async fn run_retire(id: &str) -> Result<()> { let (client, cfg) = crate::http::load_client()?; - action_archive(&ArchiveParams { id: id.to_owned() }, &client, &cfg).await?; - println!("Passport {id} archived."); + action_retire(&RetireParams { id: id.to_owned() }, &client, &cfg).await?; + println!("Passport {id} retired."); Ok(()) } diff --git a/cli/src/console/menu/passports.rs b/cli/src/console/menu/passports.rs index 8b1dfc97..88c852b7 100644 --- a/cli/src/console/menu/passports.rs +++ b/cli/src/console/menu/passports.rs @@ -9,13 +9,13 @@ use inquire::{Confirm, InquireError, Select, Text}; use crate::{ core::{ passport::{ - action_archive, action_export, action_get, action_history, action_import, action_list, - action_operator_stats, action_passport_stats, action_publish, action_suspend, - action_validate, + action_export, action_get, action_history, action_import, action_list, + action_operator_stats, action_passport_stats, action_publish, action_retire, + action_suspend, action_validate, }, types::{ - ArchiveParams, ExportParams, HistoryParams, ImportParams, ListParams, PassportSummary, - ProgressEvent, PublishParams, SuspendParams, + ExportParams, HistoryParams, ImportParams, ListParams, PassportSummary, ProgressEvent, + PublishParams, RetireParams, SuspendParams, }, }, http::OdalClient, @@ -232,7 +232,7 @@ pub(super) async fn passports() -> Result<()> { } match client() { Ok((client, cfg)) => { - match action_archive(&ArchiveParams { id: id.clone() }, &client, &cfg) + match action_retire(&RetireParams { id: id.clone() }, &client, &cfg) .await { Ok(_) => { @@ -647,7 +647,7 @@ async fn passport_actions( .prompt())? .unwrap_or(false); if ok { - match action_archive(&ArchiveParams { id: p.id.clone() }, client, cfg).await { + match action_retire(&RetireParams { id: p.id.clone() }, client, cfg).await { Ok(_) => { println!("\n {} Archived.", style("✓").green()); hint(&format!("odal passport archive {}", p.id)); diff --git a/cli/src/core/passport/lifecycle.rs b/cli/src/core/passport/lifecycle.rs index 3881cf0d..21cc0b4d 100644 --- a/cli/src/core/passport/lifecycle.rs +++ b/cli/src/core/passport/lifecycle.rs @@ -3,7 +3,7 @@ use anyhow::{Context as _, Result}; use serde_json::json; -use super::super::types::{ArchiveParams, HistoryParams, PassportAuditEntry, SuspendParams}; +use super::super::types::{HistoryParams, PassportAuditEntry, RetireParams, SuspendParams}; use crate::{ config::Config, http::{OdalClient, describe_error}, @@ -17,11 +17,7 @@ pub async fn action_suspend( lifecycle_transition(¶ms.id, "suspend", client, cfg).await } -pub async fn action_archive( - params: &ArchiveParams, - client: &OdalClient, - cfg: &Config, -) -> Result<()> { +pub async fn action_retire(params: &RetireParams, client: &OdalClient, cfg: &Config) -> Result<()> { lifecycle_transition(¶ms.id, "archive", client, cfg).await } @@ -48,12 +44,12 @@ async fn lifecycle_transition( /// The outcome of a supersession, whichever route produced it. /// -/// Both routes retire one passport in favour of another and the pair is what an +/// Both routes supersede one passport in favour of another and the pair is what an /// operator needs back; they differ in which of the two they return, so this /// names both rather than "the passport". pub struct Supersession { /// The passport now in the terminal `superseded` state. - pub retired: String, + pub superseded: String, /// The passport that takes over. pub successor: String, } @@ -61,7 +57,7 @@ pub struct Supersession { /// Correct a published passport by issuing a successor. /// /// Returns `201` with the **successor**, which is a different record from the -/// one addressed — the opposite of `supersede`, which returns the retired +/// one addressed — the opposite of `supersede`, which returns the superseded /// predecessor. Both are reported here so the caller does not have to know /// which way round a given route answers. /// @@ -86,7 +82,7 @@ pub async fn action_amend( anyhow::bail!("amend failed: {}", describe_error(status, &response)); } Ok(Supersession { - retired: id.to_owned(), + superseded: id.to_owned(), successor: successor_id(&response)?, }) } @@ -95,9 +91,9 @@ pub async fn action_amend( /// /// A missing id is an error rather than a placeholder. Falling back to one made /// a failure indistinguishable from a success: the caller was handed a -/// `Supersession` reporting that the passport had been retired in favour of +/// `Supersession` reporting that the passport had been superseded in favour of /// `(not reported)`, printed as if it were an id, and the operator was left -/// holding a retirement whose successor they could not name. +/// holding a supersession whose successor they could not name. /// /// The status was `201` before this is reached, so the amend HAS been applied /// and the successor does exist — which is why this cannot degrade quietly. The @@ -120,7 +116,7 @@ fn successor_id(response: &str) -> Result { /// Retire a published passport in favour of one that already exists. /// /// The link is **checked** here, not written: the successor must already carry -/// `supersedesId` back to the passport being retired, set when it was created. +/// `supersedesId` back to the passport being superseded, set when it was created. /// A successor that does not is refused with `422`, and the message says so. pub async fn action_supersede( id: &str, @@ -139,7 +135,7 @@ pub async fn action_supersede( anyhow::bail!("supersede failed: {}", describe_error(status, &response)); } Ok(Supersession { - retired: id.to_owned(), + superseded: id.to_owned(), successor: superseded_by.to_owned(), }) } diff --git a/cli/src/core/passport/mod.rs b/cli/src/core/passport/mod.rs index 84e90cbb..4bcff27c 100644 --- a/cli/src/core/passport/mod.rs +++ b/cli/src/core/passport/mod.rs @@ -16,7 +16,7 @@ pub use export::action_export; pub use import::action_import; pub use inspect::{action_eol, action_find_by_identity, action_lint, action_verify_tree}; pub use lifecycle::{ - Supersession, action_amend, action_archive, action_history, action_supersede, action_suspend, + Supersession, action_amend, action_history, action_retire, action_supersede, action_suspend, }; pub use list::{action_get, action_list}; pub use publish::action_publish; diff --git a/cli/src/core/types.rs b/cli/src/core/types.rs index 9ef099b2..f289fad0 100644 --- a/cli/src/core/types.rs +++ b/cli/src/core/types.rs @@ -109,7 +109,7 @@ pub struct SuspendParams { pub id: String, } -pub struct ArchiveParams { +pub struct RetireParams { pub id: String, } @@ -485,7 +485,7 @@ mod tests { let node = posture(&[ ("seal", "ghost"), ("compliance", "sandbox"), - ("archive", "live"), + ("backup", "live"), ]); assert_eq!(node.ghost_ports(), vec!["seal"]); } diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index a24c95f0..3309b006 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -23,7 +23,7 @@ use crate::commands::{ init::run_init, inspect::{run_eol, run_find, run_lint, run_tree}, key::{run_key_create, run_key_list, run_key_revoke, run_key_use}, - lifecycle::{run_amend, run_archive, run_history, run_supersede, run_suspend}, + lifecycle::{run_amend, run_history, run_retire, run_supersede, run_suspend}, list::run_passport_list, operator::{run_operator_set, run_operator_show}, operator_id::{ @@ -327,8 +327,8 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { command: PassportCommands::Suspend { id }, } => run_suspend(&id).await, Commands::Passport { - command: PassportCommands::Archive { id }, - } => run_archive(&id).await, + command: PassportCommands::Retire { id }, + } => run_retire(&id).await, Commands::Passport { command: PassportCommands::History { id }, } => run_history(&id).await, diff --git a/cli/src/stateless/render.rs b/cli/src/stateless/render.rs index ce322307..ef60b40e 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -156,7 +156,7 @@ fn render_trust_posture(node: &NodeState) { if !ghosts.is_empty() { // Deliberately not "carries no legal weight". That is exactly right for // `seal`, whose whole purpose is an eIDAS qualified seal, and wrong for - // a port like `archive`, which underwrites a durability obligation + // a port like `backup`, which underwrites a durability obligation // rather than producing anything that bears legal weight itself. One // sentence has to hold for every port, so it says what is true of all // of them: the service is simulated, so its output is not usable. diff --git a/crates/dpp-common/src/event.rs b/crates/dpp-common/src/event.rs index 913bdef5..bbfdf4b5 100644 --- a/crates/dpp-common/src/event.rs +++ b/crates/dpp-common/src/event.rs @@ -82,7 +82,7 @@ pub mod subjects { /// A published passport was replaced by a corrected successor. The payload /// carries `successorId`; the superseded record stays resolvable. pub const PASSPORT_SUPERSEDED: &str = "dpp.passport.superseded"; - pub const PASSPORT_ARCHIVED: &str = "dpp.passport.archived"; + pub const PASSPORT_RETIRED: &str = "dpp.passport.retired"; pub const PASSPORT_DEACTIVATED: &str = "dpp.passport.deactivated"; pub const PASSPORT_TRANSFERRED: &str = "dpp.passport.transferred"; pub const PASSPORT_FAILED: &str = "dpp.passport.failed"; diff --git a/crates/dpp-common/src/event_codes.rs b/crates/dpp-common/src/event_codes.rs index 22c34a58..d1ca7954 100644 --- a/crates/dpp-common/src/event_codes.rs +++ b/crates/dpp-common/src/event_codes.rs @@ -47,7 +47,7 @@ pub const PLUGIN_MEM_CAPPED: &str = "PLUGIN_MEM_CAPPED"; // ── Data retention ─────────────────────────────────────────────────────────── -/// Archive request was blocked by the ESPR retention policy. +/// Retire request was blocked by the ESPR retention policy. pub const RETENTION_BLOCKED: &str = "RETENTION_BLOCKED"; /// An amendment published its successor but failed to supersede the predecessor, @@ -60,7 +60,7 @@ pub const SUPERSEDE_INCOMPLETE: &str = "SUPERSEDE_INCOMPLETE"; /// Registry sync failed after exhausting all retries, or the registry is /// permanently unreachable. Also used for status-enqueue failures on -/// suspend/archive/EOL (`dpp-vault::domain::service`) — these are non-fatal, +/// suspend/retire/EOL (`dpp-vault::domain::service`) — these are non-fatal, /// the local passport state is authoritative. pub const REGISTRY_SYNC_FAILED: &str = "REGISTRY_SYNC_FAILED"; @@ -73,7 +73,7 @@ pub const RULESET_LOAD_FAILED: &str = "RULESET_LOAD_FAILED"; // ── Trust / ghost-honesty guard ────────────────────────────────────────────── /// A production node refused to boot because a required trust port (seal, -/// registry sync, archive) resolved to a ghost. Fires in `dpp-node::main`, +/// registry sync, back-up copy) resolved to a ghost. Fires in `dpp-node::main`, /// immediately before the process exits — logged for boot-loop diagnosis. pub const TRUST_GHOST_BOOT_REFUSED: &str = "TRUST_GHOST_BOOT_REFUSED"; diff --git a/crates/dpp-dal/src/pg/repo_passport_version.rs b/crates/dpp-dal/src/pg/repo_passport_version.rs index 5d96d3b6..2360ccec 100644 --- a/crates/dpp-dal/src/pg/repo_passport_version.rs +++ b/crates/dpp-dal/src/pg/repo_passport_version.rs @@ -1,6 +1,6 @@ //! Historical versions of a passport — EN 18221:2026 clause 4.2 archiving. //! -//! 🚨 Not `PassportStatus::Archived`, which is a terminal lifecycle state. See +//! 🚨 Not `PassportStatus::Retired`, which is a terminal lifecycle state. See //! `ops/pg/0040_passport_version.sql` for why both wear the word. use async_trait::async_trait; diff --git a/crates/dpp-dal/src/pg/repo_registry_sync.rs b/crates/dpp-dal/src/pg/repo_registry_sync.rs index 0fd81602..62a08879 100644 --- a/crates/dpp-dal/src/pg/repo_registry_sync.rs +++ b/crates/dpp-dal/src/pg/repo_registry_sync.rs @@ -110,7 +110,7 @@ impl RegistrySyncOutbox for PgRegistrySyncRepo { // // Annotates an existing row; it never creates one. Rows are created by // the publish transaction alone, so no row means the passport never - // published — and `Draft -> Archived` is legal, so `archive` reaches here + // published — and `Draft -> Retired` is legal, so `retire` reaches here // for exactly those. Inserting would fabricate a payload-less row that // the drain then marks `rejected`, raising an Art. 13 alarm for a // passport that never owed a registration. diff --git a/crates/dpp-dal/src/pg/repo_snapshot.rs b/crates/dpp-dal/src/pg/repo_snapshot.rs index 8da0d4d0..9dd21aa4 100644 --- a/crates/dpp-dal/src/pg/repo_snapshot.rs +++ b/crates/dpp-dal/src/pg/repo_snapshot.rs @@ -109,7 +109,7 @@ impl SnapshotOutbox for PgSnapshotOutboxRepo { // an INSERT ... ON CONFLICT would describe a case that cannot arise. // // `p.status = 'active'` — the DB spelling of `Published` — is what keeps - // the pass bounded. Without it every suspended, archived and + // the pass bounded. Without it every suspended, retired and // deactivated passport would be re-armed on every cycle for the rest of // the deployment's life, and each one would drive a `remove()` against // an object that is already gone. The set of passports needing renewal diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index b2f07db9..24bf6526 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -1088,7 +1088,7 @@ async fn t18_snapshot_refresh_renews_only_live_stale_snapshots() { // The bounded-corpus property, and the one most easily lost: a withdrawn // passport has nothing in the public tier to renew. Without the status - // filter every suspended, archived and deactivated passport would be + // filter every suspended, retired and deactivated passport would be // re-armed on every scan for the life of the deployment, each one driving a // delete against an object that is already gone. outbox.mark_reconciled(due[0].id).await.expect("reconcile"); diff --git a/crates/dpp-dal/tests/pg_passport_version.rs b/crates/dpp-dal/tests/pg_passport_version.rs index f73c930a..2276d98a 100644 --- a/crates/dpp-dal/tests/pg_passport_version.rs +++ b/crates/dpp-dal/tests/pg_passport_version.rs @@ -2,7 +2,7 @@ //! //! ✅ COMPLIANCE-PIN: EN 18221:2026 clause 4.2 (archiving). //! -//! 🚨 Not `PassportStatus::Archived`, the terminal lifecycle state. The two wear +//! 🚨 Not `PassportStatus::Retired`, the terminal lifecycle state. The two wear //! the same word and mean unrelated things — see the migration's header. //! //! What is here rather than in a unit test, and why each one has to be: diff --git a/crates/dpp-node/Cargo.toml b/crates/dpp-node/Cargo.toml index bbad9207..c029f784 100644 --- a/crates/dpp-node/Cargo.toml +++ b/crates/dpp-node/Cargo.toml @@ -65,7 +65,7 @@ thiserror = { workspace = true } aws-sdk-s3 = { workspace = true, optional = true } [features] -# ESPR Art. 13 archive + continuity-snapshot S3/MinIO adapters. Off by +# ESPR Art. 10(4) back-up copy + continuity-snapshot S3/MinIO adapters. Off by # default because object storage is an optional deployment dependency for a # self-hosted node, not because of an advisory: the AWS SDK's vulnerable TLS # edge was removed outright in the workspace `aws-sdk-s3` feature list (see @@ -74,8 +74,8 @@ aws-sdk-s3 = { workspace = true, optional = true } # feature on (see docker/node.Dockerfile), so gating it does not reduce # shipped risk and must never be relied on to. s3 = ["dep:aws-sdk-s3"] -# Pulls in `s3` so the S3-backed integration tests (crates/dpp-node/tests/s3_archive.rs) -# exercise the real adapter, not just NoOpArchive. +# Pulls in `s3` so the S3-backed integration tests (crates/dpp-node/tests/s3_backup.rs) +# exercise the real adapter, not just NoOpBackup. integration-tests = ["s3"] [dev-dependencies] diff --git a/crates/dpp-node/src/boot/trust.rs b/crates/dpp-node/src/boot/trust.rs index 237a39cc..e9a6b56c 100644 --- a/crates/dpp-node/src/boot/trust.rs +++ b/crates/dpp-node/src/boot/trust.rs @@ -14,7 +14,7 @@ use dpp_types::trust::{NodeProfile, NodeTrustReport, TrustMode, TrustPort}; pub fn build_and_enforce( seal_trust: TrustMode, registry_trust: TrustMode, - archive_trust: TrustMode, + backup_trust: TrustMode, credential_trust: TrustMode, compliance_trust: TrustMode, ) -> anyhow::Result> { @@ -44,8 +44,8 @@ pub fn build_and_enforce( required: true, }, TrustPort { - port: "archive", - mode: archive_trust, + port: "backup", + mode: backup_trust, required: false, }, // Not required: a node serving only public passport views is fully diff --git a/crates/dpp-node/src/infra/mod.rs b/crates/dpp-node/src/infra/mod.rs index a4a855d0..b04d8137 100644 --- a/crates/dpp-node/src/infra/mod.rs +++ b/crates/dpp-node/src/infra/mod.rs @@ -1,4 +1,4 @@ -//! Infrastructure adapters: NATS event bus, PostgreSQL job store, EU registry sync, S3 archive. +//! Infrastructure adapters: NATS event bus, PostgreSQL job store, EU registry sync, S3 back-up copy. pub mod credential_issuance; pub mod credential_issuers; @@ -8,7 +8,7 @@ pub mod pg_job_store; pub mod registry; pub mod registry_drain; pub mod ruleset; -pub mod s3_archive; +pub mod s3_backup; #[cfg(feature = "s3")] pub mod s3_snapshot; pub mod seal; diff --git a/crates/dpp-node/src/infra/s3_archive.rs b/crates/dpp-node/src/infra/s3_backup.rs similarity index 71% rename from crates/dpp-node/src/infra/s3_archive.rs rename to crates/dpp-node/src/infra/s3_backup.rs index e189bb7d..e2fad996 100644 --- a/crates/dpp-node/src/infra/s3_archive.rs +++ b/crates/dpp-node/src/infra/s3_backup.rs @@ -1,4 +1,7 @@ -//! S3/MinIO adapter implementing `ArchivePort` for ESPR Art. 13 DPP archival. +//! S3/MinIO adapter implementing `BackupCopyPort` — the ESPR Art. 10(4) +//! back-up copy, held by an Art. 2(32) independent provider. Not Art. 13, +//! which is the registry, and not EN 18221 clause 4.2 archiving, which is a +//! live passport's version history and lives in the DAL. //! //! Key scheme: `passports/{passport_id}/{sha256_hex}` — content-addressed, idempotent. //! Same content → same key. Different content (new version) → different key. @@ -7,13 +10,13 @@ //! //! | Variable | Required | Default | //! |--------------------------------|----------|---------------| -//! | `ARCHIVE_S3_BUCKET` | Yes | — | -//! | `ARCHIVE_S3_ACCESS_KEY_ID` | Yes | — | -//! | `ARCHIVE_S3_SECRET_ACCESS_KEY` | Yes | — | -//! | `ARCHIVE_S3_ENDPOINT` | No | real AWS | -//! | `ARCHIVE_S3_REGION` | No | `us-east-1` | +//! | `BACKUP_S3_BUCKET` | Yes | — | +//! | `BACKUP_S3_ACCESS_KEY_ID` | Yes | — | +//! | `BACKUP_S3_SECRET_ACCESS_KEY` | Yes | — | +//! | `BACKUP_S3_ENDPOINT` | No | real AWS | +//! | `BACKUP_S3_REGION` | No | `us-east-1` | //! -//! Set `ARCHIVE_S3_ENDPOINT` to a MinIO URL (e.g. `http://localhost:9000`) for +//! Set `BACKUP_S3_ENDPOINT` to a MinIO URL (e.g. `http://localhost:9000`) for //! local dev. Leave it unset to target real AWS S3, Cloudflare R2, or Hetzner. use async_trait::async_trait; @@ -21,25 +24,28 @@ use chrono::Utc; use dpp_domain::{ error::DppError, passport::{Passport, PassportId}, - ports::archive::{ArchivePort, ArchiveReceipt, ArchiveStatus, ArchiveVerification}, + ports::backup::{BackupCopyPort, BackupReceipt, BackupStatus, BackupVerification}, }; -/// Build the archive adapter from env: the real S3 adapter if the `s3` build -/// feature is enabled and `ARCHIVE_S3_BUCKET` is set, [`NoOpArchive`] otherwise. -pub fn from_env() -> (std::sync::Arc, dpp_types::trust::TrustMode) { +/// Build the back-up adapter from env: the real S3 adapter if the `s3` build +/// feature is enabled and `BACKUP_S3_BUCKET` is set, [`NoOpBackup`] otherwise. +pub fn from_env() -> ( + std::sync::Arc, + dpp_types::trust::TrustMode, +) { #[cfg(feature = "s3")] - if let Some(cfg) = S3ArchiveConfig::from_env() { - tracing::info!(bucket = %cfg.bucket, "ESPR archive: S3 adapter active"); + if let Some(cfg) = S3BackupConfig::from_env() { + tracing::info!(bucket = %cfg.bucket, "ESPR back-up copy: S3 adapter active"); return ( - std::sync::Arc::new(S3ArchiveAdapter::new(cfg)), + std::sync::Arc::new(S3BackupAdapter::new(cfg)), dpp_types::trust::TrustMode::Live, ); } tracing::info!( - "ESPR archive: no-op — set ARCHIVE_S3_BUCKET (and build with --features s3) to enable" + "ESPR back-up copy: no-op — set BACKUP_S3_BUCKET (and build with --features s3) to enable" ); ( - std::sync::Arc::new(NoOpArchive), + std::sync::Arc::new(NoOpBackup), dpp_types::trust::TrustMode::Ghost, ) } @@ -56,7 +62,7 @@ use sha2::{Digest, Sha256}; // ─── Config ────────────────────────────────────────────────────────────────── #[cfg(feature = "s3")] -pub struct S3ArchiveConfig { +pub struct S3BackupConfig { pub endpoint: Option, pub bucket: String, pub access_key_id: String, @@ -65,22 +71,22 @@ pub struct S3ArchiveConfig { } #[cfg(feature = "s3")] -impl S3ArchiveConfig { - /// Load from env vars. Returns `None` if `ARCHIVE_S3_BUCKET` is absent or empty. +impl S3BackupConfig { + /// Load from env vars. Returns `None` if `BACKUP_S3_BUCKET` is absent or empty. pub fn from_env() -> Option { - let bucket = std::env::var("ARCHIVE_S3_BUCKET") + let bucket = std::env::var("BACKUP_S3_BUCKET") .ok() .filter(|s| !s.is_empty())?; - let access_key_id = std::env::var("ARCHIVE_S3_ACCESS_KEY_ID") + let access_key_id = std::env::var("BACKUP_S3_ACCESS_KEY_ID") .ok() .filter(|s| !s.is_empty())?; - let secret_access_key = std::env::var("ARCHIVE_S3_SECRET_ACCESS_KEY") + let secret_access_key = std::env::var("BACKUP_S3_SECRET_ACCESS_KEY") .ok() .filter(|s| !s.is_empty())?; - let endpoint = std::env::var("ARCHIVE_S3_ENDPOINT") + let endpoint = std::env::var("BACKUP_S3_ENDPOINT") .ok() .filter(|s| !s.is_empty()); - let region = std::env::var("ARCHIVE_S3_REGION").unwrap_or_else(|_| "us-east-1".into()); + let region = std::env::var("BACKUP_S3_REGION").unwrap_or_else(|_| "us-east-1".into()); Some(Self { endpoint, bucket, @@ -94,14 +100,14 @@ impl S3ArchiveConfig { // ─── Adapter ───────────────────────────────────────────────────────────────── #[cfg(feature = "s3")] -pub struct S3ArchiveAdapter { +pub struct S3BackupAdapter { client: Client, bucket: String, } #[cfg(feature = "s3")] -impl S3ArchiveAdapter { - pub fn new(cfg: S3ArchiveConfig) -> Self { +impl S3BackupAdapter { + pub fn new(cfg: S3BackupConfig) -> Self { let credentials = Credentials::new( cfg.access_key_id, cfg.secret_access_key, @@ -174,39 +180,39 @@ impl S3ArchiveAdapter { #[cfg(feature = "s3")] #[async_trait] -impl ArchivePort for S3ArchiveAdapter { - async fn archive( +impl BackupCopyPort for S3BackupAdapter { + async fn store( &self, passport: &Passport, retention_years: u32, - ) -> Result { + ) -> Result { let (bytes, hash) = Self::hash(passport)?; let key = Self::object_key(passport.id, &hash); self.put(&key, bytes).await?; let now = Utc::now(); - Ok(ArchiveReceipt { - archive_id: key, + Ok(BackupReceipt { + backup_id: key, passport_id: passport.id, content_hash: hash, - archived_at: now, + stored_at: now, retention_until: now + chrono::Duration::days(365 * retention_years as i64), }) } - async fn update_archive(&self, passport: &Passport) -> Result { + async fn update(&self, passport: &Passport) -> Result { // New content → new hash → new key. All versions coexist in the bucket - // (append-only archive). `retrieve()` returns the most recently written. + // (append-only store). `retrieve()` returns the most recently written. let (bytes, hash) = Self::hash(passport)?; let key = Self::object_key(passport.id, &hash); self.put(&key, bytes).await?; let now = Utc::now(); - Ok(ArchiveReceipt { - archive_id: key, + Ok(BackupReceipt { + backup_id: key, passport_id: passport.id, content_hash: hash, - archived_at: now, + stored_at: now, retention_until: now + chrono::Duration::days(365 * 10), }) } @@ -215,7 +221,7 @@ impl ArchivePort for S3ArchiveAdapter { &self, passport_id: PassportId, expected_hash: &str, - ) -> Result { + ) -> Result { let key = Self::object_key(passport_id, expected_hash); let exists = self .client @@ -226,13 +232,13 @@ impl ArchivePort for S3ArchiveAdapter { .await .is_ok(); - Ok(ArchiveVerification { + Ok(BackupVerification { integrity_ok: exists, accessible: exists, status: if exists { - ArchiveStatus::Active + BackupStatus::Active } else { - ArchiveStatus::Expired + BackupStatus::Expired }, last_verified_at: Utc::now(), }) @@ -280,7 +286,7 @@ impl ArchivePort for S3ArchiveAdapter { .into_bytes(); let passport: Passport = serde_json::from_slice(&data) - .map_err(|e| DppError::Serialisation(format!("corrupt archive record: {e}")))?; + .map_err(|e| DppError::Serialisation(format!("corrupt back-up record: {e}")))?; Ok(Some(passport)) } @@ -288,40 +294,40 @@ impl ArchivePort for S3ArchiveAdapter { // ─── NoOp fallback ─────────────────────────────────────────────────────────── -/// Used when `ARCHIVE_S3_BUCKET` is not configured (dev / CI without object storage). -/// Logs a warning on every archive call and returns a stub receipt. -pub struct NoOpArchive; +/// Used when `BACKUP_S3_BUCKET` is not configured (dev / CI without object storage). +/// Logs a warning on every back-up call and returns a stub receipt. +pub struct NoOpBackup; #[async_trait] -impl ArchivePort for NoOpArchive { - async fn archive( +impl BackupCopyPort for NoOpBackup { + async fn store( &self, passport: &Passport, _retention_years: u32, - ) -> Result { + ) -> Result { tracing::warn!( passport_id = %passport.id, - "ESPR archive skipped — ARCHIVE_S3_BUCKET not configured" + "ESPR back-up copy skipped — BACKUP_S3_BUCKET not configured" ); - Ok(ArchiveReceipt { - archive_id: "no-op".into(), + Ok(BackupReceipt { + backup_id: "no-op".into(), passport_id: passport.id, content_hash: String::new(), - archived_at: Utc::now(), + stored_at: Utc::now(), retention_until: Utc::now() + chrono::Duration::days(365 * 10), }) } - async fn update_archive(&self, passport: &Passport) -> Result { + async fn update(&self, passport: &Passport) -> Result { tracing::warn!( passport_id = %passport.id, - "ESPR archive update skipped — ARCHIVE_S3_BUCKET not configured" + "ESPR back-up refresh skipped — BACKUP_S3_BUCKET not configured" ); - Ok(ArchiveReceipt { - archive_id: "no-op".into(), + Ok(BackupReceipt { + backup_id: "no-op".into(), passport_id: passport.id, content_hash: String::new(), - archived_at: Utc::now(), + stored_at: Utc::now(), retention_until: Utc::now() + chrono::Duration::days(365 * 10), }) } @@ -330,11 +336,11 @@ impl ArchivePort for NoOpArchive { &self, _passport_id: PassportId, _expected_hash: &str, - ) -> Result { - Ok(ArchiveVerification { + ) -> Result { + Ok(BackupVerification { integrity_ok: false, accessible: false, - status: ArchiveStatus::Expired, + status: BackupStatus::Expired, last_verified_at: Utc::now(), }) } diff --git a/crates/dpp-node/src/infra/s3_snapshot.rs b/crates/dpp-node/src/infra/s3_snapshot.rs index f54da64c..370c76e3 100644 --- a/crates/dpp-node/src/infra/s3_snapshot.rs +++ b/crates/dpp-node/src/infra/s3_snapshot.rs @@ -4,7 +4,7 @@ //! `validUntil` proof, to a **public** bucket under `{dpp_id}/public.json`, so a //! CDN or bucket-website can serve it under a stable //! path when the live node is unreachable. This bucket is deliberately separate -//! from the (private) ESPR Art. 13 archive bucket: snapshots are public by +//! from the (private) ESPR Art. 10(4) back-up bucket: snapshots are public by //! design, archives are not — never colocate them. //! //! # Configuration diff --git a/crates/dpp-node/src/infra/seal.rs b/crates/dpp-node/src/infra/seal.rs index 0189bf3b..ac490e0c 100644 --- a/crates/dpp-node/src/infra/seal.rs +++ b/crates/dpp-node/src/infra/seal.rs @@ -1,7 +1,7 @@ //! Selects the eIDAS qualified-seal backend from the environment. //! //! The composition root for sealing, in the same place every other adapter's -//! lives — `s3_archive`, `snapshot_store` and `credential_issuers` all resolve +//! lives — `s3_backup`, `snapshot_store` and `credential_issuers` all resolve //! themselves here rather than in `main`, so the binary asks for a port and does //! not learn how any of them are built. //! diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 56ec28b9..468964d6 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -18,7 +18,7 @@ use dpp_common::{ }; use dpp_crypto::keystore::KeyStore; use dpp_domain::{ - ports::archive::ArchivePort, ports::compliance::ComplianceRegistry, + ports::backup::BackupCopyPort, ports::compliance::ComplianceRegistry, ports::registry_sync::RegistrySyncPort, }; use dpp_identity_service::state::AppState as IdentityState; @@ -152,9 +152,9 @@ async fn main() -> anyhow::Result<()> { } }; - // ── ESPR Art. 13 archive (S3/MinIO or NoOp) ────────────────────────────── - let (archive, archive_trust): (Arc, TrustMode) = - dpp_node::infra::s3_archive::from_env(); + // ── ESPR Art. 10(4) back-up copy (S3/MinIO or NoOp) ───────────────────── + let (backup, backup_trust): (Arc, TrustMode) = + dpp_node::infra::s3_backup::from_env(); // Credential issuers: who may attest which audience. Ghost when unconfigured, // so a node that cannot grant credentialed access says so rather than @@ -182,7 +182,7 @@ async fn main() -> anyhow::Result<()> { let trust = boot::trust::build_and_enforce( seal_wiring.trust, registry_trust, - archive_trust, + backup_trust, credential_trust, plugins::compliance_trust(&plugin_host), )?; @@ -254,7 +254,7 @@ async fn main() -> anyhow::Result<()> { db.audit_repo.clone(), event_bus, registry_sync, - archive, + backup, operator, ) .with_registry_reader(db.operator_repo.clone()) diff --git a/crates/dpp-node/src/router.rs b/crates/dpp-node/src/router.rs index ee66aa01..e8d045b6 100644 --- a/crates/dpp-node/src/router.rs +++ b/crates/dpp-node/src/router.rs @@ -23,7 +23,7 @@ use dpp_vault::state::AppState as VaultState; /// Assemble the top-level node router by nesting each service's router. /// /// Route prefixes: -/// - `/vault` — DPP write engine (create, update, publish, archive) +/// - `/vault` — DPP write engine (create, update, publish, retire) /// - `/identity` — did:web identity management and signing /// - `/integrator` — CSV/Excel inbound adapter /// diff --git a/crates/dpp-node/tests/nats_event_bus.rs b/crates/dpp-node/tests/nats_event_bus.rs index e2cde824..764bce5e 100644 --- a/crates/dpp-node/tests/nats_event_bus.rs +++ b/crates/dpp-node/tests/nats_event_bus.rs @@ -166,11 +166,7 @@ async fn event_envelope_uses_camel_case_on_wire() { .await .expect("connect failed"); - let event = DppEvent::v1( - subjects::PASSPORT_ARCHIVED, - "op-wire", - serde_json::json!({}), - ); + let event = DppEvent::v1(subjects::PASSPORT_RETIRED, "op-wire", serde_json::json!({})); bus.publish(&event).await.unwrap(); let client = async_nats::connect(&url).await.unwrap(); @@ -179,7 +175,7 @@ async fn event_envelope_uses_camel_case_on_wire() { let consumer = stream .create_consumer(consumer::pull::Config { durable_name: Some("test-wire-format".to_string()), - filter_subject: "dpp.passport.archived".to_string(), + filter_subject: "dpp.passport.retired".to_string(), ..Default::default() }) .await diff --git a/crates/dpp-node/tests/openapi_contract.rs b/crates/dpp-node/tests/openapi_contract.rs index c257595d..f6b596f7 100644 --- a/crates/dpp-node/tests/openapi_contract.rs +++ b/crates/dpp-node/tests/openapi_contract.rs @@ -2176,7 +2176,6 @@ mod handler_sources { pub const VAULT: &[&str] = &[ include_str!("../../dpp-vault/src/handlers/amend.rs"), include_str!("../../dpp-vault/src/handlers/api_keys.rs"), - include_str!("../../dpp-vault/src/handlers/archive.rs"), include_str!("../../dpp-vault/src/handlers/audience_read.rs"), include_str!("../../dpp-vault/src/handlers/create.rs"), include_str!("../../dpp-vault/src/handlers/credentials.rs"), @@ -2197,6 +2196,7 @@ mod handler_sources { include_str!("../../dpp-vault/src/handlers/read.rs"), include_str!("../../dpp-vault/src/handlers/registry_identity.rs"), include_str!("../../dpp-vault/src/handlers/registry_status.rs"), + include_str!("../../dpp-vault/src/handlers/retire.rs"), include_str!("../../dpp-vault/src/handlers/ruleset.rs"), include_str!("../../dpp-vault/src/handlers/scan_ingest.rs"), include_str!("../../dpp-vault/src/handlers/seal.rs"), @@ -2825,7 +2825,7 @@ mod fixtures { PassportStatus::Draft, PassportStatus::Published, PassportStatus::Suspended, - PassportStatus::Archived, + PassportStatus::Retired, PassportStatus::Superseded, PassportStatus::Deactivated, ] diff --git a/crates/dpp-node/tests/registry_outbox.rs b/crates/dpp-node/tests/registry_outbox.rs index 20a31c9c..25f0d3d7 100644 --- a/crates/dpp-node/tests/registry_outbox.rs +++ b/crates/dpp-node/tests/registry_outbox.rs @@ -10,7 +10,7 @@ //! (c) a transient registry failure backs off (attempts++ , future retry), //! keeping the row `pending`; //! (d) a terminal rejection marks the row `rejected` (alarm), never dropped; -//! (e) suspend/archive enqueue a durable status intent. +//! (e) suspend/retire enqueue a durable status intent. //! //! And that a status intent is kept strictly out of the registration queue //! state (`ops/pg/0024`), since conflating them silently dropped registrations: @@ -527,7 +527,7 @@ async fn suspend_enqueues_status_intent_and_counts_reflect_state() { // ─── (h) a passport that was never published owes no registration ───────────── -/// `Draft -> Archived` is a legal transition, so `archive` reaches +/// `Draft -> Retired` is a legal transition, so `retire` reaches /// `enqueue_status` for passports that never published and therefore have no /// outbox row. Recording an intent must not invent one: a fabricated row has no /// payload, so the drain would mark it `rejected` and raise an Art. 13 alarm for diff --git a/crates/dpp-node/tests/s3_archive.rs b/crates/dpp-node/tests/s3_backup.rs similarity index 94% rename from crates/dpp-node/tests/s3_archive.rs rename to crates/dpp-node/tests/s3_backup.rs index 8869e17f..9e297ef1 100644 --- a/crates/dpp-node/tests/s3_archive.rs +++ b/crates/dpp-node/tests/s3_backup.rs @@ -1,4 +1,4 @@ -//! Integration test: `S3ArchiveAdapter` against a real MinIO instance. +//! Integration test: `S3BackupAdapter` against a real MinIO instance. //! //! Run: `cargo test -p dpp-node --features integration-tests` @@ -13,11 +13,11 @@ use testcontainers::{ use chrono::Utc; use dpp_domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - ports::archive::ArchivePort, + ports::backup::BackupCopyPort, product_group::ProductGroup, status::PassportStatus, }; -use dpp_node::infra::s3_archive::{S3ArchiveAdapter, S3ArchiveConfig}; +use dpp_node::infra::s3_backup::{S3BackupAdapter, S3BackupConfig}; /// The MinIO build every path here tests against. /// @@ -118,8 +118,8 @@ async fn start_minio() -> Minio { } } -fn build_adapter(minio: &Minio) -> S3ArchiveAdapter { - S3ArchiveAdapter::new(S3ArchiveConfig { +fn build_adapter(minio: &Minio) -> S3BackupAdapter { + S3BackupAdapter::new(S3BackupConfig { endpoint: Some(minio.endpoint.clone()), bucket: minio.bucket.clone(), access_key_id: "minioadmin".into(), @@ -184,10 +184,10 @@ async fn archive_then_verify_integrity() { adapter.ensure_bucket().await.expect("create bucket"); let passport = make_passport(); - let receipt = adapter.archive(&passport, 10).await.expect("archive"); + let receipt = adapter.store(&passport, 10).await.expect("back up"); assert!(!receipt.content_hash.is_empty()); - assert!(receipt.archive_id.starts_with("passports/")); + assert!(receipt.backup_id.starts_with("passports/")); let verification = adapter .verify(passport.id, &receipt.content_hash) @@ -205,7 +205,7 @@ async fn verify_wrong_hash_returns_not_ok() { adapter.ensure_bucket().await.expect("create bucket"); let passport = make_passport(); - adapter.archive(&passport, 10).await.expect("archive"); + adapter.store(&passport, 10).await.expect("back up"); let v = adapter .verify(passport.id, "deadbeefdeadbeef") @@ -221,7 +221,7 @@ async fn retrieve_returns_original_passport() { adapter.ensure_bucket().await.expect("create bucket"); let passport = make_passport(); - adapter.archive(&passport, 10).await.expect("archive"); + adapter.store(&passport, 10).await.expect("back up"); let retrieved = adapter .retrieve(passport.id) diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 1b644124..5493cbc9 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -55,7 +55,7 @@ use dpp_domain::ports::passport_repo::PassportRepository; use dpp_domain::product_group::ProductGroup; use dpp_domain::seal::SealMode; use dpp_domain::status::PassportStatus; -use dpp_domain::{GhostArchive, GhostRegistrySync}; +use dpp_domain::{GhostBackup, GhostRegistrySync}; use dpp_domain::{ports::seal::SealPort, seal::SealConformanceLevel}; use dpp_node::infra::seal_drain::drain_once; use dpp_seal::QtspSealAdapter; @@ -311,7 +311,7 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { Arc::new(PgAuditRepo::new(dal.clone())), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "MK".to_owned(), @@ -478,7 +478,7 @@ async fn a_republish_needs_and_gets_its_own_seal() { Arc::new(PgAuditRepo::new(dal.clone())), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "MK".to_owned(), @@ -656,7 +656,7 @@ async fn a_locally_sealed_passport_reports_that_no_provider_issued_it() { Arc::new(PgAuditRepo::new(dal.clone())), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "MK".to_owned(), @@ -1016,7 +1016,7 @@ async fn a_seal_corrupted_at_rest_is_found_and_repaired() { Arc::new(PgAuditRepo::new(dal.clone())), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "MK".to_owned(), @@ -1520,7 +1520,7 @@ async fn a_seal_made_under_an_invalid_certificate_is_found_and_not_called_broken Arc::new(PgAuditRepo::new(dal.clone())), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "MK".to_owned(), diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index 47da3060..b976c7b1 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -24,7 +24,7 @@ use dpp_dal::pg::{ }; use dpp_dal::test_harness::{TestPg, start_pg}; use dpp_domain::passport::PassportRef; -use dpp_domain::{DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry}; +use dpp_domain::{DppError, GhostBackup, GhostRegistrySync, PassthroughRegistry}; use dpp_identity_service::state::AppState as IdentityState; use dpp_integrator::{infra::vault_client::VaultHttpClient, state::AppState as IntegratorState}; use dpp_node::infra::credential_issuance::KeyStoreCredentialIssuer; @@ -180,7 +180,7 @@ async fn start_node_with_ruleset( audit_repo, event_bus, registry_sync, - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "DE".to_owned(), diff --git a/crates/dpp-node/tests/snapshot_outbox.rs b/crates/dpp-node/tests/snapshot_outbox.rs index 75495323..9c7e0ffe 100644 --- a/crates/dpp-node/tests/snapshot_outbox.rs +++ b/crates/dpp-node/tests/snapshot_outbox.rs @@ -597,7 +597,7 @@ fn the_two_halves_account_for_every_status() { const RETIRED: &[PassportStatus] = &[PassportStatus::Suspended, PassportStatus::Draft]; const SERVED: &[PassportStatus] = &[ PassportStatus::Published, - PassportStatus::Archived, + PassportStatus::Retired, PassportStatus::Superseded, PassportStatus::Deactivated, ]; @@ -627,7 +627,7 @@ fn the_two_halves_account_for_every_status() { #[tokio::test] async fn drain_keeps_a_retired_passport_that_still_serves() { for status in [ - PassportStatus::Archived, + PassportStatus::Retired, PassportStatus::Superseded, PassportStatus::Deactivated, ] { diff --git a/crates/dpp-render/src/page.rs b/crates/dpp-render/src/page.rs index a88298a5..92e77df8 100644 --- a/crates/dpp-render/src/page.rs +++ b/crates/dpp-render/src/page.rs @@ -115,7 +115,7 @@ pub fn render_page( .badge-active,.badge-published{{background:#d1fae5;color:#065f46}} .badge-draft{{background:#fef3c7;color:#92400e}} .badge-suspended{{background:#fee2e2;color:#991b1b}} - .badge-archived{{background:#e5e7eb;color:#374151}} + .badge-retired{{background:#e5e7eb;color:#374151}} table{{width:100%;border-collapse:collapse;margin-top:.5rem}} th,td{{text-align:left;padding:.5rem .4rem;border-bottom:1px solid #f3f4f6;vertical-align:top}} th{{width:44%;color:#6b7280;font-weight:500;font-size:.875rem}} @@ -288,6 +288,32 @@ mod tests { ); } + /// The badge class is `badge-{status}` interpolated from the status string, + /// so a renamed status silently loses its styling: the rule is still in the + /// stylesheet under the old name, the class on the element is the new one, + /// and nothing fails — the badge just renders unstyled on the one page a + /// consumer actually sees. That is exactly what `archived` → `retired` did + /// until this test was written. + #[test] + fn the_retired_badge_has_a_style_rule_to_match_its_class() { + let mut passport = unredacted_passport(); + passport["status"] = serde_json::json!("retired"); + let html = render_page( + DPP_ID, + &passport, + "https://id.odal-node.io", + SnapshotNotice::Live, + ); + assert!( + html.contains("badge-retired"), + "the element must carry the class" + ); + assert!( + html.contains(".badge-retired{"), + "and the stylesheet must define it, or the badge renders unstyled" + ); + } + #[test] fn missing_fields_fall_back_to_placeholders() { let html = render_page( diff --git a/crates/dpp-resolver/src/config.rs b/crates/dpp-resolver/src/config.rs index 0543412e..0c005cb5 100644 --- a/crates/dpp-resolver/src/config.rs +++ b/crates/dpp-resolver/src/config.rs @@ -17,7 +17,7 @@ pub struct Config { /// cross-service hook to evict it on suspend. A short default keeps that /// recall-blind window small; raise it only with an explicit acceptance of /// the recall-propagation latency. The complete fix is event-driven - /// invalidation (vault publishes a cache-bust on suspend/archive). + /// invalidation (vault publishes a cache-bust on suspend/retire). pub cache_ttl_secs: u64, /// Port to listen on (default: 8003) diff --git a/crates/dpp-types/src/api_key.rs b/crates/dpp-types/src/api_key.rs index 37fd913d..369b05ac 100644 --- a/crates/dpp-types/src/api_key.rs +++ b/crates/dpp-types/src/api_key.rs @@ -13,7 +13,7 @@ use uuid::Uuid; /// Scopes give operators a least-privilege path for integration/partner keys. /// /// - `Read` — read-only access (GET passport/operator/key listings). -/// - `Write` — passport lifecycle (create/update/publish/suspend/archive) but +/// - `Write` — passport lifecycle (create/update/publish/suspend/retire) but /// NOT key management or operator-config mutation. /// - `Admin` — everything, including `/api-keys` and operator-config `PATCH`. /// diff --git a/crates/dpp-types/src/audit.rs b/crates/dpp-types/src/audit.rs index 3d649db0..c55529ef 100644 --- a/crates/dpp-types/src/audit.rs +++ b/crates/dpp-types/src/audit.rs @@ -25,7 +25,7 @@ pub struct PassportAuditEntry { pub passport_id: String, /// Who triggered this change, stamped from `AuthContext` at the call site. pub actor: String, - /// Machine-readable action code, e.g. `"create"`, `"publish"`, `"archive"`. + /// Machine-readable action code, e.g. `"create"`, `"publish"`, `"retired"`. pub action: String, /// Passport status before the transition, if applicable. pub previous_status: Option, @@ -323,7 +323,7 @@ mod tests { #[test] fn tampered_content_breaks_at_exact_index() { - let mut es = [entry("created"), entry("published"), entry("archived")]; + let mut es = [entry("created"), entry("published"), entry("retired")]; chain(&mut es); es[1].new_status = Some("suspended".into()); // flip content, keep stored hash let brk = verify_audit_chain(&es).expect_err("tamper must be detected"); @@ -375,7 +375,7 @@ mod tests { /// /// ✅ COMPLIANCE-PIN: EN 18221:2026 clause 4.2. /// -/// 🚨 Nothing to do with `PassportStatus::Archived`, which is a terminal +/// 🚨 Nothing to do with `PassportStatus::Retired`, which is a terminal /// lifecycle state. This is the standard's sense of the word: historical /// versions of a passport that is still live. See /// `ops/pg/0040_passport_version.sql` for why both wear the name. diff --git a/crates/dpp-types/src/registry_sync.rs b/crates/dpp-types/src/registry_sync.rs index 1f6f148c..f7711bc4 100644 --- a/crates/dpp-types/src/registry_sync.rs +++ b/crates/dpp-types/src/registry_sync.rs @@ -115,7 +115,7 @@ impl RegistrySyncStatus { /// `status_intent` column independently of the registration queue state. /// /// Recorded by [`RegistrySyncOutbox::enqueue_status`] when a passport is -/// suspended, archived, or declared end-of-life. The registry has no published +/// suspended, retired, or declared end-of-life. The registry has no published /// status-push API yet, so nothing drains these — they are kept durably for /// that path and cleared when a passport is re-published. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -123,7 +123,7 @@ impl RegistrySyncStatus { pub enum RegistryStatusIntent { /// Passport suspended (reversible — cleared on re-publish). Suspended, - /// Passport deactivated (archive/EOL). + /// Passport deactivated (retire/EOL). Deactivated, } @@ -230,7 +230,7 @@ pub trait RegistrySyncOutbox: Send + Sync { /// A no-op when the passport has no row — deliberately *not* an error. /// [`RegistrySyncOutbox::commit_publish`] is the only thing that creates /// rows, so no row means the passport never published and owes no - /// registration; `Draft -> Archived` is legal, so this is a normal path, not + /// registration; `Draft -> Retired` is legal, so this is a normal path, not /// a missing-row bug. (A passport published before this outbox existed also /// has no row and no payload to register from. Surfacing those belongs in a /// reconciliation query over Published passports lacking a row — not in a diff --git a/crates/dpp-types/src/snapshot.rs b/crates/dpp-types/src/snapshot.rs index 5a64afae..ed1b57c5 100644 --- a/crates/dpp-types/src/snapshot.rs +++ b/crates/dpp-types/src/snapshot.rs @@ -28,8 +28,8 @@ //! availability is purely operational — the DPP standard defines the public //! view, not how a given node keeps it reachable. So this port stays engine-side //! alongside `RegistrySyncOutbox` and `WebhookOutbox`, never promoted to a core -//! port. (`ArchivePort` is a separate, core-side concern: immutable Art. 13 -//! retention, not a mutable availability mirror.) +//! port. (`BackupCopyPort` is a separate, core-side concern: the Art. 10(4) +//! back-up copy, not a mutable availability mirror.) //! //! Two ports live here: [`SnapshotStore`] is the object-storage sink, and //! [`SnapshotOutbox`] is the durable queue that decides *when* to drive it. See @@ -113,7 +113,7 @@ pub struct SnapshotMeta { /// alone cannot make it: that proof is frozen and says nothing about when the /// copy was taken. `put` overwrites (the view is re-rendered on each reconcile /// and on each refresh); `remove` retires a snapshot when the passport leaves -/// the public tier (suspend/archive), so the static tier never keeps serving +/// the public tier (suspend/retire), so the static tier never keeps serving /// `active` for a suspended passport. #[async_trait] pub trait SnapshotStore: Send + Sync { @@ -235,7 +235,7 @@ pub struct SnapshotOutboxCounts { /// /// Enqueue is **after-commit**, matching `WebhookOutbox` rather than /// `RegistrySyncOutbox`'s in-transaction coupling: the status-change paths -/// (`suspend`/`archive`/`declare_eol`) have no transaction to join — they +/// (`suspend`/`retire`/`declare_eol`) have no transaction to join — they /// already enqueue their EU-registry status intent best-effort — so making the /// snapshot strictly stronger on the identical code path would be incoherent. /// Once a row exists it is loss-proof: failures back off and stay `pending`, so diff --git a/crates/dpp-types/src/trust.rs b/crates/dpp-types/src/trust.rs index c28660b1..3527cca4 100644 --- a/crates/dpp-types/src/trust.rs +++ b/crates/dpp-types/src/trust.rs @@ -1,7 +1,7 @@ //! Ghost-honesty invariant. //! //! The system refuses to present placeholder trust as real trust. Every trust -//! port (seal, registry sync, archive, …) reports the *tier* that produced it — +//! port (seal, registry sync, back-up copy, …) reports the *tier* that produced it — //! `Ghost` (placeholder), `Sandbox` (real service, non-production), or `Live` — //! and a production node **fails to boot** if a required port resolved to a //! ghost. The guard is list-driven: a newly-added port inherits the invariant @@ -114,7 +114,7 @@ impl NodeProfile { /// One resolved trust port and the tier it operates at. #[derive(Debug, Clone, Copy, Serialize)] pub struct TrustPort { - /// Stable port name (`"seal"`, `"registry_sync"`, `"archive"`). + /// Stable port name (`"seal"`, `"registry_sync"`, `"backup"`). pub port: &'static str, /// The tier the resolved adapter operates at. pub mode: TrustMode, @@ -227,7 +227,7 @@ impl NodeTrustReport { mod tests { use super::*; - fn ports(seal: TrustMode, registry: TrustMode, archive: TrustMode) -> Vec { + fn ports(seal: TrustMode, registry: TrustMode, backup: TrustMode) -> Vec { vec![ TrustPort { port: "seal", @@ -240,8 +240,8 @@ mod tests { required: true, }, TrustPort { - port: "archive", - mode: archive, + port: "backup", + mode: backup, required: false, }, ] @@ -265,8 +265,8 @@ mod tests { err.contains("seal"), "message names the offending port: {err}" ); - // archive is Ghost but not required → not a blocker. - assert!(!err.contains("archive")); + // backup is Ghost but not required → not a blocker. + assert!(!err.contains("backup")); assert_eq!(report.ghosted_required(), vec!["seal"]); } @@ -327,7 +327,7 @@ mod tests { assert_eq!(j["profile"], "production"); assert_eq!(j["trustMode"]["seal"], "ghost"); assert_eq!(j["trustMode"]["registry_sync"], "sandbox"); - assert_eq!(j["trustMode"]["archive"], "live"); + assert_eq!(j["trustMode"]["backup"], "live"); } /// A production node refuses a **sandbox** tier, not only a ghost. diff --git a/crates/dpp-vault/src/domain/service/amend.rs b/crates/dpp-vault/src/domain/service/amend.rs index bbfd7f0e..34adb6f8 100644 --- a/crates/dpp-vault/src/domain/service/amend.rs +++ b/crates/dpp-vault/src/domain/service/amend.rs @@ -82,7 +82,7 @@ impl PassportService { let predecessor = self.find_by_id(id).await?; // `Published` is the only state that can be superseded — a draft is - // edited in place, and `Suspended`, `Archived`, `Superseded` and + // edited in place, and `Suspended`, `Retired`, `Superseded` and // `Deactivated` are either reversible or terminal by another route. // Asking the state machine rather than matching on the variant keeps // this in step with `can_transition_to` if the table ever widens. @@ -244,12 +244,12 @@ impl PassportService { ) .await; - // A superseded passport keeps serving publicly, like an archived or + // A superseded passport keeps serving publicly, like a retired or // deactivated one — products made under the old specification are still // in the field carrying carriers that resolve to it. The reconcile is // still needed, and for the opposite reason to a withdrawal: the stored // snapshot has to be refreshed to carry the new status rather than the - // old one, not removed. Same reconcile `suspend` and `archive` do, and + // old one, not removed. Same reconcile `suspend` and `retire` do, and // non-fatal for the same reason: the database is the source of truth. self.enqueue_snapshot_reconcile(superseded.id).await; diff --git a/crates/dpp-vault/src/domain/service/evidence.rs b/crates/dpp-vault/src/domain/service/evidence.rs index 2a2c9095..772674e8 100644 --- a/crates/dpp-vault/src/domain/service/evidence.rs +++ b/crates/dpp-vault/src/domain/service/evidence.rs @@ -97,7 +97,7 @@ impl PassportService { // The exact bytes that were signed, recovered from the most recent // "published" audit entry — never reconstructed from the current - // passport row, which may have since mutated (suspend/archive/eol + // passport row, which may have since mutated (suspend/retire/eol // change `status` without re-signing). See `publish.rs` for why this // metadata is stamped there. let (full_view_payload, public_view_payload) = audit_raw diff --git a/crates/dpp-vault/src/domain/service/lifecycle.rs b/crates/dpp-vault/src/domain/service/lifecycle.rs index b0a87a3a..d4b229ca 100644 --- a/crates/dpp-vault/src/domain/service/lifecycle.rs +++ b/crates/dpp-vault/src/domain/service/lifecycle.rs @@ -1,4 +1,4 @@ -//! `suspend`, `supersede` and `archive` — reversible and terminal passport +//! `suspend`, `supersede` and `retire` — reversible and terminal passport //! status transitions. //! //! `supersede` links a passport to an already-published replacement. It is the @@ -188,25 +188,25 @@ impl PassportService { .await } - /// Permanently archive a passport after retention expiry. + /// Permanently retire a passport after retention expiry. /// /// Blocked by the ESPR retention guard: if `retention_locked` is set and the /// product group's minimum retention period has not yet elapsed from `published_at`, - /// returns `DppError::Validation`. Emits `dpp.passport.archived`. + /// returns `DppError::Validation`. Emits `dpp.passport.retired`. #[tracing::instrument(skip(self), fields(passport_id = %id))] - pub async fn archive(&self, id: PassportId, auth: &AuthContext) -> Result { + pub async fn retire(&self, id: PassportId, auth: &AuthContext) -> Result { let passport = self.find_by_id(id).await?; - if !passport.status.can_transition_to(&PassportStatus::Archived) { + if !passport.status.can_transition_to(&PassportStatus::Retired) { return Err(DppError::InvalidTransition { current: passport.status.to_string(), - required: PassportStatus::Archived.to_string(), + required: PassportStatus::Retired.to_string(), }); } // ── Retention guard ───────────────────────────────────────────── // EU ESPR requires that published DPPs remain accessible for the - // period defined in the applicable delegated act. Archiving before + // period defined in the applicable delegated act. Retiring before // the retention period expires is blocked. if passport.retention_locked && let Some(published_at) = passport.published_at @@ -220,11 +220,11 @@ impl PassportService { code = event_codes::RETENTION_BLOCKED, passport_id = %id, retention_end = %retention_end.format("%Y-%m-%d"), - "archive blocked by retention policy" + "retirement blocked by retention policy" ); return Err(DppError::Validation( format!( - "retention policy forbids archiving before {}", + "retention policy forbids retiring before {}", retention_end.format("%Y-%m-%d") ) .into(), @@ -233,17 +233,14 @@ impl PassportService { } let prev_status = passport.status.to_string(); - let updated = self - .repo - .update_status(id, PassportStatus::Archived) - .await?; + let updated = self.repo.update_status(id, PassportStatus::Retired).await?; let entry = PassportAuditEntry::new( &updated.id.to_string(), - "archived", + "retired", &auth.user_id, Some(&prev_status), - Some(&PassportStatus::Archived.to_string()), + Some(&PassportStatus::Retired.to_string()), ); self.audit.append(entry).await?; @@ -263,16 +260,16 @@ impl PassportService { } self.emit( - event::subjects::PASSPORT_ARCHIVED, + event::subjects::PASSPORT_RETIRED, serde_json::json!({ "passportId": updated.id.to_string(), - "status": "archived", + "status": "retired", "previousStatus": prev_status, }), ) .await; - // Reconcile the continuity tier — an archived passport leaves the + // Reconcile the continuity tier — a retired passport leaves the // public tier (non-fatal). self.enqueue_snapshot_reconcile(updated.id).await; diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index 75b3ec3a..7d39ea1e 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -1,4 +1,4 @@ -//! Core domain service for the passport lifecycle (create → publish → suspend → archive). +//! Core domain service for the passport lifecycle (create → publish → suspend → retire). //! //! Split by lifecycle stage — each sibling file is one or more `impl //! PassportService` blocks for the same type. Every method here owes the @@ -12,7 +12,7 @@ //! - `publish` — `publish` and its private helpers `validate_schema_for_publish`/`build_carrier_url` //! - `amend` — `amend`: issue a corrected successor and supersede its predecessor //! - `lint` — `relint` (advisory lint re-check; never blocks publish) -//! - `lifecycle` — `suspend`, `archive` +//! - `lifecycle` — `suspend`, `retire` //! - `eol` — `declare_eol` //! - `transfer` — `initiate_transfer`, `accept_transfer` //! - `evidence` — `generate_evidence`/`list_evidence`/`get_evidence`/`verify_evidence` @@ -43,7 +43,7 @@ use std::sync::Arc; use dpp_common::event::{DppEvent, EventBus}; use dpp_domain::passport::PassportId; use dpp_domain::{ - ports::archive::ArchivePort, ports::compliance::ComplianceRegistry, + ports::backup::BackupCopyPort, ports::compliance::ComplianceRegistry, ports::identity::IdentityPort, ports::passport_repo::PassportRepository, ports::registry_sync::RegistrySyncPort, }; @@ -72,7 +72,7 @@ pub struct OperatorIdentity { /// Core domain service for the passport lifecycle. /// -/// Orchestrates create / update / publish / suspend / archive and history +/// Orchestrates create / update / publish / suspend / retire and history /// with audit logging, event emission, compliance enrichment, and EU registry sync. /// Single-tenant: the service has no tenant/operator scope — one service per node. pub struct PassportService { @@ -82,7 +82,7 @@ pub struct PassportService { pub audit: Arc, pub events: Arc, pub registry_sync: Arc, - pub archive: Arc, + pub backup: Arc, /// Transactional outbox for EU registry registration. When present (the /// Postgres node), publish persists the passport and enqueues its /// registration atomically, and a background drain task calls @@ -92,9 +92,11 @@ pub struct PassportService { pub registry_outbox: Option>, /// Archived versions of passports — EN 18221:2026 clause 4.2. /// - /// 🚨 Nothing to do with `archive` above, which is object storage, or with - /// the terminal `archived` lifecycle status. Three uses of one word in one - /// struct; this is the standard's, and the only one about history. + /// This is now the only thing in this struct called an archive, and it is + /// the standard's sense of the word: the history of a passport that is + /// still live. Two others wore it and no longer do — `backup` above is the + /// ESPR Art. 10(4) back-up copy, a copy rather than a history, and the + /// terminal lifecycle status is `Retired`. /// /// `None` disables the versions route. It does **not** disable archiving — /// that happens in the repository decorator, which a node wires or does @@ -127,7 +129,7 @@ pub struct PassportService { /// / deployments without webhooks) simply skips enqueue. pub webhooks: Option>, /// Durable reconcile queue for the static continuity tier. When present, - /// every change to a passport's public state (publish, suspend, archive, + /// every change to a passport's public state (publish, suspend, retire, /// end-of-life) enqueues a reconcile row after commit; the node's drain task /// re-derives and mirrors — or retires — the public view, so a published /// passport stays reachable under a stable path when the live node is down. @@ -144,7 +146,7 @@ pub struct PassportService { /// /// Independent of [`Self::seal_outbox`] on purpose: a node that no longer /// seals — the provider was dropped, or this is a standalone vault serving - /// archived passports — still serves seals it holds, and *those* are the ones + /// retired passports — still serves seals it holds, and *those* are the ones /// whose origin a reader most needs. Tying the two would make the answer /// disappear exactly when the seal is oldest and least self-explanatory. /// @@ -176,7 +178,7 @@ impl PassportService { audit: Arc, events: Arc, registry_sync: Arc, - archive: Arc, + backup: Arc, operator: OperatorIdentity, ) -> Self { Self { @@ -186,7 +188,7 @@ impl PassportService { audit, events, registry_sync, - archive, + backup, registry_outbox: None, versions: None, transfer_store: None, @@ -347,7 +349,7 @@ impl PassportService { } /// Queue a continuity-tier reconcile for a passport whose public state just - /// changed — publish, suspend, archive, or end-of-life alike. + /// changed — publish, suspend, retire, or end-of-life alike. /// /// Deliberately says only *which* passport changed, never *what to do*: the /// drain re-reads the passport and derives put-or-remove from its current diff --git a/crates/dpp-vault/src/domain/service/publish.rs b/crates/dpp-vault/src/domain/service/publish.rs index 04623c44..2b7a167d 100644 --- a/crates/dpp-vault/src/domain/service/publish.rs +++ b/crates/dpp-vault/src/domain/service/publish.rs @@ -414,7 +414,7 @@ impl PassportService { // Stamp the exact payloads that were signed (not the current row) as // metadata on this publish's audit entry. `jws_signature` and // `public_jws_signature` are frozen at this moment and never re-signed - // by later lifecycle transitions (suspend/archive/eol only touch + // by later lifecycle transitions (suspend/retire/eol only touch // `status`), so evidence dossier generation must recover *this* // snapshot rather than reconstruct one from the passport's current — // by then possibly mutated — row. A re-publish (Suspend -> Published) @@ -433,16 +433,16 @@ impl PassportService { })); self.audit.append(entry).await?; - // ESPR Art. 13 third-party archive — fire-after-commit, non-blocking. + // ESPR Art. 10(4) third-party back-up copy — fire-after-commit, non-blocking. // Failures are logged but never propagated; the DB write is the source of truth. - // Same resolver as the seal above, so the archived copy and the sealed + // Same resolver as the seal above, so the backed-up copy and the sealed // deadline cannot disagree. let retention_years = retention_years_for(&updated.product_group); - if let Err(e) = self.archive.archive(&updated, retention_years).await { + if let Err(e) = self.backup.store(&updated, retention_years).await { tracing::warn!( passport_id = %updated.id, error = %e, - "ESPR archive failed (non-fatal)" + "ESPR back-up copy failed (non-fatal)" ); } diff --git a/crates/dpp-vault/src/domain/service/query.rs b/crates/dpp-vault/src/domain/service/query.rs index e4fa38bd..fb22093e 100644 --- a/crates/dpp-vault/src/domain/service/query.rs +++ b/crates/dpp-vault/src/domain/service/query.rs @@ -52,7 +52,7 @@ impl PassportService { self.repo.find_by_identity(identity).await } - /// Fetch a passport in any status, including `Archived`. Returns `None` if unknown. + /// Fetch a passport in any status, including `Retired`. Returns `None` if unknown. pub async fn find_by_id_any_status( &self, id: PassportId, diff --git a/crates/dpp-vault/src/handlers/mod.rs b/crates/dpp-vault/src/handlers/mod.rs index 88a5f26d..2d8518aa 100644 --- a/crates/dpp-vault/src/handlers/mod.rs +++ b/crates/dpp-vault/src/handlers/mod.rs @@ -2,7 +2,6 @@ pub mod amend; pub mod api_keys; -pub mod archive; pub mod audience_read; pub mod create; pub mod credentials; @@ -24,6 +23,7 @@ pub mod publish; pub mod read; pub mod registry_identity; pub mod registry_status; +pub mod retire; pub mod ruleset; pub mod scan_ingest; pub mod seal; diff --git a/crates/dpp-vault/src/handlers/archive.rs b/crates/dpp-vault/src/handlers/retire.rs similarity index 78% rename from crates/dpp-vault/src/handlers/archive.rs rename to crates/dpp-vault/src/handlers/retire.rs index 3de8c059..1d1681d2 100644 --- a/crates/dpp-vault/src/handlers/archive.rs +++ b/crates/dpp-vault/src/handlers/retire.rs @@ -1,4 +1,4 @@ -//! `POST /api/v1/dpp/{dppId}/archive` — archive a passport after retention expiry. +//! `POST /api/v1/dpp/{dppId}/retire` — retire a passport after retention expiry. use axum::{ Json, @@ -14,11 +14,11 @@ use super::error::{ require_write, }; -/// `POST /api/v1/dpp/{dppId}/archive` — permanently archive a published or suspended passport. +/// `POST /api/v1/dpp/{dppId}/retire` — permanently retire a published or suspended passport. /// /// Blocked by the ESPR retention guard until the product group's minimum retention /// period has elapsed from `published_at`. Returns `422` on a policy violation. -pub async fn archive_handler( +pub async fn retire_handler( State(state): State, Extension(auth): Extension, Path(dpp_id): Path, @@ -31,11 +31,11 @@ pub async fn archive_handler( Err(e) => return e, }; - match state.service.archive(passport_id, &auth).await { + match state.service.retire(passport_id, &auth).await { Ok(p) => (StatusCode::OK, Json(crate::api::PassportResponse::from(&p))).into_response(), Err(dpp_domain::DppError::NotFound(_)) => not_found_error("DPP not found."), Err(dpp_domain::DppError::InvalidTransition { .. }) => { - conflict_error("DPP cannot be archived from its current state.") + conflict_error("DPP cannot be retired from its current state.") } // Business-rule rejection (e.g. the ESPR retention guard) — a client // error, not a server fault. diff --git a/crates/dpp-vault/src/handlers/versions.rs b/crates/dpp-vault/src/handlers/versions.rs index e6108d87..36b952a3 100644 --- a/crates/dpp-vault/src/handlers/versions.rs +++ b/crates/dpp-vault/src/handlers/versions.rs @@ -4,11 +4,13 @@ //! corresponding to a given point in time shall be retrievable by authenticated //! and authorized actors"*. //! -//! 🚨 **Not `POST .../archive`**, which moves a passport to the terminal -//! `archived` lifecycle state after its retention period. Two different things -//! wear the word; this is the standard's sense — historical versions of a -//! passport that is still live. The naming collision is tracked upstream, where -//! the lifecycle status lives. +//! 🚨 **Not `POST .../retire`**, which moves a passport to the terminal +//! `retired` lifecycle state after its retention period. That route was called +//! `/archive` and the status was `archived`, which made this route look like a +//! duplicate of something already built. It never was: this is the standard's +//! sense of the word — historical versions of a passport that is still live — +//! and it is now the only thing here that wears it. +//! `scripts/vocabulary-check.sh` keeps it that way. use axum::{ Json, diff --git a/crates/dpp-vault/src/main.rs b/crates/dpp-vault/src/main.rs index 30c7e27f..1c1ec81a 100644 --- a/crates/dpp-vault/src/main.rs +++ b/crates/dpp-vault/src/main.rs @@ -11,7 +11,7 @@ use dpp_dal::pg::{ PgRegistryIdentityRepo, PgScanTelemetryRepo, PgWebhookRepo, }; use dpp_domain::{ - DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry, + DppError, GhostBackup, GhostRegistrySync, PassthroughRegistry, ports::registry_sync::RegistrySyncPort, }; use dpp_types::trust::{NodeProfile, NodeTrustReport, TrustMode, TrustPort}; @@ -93,7 +93,7 @@ async fn main() -> anyhow::Result<()> { required: true, }, TrustPort { - port: "archive", + port: "backup", mode: TrustMode::Ghost, required: false, }, @@ -124,7 +124,7 @@ async fn main() -> anyhow::Result<()> { audit_repo, event_bus, registry_sync, - Arc::new(GhostArchive), + Arc::new(GhostBackup), domain::service::OperatorIdentity::default(), ) .with_registry_reader(operator_repo.clone()) diff --git a/crates/dpp-vault/src/public_view.rs b/crates/dpp-vault/src/public_view.rs index fce280f2..3fe8a742 100644 --- a/crates/dpp-vault/src/public_view.rs +++ b/crates/dpp-vault/src/public_view.rs @@ -408,7 +408,7 @@ pub fn serves_publicly(status: &PassportStatus) -> bool { PassportStatus::Published | PassportStatus::Deactivated | PassportStatus::Superseded - | PassportStatus::Archived => true, + | PassportStatus::Retired => true, PassportStatus::Suspended | PassportStatus::Draft => false, other => { tracing::warn!( diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index deefca66..2c951458 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -22,7 +22,6 @@ use crate::{ handlers::{ amend::amend_handler, api_keys::{api_keys_create_handler, api_keys_delete_handler, api_keys_list_handler}, - archive::archive_handler, create::create_handler, credentials::issue_credential_handler, eol::eol_handler, @@ -50,6 +49,7 @@ use crate::{ operator_ids_set_primary_handler, }, registry_status::{passport_registry_handler, registry_rollup_handler}, + retire::retire_handler, ruleset::reload_ruleset_handler, scan_ingest::{scan_ingest_handler, scan_ingest_mtls}, seal::{seal_handler, seal_repair_handler, seal_summary_handler}, @@ -100,7 +100,7 @@ pub fn build(state: AppState) -> Router { .route("/dpp/{dppId}/supersede", post(supersede_handler)) .route("/dpp/{dppId}/lint", post(lint_handler)) .route("/dpp/{dppId}/suspend", post(suspend_handler)) - .route("/dpp/{dppId}/archive", post(archive_handler)) + .route("/dpp/{dppId}/retire", post(retire_handler)) .route("/dpp/{dppId}/eol", post(eol_handler)) .route( "/dpp/{dppId}/transfer/initiate", diff --git a/crates/dpp-vault/tests/continuity_snapshot.rs b/crates/dpp-vault/tests/continuity_snapshot.rs index 5cc166a6..532857d3 100644 --- a/crates/dpp-vault/tests/continuity_snapshot.rs +++ b/crates/dpp-vault/tests/continuity_snapshot.rs @@ -17,7 +17,7 @@ use chrono::Utc; use dpp_dal::in_memory_repo::InMemoryPassportRepo; use dpp_domain::{ - DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry, + DppError, GhostBackup, GhostRegistrySync, PassthroughRegistry, eol::{DeactivationReason, EolEvent}, passport::{FacilitySnapshot, ManufacturerInfo, Passport, PassportId}, product_group::ProductGroup, @@ -184,7 +184,7 @@ async fn build_service() -> (PassportService, InMemorySnapshotOutbox) { Arc::new(InMemoryAuditRepo::default()), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "DE".to_owned(), diff --git a/crates/dpp-vault/tests/evidence_dossier.rs b/crates/dpp-vault/tests/evidence_dossier.rs index 0e531234..c2c27b69 100644 --- a/crates/dpp-vault/tests/evidence_dossier.rs +++ b/crates/dpp-vault/tests/evidence_dossier.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use dpp_dal::in_memory_repo::InMemoryPassportRepo; use dpp_domain::{ - DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry, + DppError, GhostBackup, GhostRegistrySync, PassthroughRegistry, eol::{DeactivationReason, EolEvent}, operator::{OperatorRole, ResponsibleOperator}, passport::{FacilitySnapshot, ManufacturerInfo, Passport, PassportId}, @@ -198,7 +198,7 @@ async fn build_service() -> (PassportService, Arc, String) Arc::new(InMemoryAuditRepo::default()), Arc::new(dpp_common::event::NoOpEventBus), Arc::new(GhostRegistrySync), - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "DE".to_owned(), diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index de0c61d0..0dce780e 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -18,7 +18,7 @@ use dpp_dal::pg::{ PgSealOutboxRepo, PgTransferRepo, PgWebhookRepo, }; use dpp_domain::{ - DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry, + DppError, GhostBackup, GhostRegistrySync, PassthroughRegistry, credential::{PassportCredential, PassportCredentialSubject, SignedCredential}, passport::PassportId, ports::identity::IdentityPort, @@ -312,7 +312,7 @@ async fn start_vault_with_identity( audit_repo, event_bus, registry_sync, - Arc::new(GhostArchive), + Arc::new(GhostBackup), OperatorIdentity { legal_name: "Test Operator GmbH".to_owned(), country: "DE".to_owned(), diff --git a/docs/architecture/DATA-MODEL.md b/docs/architecture/DATA-MODEL.md index 5c89fdfa..c5c547a3 100644 --- a/docs/architecture/DATA-MODEL.md +++ b/docs/architecture/DATA-MODEL.md @@ -146,7 +146,7 @@ The core DPP record. Contains all product data, product-group-specific extension |---|---|---| | `operatorId` | string | Owner operator | | `schemaVersion` | string | Semver schema version | -| `status` | string | draft, active, suspended, archived | +| `status` | string | draft, active, suspended, retired | | `productName` | string | Product display name | | `productCategory` | string | BATTERY, TEXTILE, STEEL | | `manufacturer` | object | { name, address } | @@ -180,7 +180,7 @@ Append-only audit trail for passport lifecycle events. | `passportId` | string | FK to passport | | `operatorId` | string | Operator who owns the passport | | `actor` | string | Who performed the action (key name or user) | -| `action` | string | created, updated, published, suspended, archived | +| `action` | string | created, updated, published, suspended, retired | | `previousStatus` | string | nullable | | `newStatus` | string | | | `timestamp` | datetime | | diff --git a/docs/architecture/EVENT-BUS.md b/docs/architecture/EVENT-BUS.md index af51de8f..72160b7d 100644 --- a/docs/architecture/EVENT-BUS.md +++ b/docs/architecture/EVENT-BUS.md @@ -50,7 +50,7 @@ JSON serialisation uses `#[serde(rename_all = "camelCase")]`: | `dpp.passport.updated` | Draft passport fields updated | | `dpp.passport.published` | Passport signed and published (draft -> active) | | `dpp.passport.suspended` | Active passport suspended | -| `dpp.passport.archived` | Passport archived (irreversible) | +| `dpp.passport.retired` | Passport retired (irreversible) | | `dpp.passport.failed` | Passport operation failed | | `dpp.import.completed` | Bulk import job completed | | `dpp.import.failed` | Bulk import job failed | diff --git a/docs/architecture/EVIDENCE-DOSSIER.md b/docs/architecture/EVIDENCE-DOSSIER.md index 3f76fee5..dac7b5da 100644 --- a/docs/architecture/EVIDENCE-DOSSIER.md +++ b/docs/architecture/EVIDENCE-DOSSIER.md @@ -51,7 +51,7 @@ Each member is independently verifiable on its own — its own JWS, or its own h ## Why `SignedLayer` embeds the payload -An earlier version of this format had the verifier reconstruct the full/public view payloads from the passport record itself. This was found to be unreliable: `jws_signature`/`public_jws_signature` are frozen at publish time, but a passport's `status` (and other fields) mutate afterward on suspend/archive/end-of-life — those transitions never re-sign. A verifier reconstructing "the current record" would produce a payload that no longer matches what was actually signed, and falsely report tamper on a perfectly legitimate, unmodified signature. Embedding the exact signed payload sidesteps this: verification only has to confirm the signature covers *this* payload, never derive what the payload should be. +An earlier version of this format had the verifier reconstruct the full/public view payloads from the passport record itself. This was found to be unreliable: `jws_signature`/`public_jws_signature` are frozen at publish time, but a passport's `status` (and other fields) mutate afterward on suspend/retire/end-of-life — those transitions never re-sign. A verifier reconstructing "the current record" would produce a payload that no longer matches what was actually signed, and falsely report tamper on a perfectly legitimate, unmodified signature. Embedding the exact signed payload sidesteps this: verification only has to confirm the signature covers *this* payload, never derive what the payload should be. ## Trust model diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index deb82e15..6d0b56b9 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -83,7 +83,7 @@ Sub-services communicate via localhost HTTP (e.g., vault calls identity for sign transaction; a background drain retries with backoff — the HTTP adapter stays Ghost until the Commission publishes the Art. 13 registry API, so nothing is lost while waiting and publish never blocks on the registry -7. Later lifecycle: suspend / archive / end-of-life (typed reason) / +7. Later lifecycle: suspend / retire / end-of-life (typed reason) / transfer-of-responsibility (dual-signed handshake) — each appends to the hash-chained audit trail; a signed evidence dossier of the whole history is exportable at any time (GET /vault/api/v1/dpp/{id}/evidence) diff --git a/docs/guides/OPERATOR-SETUP.md b/docs/guides/OPERATOR-SETUP.md index d8be2d6c..6631b6e6 100644 --- a/docs/guides/OPERATOR-SETUP.md +++ b/docs/guides/OPERATOR-SETUP.md @@ -160,7 +160,7 @@ odal # launch the Console (recommended) From the Console you can: - **Infrastructure** — check status, start/stop services, update container images -- **Passports** — import, validate, publish, suspend, archive, export +- **Passports** — import, validate, publish, suspend, retire, export - **Operator** — view or update your operator profile - **API keys** — create, list, revoke - **Registry identity** — facilities (ESPR Annex III) and operator identifiers (ESPR Art. 13) @@ -319,17 +319,17 @@ Run `odal status` after onboarding and read the **TRUST** section: TRUST profile development seal ghost -archive ghost +backup ghost ruleset baseline -! Running on a stand-in: archive, credential_issuers, registry_sync, seal. +! Running on a stand-in: backup, credential_issuers, registry_sync, seal. Simulated, not the real service — nothing this node produces is fit for compliance use. ``` A stock node runs every trust port on a stand-in. Passports it issues are well-formed, signed with your own Ed25519 key, and independently verifiable — -but the qualified seal, the third-party archive, and the registry notifications +but the qualified seal, the third-party back-up copy, and the registry notifications are simulated. Wiring those up is a separate exercise; until then, treat the output as operationally real and legally not. @@ -357,15 +357,15 @@ Two things to know before you publish at scale: passport's QR code at publish time. Getting it wrong means reprinting labels. **Publishing starts a retention clock.** ESPR retention is enforced by the node, -not just documented: `odal passport archive` refuses inside the window. +not just documented: `odal passport retire` refuses inside the window. ``` -Error: archive failed: retention policy forbids archiving before 2036-08-18 +Error: retire failed: retention policy forbids retiring before 2036-08-18 ``` To withdraw a passport from public view, suspend it — `odal passport suspend ` — which serves `410 Gone` on the passport's own URL. Suspension is -reversible; archiving is terminal and gated. +reversible; retirement is terminal and gated. ## Updating the node @@ -398,7 +398,7 @@ ever signed. # Copy the key store out of the running node docker compose cp node:/data/keystore.enc ./keystore-backup.enc -# Or archive the whole volume +# Or back up the whole volume docker run --rm -v odal-node_node-data:/data -v "$PWD":/backup alpine \ tar czf /backup/node-data.tar.gz -C /data . ``` @@ -442,7 +442,7 @@ location / { | Any command: *"No profile configured yet"* | Nothing is configured on this machine | `odal init` or `odal profile create --node-url ` | | `odal passport publish` fails 422: *"missing required registry identity"* | No default facility and/or no primary operator identifier | `odal facility add … --default` and `odal operator-id add … --primary` | | `odal passport import` rejects every row on `gtin` | GTINs are 13-digit, or their check digit is wrong | Use GTIN-14; the error names the expected check digit | -| `odal passport archive` fails: *"retention policy forbids archiving before …"* | ESPR retention is still running on that passport | Use `odal passport suspend ` to withdraw it from public view instead | +| `odal passport retire` fails: *"retention policy forbids retiring before …"* | ESPR retention is still running on that passport | Use `odal passport suspend ` to withdraw it from public view instead | | `odal verify ` says *"Dossier not found"* | A **passport** id was passed | `verify` takes a **dossier** id — generate one with `odal passport evidence ` | | Scanned QR codes resolve to nothing | `RESOLVER_BASE_URL` was left at its default when those passports were published | Set it to your own resolver before publishing; already-published carriers cannot be changed | | A second `odal up` elsewhere on the host took over the first deployment | The compose project name is fixed, so all install roots share one deployment | Run one deployment per host, or set `COMPOSE_PROJECT_NAME` | diff --git a/docs/guides/WEBHOOKS.md b/docs/guides/WEBHOOKS.md index 7fc60df5..6c1d8d90 100644 --- a/docs/guides/WEBHOOKS.md +++ b/docs/guides/WEBHOOKS.md @@ -1,7 +1,7 @@ # Signed Outbound Webhooks Your node can POST an event to your own systems (ERP, PLM, a no-code automation) -every time a passport changes — created, updated, published, suspended, archived, +every time a passport changes — created, updated, published, suspended, retired, deactivated, or transferred. Deliveries are **signed** so your receiver can prove the request really came from your node. @@ -39,7 +39,7 @@ cannot be recovered later. ### Event subjects `dpp.passport.created`, `dpp.passport.updated`, `dpp.passport.published`, -`dpp.passport.suspended`, `dpp.passport.archived`, `dpp.passport.deactivated`, +`dpp.passport.suspended`, `dpp.passport.retired`, `dpp.passport.deactivated`, `dpp.passport.transferred`, and `dpp.webhook.test`. Use `*` to receive all. --- diff --git a/docs/legal/DPP-RETENTION.md b/docs/legal/DPP-RETENTION.md index 36644c04..81464416 100644 --- a/docs/legal/DPP-RETENTION.md +++ b/docs/legal/DPP-RETENTION.md @@ -48,7 +48,7 @@ passport.retention_locked = true; | Operation | Behaviour on locked passport | |---|---| | Status -> Suspended | Allowed — passport remains accessible | -| Status -> Archived | Allowed — passport remains accessible | +| Status -> Retired | Allowed — passport remains accessible | | Field update | Blocked — only Draft passports can be patched | | Delete | **No delete path exists** in `PassportRepository` by design | diff --git a/docs/ops/PRODUCTION-RUNBOOK.md b/docs/ops/PRODUCTION-RUNBOOK.md index 77597b09..5bb0bced 100644 --- a/docs/ops/PRODUCTION-RUNBOOK.md +++ b/docs/ops/PRODUCTION-RUNBOOK.md @@ -77,7 +77,7 @@ Auto-HTTPS, zero certificate ops. (Traefik equivalent if preferred; Caddy is les | `RULESET_POLL_INTERVAL_SECS` | opt | Default `300`. `0` polls never, leaving `POST /vault/api/v1/ruleset/reload` as the only trigger | | `NODE_PROFILE` | opt | **Leave unset** (T1). Set `production` only at T2 — it will refuse ghosts, correctly | | `DATABASE_MIGRATE_URL` | opt | Keep for pilot (idempotent sqlx migrations at boot); the least-privilege upgrade (external `just migrate`, app role only at runtime) is a later hardening | -| `EU_REGISTRY_CLIENT_ID/SECRET`, `ARCHIVE_S3_BUCKET`… | opt | T3 / archive tier — leave unset until real | +| `EU_REGISTRY_CLIENT_ID/SECRET`, `BACKUP_S3_BUCKET`… | opt | T3 / back-up-copy tier — leave unset until real | | `ODAL_VERSION` | ✔ | **Pin a tag/digest. Never `latest` in production.** Same for the `postgres:17` image (pin digest — the compose header says so itself) | `chmod 600 .env`; it is a secret. diff --git a/docs/project/BLUEPRINT.md b/docs/project/BLUEPRINT.md index 1b058f84..1ea2890a 100644 --- a/docs/project/BLUEPRINT.md +++ b/docs/project/BLUEPRINT.md @@ -62,7 +62,7 @@ fails, the operation still succeeds. Consumers must be idempotent. | Feature | Status | Notes | |---|---|---| | Passport CRUD (create, read, update, list) | Done | 7 endpoints | -| Passport lifecycle (publish, suspend, archive) | Done | State machine enforcement | +| Passport lifecycle (publish, suspend, retire) | Done | State machine enforcement | | Ed25519 signing on publish | Done | JWS compact serialisation via identity service | | did:web DID document serving | Done | `/.well-known/did.json` | | Key rotation | Done | Non-destructive, old signatures remain valid | diff --git a/docs/project/ENDPOINTS.md b/docs/project/ENDPOINTS.md index 0b9b34d0..a2f33531 100644 --- a/docs/project/ENDPOINTS.md +++ b/docs/project/ENDPOINTS.md @@ -33,7 +33,7 @@ here; the vault signs in-process instead (see `ATK-1` regression test). | PUT | `/vault/api/v1/dpp/{id}` | Bearer | Vault | | POST | `/vault/api/v1/dpp/{id}/publish` | Bearer | Vault | | POST | `/vault/api/v1/dpp/{id}/suspend` | Bearer | Vault | -| POST | `/vault/api/v1/dpp/{id}/archive` | Bearer | Vault | +| POST | `/vault/api/v1/dpp/{id}/retire` | Bearer | Vault | | GET | `/vault/api/v1/dpp/{id}/history` | Bearer | Vault | | POST | `/vault/api/v1/dpp/{id}/eol` | Bearer | Vault — end-of-life declaration (typed reason) | | POST | `/vault/api/v1/dpp/{id}/transfer/initiate` | Bearer | Vault — transfer-of-responsibility (signs) | diff --git a/justfile b/justfile index 88bff337..48367db2 100644 --- a/justfile +++ b/justfile @@ -271,6 +271,20 @@ no-rsa-private-key: subjects-check: bash scripts/subjects-check.sh +# Keep "archive" meaning EN 18221 clause 4.2 and nothing else. +# +# The word once named three things here — clause 4.2's version history, a +# terminal lifecycle status, and the ESPR Art. 10(4) back-up copy — and while it +# did, the clause 4.2 gap was invisible because the name looked taken. Renaming +# the other two fixed it once; nothing stopped it recurring. The rule and its +# reasoning live in the script. +vocabulary-check: + bash scripts/vocabulary-check.sh + +# Prove each of vocabulary-check's three rules actually refuses something. +vocabulary-check-self-test: + bash scripts/vocabulary-check.test.sh + # Every table with a live DELETE grant must be named in ops/pg/README.md. # # "The app role cannot DELETE" is the sentence a reader uses to reason about @@ -328,7 +342,7 @@ doc: cargo doc --workspace --no-deps # Fast gate (no Docker) — mirrors CI jobs: fmt, clippy, debug-prints, test-unit, audit -check: fmt-check lint debug-check no-rsa-private-key subjects-check mod-rs-check harness-check contract-fixture-check contract-fixture-check-self-test spec-version-check outbound-check grants-check migrations-check check-plugins test check-integration audit +check: fmt-check lint debug-check no-rsa-private-key subjects-check vocabulary-check vocabulary-check-self-test mod-rs-check harness-check contract-fixture-check contract-fixture-check-self-test spec-version-check outbound-check grants-check migrations-check check-plugins test check-integration audit # Full local CI mirror — adds integration-feature clippy + the Docker tiers (needs Docker running) ci: check lint-integration test-integration test-pg diff --git a/ops/pg/0041_retired_status.sql b/ops/pg/0041_retired_status.sql new file mode 100644 index 00000000..9f411043 --- /dev/null +++ b/ops/pg/0041_retired_status.sql @@ -0,0 +1,56 @@ +-- ============================================================================ +-- 0041 — the terminal status is `retired`; `archived` keeps its other meaning. +-- +-- `archived` was this system's terminal *publication* status: the state a record +-- reaches once the ESPR retention period has elapsed and it stops changing. +-- EN 18221:2026 clause 4.2 — one of the six standards cited by Commission +-- Implementing Decision (EU) 2026/1736 — uses "archiving" for something else +-- entirely: the retention of historical versions of a passport that is **still +-- live**, which this node does in `passport_version` (0040). +-- +-- Two things wore one word, so anyone mapping this schema onto the standard by +-- name ticked a box that was not ticked. The status is renamed; the word stays, +-- and now means only what the standard means by it. +-- +-- ── Two tables, two different treatments, deliberately ────────────────────── +-- +-- `passport.status` is *current state*, so it is rewritten: a row saying +-- `archived` and a row saying `retired` describe the same record, and only one +-- of those spellings is a status any longer. +-- +-- `passport_audit.action` is *history*, and is NOT rewritten. The action, +-- prevStatus and newStatus of every entry are covered by the append-only hash +-- chain (`dpp_types::audit`, columns chained in 0015), so an UPDATE here would +-- break `verify_audit_chain` from the edited row onward and every later entry +-- would read as tampered. An entry that says `archived` is a true record of a +-- transition performed while that was the word: history keeps the name it +-- happened under. So `retired` is ADDED to the permitted set and `archived` +-- STAYS, spending one permanently permitted legacy value to keep the trail +-- verifiable. +-- +-- The append-only trigger, the hash chain and the grants are unchanged. +-- ============================================================================ + +-- Current state: rewrite, then narrow the constraint onto the new spelling. +UPDATE odal.passport SET status = 'retired' WHERE status = 'archived'; + +ALTER TABLE odal.passport DROP CONSTRAINT passport_status_check; +ALTER TABLE odal.passport ADD CONSTRAINT passport_status_check + CHECK (status IN ('draft','active','suspended','retired','superseded','deactivated')); + +-- History: widen only. `archived` remains legal so the chain still verifies over +-- entries written before the rename. +ALTER TABLE odal.passport_audit DROP CONSTRAINT passport_audit_action_check; +ALTER TABLE odal.passport_audit ADD CONSTRAINT passport_audit_action_check + CHECK (action IN ( + 'created', + 'updated', + 'published', + 'suspended', + 'archived', + 'retired', + 'deactivated', + 'transferred', + 'credentialed_read', + 'superseded' + )); diff --git a/scripts/vocabulary-check.sh b/scripts/vocabulary-check.sh new file mode 100644 index 00000000..0e90bc51 --- /dev/null +++ b/scripts/vocabulary-check.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Keep "archive" meaning one thing on the wire. +# +# 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**. This system once +# spent the same word on two other things: a terminal lifecycle status (now +# `retired`) and the ESPR Art. 10(4) back-up copy (now `backup`). While all +# three wore it, anyone mapping this system onto the standard by name ticked a +# box that was not ticked, and the clause 4.2 gap stayed invisible for months +# because the name looked already taken. +# +# The rename is not self-sustaining: nothing stops the next lifecycle verb from +# being called `archive` again, and nothing would fail if it were. This gate is +# what stops it. +# +# ── The rule, and why it needs no allow-list ──────────────────────────────── +# +# Clause 4.2 archiving is served by `GET /dpp/{dppId}/versions` and carries no +# "archive" in any route path, path file or event subject. So the rule is +# absolute rather than a list of blessed exceptions: **no route path, no +# OpenAPI path file, and no event subject may contain "archiv" at all.** +# +# An absolute rule is the point. An allow-list is a place to add the next one. +# +# Deliberately NOT checked, because each is correct and qualified by its own +# object: `ArchivingPassportRepo` and `PassportVersion` (clause 4.2 itself), +# "archived keys" in the keystore, a seal's "archival timestamp" (ETSI's own +# term), and the `archived` value still permitted in `passport_audit.action`, +# which is history written under the old name and cannot be rewritten without +# breaking the audit hash chain. +set -euo pipefail + +status=0 + +# 1. Route paths. Any `.route("…archive…")` in any router. +if grep -rn --include="*.rs" -E '\.route\("[^"]*archiv' crates/ cli/; then + echo "ERROR: a route path contains \"archiv\" — the word belongs to EN 18221 clause 4.2 version archiving, which is served at /versions. A lifecycle transition is \`retire\`; the ESPR Art. 10(4) copy is \`backup\`." + status=1 +fi + +# 2. OpenAPI path files. A renamed route with a stale file name republishes the +# word in the description every consumer generates from. +if ls api/paths/**/*archiv* 2>/dev/null || ls api/paths/*archiv* 2>/dev/null; then + echo "ERROR: an api/paths/ file name contains \"archiv\" — see above." + status=1 +fi + +# 3. Event subjects. Renaming a subject is breaking for subscribers, so it is +# worth failing the build rather than discovering it in a consumer. +if grep -rn --include="*.rs" -E '"dpp\.[a-z]+\.[a-z]*archiv' crates/; then + echo "ERROR: an event subject contains \"archiv\" — see above." + status=1 +fi + +exit "$status" diff --git a/scripts/vocabulary-check.test.sh b/scripts/vocabulary-check.test.sh new file mode 100644 index 00000000..bf1b9683 --- /dev/null +++ b/scripts/vocabulary-check.test.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Self-test for scripts/vocabulary-check.sh. +# +# A gate nobody has watched fail is a gate nobody knows works — and this one is +# three independent greps, so a green run proves nothing about any single rule. +# Each case below reintroduces exactly one of the three collisions the rename +# removed and asserts the gate refuses it. +# +# The gate reads relative paths (`crates/`, `cli/`, `api/paths/`), so each case +# builds a synthetic tree in a temp directory and runs the gate from inside it. +# No network, no cargo. +set -euo pipefail + +GATE="$(cd "$(dirname "$0")" && pwd)/vocabulary-check.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +pass=0 +fail=0 + +# Build a tree that the gate must accept: the real shape, clause 4.2 included. +scaffold() { + local root="$1" + mkdir -p "$root/crates/dpp-vault/src" "$root/cli/src" "$root/api/paths/vault" + cat > "$root/crates/dpp-vault/src/router.rs" <<'EOF' + .route("/dpp/{dppId}/retire", post(retire_handler)) + .route("/dpp/{dppId}/versions", get(versions_handler)) +EOF + cat > "$root/crates/dpp-vault/src/event.rs" <<'EOF' + pub const PASSPORT_RETIRED: &str = "dpp.passport.retired"; +EOF + # Clause 4.2's own vocabulary, which must NOT trip the gate. + cat > "$root/crates/dpp-vault/src/archiving_repo.rs" <<'EOF' +/// Archives every version. EN 18221 clause 4.2. +pub struct ArchivingPassportRepo; +EOF + touch "$root/api/paths/vault/vault_api_v1_dpp_{dppId}_retire.yaml" + touch "$root/api/paths/vault/vault_api_v1_dpp_{dppId}_versions.yaml" +} + +run_case() { + local name="$1" expect="$2" mutate="$3" + local root="$TMP/$name" + scaffold "$root" + ( cd "$root" && eval "$mutate" ) + if ( cd "$root" && bash "$GATE" >/dev/null 2>&1 ); then + got="accept" + else + got="reject" + fi + if [ "$got" = "$expect" ]; then + echo "ok: $name ($got)" + pass=$((pass + 1)) + else + echo "FAIL: $name — gate said $got, expected $expect" + fail=$((fail + 1)) + fi +} + +# The clean tree, including clause 4.2's archiving repo, is accepted. Without +# this case the other three would pass against a gate that rejects everything. +run_case "a_clean_tree_is_accepted" accept "true" + +# 1. A lifecycle route takes the word back. +run_case "a_route_path_naming_archive_is_refused" reject \ + 'printf %s\\n " .route(\"/dpp/{dppId}/archive\", post(h))" >> crates/dpp-vault/src/router.rs' + +# 2. The route is renamed but its OpenAPI file keeps the old name, which is what +# every generated client and the rendered description would still say. +run_case "a_stale_openapi_path_file_is_refused" reject \ + 'touch "api/paths/vault/vault_api_v1_dpp_{dppId}_archive.yaml"' + +# 3. An event subject takes the word back — breaking for subscribers, and +# invisible until one stops matching. +run_case "an_event_subject_naming_archived_is_refused" reject \ + 'printf %s\\n " pub const P: \&str = \"dpp.passport.archived\";" >> crates/dpp-vault/src/event.rs' + +# 4. The CLI is in scope too — the gate reads `cli/` as well as `crates/`. +run_case "a_route_in_the_cli_tree_is_refused" reject \ + 'mkdir -p cli/src && printf %s\\n " .route(\"/archive\", post(h))" > cli/src/r.rs' + +echo +echo "$pass passed, $fail failed" +[ "$fail" -eq 0 ] From 96abd82a122877448a06333f172d024b691aa63a Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 17 Sep 2026 05:12:12 +0200 Subject: [PATCH 2/4] docs: correct who owes a version history --- CHANGELOG.md | 10 +++++++++- crates/dpp-node/src/infra/s3_backup.rs | 13 ++++++++++--- crates/dpp-types/src/audit.rs | 9 +++++++++ crates/dpp-vault/src/domain/service/mod.rs | 13 ++++++++++--- ops/pg/0041_retired_status.sql | 17 +++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4885d4..9359f50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): 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 unrelated things here. **EN 18221:2026 clause + **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` @@ -34,6 +34,14 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): 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 diff --git a/crates/dpp-node/src/infra/s3_backup.rs b/crates/dpp-node/src/infra/s3_backup.rs index e2fad996..8b79c574 100644 --- a/crates/dpp-node/src/infra/s3_backup.rs +++ b/crates/dpp-node/src/infra/s3_backup.rs @@ -1,7 +1,14 @@ //! S3/MinIO adapter implementing `BackupCopyPort` — the ESPR Art. 10(4) -//! back-up copy, held by an Art. 2(32) independent provider. Not Art. 13, -//! which is the registry, and not EN 18221 clause 4.2 archiving, which is a -//! live passport's version history and lives in the DAL. +//! back-up copy, held by an Art. 2(32) independent provider. Not ESPR Art. 13, +//! which is the registry. +//! +//! **Not EN 18221 clause 4.2 archiving either — but not because a back-up +//! provider is exempt from it.** Clause 4.2 expects a passport's archived +//! versions to be held by the back-up provider as well as by the main one. It +//! is that this adapter implements a port with no series in it: one key per +//! content hash, `retrieve` answering with one passport. This node's clause 4.2 +//! archiving is `ArchivingPassportRepo` in the DAL, and a provider's is the +//! provider's to build. //! //! Key scheme: `passports/{passport_id}/{sha256_hex}` — content-addressed, idempotent. //! Same content → same key. Different content (new version) → different key. diff --git a/crates/dpp-types/src/audit.rs b/crates/dpp-types/src/audit.rs index c55529ef..32d7340c 100644 --- a/crates/dpp-types/src/audit.rs +++ b/crates/dpp-types/src/audit.rs @@ -405,6 +405,15 @@ pub struct PassportVersion { /// redacted it cannot satisfy the clause's access-restriction limb even in /// principle. /// +/// And not `dpp_domain::ports::backup::BackupCopyPort`, the ESPR Art. 10(4) +/// back-up copy — the one on this list that is easiest to mis-read in the +/// *other* direction. Clause 4.2 expects archived versions to be held by the +/// back-up provider as well as by this node, so the provider is not exempt from +/// the clause. It is that the port carries no series — one copy per passport, +/// `retrieve` answering with one — so whatever a provider does about clause 4.2 +/// happens outside it. This trait is the node's own side, and wiring a back-up +/// adapter leaves it unwired. +/// /// # Archiving begins at the first change, not at create /// /// The clause is explicit about it, and the distinction is load-bearing: the diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index 7d39ea1e..d55783bb 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -94,9 +94,16 @@ pub struct PassportService { /// /// This is now the only thing in this struct called an archive, and it is /// the standard's sense of the word: the history of a passport that is - /// still live. Two others wore it and no longer do — `backup` above is the - /// ESPR Art. 10(4) back-up copy, a copy rather than a history, and the - /// terminal lifecycle status is `Retired`. + /// still live. Two others wore it and no longer do — the terminal lifecycle + /// status is `Retired`, and `backup` above is the ESPR Art. 10(4) back-up + /// copy. + /// + /// 🚨 `backup` is not a weaker version of this field and does not stand in + /// for it. Clause 4.2 expects archived versions to be held by the back-up + /// provider as well as by this node, so the provider is not exempt — but + /// `BackupCopyPort` has no method that carries a series, so whatever a + /// provider does about clause 4.2 happens outside this struct. Inside it, + /// this field is the whole of our clause 4.2 read path. /// /// `None` disables the versions route. It does **not** disable archiving — /// that happens in the repository decorator, which a node wires or does diff --git a/ops/pg/0041_retired_status.sql b/ops/pg/0041_retired_status.sql index 9f411043..69120d95 100644 --- a/ops/pg/0041_retired_status.sql +++ b/ops/pg/0041_retired_status.sql @@ -28,6 +28,23 @@ -- STAYS, spending one permanently permitted legacy value to keep the trail -- verifiable. -- +-- ── 0040's header is now stale, and cannot be fixed ──────────────────────── +-- +-- It says "THIS IS NOT WHAT `status = 'archived'` MEANS" and contrasts this +-- table with `PassportStatus::Archived`. Both name a status that no longer +-- exists; read it as `retired`. An applied migration cannot be edited — sqlx +-- checksums every file, so a comment-only change makes a node that has already +-- run it refuse to boot — so the correction lives here, in the migration that +-- caused it, rather than there. +-- +-- While correcting it: the two things 0040 contrasts are not the only two. A +-- third, `BackupCopyPort`, is the ESPR Art. 10(4) back-up copy. 🚨 Clause 4.2 +-- expects archived versions to be held by the back-up provider as well as by +-- this node, so the provider is not exempt from the clause — the port simply +-- carries no series, so nothing about clause 4.2 is expressible through it. +-- This table is this node's side of the clause and is not made redundant by +-- any back-up arrangement. +-- -- The append-only trigger, the hash chain and the grants are unchanged. -- ============================================================================ From 8b14f1a97eb4303c86c291570d0fb2d825b508a4 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 17 Sep 2026 05:20:28 +0200 Subject: [PATCH 3/4] docs: pin the archive vocabulary where it is enforced --- CLAUDE.md | 9 +++++++++ scripts/vocabulary-check.sh | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 535c2018..02505fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/scripts/vocabulary-check.sh b/scripts/vocabulary-check.sh index 0e90bc51..fd32123a 100644 --- a/scripts/vocabulary-check.sh +++ b/scripts/vocabulary-check.sh @@ -10,6 +10,13 @@ # box that was not ticked, and the clause 4.2 gap stayed invisible for months # because the name looked already taken. # +# 🚨 The three differ by SHAPE, not by who holds what — a history, a state, a +# copy. Clause 4.2 expects archived versions to be held by the back-up provider +# as well as by this node, so "back-up" never means "exempt from archiving". +# What makes `BackupCopyPort` not-archiving is that it carries no series at all: +# one copy per passport, `retrieve` answering with one. Separating them by +# actor instead is the mistake this comment exists to stop being made twice. +# # The rename is not self-sustaining: nothing stops the next lifecycle verb from # being called `archive` again, and nothing would fail if it were. This gate is # what stops it. From 1fa78c317652d02bc87af6415ec444f2d1b66f4b Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Thu, 17 Sep 2026 05:56:22 +0200 Subject: [PATCH 4/4] fix(dal): rewrite the document, not just the column --- .../tests/pg_retired_status_migration.rs | 174 ++++++++++++++++++ ops/pg/0041_retired_status.sql | 29 ++- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 crates/dpp-dal/tests/pg_retired_status_migration.rs diff --git a/crates/dpp-dal/tests/pg_retired_status_migration.rs b/crates/dpp-dal/tests/pg_retired_status_migration.rs new file mode 100644 index 00000000..2010de1b --- /dev/null +++ b/crates/dpp-dal/tests/pg_retired_status_migration.rs @@ -0,0 +1,174 @@ +//! `ops/pg/0041` must rewrite `doc`, not just the `status` column. +//! +//! # Why this test exists +//! +//! `passport.status` is a **projection**; `passport.doc` is the record. Reads go +//! `SELECT doc` → `Passport::from_stored`, and core 0.21.0 refuses `"archived"` +//! on deserialisation. So a migration that rewrote only the column would leave +//! every pre-rename row *unreadable* — a 500 per request, on exactly the +//! passports that already existed, with the column reading `retired` the whole +//! time so the migration looked like it had worked. +//! +//! That is the same failure as the `product_group` envelope rename, which cost +//! 244 of 276 passports. It was caught here by review rather than by a test, +//! which is why the test is now the thing that holds it. +//! +//! # It asserts both sides of the migration, on purpose +//! +//! A test that only checked the row reads afterwards would still pass if +//! `from_stored` had quietly started accepting `"archived"` — it would be +//! asserting nothing about the migration at all. So this first proves the row +//! is **unreadable before** 0041 runs, which is the hazard, and only then that +//! 0041 makes it readable. Delete the `doc` rewrite from the migration and the +//! second half fails; make core accept the old value and the first half fails. + +#![cfg(feature = "integration-tests")] + +use chrono::Utc; + +use dpp_dal::pg::{PgDal, PgPassportRepo}; +use dpp_dal::test_harness::start_pg_before; +use dpp_domain::passport::{ManufacturerInfo, Passport, PassportId}; +use dpp_domain::ports::passport_repo::PassportRepository; +use dpp_domain::product_group::ProductGroup; +use dpp_domain::status::PassportStatus; + +/// A passport as a pre-rename node would have stored it: everything current +/// except the one word. +/// +/// Built by serialising a `Retired` passport and putting the old spelling back, +/// because core can no longer produce `"archived"` — which is the point of the +/// change and also why the fixture has to be made this way rather than by +/// constructing the old variant. +fn doc_with_the_old_spelling(id: PassportId) -> serde_json::Value { + let passport = Passport { + id, + batch_id: None, + serial_number: None, + product_name: "Pre-rename Battery".into(), + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, + manufacturer: ManufacturerInfo { + name: "TestCorp GmbH".into(), + address: "Berlin, DE".into(), + registered_trade_name: None, + electronic_address: None, + country: None, + did_web_url: None, + }, + materials: vec![], + co2e_per_unit: None, + repairability_score: None, + compliance_result: None, + lint_result: None, + product_group_data: None, + status: PassportStatus::Retired, + qr_code_url: None, + jws_signature: None, + public_jws_signature: None, + disclosure_signatures: Default::default(), + created_at: Utc::now(), + updated_at: Utc::now(), + published_at: None, + placed_on_market_date: None, + schema_version: "2.0.0".into(), + retention_locked: false, + version: 1, + supersedes_id: None, + derived_from: Vec::new(), + component_refs: Vec::new(), + life_status: None, + retention_until: None, + product_id: None, + commodity_code: None, + operator_identifier: None, + responsible_operator: None, + facility: None, + seal: None, + }; + + let mut doc = serde_json::to_value(&passport).expect("serialise passport"); + doc["status"] = serde_json::Value::String("archived".into()); + doc +} + +#[tokio::test(flavor = "multi_thread")] +async fn migration_0041_rewrites_the_document_not_only_the_column() { + let pg = start_pg_before("0041_").await; + + let admin = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&pg.admin_url) + .await + .expect("admin connect"); + + let id = PassportId::new(); + let doc = doc_with_the_old_spelling(id); + + // Same column-from-`doc` derivation the repository's own INSERT uses, so + // the row is shaped exactly as a pre-rename node would have written it — + // including `status` taking the old spelling from the document. + sqlx::query( + r#"INSERT INTO odal.passport + (id, product_group, status, retention_locked, schema_version, + version, created_at, updated_at, doc) + VALUES ($1, + $2->>'productGroup', + COALESCE($2->>'status','draft'), + COALESCE(($2->>'retentionLocked')::boolean, false), + COALESCE($2->>'schemaVersion','1.0.0'), + COALESCE(($2->>'version')::integer, 1), + now(), now(), $2)"#, + ) + .bind(id.0) + .bind(&doc) + .execute(&admin) + .await + .expect("insert a pre-rename row"); + + // ── Before: the hazard is real ────────────────────────────────────────── + let dal = PgDal::connect(&pg.app_url).await.expect("dal connect"); + let repo = PgPassportRepo::new(dal); + let before = repo.find_by_id(id).await; + assert!( + before.is_err(), + "a row whose doc still says \"archived\" must fail to read — if this \ + passes, core accepts the old value again and the migration below is \ + no longer what keeps these rows readable" + ); + + // ── Apply 0041 ────────────────────────────────────────────────────────── + // Straight from the file: the manual 0001–0040 run leaves `_sqlx_migrations` + // empty, so `PgDal::migrate` would try to replay them. + let sql = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../ops/pg/0041_retired_status.sql" + )) + .expect("read 0041"); + // Repo-controlled migration text from ops/pg, not caller input. + sqlx::raw_sql(sqlx::AssertSqlSafe(sql)) + .execute(&admin) + .await + .expect("apply 0041"); + + // ── After: the record reads, and reads as Retired ─────────────────────── + let after = repo + .find_by_id(id) + .await + .expect("0041 must leave the document readable") + .expect("the row is still there"); + assert_eq!( + after.status, + PassportStatus::Retired, + "the document's status must carry the new spelling, not just the column" + ); + + // And the projection agrees with it. + let column: String = sqlx::query_scalar("SELECT status FROM odal.passport WHERE id = $1") + .bind(id.0) + .fetch_one(&admin) + .await + .expect("read the status column"); + assert_eq!(column, "retired", "the projection must follow the document"); +} diff --git a/ops/pg/0041_retired_status.sql b/ops/pg/0041_retired_status.sql index 69120d95..cc134552 100644 --- a/ops/pg/0041_retired_status.sql +++ b/ops/pg/0041_retired_status.sql @@ -48,9 +48,36 @@ -- The append-only trigger, the hash chain and the grants are unchanged. -- ============================================================================ --- Current state: rewrite, then narrow the constraint onto the new spelling. +-- ── `doc` is the one that matters; the column is only a projection ───────── +-- +-- 🚨 Rewriting `passport.status` alone would leave every pre-rename row +-- unreadable. `doc` holds the full serde `Passport` — `status` included — and +-- `PgPassportRepo::read_doc` calls `Passport::from_stored` on **`doc`**, never +-- on the column, which 0004's own header and the insert's comment both say is +-- "a projection of `doc`, never a second source of truth". Core refuses +-- `"archived"` on deserialisation as of 0.21.0, so a row whose `doc` still +-- carries it fails to read at all: a 500 per request, on exactly the passports +-- that already existed. That is the shape of the `product_group` envelope +-- rename, which cost 244 of 276 passports. +-- +-- So `doc` is rewritten first and the column follows it, in that order and in +-- one transaction, because a migration that did only the second would look +-- like it had worked. +UPDATE odal.passport + SET doc = jsonb_set(doc, '{status}', '"retired"'::jsonb) + WHERE doc->>'status' = 'archived'; + UPDATE odal.passport SET status = 'retired' WHERE status = 'archived'; +-- `passport_version.doc` is deliberately NOT rewritten, for two reasons that +-- agree. It is history, like `passport_audit` above. And it is append-only by +-- the `passport_version_immutable` trigger (BEFORE UPDATE OR DELETE, 0040), so +-- an UPDATE here raises rather than silently succeeding. Nothing deserialises a +-- version into a `Passport` — `PassportVersion.doc` is a `serde_json::Value` +-- the versions route filters and serves as-is — so a version carrying the old +-- spelling reads fine and says what the record said at the time, which is the +-- whole point of keeping it. + ALTER TABLE odal.passport DROP CONSTRAINT passport_status_check; ALTER TABLE odal.passport ADD CONSTRAINT passport_status_check CHECK (status IN ('draft','active','suspended','retired','superseded','deactivated'));