From 3151d01b27a8b35950269600325c55c604cd4b19 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Mon, 24 Aug 2026 23:10:46 +0200 Subject: [PATCH 1/5] feat(vault): retire sector, record instruments --- .github/workflows/ci.yml | 51 +- CHANGELOG.md | 48 + CLAUDE.md | 16 +- CONTRIBUTING.md | 2 +- Cargo.lock | 20 - README.md | 2 +- api/README.md | 42 +- api/components/responses/Conflict.yaml | 9 +- api/components/responses/Forbidden.yaml | 10 +- api/components/responses/NotAcceptable.yaml | 10 +- api/components/responses/NotFound.yaml | 10 +- api/components/responses/Unauthorized.yaml | 10 +- api/components/responses/ValidationError.yaml | 9 +- api/components/schemas/ApiError.yaml | 11 - api/components/schemas/ApiKey.yaml | 3 + api/components/schemas/ApiKeyScope.yaml | 9 + api/components/schemas/AuditEntry.yaml | 20 +- api/components/schemas/CarbonFootprint.yaml | 39 + api/components/schemas/ComplianceFinding.yaml | 21 + api/components/schemas/ComplianceResult.yaml | 54 + api/components/schemas/ComplianceStatus.yaml | 10 + api/components/schemas/Coverage.yaml | 10 + .../schemas/CreateApiKeyRequest.yaml | 6 + api/components/schemas/CreateRequest.yaml | 48 +- .../schemas/CreatedWebhookResponse.yaml | 12 + .../schemas/CurrentOperatorView.yaml | 22 + api/components/schemas/DailyScanCount.yaml | 10 + api/components/schemas/DossierManifest.yaml | 10 + api/components/schemas/FacilitySnapshot.yaml | 26 + .../schemas/ImportSyncResponse.yaml | 9 + .../schemas/ImportUpdatedEntry.yaml | 12 + api/components/schemas/InstalledPlugin.yaml | 14 + api/components/schemas/LintFinding.yaml | 17 + api/components/schemas/LintResult.yaml | 21 + api/components/schemas/LintSeverity.yaml | 7 + api/components/schemas/MaterialEntry.yaml | 2 +- .../schemas/NewWebhookSubscription.yaml | 18 + api/components/schemas/OperatorConfig.yaml | 8 + api/components/schemas/OperatorScanStats.yaml | 16 + .../schemas/PassportListResponse.yaml | 23 + .../schemas/PassportRegistryView.yaml | 30 + api/components/schemas/PassportResponse.yaml | 212 +- api/components/schemas/PassportScanStats.yaml | 29 + api/components/schemas/PassportStatus.yaml | 13 +- api/components/schemas/Problem.yaml | 2 +- api/components/schemas/ProductGroupData.yaml | 25 + .../schemas/RegistrationCounts.yaml | 35 + api/components/schemas/RegistrationView.yaml | 29 + .../schemas/RegistryRollupView.yaml | 16 + api/components/schemas/RepairCriterion.yaml | 14 + .../schemas/RepairabilityScore.yaml | 18 + api/components/schemas/SealFormat.yaml | 7 + api/components/schemas/SealResponse.yaml | 60 + .../schemas/SealSummaryResponse.yaml | 24 + api/components/schemas/SealedEnvelope.yaml | 27 + api/components/schemas/SectorData.yaml | 25 - api/components/schemas/TransferCounts.yaml | 15 + api/components/schemas/TransferView.yaml | 22 + .../schemas/UpdateOperatorConfig.yaml | 8 + api/components/schemas/ValidateResponse.yaml | 42 + api/components/schemas/VerificationView.yaml | 28 + .../schemas/WebhookSubscription.yaml | 38 + api/components/schemas/WhoamiResponse.yaml | 20 + api/openapi.bundled.json | 6823 +++++++++++++++++ api/openapi.bundled.yaml | 1593 ++-- api/openapi.yaml | 26 +- ...egrator_api_v1_import_{productGroup}.yaml} | 6 +- api/paths/integrator_api_v1_schemas.yaml | 8 +- ...grator_api_v1_schemas_{productGroup}.yaml} | 8 +- ..._v1_schemas_{productGroup}_{version}.yaml} | 6 +- ...ator_api_v1_templates_{productGroup}.yaml} | 6 +- api/paths/vault_api_v1_api-keys_{id}.yaml | 4 +- api/paths/vault_api_v1_dpp.yaml | 6 +- api/paths/vault_api_v1_dpp_by-identity.yaml | 4 +- api/paths/vault_api_v1_dpp_validate.yaml | 35 +- api/paths/vault_api_v1_dpp_{dppId}_lint.yaml | 2 +- .../vault_api_v1_dpp_{dppId}_registry.yaml | 36 + api/paths/vault_api_v1_dpp_{dppId}_seal.yaml | 80 +- api/paths/vault_api_v1_dpp_{dppId}_stats.yaml | 28 +- ...lt_api_v1_dpp_{dppId}_transfer_accept.yaml | 4 +- api/paths/vault_api_v1_dpps.yaml | 19 +- api/paths/vault_api_v1_plugins.yaml | 23 +- api/paths/vault_api_v1_registry.yaml | 27 + api/paths/vault_api_v1_seal.yaml | 33 +- api/paths/vault_api_v1_stats.yaml | 15 +- api/paths/vault_api_v1_webhooks.yaml | 70 +- api/paths/vault_api_v1_webhooks_{id}.yaml | 4 +- api/paths/vault_api_v1_whoami.yaml | 25 +- api/paths/vault_credential_dpp_{dppId}.yaml | 12 +- .../vault_public_dpp_by-gtin_{gtin}.yaml | 4 +- api/paths/vault_public_dpp_{dppId}.yaml | 16 +- cli/src/cli_args.rs | 4 +- cli/src/commands/plugin.rs | 6 +- cli/src/console/menu/passports.rs | 2 +- cli/src/core/passport/import.rs | 46 +- cli/src/core/passport/list.rs | 8 +- cli/src/core/passport/validate.rs | 60 +- cli/src/core/plugin.rs | 8 +- cli/src/core/types.rs | 6 +- cli/src/http.rs | 4 +- cli/src/stateless/render.rs | 16 +- crates/dpp-common/src/plugin_admin.rs | 8 +- crates/dpp-dal/src/in_memory_repo.rs | 8 + crates/dpp-dal/src/pg/repo_passport.rs | 200 +- crates/dpp-dal/tests/passport_doc_compat.rs | 113 +- .../tests/passport_key_literal_drift.rs | 171 + crates/dpp-dal/tests/passport_read_upcast.rs | 32 +- crates/dpp-dal/tests/pg_integration.rs | 30 +- crates/dpp-dal/tests/pg_seal_outbox.rs | 8 +- crates/dpp-dal/tests/retention_guard_drift.rs | 114 + .../dpp-identity/src/handlers/rotate_key.rs | 40 +- crates/dpp-identity/src/handlers/sign.rs | 23 +- crates/dpp-identity/src/handlers/verify.rs | 22 +- crates/dpp-integrator/src/domain/fields.rs | 8 +- crates/dpp-integrator/src/domain/matcher.rs | 22 +- crates/dpp-integrator/src/domain/request.rs | 8 +- .../src/domain/validate/aluminium.rs | 14 +- .../src/domain/validate/battery.rs | 25 +- .../dpp-integrator/src/domain/validate/mod.rs | 22 +- .../src/domain/validate/steel.rs | 14 +- .../src/domain/validate/textile.rs | 18 +- .../src/domain/validate/tyre.rs | 14 +- crates/dpp-integrator/src/handlers/import.rs | 57 +- .../dpp-integrator/src/handlers/job_status.rs | 45 +- crates/dpp-integrator/src/handlers/schemas.rs | 74 +- .../dpp-integrator/src/handlers/templates.rs | 25 +- .../dpp-integrator/src/infra/vault_client.rs | 13 +- crates/dpp-integrator/src/router.rs | 18 +- crates/dpp-node/src/boot/trust.rs | 2 +- crates/dpp-node/src/config.rs | 2 +- crates/dpp-node/src/infra/registry/mapping.rs | 2 +- crates/dpp-node/src/plugins.rs | 164 +- crates/dpp-node/tests/openapi_contract.rs | 2179 ++++++ crates/dpp-node/tests/registry_outbox.rs | 10 +- crates/dpp-node/tests/s3_archive.rs | 8 +- crates/dpp-node/tests/seal_outbox.rs | 16 +- crates/dpp-node/tests/smoke.rs | 20 +- crates/dpp-node/tests/snapshot_outbox.rs | 8 +- crates/dpp-node/tests/transfer_outbox.rs | 8 +- crates/dpp-plugin-host/benches/wasm_invoke.rs | 14 +- crates/dpp-plugin-host/src/host.rs | 167 +- crates/dpp-plugin-host/src/lib.rs | 6 +- crates/dpp-plugin-host/src/loader/discover.rs | 10 +- crates/dpp-plugin-host/src/loader/mod.rs | 2 +- crates/dpp-plugin-host/src/loader/plugin.rs | 28 +- crates/dpp-plugin-host/src/runtime.rs | 4 +- crates/dpp-plugin-host/src/tests.rs | 106 +- .../tests/fixtures/abi-echo/src/lib.rs | 6 +- crates/dpp-plugin-host/tests/integration.rs | 38 +- crates/dpp-render/examples/preview.rs | 6 +- crates/dpp-render/src/carrier.rs | 18 +- crates/dpp-render/src/fields.rs | 4 +- crates/dpp-render/src/page.rs | 14 +- crates/dpp-render/src/remainder.rs | 31 +- crates/dpp-render/src/sections/aluminium.rs | 10 +- crates/dpp-render/src/sections/battery.rs | 4 +- .../dpp-render/src/sections/construction.rs | 12 +- crates/dpp-render/src/sections/detergent.rs | 12 +- crates/dpp-render/src/sections/electronics.rs | 10 +- crates/dpp-render/src/sections/furniture.rs | 10 +- crates/dpp-render/src/sections/mod.rs | 75 +- crates/dpp-render/src/sections/steel.rs | 10 +- crates/dpp-render/src/sections/textile.rs | 16 +- crates/dpp-render/src/sections/toy.rs | 12 +- crates/dpp-render/src/sections/tyre.rs | 10 +- .../dpp-resolver/src/handlers/resolve_aas.rs | 8 +- .../src/handlers/resolve_html/mod.rs | 2 +- .../dpp-resolver/src/handlers/resolve_json.rs | 102 +- .../dpp-resolver/src/handlers/resolve_qr.rs | 4 +- .../src/jws_verification_tests.rs | 10 +- crates/dpp-resolver/tests/resolver_e2e.rs | 24 +- crates/dpp-types/src/api_key.rs | 2 +- crates/dpp-types/src/operator.rs | 8 +- crates/dpp-types/src/registry_identity.rs | 4 +- .../src/domain/compliance/calc_battery.rs | 10 +- .../dpp-vault/src/domain/compliance/tests.rs | 20 +- crates/dpp-vault/src/domain/service/create.rs | 69 +- .../dpp-vault/src/domain/service/lifecycle.rs | 4 +- crates/dpp-vault/src/domain/service/lint.rs | 8 +- crates/dpp-vault/src/domain/service/mod.rs | 69 +- .../dpp-vault/src/domain/service/publish.rs | 119 +- crates/dpp-vault/src/domain/service/query.rs | 12 +- crates/dpp-vault/src/handlers/archive.rs | 2 +- .../dpp-vault/src/handlers/audience_read.rs | 63 +- crates/dpp-vault/src/handlers/create.rs | 130 +- crates/dpp-vault/src/handlers/eol.rs | 4 +- .../src/handlers/find_by_identity.rs | 8 +- crates/dpp-vault/src/handlers/info.rs | 44 +- crates/dpp-vault/src/handlers/lint.rs | 2 +- crates/dpp-vault/src/handlers/list.rs | 33 +- crates/dpp-vault/src/handlers/node_state.rs | 18 +- crates/dpp-vault/src/handlers/plugins.rs | 42 +- crates/dpp-vault/src/handlers/public_read.rs | 34 +- .../src/handlers/public_read_by_gtin.rs | 35 +- crates/dpp-vault/src/handlers/publish.rs | 4 +- .../dpp-vault/src/handlers/registry_status.rs | 98 +- crates/dpp-vault/src/handlers/transfer.rs | 4 +- crates/dpp-vault/src/handlers/validate.rs | 42 +- crates/dpp-vault/src/handlers/webhooks.rs | 6 +- crates/dpp-vault/src/handlers/whoami.rs | 31 +- crates/dpp-vault/src/infra/status_list.rs | 2 +- crates/dpp-vault/src/middleware/credential.rs | 78 +- crates/dpp-vault/src/public_view.rs | 119 +- crates/dpp-vault/src/router.rs | 2 +- crates/dpp-vault/tests/audience_read_route.rs | 42 +- crates/dpp-vault/tests/audit_history.rs | 4 +- crates/dpp-vault/tests/battery.rs | 18 +- crates/dpp-vault/tests/capture_doc_fixture.rs | 36 +- crates/dpp-vault/tests/compliance_tests.rs | 8 +- crates/dpp-vault/tests/continuity_snapshot.rs | 8 +- .../dpp-vault/tests/cross_repo_lifecycle.rs | 18 +- crates/dpp-vault/tests/evidence_api.rs | 4 +- crates/dpp-vault/tests/evidence_dossier.rs | 8 +- .../tests/facility_operator_stamping.rs | 4 +- crates/dpp-vault/tests/facility_retire.rs | 4 +- crates/dpp-vault/tests/find_by_identity.rs | 12 +- crates/dpp-vault/tests/helpers/mod.rs | 2 +- crates/dpp-vault/tests/integration_test.rs | 6 +- crates/dpp-vault/tests/lint_findings.rs | 10 +- .../tests/operator_identifier_retire.rs | 4 +- crates/dpp-vault/tests/publish_serve_cycle.rs | 4 +- .../tests/registry_identity_governance.rs | 6 +- .../dpp-vault/tests/registry_status_route.rs | 14 +- crates/dpp-vault/tests/seal_route.rs | 8 +- crates/dpp-vault/tests/suspension.rs | 4 +- crates/dpp-vault/tests/textile.rs | 20 +- crates/dpp-vault/tests/validation_failures.rs | 30 +- docs/architecture/DATA-MODEL.md | 10 +- docs/architecture/DESIGN-PATTERNS.md | 4 +- docs/architecture/OVERVIEW.md | 4 +- docs/governance/RELEASE.md | 2 +- docs/guides/DEVELOPER-GUIDE.md | 14 +- docs/guides/OPERATOR-SETUP.md | 16 +- docs/legal/DPP-RETENTION.md | 18 +- docs/ops/PRODUCTION-RUNBOOK.md | 2 +- docs/project/BLUEPRINT.md | 4 +- docs/project/ENDPOINTS.md | 4 +- justfile | 31 +- ops/pg/0032_product_group_rename.sql | 45 + scripts/contract-fixture-check.sh | 77 + scripts/contract-fixture-check.test.sh | 118 + 241 files changed, 14185 insertions(+), 2433 deletions(-) delete mode 100644 api/components/schemas/ApiError.yaml create mode 100644 api/components/schemas/ApiKeyScope.yaml create mode 100644 api/components/schemas/CarbonFootprint.yaml create mode 100644 api/components/schemas/ComplianceFinding.yaml create mode 100644 api/components/schemas/ComplianceResult.yaml create mode 100644 api/components/schemas/ComplianceStatus.yaml create mode 100644 api/components/schemas/Coverage.yaml create mode 100644 api/components/schemas/CreatedWebhookResponse.yaml create mode 100644 api/components/schemas/CurrentOperatorView.yaml create mode 100644 api/components/schemas/DailyScanCount.yaml create mode 100644 api/components/schemas/FacilitySnapshot.yaml create mode 100644 api/components/schemas/ImportUpdatedEntry.yaml create mode 100644 api/components/schemas/InstalledPlugin.yaml create mode 100644 api/components/schemas/LintFinding.yaml create mode 100644 api/components/schemas/LintResult.yaml create mode 100644 api/components/schemas/LintSeverity.yaml create mode 100644 api/components/schemas/NewWebhookSubscription.yaml create mode 100644 api/components/schemas/OperatorScanStats.yaml create mode 100644 api/components/schemas/PassportListResponse.yaml create mode 100644 api/components/schemas/PassportRegistryView.yaml create mode 100644 api/components/schemas/PassportScanStats.yaml create mode 100644 api/components/schemas/ProductGroupData.yaml create mode 100644 api/components/schemas/RegistrationCounts.yaml create mode 100644 api/components/schemas/RegistrationView.yaml create mode 100644 api/components/schemas/RegistryRollupView.yaml create mode 100644 api/components/schemas/RepairCriterion.yaml create mode 100644 api/components/schemas/RepairabilityScore.yaml create mode 100644 api/components/schemas/SealFormat.yaml create mode 100644 api/components/schemas/SealResponse.yaml create mode 100644 api/components/schemas/SealSummaryResponse.yaml create mode 100644 api/components/schemas/SealedEnvelope.yaml delete mode 100644 api/components/schemas/SectorData.yaml create mode 100644 api/components/schemas/TransferCounts.yaml create mode 100644 api/components/schemas/TransferView.yaml create mode 100644 api/components/schemas/ValidateResponse.yaml create mode 100644 api/components/schemas/VerificationView.yaml create mode 100644 api/components/schemas/WebhookSubscription.yaml create mode 100644 api/components/schemas/WhoamiResponse.yaml create mode 100644 api/openapi.bundled.json rename api/paths/{integrator_api_v1_import_{sector}.yaml => integrator_api_v1_import_{productGroup}.yaml} (92%) rename api/paths/{integrator_api_v1_schemas_{sector}.yaml => integrator_api_v1_schemas_{productGroup}.yaml} (83%) rename api/paths/{integrator_api_v1_schemas_{sector}_{version}.yaml => integrator_api_v1_schemas_{productGroup}_{version}.yaml} (87%) rename api/paths/{integrator_api_v1_templates_{sector}.yaml => integrator_api_v1_templates_{productGroup}.yaml} (82%) create mode 100644 api/paths/vault_api_v1_dpp_{dppId}_registry.yaml create mode 100644 api/paths/vault_api_v1_registry.yaml create mode 100644 crates/dpp-dal/tests/passport_key_literal_drift.rs create mode 100644 crates/dpp-dal/tests/retention_guard_drift.rs create mode 100644 crates/dpp-node/tests/openapi_contract.rs create mode 100644 ops/pg/0032_product_group_rename.sql create mode 100644 scripts/contract-fixture-check.sh create mode 100644 scripts/contract-fixture-check.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd2a68e..f88ac0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,20 @@ jobs: uses: taiki-e/install-action@82cd3e7658a6f96c86c0234aeeda1748937cb0a1 # v2.85.13 with: tool: nextest + # Run first, and as its own step, so "the API description no longer + # matches the code" is a named CI failure rather than one line inside a + # check called "Unit tests". It is cheap here (the toolchain and cache are + # already warm) and the `--workspace` run below re-runs it for free. + # + # This is only half the enforcement: the test reads + # `api/openapi.bundled.json`, embedded at compile time, so a *stale* + # bundle would let it pass against an outdated spec. The `openapi-lint` + # job regenerates both bundles and diffs them, which is what closes that. + # Both jobs must be required checks — either one alone can be satisfied + # while the API and the code disagree. + - name: OpenAPI contract — spec vs. the types and routes + working-directory: dpp-engine + run: cargo nextest run -p dpp-node --test openapi_contract --no-tests=fail # `--workspace` (no integration feature) runs every non-Docker test: # lib tests, the `odal` CLI binary tests, and the resolver e2e / JWS # verification + security-regression tests. The feature-gated @@ -256,6 +270,26 @@ jobs: exit 1 fi + contract-fixture-check: + name: OpenAPI contract fixtures stay exhaustive + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: dpp-engine + # Shell only — no Rust toolchain, so this reports in seconds and is + # independent of whether the workspace builds. Its own job rather than a + # step inside another, so "someone disabled the API drift gate" is a + # distinct red check. + - name: Fixtures use no struct-update syntax + working-directory: dpp-engine + run: bash scripts/contract-fixture-check.sh + # A grep gate with a broken anchor exits 0 forever and is + # indistinguishable from a passing one, so the gate is itself tested. + - name: Gate self-test + working-directory: dpp-engine + run: bash scripts/contract-fixture-check.test.sh + mod-rs-check: name: No public items in mod.rs (index files only) runs-on: ubuntu-latest @@ -306,15 +340,20 @@ jobs: - name: Check the spec version matches the crate version working-directory: dpp-engine run: bash scripts/spec-version-check.sh - # The bundle is a committed build artifact. `redocly bundle` is - # byte-deterministic, so regenerating it and diffing proves the shipped - # single file still matches the multi-file tree it came from. - - name: Check the bundle is current + # The bundles are committed build artifacts. `redocly bundle` is + # byte-deterministic, so regenerating them and diffing proves the shipped + # single files still match the multi-file tree they came from. + # + # The JSON bundle is the same document in the form the OpenAPI contract + # test reads (crates/dpp-node/tests/openapi_contract.rs). A stale one + # would let that test check the types against an outdated spec and pass. + - name: Check the bundles are current working-directory: dpp-engine run: | npx --yes @redocly/cli@2.46.2 bundle api/openapi.yaml -o api/openapi.bundled.yaml - git diff --exit-code -- api/openapi.bundled.yaml \ - || { echo "ERROR: api/openapi.bundled.yaml is stale — run 'just openapi-bundle' and commit it"; exit 1; } + npx --yes @redocly/cli@2.46.2 bundle api/openapi.yaml -o api/openapi.bundled.json + git diff --exit-code -- api/openapi.bundled.yaml api/openapi.bundled.json \ + || { echo "ERROR: an api/openapi.bundled.* file is stale — run 'just openapi-bundle' and commit both"; exit 1; } # The version is pinned and must stay equal to the one in the `just # openapi-check` recipe. With `@latest` the two disagree about what is # valid the moment Redocly publishes, and a spec that did not change diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e5f85c..1966b4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,54 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ## [Unreleased] +### Breaking + +- **`sector` is `productGroup` everywhere the engine touches it.** *(Breaking: + the `sector` request and response field becomes `productGroup`, `sectorData` + becomes `productGroupData`, `sectorDataValid` becomes `productGroupDataValid`, + the four integrator routes take `{productGroup}` instead of `{sector}`, the + `SectorData` schema is renamed `ProductGroupData`, plugin artifacts are + `product-group-.wasm` rather than `sector-.wasm`, and the + `passport.sector` column becomes `passport.product_group`.)* + + ESPR defines **product group**; "sector" is not a term of art anywhere in the + Regulation. The core library retired the word and this follows it. No + compatibility aliases: two spellings in circulation is the problem the rename + exists to end. + + **Database:** migration `0032` renames the column, renames its index, and + drops and rebuilds the identity index — that one indexes an expression over + the document, and the JSON key inside it changed too, so a rename would have + left it matching a key no passport emits. Added as a new migration rather than + edited into `0004`/`0019`: `sqlx::migrate!` checksums every file, so editing an + applied one stops a node that already ran it from booting. No data moves; the + column's values are catalog keys like `battery`, which did not change. + + **Stored documents do not survive this.** Every frozen fixture under + `crates/dpp-dal/tests/fixtures/passport_docs` is now listed in + `UNREADABLE_FIXTURES` — `productGroup` is required, so a document of the old + shape is refused loudly rather than read with the field silently missing. That + is defensible only because no such document exists in any deployment, which is + the condition the guard itself states. The consequence is that the guard is + currently vacuous, and it says so at the top of the file: it passes because + every failure is documented, not because the read path works. + +- **A passport records the acts it was issued under.** *(Breaking: two new + `PassportResponse` fields, `applicableInstruments` and `granularity`.)* + + `applicableInstruments` names each applicable instrument and whether it was + resolved from the catalog or asserted by the operator. Recorded at creation and + never recomputed: the law that governs a product is the law at placing on the + market, and the set is not derivable from the product group, so re-deriving it + could only narrow it. `granularity` is the model/batch/item level the + applicable delegated act fixes, absent while no adopted act fixes one. + + The determination gate moves with them. It asked "is this product group in + force", which is *yes* both for an act that imposes no passport at all and for + one whose information duty is discharged through another system — so it enforced + passport obligations that do not exist. It now requires an in-force act that + also requires a passport. + ## [0.12.0] - 2026-08-23 ### Breaking diff --git a/CLAUDE.md b/CLAUDE.md index 5cb81c80..89dad5c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ dpp-render — the ONE renderer for the public passport pag snapshot, so the two cannot drift dpp-integrator — CSV/XLSX bulk import dpp-common — event bus trait, telemetry, config helpers, RFC 7807 errors -dpp-plugin-host — wasmtime sandbox for sector Wasm plugins +dpp-plugin-host — wasmtime sandbox for product group Wasm plugins dpp-node — MVP single binary fusing vault + identity + integrator dpp-seal — eIDAS qualified seal adapter: one `SealBackend` behind the `SealPort`, selected by SEAL_PROVIDER (hosted QTSP / local dev @@ -189,7 +189,7 @@ development, copy `.cargo/config.toml.example` to `.cargo/config.toml` (or run `just core-local`) to add a `[patch.crates-io]` override that points each core crate at the sibling `../dpp-core` working tree. That file is git-ignored, so it never reaches CI; `just core-published` removes it to build against the registry again. -- `dpp-domain` — domain types (`Passport`, `SectorData`), port traits (`PassportRepository`, `IdentityPort`, `ComplianceRegistry`), schema validation, per-field disclosure policy (`access`) +- `dpp-domain` — domain types (`Passport`, `ProductGroupData`), port traits (`PassportRepository`, `IdentityPort`, `ComplianceRegistry`), schema validation, per-field disclosure policy (`access`) - `dpp-crypto` — Ed25519, JWS compact serialisation, encrypted key store - `dpp-vc` — W3C Verifiable Credentials, `did:web` document builder, status lists, `LocalIdentityService`, JSON-LD context - `dpp-digital-link` — GS1 Digital Link parser and link-type negotiation @@ -299,7 +299,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older ### Wasm Plugin Host -`dpp-plugin-host` loads `*.wasm` sector plugins from `PLUGINS_DIR`. Implements `ComplianceRegistry` from `dpp-domain::ports`. Sandbox: 10M fuel, 64 MiB memory, deny-all WASI. Falls back to `PassthroughRegistry` when no plugin is available for a sector. +`dpp-plugin-host` loads `*.wasm` product group plugins from `PLUGINS_DIR`. Implements `ComplianceRegistry` from `dpp-domain::ports`. Sandbox: 10M fuel, 64 MiB memory, deny-all WASI. Falls back to `PassthroughRegistry` when no plugin is available for a product group. ## All HTTP Routes @@ -326,7 +326,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | GET | `/vault/public/dpp/by-gtin/{gtin}` | None | Public passport read by GTIN | | GET | `/vault/credential/dpp/{dppId}` | **None** — `X-DPP-Credential` only | Audience-scoped read. Deliberately outside both `/public` (a public URL whose body varies by caller breaks caching and the meaning of `publicJwsSignature`) and `/api/v1` (a repairer or authority holds a credential and no API key). **Unauthenticated and network-touching**: it resolves the credential issuer's `did:web` over the guarded outbound path before anything is verified, and a verified read appends to the passport's audit trail. No credential ⇒ the public view, byte-identical to `/public/dpp/{dppId}` | | POST | `/vault/api/v1/dpp` | Bearer | Create passport | -| POST | `/vault/api/v1/dpp/validate` | Bearer **(write)** | Dry-run a create body, persisting nothing. Runs the same `validate_create_request` the create route runs, so the preview cannot disagree with it, and returns the identical `422` on rejection. Reports `createValid` **and** `publishValid` separately — create is lenient about an unresolvable sector schema, publish fails closed on it | +| POST | `/vault/api/v1/dpp/validate` | Bearer **(write)** | Dry-run a create body, persisting nothing. Runs the same `validate_create_request` the create route runs, so the preview cannot disagree with it, and returns the identical `422` on rejection. Reports `createValid` **and** `publishValid` separately — create is lenient about an unresolvable product group schema, publish fails closed on it | | GET | `/vault/api/v1/dpps` | Bearer | List passports | | GET | `/vault/api/v1/dpp/{dppId}` | Bearer | Read passport | | PUT | `/vault/api/v1/dpp/{dppId}` | Bearer | Update passport (draft only) | @@ -337,7 +337,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | 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 | | POST | `/vault/api/v1/dpp/{dppId}/transfer/accept` | Bearer (write) | Countersign and complete it | -| GET | `/vault/api/v1/dpp/by-identity` | Bearer | Find by (sector, GTIN, batch) — backs the import delta-matcher | +| GET | `/vault/api/v1/dpp/by-identity` | Bearer | Find by (product group, GTIN, batch) — backs the import delta-matcher | | GET | `/vault/api/v1/dpp/{dppId}/verify-tree` | Bearer | Walk and verify the component (BOM) graph | | 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 | @@ -359,7 +359,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | GET | `/vault/api/v1/api-keys` | Bearer (admin) | List API keys | | POST | `/vault/api/v1/api-keys` | Bearer (admin) | Create API key | | DELETE | `/vault/api/v1/api-keys/{id}` | Bearer (admin) | Revoke API key | -| POST | `/vault/api/v1/plugins` | Bearer (admin) | Install a **signed** sector plugin and hot-swap it | +| POST | `/vault/api/v1/plugins` | Bearer (admin) | Install a **signed** product group plugin and hot-swap it | | GET | `/vault/api/v1/webhooks` | Bearer (admin) | List webhook subscriptions | | POST | `/vault/api/v1/webhooks` | Bearer (admin) | Create one (SSRF-guarded URL) | | DELETE | `/vault/api/v1/webhooks/{id}` | Bearer (admin) | Remove one | @@ -378,8 +378,8 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | GET | `/identity/ready` | None | Identity ready | | GET | `/identity/.well-known/did.json` | None | DID document | | GET | `/integrator/health` | None | Integrator health | -| GET | `/integrator/api/v1/templates/{sector}` | None | CSV template download | -| POST | `/integrator/api/v1/import/{sector}` | Bearer (forwarded) | File upload import | +| GET | `/integrator/api/v1/templates/{productGroup}` | None | CSV template download | +| POST | `/integrator/api/v1/import/{productGroup}` | Bearer (forwarded) | File upload import | | GET | `/integrator/api/v1/imports/{job_id}` | Bearer | Poll job status | > The node mounts identity via `build_public` — only the public `/identity/*` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d828480..5f65e451 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ dpp-engine/ dpp-resolver/ # Public QR resolver (4 endpoints) dpp-integrator/ # CSV/XLSX bulk import (4 endpoints) dpp-common/ # Event bus trait, telemetry, RFC 7807 - dpp-plugin-host/ # Wasmtime sandbox for sector Wasm plugins + dpp-plugin-host/ # Wasmtime sandbox for product group Wasm plugins dpp-node/ # MVP single binary dpp-seal/ # eIDAS qualified seal adapter stub (NOT YET WIRED) dpp-factor-data/ # Licensed LCI factor data store stub (NOT YET WIRED) diff --git a/Cargo.lock b/Cargo.lock index 13ed7a16..e8c4f4f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2140,8 +2140,6 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpp-aas" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac847a91130fde81caa0cb7d0a86c1048f6e81dec7044135dd4e0297c44df2c" dependencies = [ "dpp-domain", "dpp-vocab", @@ -2152,8 +2150,6 @@ dependencies = [ [[package]] name = "dpp-calc" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1468d9923dd7635936568b0ec3b96f1170ac8b84f332db609ac7942a2e8156ad" dependencies = [ "chrono", "dpp-rules", @@ -2219,8 +2215,6 @@ dependencies = [ [[package]] name = "dpp-crypto" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58cc7c2e37250bf40c01beab1da7ac6f1544df062df2416ec1e7db65a31dad0" dependencies = [ "aes-gcm", "anyhow", @@ -2269,8 +2263,6 @@ dependencies = [ [[package]] name = "dpp-digital-link" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3658cbdf53ac901ca90fd258486a840b1c19ba5e54e9677fb7d99a196c6773" dependencies = [ "dpp-domain", "serde", @@ -2281,8 +2273,6 @@ dependencies = [ [[package]] name = "dpp-domain" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3f2996d3e47f21a20e81d3697a6af2ebf4f7d2d892ce94b9d4d28a0394c7ccc" dependencies = [ "async-trait", "chrono", @@ -2450,8 +2440,6 @@ dependencies = [ [[package]] name = "dpp-plugin-traits" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc790d7173a9242f7910f4cf4cecc9ff7b7ad41d72ee81a3efb4ca2fe2dcf35" dependencies = [ "semver", "serde", @@ -2462,8 +2450,6 @@ dependencies = [ [[package]] name = "dpp-registry" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3baf4f4295c8b2db71ec63dcde647dde90b41421713984fddaeca960d826b19b" dependencies = [ "chrono", "dpp-domain", @@ -2526,8 +2512,6 @@ dependencies = [ [[package]] name = "dpp-rules" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e11672f8f98d72fcecd7cec25009dd28354ac2df55fbf358a16299f5932813" dependencies = [ "base64 0.23.1", "chrono", @@ -2631,8 +2615,6 @@ dependencies = [ [[package]] name = "dpp-vc" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bded71bc2746b7372848daf3b7577e61d5d89b5198c739e5af8a250438855701" dependencies = [ "anyhow", "async-trait", @@ -2653,8 +2635,6 @@ dependencies = [ [[package]] name = "dpp-vocab" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a592a5007423d4eef371f12b8073ecf7d1b3029ec57d0746bf91e2018bcaf9e" dependencies = [ "serde", "serde_json", diff --git a/README.md b/README.md index df31cd00..fdf9da2c 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The engine ships as a **single binary** (`dpp-node`) that fuses all services und | `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-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-integrator` | bin+lib | CSV/XLSX-to-DPP bulk import adapter with per-sector templates | +| `dpp-integrator` | bin+lib | CSV/XLSX-to-DPP bulk import adapter with per-product group templates | | `dpp-common` | lib | Event bus trait + well-known subjects, telemetry, RFC 7807 HTTP errors | | `dpp-plugin-host` | lib | wasmtime sandbox — fuel metering, memory cap, deny-all WASI, signed-plugin policy | | `dpp-node` | bin | **The single binary — fuses all services**, boot trust-report, registry outbox drain, signed-ruleset loader | diff --git a/api/README.md b/api/README.md index ff790c50..c9e26dd3 100644 --- a/api/README.md +++ b/api/README.md @@ -8,13 +8,51 @@ - `components/schemas/`, `components/responses/` — one file per named component. - `openapi.bundled.yaml` — **generated, committed, never hand-edited.** The single-file form every consumer reads. +- `openapi.bundled.json` — the same document in JSON, for the contract test + below. Also generated and committed; also never hand-edited. ``` -just openapi-bundle # regenerate openapi.bundled.yaml from the tree -just openapi-check # lint the bundle +just openapi-bundle # regenerate both bundles from the tree +just openapi-check # diff the bundles, then lint the YAML one just openapi-html # regenerate the browsable spec (git-ignored) ``` +## The spec is checked against the code, not just against itself + +`crates/dpp-node/tests/openapi_contract.rs` runs in the ordinary `just check` +gate and fails when this description and the Rust types disagree: + +- **every named schema** is compared against the keys `serde` emits for a + maximally-populated instance of the type behind it — a field the server sends + and the spec omits fails, and so does a property the spec promises and the + server never sends; +- **every enum schema** is compared against the wire strings the Rust enum + actually serialises to; +- **every route** registered by each of the three deployables this document + describes — the node, the resolver, and the standalone identity service that + `servers` names as host of the mTLS signing surface — is compared against the + paths documented here, in both directions, with no exception list; +- **every schema in this directory** must be registered in that test or listed + in its `UNCHECKED` table with a reason. A new schema nothing checks fails the + build rather than passing quietly. + +This exists because `openapi-check` reads only the spec. Redocly can prove this +description is *valid*; it cannot prove it is *true*. Before the contract test, +nothing in CI opened a `.rs` file on the spec's behalf, and the two had drifted +apart in fourteen schemas, one enum and two routes — including a required +property no endpoint ever returned, and two lifecycle states the server emits +that the spec did not list. + +**Known limit: the contract test compares property *names*, not their types.** +A property documented as `type: string` whose field is an object still passes. +That gap is real — `co2ePerUnit` and `repairabilityScore` were both documented +as bare numbers long after they became objects. Check the type when you touch a +schema; the gate will not do it for you. + +When it fails, fix the spec or fix the type. Do not edit the test's fixtures to +agree with a wrong spec — the fixtures are the statement of what the server +sends. + ## Why the bundle is committed rather than built on demand The documentation site vendors this spec and verifies its copy against the diff --git a/api/components/responses/Conflict.yaml b/api/components/responses/Conflict.yaml index 3fbb9c68..7680a110 100644 --- a/api/components/responses/Conflict.yaml +++ b/api/components/responses/Conflict.yaml @@ -2,6 +2,11 @@ description: | State conflict — e.g. attempting to publish an already-published DPP or updating an archived DPP. content: - application/json: + application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml + example: + type: https://problems.odal-node.io/conflict + title: Conflict + status: 409 + detail: DPP is already published. diff --git a/api/components/responses/Forbidden.yaml b/api/components/responses/Forbidden.yaml index 9f63116e..bf41b1bc 100644 --- a/api/components/responses/Forbidden.yaml +++ b/api/components/responses/Forbidden.yaml @@ -2,9 +2,11 @@ description: | The credential is authenticated but lacks the required scope — e.g. a `write`/`read` key attempting an admin-only action (registry identity). content: - application/json: + application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml example: - error: FORBIDDEN - message: Registry-identity management requires an admin-scoped credential. + type: https://problems.odal-node.io/forbidden + title: Forbidden + status: 403 + detail: Registry-identity management requires an admin-scoped credential. diff --git a/api/components/responses/NotAcceptable.yaml b/api/components/responses/NotAcceptable.yaml index 127fb334..881d4979 100644 --- a/api/components/responses/NotAcceptable.yaml +++ b/api/components/responses/NotAcceptable.yaml @@ -3,15 +3,17 @@ description: | body names the media types this resource can produce. A passport carrying no GTIN — an unsold-goods report, or an untyped - sector — also gets this for `application/aas+json`: it identifies no + product group — also gets this for `application/aas+json`: it identifies no trade item, so it has no AAS asset identity and therefore no AAS representation. content: application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml example: - error: NOT_ACCEPTABLE - message: >- + type: https://problems.odal-node.io/not-acceptable + title: Not Acceptable + status: 406 + detail: >- No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json. diff --git a/api/components/responses/NotFound.yaml b/api/components/responses/NotFound.yaml index a4929226..3aadd506 100644 --- a/api/components/responses/NotFound.yaml +++ b/api/components/responses/NotFound.yaml @@ -1,8 +1,10 @@ description: Resource not found within the operator's scope. content: - application/json: + application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml example: - error: NOT_FOUND - message: DPP not found. + type: https://problems.odal-node.io/not-found + title: Not Found + status: 404 + detail: DPP not found. diff --git a/api/components/responses/Unauthorized.yaml b/api/components/responses/Unauthorized.yaml index 88dc44ab..4815fa30 100644 --- a/api/components/responses/Unauthorized.yaml +++ b/api/components/responses/Unauthorized.yaml @@ -1,8 +1,10 @@ description: Missing or invalid authentication credentials. content: - application/json: + application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml example: - error: UNAUTHORIZED - message: Missing or invalid Authorization header. + type: https://problems.odal-node.io/unauthorized + title: Unauthorized + status: 401 + detail: Missing or invalid Authorization header. diff --git a/api/components/responses/ValidationError.yaml b/api/components/responses/ValidationError.yaml index fbd6e3a4..a9651b4f 100644 --- a/api/components/responses/ValidationError.yaml +++ b/api/components/responses/ValidationError.yaml @@ -1,5 +1,10 @@ description: One or more fields failed validation. content: - application/json: + application/problem+json: schema: - $ref: ../schemas/ApiError.yaml + $ref: ../schemas/Problem.yaml + example: + type: https://problems.odal-node.io/unprocessable-entity + title: Unprocessable Entity + status: 422 + detail: productName must not be empty. diff --git a/api/components/schemas/ApiError.yaml b/api/components/schemas/ApiError.yaml deleted file mode 100644 index 7ed84e9d..00000000 --- a/api/components/schemas/ApiError.yaml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -required: - - error - - message -properties: - error: - type: string - example: VALIDATION_ERROR - message: - type: string - example: productName is required diff --git a/api/components/schemas/ApiKey.yaml b/api/components/schemas/ApiKey.yaml index fa092353..9cc9e443 100644 --- a/api/components/schemas/ApiKey.yaml +++ b/api/components/schemas/ApiKey.yaml @@ -4,6 +4,7 @@ required: - name - keyPrefix - isActive + - scope - createdAt properties: id: @@ -31,3 +32,5 @@ properties: - string - 'null' format: date-time + scope: + $ref: ./ApiKeyScope.yaml diff --git a/api/components/schemas/ApiKeyScope.yaml b/api/components/schemas/ApiKeyScope.yaml new file mode 100644 index 00000000..e41cc594 --- /dev/null +++ b/api/components/schemas/ApiKeyScope.yaml @@ -0,0 +1,9 @@ +type: string +description: >- + What an API key authorises. `admin` is the default when a key is minted + without an explicit scope, so a key issued to an integration should name + `read` or `write` deliberately. +enum: + - read + - write + - admin diff --git a/api/components/schemas/AuditEntry.yaml b/api/components/schemas/AuditEntry.yaml index c0d6642d..dff52d6c 100644 --- a/api/components/schemas/AuditEntry.yaml +++ b/api/components/schemas/AuditEntry.yaml @@ -2,7 +2,6 @@ type: object required: - id - passportId - - operatorId - actor - action - timestamp @@ -12,8 +11,6 @@ properties: format: uuid passportId: type: string - operatorId: - type: string actor: type: string example: admin@example.com @@ -35,3 +32,20 @@ properties: timestamp: type: string format: date-time + prevHash: + type: + - string + - 'null' + description: >- + Hex SHA-256 of the previous entry's `entryHash`, or null for the first + entry in a passport's chain. Together with `entryHash` this makes the + trail append-only and tamper-evident: recomputing the chain detects any + inserted, removed or edited entry. + entryHash: + type: + - string + - 'null' + description: >- + Hex SHA-256 over this entry's canonical (RFC 8785) bytes including + `prevHash`. Null only for entries written before the chain was + introduced. diff --git a/api/components/schemas/CarbonFootprint.yaml b/api/components/schemas/CarbonFootprint.yaml new file mode 100644 index 00000000..f60603a8 --- /dev/null +++ b/api/components/schemas/CarbonFootprint.yaml @@ -0,0 +1,39 @@ +type: object +description: >- + A CO₂-equivalent figure with the LCA context needed to read it. A bare number + is not comparable across products — the lifecycle stage and system boundary + are what make two figures mean the same thing. +required: + - valueKg +properties: + valueKg: + type: number + description: CO₂-equivalent value in kg per functional unit. + example: 45.2 + lifecycleStage: + type: string + description: LCA lifecycle stage this figure covers. + enum: + - cradle-to-gate + - cradle-to-grave + - cradle-to-cradle + - gate-to-grave + - other + systemBoundary: + type: string + description: LCA system-boundary standard used. + enum: + - EN-15804 + - ISO-14044 + - GHG-protocol + - other + methodologyRef: + type: string + description: Citation for the methodology behind the figure. + performanceClass: + type: string + maxLength: 8 + description: >- + Performance class label as defined by the applicable delegated act (e.g. + an A–G band). Free text because the banding is per product group. + example: B diff --git a/api/components/schemas/ComplianceFinding.yaml b/api/components/schemas/ComplianceFinding.yaml new file mode 100644 index 00000000..3f6fa8ea --- /dev/null +++ b/api/components/schemas/ComplianceFinding.yaml @@ -0,0 +1,21 @@ +type: object +description: >- + A single compliance finding. Severity is encoded by which array it lands in + on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no + separate severity field. +required: + - code + - message +properties: + code: + type: string + description: Stable machine-readable code. + example: battery.recycled_content.cobalt_below_2031 + field: + type: string + description: >- + JSON-pointer-style locator, or absent when the finding is not tied to a + single field. + example: /recycledContentCobaltPct + message: + type: string diff --git a/api/components/schemas/ComplianceResult.yaml b/api/components/schemas/ComplianceResult.yaml new file mode 100644 index 00000000..026e2a97 --- /dev/null +++ b/api/components/schemas/ComplianceResult.yaml @@ -0,0 +1,54 @@ +type: object +description: >- + The computed compliance determination. Part of the signed passport payload and + immutable after retention lock. Absent until a determination has been computed + — for example on a product group with no plugin loaded. +required: + - co2eScore + - repairabilityIndex + - recycledContentPct + - complianceStatus +properties: + co2eScore: + type: + - number + - 'null' + description: Calculated or manufacturer-supplied CO₂e score in kg. + repairabilityIndex: + type: + - number + - 'null' + description: Calculated or manufacturer-supplied repairability index (0.0–10.0). + recycledContentPct: + type: + - number + - 'null' + complianceStatus: + $ref: ./ComplianceStatus.yaml + violations: + type: array + description: >- + Binding findings — these block publish when the product group is in force. Empty + for passthrough or not-assessed determinations. + items: + $ref: ./ComplianceFinding.yaml + warnings: + type: array + description: >- + Advisory findings — surfaced but never blocking (e.g. thresholds not yet + in force). + items: + $ref: ./ComplianceFinding.yaml + rulesetVersion: + type: string + description: Version of the resolved calculation ruleset, when one ran. + assessedAt: + type: string + format: date-time + receipt: + type: object + additionalProperties: true + description: >- + Calculation receipt (input hash, ruleset id and version, factor dataset + version and table hash) for notified-body audit. Present only when a + calculation actually ran. diff --git a/api/components/schemas/ComplianceStatus.yaml b/api/components/schemas/ComplianceStatus.yaml new file mode 100644 index 00000000..9e22eb82 --- /dev/null +++ b/api/components/schemas/ComplianceStatus.yaml @@ -0,0 +1,10 @@ +type: string +description: >- + Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group + plugin was loaded and nothing was assessed — it is not a pass. +enum: + - PASSTHROUGH_NO_VALIDATION + - COMPLIANT + - NON_COMPLIANT + - NOT_ASSESSED + - NOT_IMPLEMENTED diff --git a/api/components/schemas/Coverage.yaml b/api/components/schemas/Coverage.yaml new file mode 100644 index 00000000..0a4cc4f4 --- /dev/null +++ b/api/components/schemas/Coverage.yaml @@ -0,0 +1,10 @@ +type: string +description: >- + Whether the stored seal covers the passport's current signature. Answered from + this node's record of what it *asked* to be sealed — weaker than a validator's + verdict, stronger than nothing: it cannot confirm the CAdES, but a passport + re-published after sealing is knowable without any AdES tooling. +enum: + - current + - superseded + - unknown diff --git a/api/components/schemas/CreateApiKeyRequest.yaml b/api/components/schemas/CreateApiKeyRequest.yaml index b5e652f7..c5f66d10 100644 --- a/api/components/schemas/CreateApiKeyRequest.yaml +++ b/api/components/schemas/CreateApiKeyRequest.yaml @@ -12,3 +12,9 @@ properties: - 'null' format: date-time description: Optional expiration. Null = never expires. + scope: + allOf: + - $ref: ./ApiKeyScope.yaml + description: >- + Defaults to `admin` when omitted. Name `read` or `write` explicitly for a + key issued to an integration. diff --git a/api/components/schemas/CreateRequest.yaml b/api/components/schemas/CreateRequest.yaml index 32c7b349..1d46deb9 100644 --- a/api/components/schemas/CreateRequest.yaml +++ b/api/components/schemas/CreateRequest.yaml @@ -11,11 +11,11 @@ properties: type: string description: Human-readable product name. Must not be blank. example: EcoCell Pro 48V - sector: + productGroup: type: string description: | - EU ESPR sector (the dispatch key), e.g. `battery`, `textile`, - `electronics`. Optional — derived from `sectorData` when omitted. + EU ESPR product group (the dispatch key), e.g. `battery`, `textile`, + `electronics`. Optional — derived from `productGroupData` when omitted. example: battery manufacturer: $ref: ./ManufacturerInfo.yaml @@ -25,17 +25,36 @@ properties: $ref: ./MaterialEntry.yaml co2ePerUnit: type: number - description: CO₂ equivalent per unit in kg + minimum: 0 + description: >- + CO₂ equivalent per unit, in kg. Must be finite and non-negative; + anything else is rejected with `422`. + + Supplied here as a scalar and stored as a `CarbonFootprint` object, so the + value echoed back on `PassportResponse` is `{ "valueKg": … }` rather than + the bare number sent. The lifecycle stage and system boundary that make + two figures comparable cannot be set through this route. example: 4.2 repairabilityScore: type: number minimum: 0 - maximum: 100 + maximum: 10 description: >- - Non-regulatory repairability indicator (0–100). NOT the EU 2023/1669 - repairability class. Optional. - sectorData: - $ref: ./SectorData.yaml + Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU + 2023/1669 — those have prescribed methodologies this does not implement. + Anything outside 0–10 is rejected with `422`. + + Supplied here as a scalar and stored as a `RepairabilityScore` object, so + the value echoed back on `PassportResponse` is `{ "overall": … }` rather + than the bare number sent. + example: 7.5 + productGroupData: + description: >- + Optional at create; an explicit `null` is accepted and equivalent to + omitting it. Publish validates it only when present. + anyOf: + - $ref: ./ProductGroupData.yaml + - type: 'null' batchId: type: string description: Optional batch or lot identifier @@ -57,8 +76,8 @@ properties: schemaVersion: type: string description: | - Sector schema version. Optional, and the only accepted value is the - sector's **current** version — omitting it is equivalent. Any other + Product group schema version. Optional, and the only accepted value is the + product group's **current** version — omitting it is equivalent. Any other value is rejected with `422`. It is not the caller's to choose: the stored version selects the @@ -78,3 +97,10 @@ properties: Cross-operator references to this product's constituent passports (its bill of materials). Local cycles / over-depth are refused at create/update; immutable after publish. + commodityCode: + type: string + description: >- + Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, + no separators). Registration data the EU registry verifies against the + ranges its product group permits. + example: '85076000' diff --git a/api/components/schemas/CreatedWebhookResponse.yaml b/api/components/schemas/CreatedWebhookResponse.yaml new file mode 100644 index 00000000..0d69b322 --- /dev/null +++ b/api/components/schemas/CreatedWebhookResponse.yaml @@ -0,0 +1,12 @@ +description: >- + The created subscription's fields, inlined, plus the signing secret. The + secret is shown **once** — it is not recoverable from any later read. +allOf: + - $ref: ./WebhookSubscription.yaml + - type: object + required: + - secret + properties: + secret: + type: string + description: Signing secret. Store it now; it is never shown again. diff --git a/api/components/schemas/CurrentOperatorView.yaml b/api/components/schemas/CurrentOperatorView.yaml new file mode 100644 index 00000000..05ef37c9 --- /dev/null +++ b/api/components/schemas/CurrentOperatorView.yaml @@ -0,0 +1,22 @@ +type: object +description: >- + The operator responsible for a passport **today**, derived from its transfer + chain. Reported separately from the passport's own `operatorIdentifier`, which + is the operator that *published* it — frozen at publish and covered by the + signature, so a transfer does not rewrite it. For a passport that has changed + hands the two differ, and that difference is a fact about the product. +required: + - did + - name + - country + - transferCount +properties: + did: + type: string + name: + type: string + country: + type: string + transferCount: + type: integer + description: Completed handovers this passport has been through. diff --git a/api/components/schemas/DailyScanCount.yaml b/api/components/schemas/DailyScanCount.yaml new file mode 100644 index 00000000..2bfc73fb --- /dev/null +++ b/api/components/schemas/DailyScanCount.yaml @@ -0,0 +1,10 @@ +type: object +required: + - day + - count +properties: + day: + type: string + format: date + count: + type: integer diff --git a/api/components/schemas/DossierManifest.yaml b/api/components/schemas/DossierManifest.yaml index 5b6ed722..7af758df 100644 --- a/api/components/schemas/DossierManifest.yaml +++ b/api/components/schemas/DossierManifest.yaml @@ -8,6 +8,7 @@ required: - issuerDid - createdAt - nodeVersion + - coreVersion - contentHashes properties: formatVersion: @@ -32,3 +33,12 @@ properties: description: member name -> hex SHA-256 of that member's JCS-canonical bytes. additionalProperties: type: string + coreVersion: + type: string + description: >- + The `dpp-core` version this node was built against. Recorded alongside + `nodeVersion` because the two move independently — the regulatory logic, + schemas and disclosure policy behind a determination live in core, so a + dossier naming only the node version cannot be traced back to the code + that produced its verdict. + example: 0.18.0 diff --git a/api/components/schemas/FacilitySnapshot.yaml b/api/components/schemas/FacilitySnapshot.yaml new file mode 100644 index 00000000..c3672815 --- /dev/null +++ b/api/components/schemas/FacilitySnapshot.yaml @@ -0,0 +1,26 @@ +type: object +description: >- + Annex III facility details copied by value into the passport at create time. + Self-contained on purpose: the signed passport stays a complete record even + after the operator retires the facility from their mutable registry. +required: + - scheme + - value + - name + - country +properties: + scheme: + type: string + example: gln + value: + type: string + example: '4012345000009' + name: + type: string + country: + type: string + minLength: 2 + maxLength: 2 + description: ISO 3166-1 alpha-2 country code. + address: + type: string diff --git a/api/components/schemas/ImportSyncResponse.yaml b/api/components/schemas/ImportSyncResponse.yaml index 0ccad6cd..181af61d 100644 --- a/api/components/schemas/ImportSyncResponse.yaml +++ b/api/components/schemas/ImportSyncResponse.yaml @@ -5,6 +5,7 @@ required: - successCount - errorCount - created + - updated - errors description: Returned for synchronous imports (≤ 100 valid rows) and dry runs. properties: @@ -38,3 +39,11 @@ properties: type: array items: $ref: ./ImportErrorEntry.yaml + updated: + type: array + description: >- + Rows that matched an existing passport and updated it. Counted in + `successCount`, which is deliberately not `created.length + + updated.length` — see that field. + items: + $ref: ./ImportUpdatedEntry.yaml diff --git a/api/components/schemas/ImportUpdatedEntry.yaml b/api/components/schemas/ImportUpdatedEntry.yaml new file mode 100644 index 00000000..f1c20ae4 --- /dev/null +++ b/api/components/schemas/ImportUpdatedEntry.yaml @@ -0,0 +1,12 @@ +type: object +description: One row that matched an existing passport and updated it. +required: + - row + - passportId +properties: + row: + type: integer + description: 1-based row number in the uploaded file, excluding the header. + passportId: + type: string + format: uuid diff --git a/api/components/schemas/InstalledPlugin.yaml b/api/components/schemas/InstalledPlugin.yaml new file mode 100644 index 00000000..d957645c --- /dev/null +++ b/api/components/schemas/InstalledPlugin.yaml @@ -0,0 +1,14 @@ +type: object +description: What the node installed, reported back after a successful hot-swap. +required: + - productGroup + - abiVersion +properties: + productGroup: + type: string + description: Product group catalog key the plugin is bound to. + example: battery + abiVersion: + type: string + description: ABI version the plugin declared, formatted `major.minor`. + example: '1.0' diff --git a/api/components/schemas/LintFinding.yaml b/api/components/schemas/LintFinding.yaml new file mode 100644 index 00000000..a7263075 --- /dev/null +++ b/api/components/schemas/LintFinding.yaml @@ -0,0 +1,17 @@ +type: object +required: + - code + - field + - severity + - message +properties: + code: + type: string + example: mass.balance + field: + type: string + example: /materials + severity: + $ref: ./LintSeverity.yaml + message: + type: string diff --git a/api/components/schemas/LintResult.yaml b/api/components/schemas/LintResult.yaml new file mode 100644 index 00000000..b52f15ca --- /dev/null +++ b/api/components/schemas/LintResult.yaml @@ -0,0 +1,21 @@ +type: object +description: >- + Non-binding plausibility findings — arithmetic and physical-plausibility + checks, distinct from binding compliance rules. Never gates publish, and may + be recomputed at any time after publish via `POST /dpp/{dppId}/lint` (unlike + `complianceResult`, which is frozen into the signed payload). +required: + - packVersion + - assessedAt +properties: + packVersion: + type: string + description: Version of the lint pack that produced these findings. + example: 1.0.0 + findings: + type: array + items: + $ref: ./LintFinding.yaml + assessedAt: + type: string + format: date-time diff --git a/api/components/schemas/LintSeverity.yaml b/api/components/schemas/LintSeverity.yaml new file mode 100644 index 00000000..fd290f0b --- /dev/null +++ b/api/components/schemas/LintSeverity.yaml @@ -0,0 +1,7 @@ +type: string +description: >- + Severity of a plausibility finding. Neither value blocks publish — lint is + advisory by construction, unlike a compliance violation. +enum: + - WARNING + - NOTICE diff --git a/api/components/schemas/MaterialEntry.yaml b/api/components/schemas/MaterialEntry.yaml index 14c17ddc..11bea22e 100644 --- a/api/components/schemas/MaterialEntry.yaml +++ b/api/components/schemas/MaterialEntry.yaml @@ -16,7 +16,7 @@ properties: maximum: 100 description: Percentage of recycled content (0.0–100.0) example: 35 - originCountry: + countryOfOrigin: type: string minLength: 2 maxLength: 2 diff --git a/api/components/schemas/NewWebhookSubscription.yaml b/api/components/schemas/NewWebhookSubscription.yaml new file mode 100644 index 00000000..78d57b73 --- /dev/null +++ b/api/components/schemas/NewWebhookSubscription.yaml @@ -0,0 +1,18 @@ +type: object +description: >- + Input for creating a subscription. The signing secret is generated + server-side and is never client-supplied, so it is not part of this body. +required: + - url + - events +properties: + url: + type: string + format: uri + description: Receiver URL. SSRF-validated before it is persisted. + events: + type: array + items: + type: string + description: + type: string diff --git a/api/components/schemas/OperatorConfig.yaml b/api/components/schemas/OperatorConfig.yaml index 2b5230e3..5023134e 100644 --- a/api/components/schemas/OperatorConfig.yaml +++ b/api/components/schemas/OperatorConfig.yaml @@ -78,3 +78,11 @@ properties: - string - 'null' format: date-time + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: >- + When this operator's identity was last verified against the EU registry. + Null when no verification has succeeded — never treat absence as verified. diff --git a/api/components/schemas/OperatorScanStats.yaml b/api/components/schemas/OperatorScanStats.yaml new file mode 100644 index 00000000..8de4b0ac --- /dev/null +++ b/api/components/schemas/OperatorScanStats.yaml @@ -0,0 +1,16 @@ +type: object +description: Operator-wide scan rollup over a trailing window. +required: + - windowDays + - totalScans + - totalQrRenders + - distinctPassportsScanned +properties: + windowDays: + type: integer + totalScans: + type: integer + totalQrRenders: + type: integer + distinctPassportsScanned: + type: integer diff --git a/api/components/schemas/PassportListResponse.yaml b/api/components/schemas/PassportListResponse.yaml new file mode 100644 index 00000000..43df88ae --- /dev/null +++ b/api/components/schemas/PassportListResponse.yaml @@ -0,0 +1,23 @@ +type: object +description: One page of passports, with the totals needed to page through them. +required: + - dpps + - total + - limit + - skip +properties: + dpps: + type: array + items: + $ref: ./PassportResponse.yaml + total: + type: integer + description: >- + Total matching the filter across every page — deliberately not the length + of `dpps`. + limit: + type: integer + description: The page size actually applied, after clamping. + skip: + type: integer + description: The offset this page starts at. diff --git a/api/components/schemas/PassportRegistryView.yaml b/api/components/schemas/PassportRegistryView.yaml new file mode 100644 index 00000000..9571906f --- /dev/null +++ b/api/components/schemas/PassportRegistryView.yaml @@ -0,0 +1,30 @@ +type: object +description: >- + EU-registry state for one passport. `configured: false` means this deployment + has no registry queues at all — reported instead of a row of zeros, which + would read as "everything is registered". +required: + - passportId + - configured +properties: + passportId: + type: string + configured: + type: boolean + registration: + allOf: + - $ref: ./RegistrationView.yaml + description: >- + Absent when the passport has never been published: it owes no + registration, which is different from owing one that has not happened. + transfers: + type: array + description: Handover notifications recorded for this passport, newest first. + items: + $ref: ./TransferView.yaml + currentOperator: + allOf: + - $ref: ./CurrentOperatorView.yaml + description: >- + Absent when the passport has never been transferred, in which case its own + `operatorIdentifier` is current. diff --git a/api/components/schemas/PassportResponse.yaml b/api/components/schemas/PassportResponse.yaml index aea2e543..030a6438 100644 --- a/api/components/schemas/PassportResponse.yaml +++ b/api/components/schemas/PassportResponse.yaml @@ -1,7 +1,12 @@ type: object +description: >- + The canonical Digital Product Passport record. Fields marked required are + emitted on every read; the rest are omitted when unset rather than sent as + null, so a consumer must treat absence and null as the same thing. required: - id - productName + - productGroup - manufacturer - materials - status @@ -9,6 +14,7 @@ required: - createdAt - updatedAt - retentionLocked + - version properties: id: $ref: ./DppId.yaml @@ -19,6 +25,62 @@ properties: productName: type: string example: EcoCell Pro 48V + productGroup: + type: string + description: >- + EU ESPR product group — the delegated-act bucket selecting the applicable schema + and plugin. Deliberately an open string, not a closed enum: adding a + product group is a catalog manifest plus a schema, not a release, and a + product group this build does not know still round-trips its wire tag verbatim. + example: battery + applicableInstruments: + type: array + description: >- + The legal instruments this passport was issued under, fixed when the + product was placed on the market and never recomputed afterwards. + + + A set rather than a single value because acts accumulate: ESPR Art. 5(7) + lets a group-specific delegated act supplement a horizontal one and the + Regulation states no precedence rule, so the governing law is the union of + the members' requirements. + + + Not derivable from `productGroup`. A horizontal act can reach a product + whose product group is not one the catalog models, so an entry may be + asserted by the economic operator rather than resolved from the catalog — + which `recorded` distinguishes. Read it as who asserted the entry, not as + how much to trust it. + items: + type: object + required: + - instrument + - recorded + properties: + instrument: + type: string + description: The instrument's catalog id. + example: battery-reg-2023-1542 + recorded: + type: string + enum: [catalog, operator] + description: >- + `catalog` — resolved from the instrument catalog at issuance. + `operator` — asserted by the economic operator placing the product on + the market. + example: catalog + additionalProperties: false + granularity: + type: string + enum: [model, batch, item] + description: >- + The level this passport describes. Set by the applicable delegated act + (ESPR Art. 9(2)(d)), so it is a property of the law rather than a choice + made here, and absent while no adopted act has fixed one — which is every + product group today. Absent is not `item`: the EU registry registers + batteries per item, but that is the registry's operational position, not a + level any act has set. + example: item manufacturer: $ref: ./ManufacturerInfo.yaml materials: @@ -26,15 +88,39 @@ properties: items: $ref: ./MaterialEntry.yaml co2ePerUnit: - type: - - number - - 'null' + description: >- + CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not + a bare number: a figure without its lifecycle stage and system boundary is + not comparable to another product's. + anyOf: + - $ref: ./CarbonFootprint.yaml + - type: 'null' repairabilityScore: - type: - - number - - 'null' - sectorData: - $ref: ./SectorData.yaml + description: Non-regulatory repairability heuristic — see the schema's own note. + anyOf: + - $ref: ./RepairabilityScore.yaml + - type: 'null' + complianceResult: + allOf: + - $ref: ./ComplianceResult.yaml + description: >- + The computed compliance determination, attached at create/update. Part of + the signed payload and immutable after retention lock. Absent until a + determination has been computed. + lintResult: + allOf: + - $ref: ./LintResult.yaml + description: >- + Advisory plausibility findings. Absent until a lint pass has run, and + recomputable after publish — unlike `complianceResult`. + productGroupData: + description: >- + Explicitly `null` — not omitted — for a passport whose product group data has not + been supplied yet, which is every draft created without it. The field has + no `skip_serializing_if`, so it is always present on the wire. + anyOf: + - $ref: ./ProductGroupData.yaml + - type: 'null' status: $ref: ./PassportStatus.yaml qrCodeUrl: @@ -51,7 +137,27 @@ properties: type: - string - 'null' - description: JWS compact serialisation (Ed25519). Null until published. + description: >- + Compact JWS (Ed25519) over the **full** canonical payload. Null until + published. + publicJwsSignature: + type: string + description: >- + Compact JWS over the **public (redacted) view**, so anyone can verify the + public passport independently — the resolver checks this on the + unauthenticated public route. Set at publish; absent for drafts. + disclosureSignatures: + type: object + additionalProperties: + type: string + description: >- + Compact JWS signatures over the **non-public** redacted views, keyed by + disclosure set (e.g. `public+restricted+individual`), never by audience + name. Every audience receiving more than the public view needs a proof + over *its* view: `publicJwsSignature` covers only the public payload and + `jwsSignature` only the full one, so a reader handed a filtered body and + either of those holds a signature that cannot verify against the bytes it + received. Empty for drafts. createdAt: type: string format: date-time @@ -63,11 +169,99 @@ properties: - string - 'null' format: date-time + placedOnMarketDate: + type: string + format: date + description: >- + The date the product was placed on the EU market — the regulated + triggering event fixing **which law governs it**, distinct from the three + lifecycle dates above, none of which selects a rule. Staged EU obligations + attach at placing on the market and do not move afterwards. Absence means + the date was not declared; it is **not** licence to substitute the current + date, and a determination depending on it has no answer. schemaVersion: type: string + description: >- + Semantic version of the **product group** schema `productGroupData` was validated + against. Scoped to `productGroupData` only — the envelope fields have no + equivalent version and never will, because they are shared by every + product group's stored documents. The envelope's compatibility rule is additive + only, permanently. example: 1.0.0 retentionLocked: type: boolean description: | Set to `true` permanently on first publish. Retention-locked passports must remain accessible for the EU ESPR retention period. + version: + type: integer + minimum: 1 + description: >- + Monotonic version counter. `1` on first publish; incremented on the + successor each time a new version supersedes this record. + supersedesId: + allOf: + - $ref: ./DppId.yaml + description: The passport this record supersedes. Absent for first versions. + parentPassportRef: + allOf: + - $ref: ./PassportRef.yaml + description: >- + Cross-operator reference to the predecessor this passport derives from + (second-life successor linkage). + componentRefs: + type: array + description: >- + Cross-operator references to the constituent passports this product is + assembled from — its bill of materials. The inverse edge of + `parentPassportRef`: these point down to many constituents, that points up + to one predecessor. + items: + $ref: ./PassportRef.yaml + retentionUntil: + type: string + format: date-time + description: >- + Deadline by which this record must remain accessible, computed at publish + from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) + requires the delegated act to specify a period corresponding to at least + the product's expected lifetime; Art. 11(e) restates it as an essential + requirement, available including after the responsible operator's + insolvency, liquidation or cessation of activity. + productId: + type: string + format: uuid + description: >- + Opaque link to an internal product-template record. Not a legal + identifier. + commodityCode: + type: string + description: >- + Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the + product group does not call for one; this node will not invent a + classification it cannot derive. + example: '85076000' + operatorIdentifier: + type: string + description: >- + EORI or national economic-operator identifier for the responsible party + (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). + **Frozen at publish — this is the operator that published the passport, + not necessarily the one responsible for it now.** A transfer of + responsibility does not rewrite it and cannot: published content is + immutable and covered by the signature over it. For current + responsibility, read the transfer chain. + example: DE123456789 + facility: + allOf: + - $ref: ./FacilitySnapshot.yaml + description: >- + Snapshot of the Annex III facility where this product was manufactured or + processed, copied by value at create time. + seal: + allOf: + - $ref: ./SealedEnvelope.yaml + description: >- + The eIDAS electronic seal applied to this passport. Absent until a seal + has been applied; check its `placeholder` flag rather than inferring + validity from presence. diff --git a/api/components/schemas/PassportScanStats.yaml b/api/components/schemas/PassportScanStats.yaml new file mode 100644 index 00000000..c008dc11 --- /dev/null +++ b/api/components/schemas/PassportScanStats.yaml @@ -0,0 +1,29 @@ +type: object +description: >- + Per-passport scan aggregates over a trailing window. `totalScans` and + `qrRenders` sit side by side and are never combined — a QR render is label + production, not a resolution. Nothing about the scanner (IP, agent, session) + is collected or returned; the counters carry no such fields. +required: + - windowDays + - totalScans + - scansHtml + - scansJson + - daily + - qrRenders +properties: + windowDays: + type: integer + totalScans: + type: integer + scansHtml: + type: integer + scansJson: + type: integer + daily: + type: array + description: Per-day scan totals, oldest first. + items: + $ref: ./DailyScanCount.yaml + qrRenders: + type: integer diff --git a/api/components/schemas/PassportStatus.yaml b/api/components/schemas/PassportStatus.yaml index 475ccab4..a624ee5f 100644 --- a/api/components/schemas/PassportStatus.yaml +++ b/api/components/schemas/PassportStatus.yaml @@ -4,6 +4,8 @@ enum: - active - suspended - archived + - superseded + - deactivated description: | DPP lifecycle state. The domain model uses `Published` internally; the wire format uses `active`. Deserialization accepts both. @@ -11,8 +13,15 @@ description: | - `active`: published and signed; publicly resolvable via QR - `suspended`: temporarily hidden (recall, dispute); JWS preserved - `archived`: end-of-life; immutable; retained for regulatory record-keeping + - `superseded`: replaced by a newer passport version; terminal + - `deactivated`: end-of-life declared (recycled, destroyed under a + derogation, exported, or lost); terminal. The typed reason is carried by + the EOL event, not this field Valid transitions: draft → active, draft → archived, - active → suspended, active → archived, - suspended → active, suspended → archived. + active → suspended, active → archived, active → superseded, + active → deactivated, + suspended → active, suspended → archived, suspended → deactivated. + + `archived`, `superseded` and `deactivated` are terminal. diff --git a/api/components/schemas/Problem.yaml b/api/components/schemas/Problem.yaml index 07a779df..9c080888 100644 --- a/api/components/schemas/Problem.yaml +++ b/api/components/schemas/Problem.yaml @@ -27,7 +27,7 @@ properties: detail: type: string description: Human-readable explanation for this specific occurrence. - example: "No schema for sector 'nosuchsector'. Known sectors: aluminium, battery." + example: "No schema for product group 'nosuchsector'. Known product groups: aluminium, battery." instance: type: string format: uri-reference diff --git a/api/components/schemas/ProductGroupData.yaml b/api/components/schemas/ProductGroupData.yaml new file mode 100644 index 00000000..7c6cade7 --- /dev/null +++ b/api/components/schemas/ProductGroupData.yaml @@ -0,0 +1,25 @@ +type: object +description: | + Product group-specific data, **internally tagged** by a `productGroup` discriminator + (`#[serde(tag = "product group")]`) — e.g. + `{ "product group": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. + The remaining fields are product group-specific and validated against the + product group's versioned JSON schema. Sectors include `battery`, `textile`, + `steel`, `aluminium`, `tyre`, `electronics`, and others. +required: + - productGroup +discriminator: + propertyName: product group +properties: + productGroup: + type: string + example: battery +additionalProperties: true +example: + productGroup: battery + gtin: '09506000134352' + batteryChemistry: LFP + nominalVoltageV: 48 + nominalCapacityAh: 100 + expectedLifetimeCycles: 3000 + co2ePerUnitKg: 45.2 diff --git a/api/components/schemas/RegistrationCounts.yaml b/api/components/schemas/RegistrationCounts.yaml new file mode 100644 index 00000000..e7d53a50 --- /dev/null +++ b/api/components/schemas/RegistrationCounts.yaml @@ -0,0 +1,35 @@ +type: object +required: + - pending + - submitted + - registered + - rejected + - deactivated + - statusIntents + - stalled + - unregisteredPublished +properties: + pending: + type: integer + submitted: + type: integer + registered: + type: integer + rejected: + type: integer + deactivated: + type: integer + statusIntents: + type: integer + description: Status changes owed to the registry that nothing drains. + stalled: + type: integer + description: Rows that have retried past the point of self-recovery. + unregisteredPublished: + type: integer + description: >- + Published passports with **no** outbox row at all — they owe a + registration nobody is tracking (published before the outbox existed, or + lost to an older write path). Reported, not repaired: the queued payload + is what a drain replays and there is none to rebuild, so fabricating a row + would create an entry that can never drain. diff --git a/api/components/schemas/RegistrationView.yaml b/api/components/schemas/RegistrationView.yaml new file mode 100644 index 00000000..1f053da5 --- /dev/null +++ b/api/components/schemas/RegistrationView.yaml @@ -0,0 +1,29 @@ +type: object +description: One passport's registration, as the EU-registry queue holds it. +required: + - status + - attempts + - stalled +properties: + status: + type: string + enum: [pending, submitted, registered, rejected, deactivated] + registryId: + type: string + description: The registry's own record id, once it has issued one. + message: + type: string + description: The last thing the registry (or the drain) said about it. + attempts: + type: integer + stalled: + type: boolean + description: >- + True once `attempts` reaches the drain threshold — the row is not going to + succeed without someone looking at it. + statusIntent: + type: string + description: >- + A status change owed to the registry, independent of the queue state. + Nothing drains these: the registry publishes no status-push API, so they + are held durably and reported rather than accumulating out of sight. diff --git a/api/components/schemas/RegistryRollupView.yaml b/api/components/schemas/RegistryRollupView.yaml new file mode 100644 index 00000000..fac6e5ea --- /dev/null +++ b/api/components/schemas/RegistryRollupView.yaml @@ -0,0 +1,16 @@ +type: object +description: >- + Operator-wide EU-registry state. `configured: false` means this deployment has + no registry queues; the counts are then omitted rather than reported as zero. +required: + - configured + - verification +properties: + configured: + type: boolean + verification: + $ref: ./VerificationView.yaml + registrations: + $ref: ./RegistrationCounts.yaml + transfers: + $ref: ./TransferCounts.yaml diff --git a/api/components/schemas/RepairCriterion.yaml b/api/components/schemas/RepairCriterion.yaml new file mode 100644 index 00000000..39169855 --- /dev/null +++ b/api/components/schemas/RepairCriterion.yaml @@ -0,0 +1,14 @@ +type: object +description: One weighted criterion contributing to a repairability score. +required: + - name + - score + - weight +properties: + name: + type: string + example: disassembly_depth + score: + type: number + weight: + type: number diff --git a/api/components/schemas/RepairabilityScore.yaml b/api/components/schemas/RepairabilityScore.yaml new file mode 100644 index 00000000..c26172e3 --- /dev/null +++ b/api/components/schemas/RepairabilityScore.yaml @@ -0,0 +1,18 @@ +type: object +description: >- + Repairability as a **non-regulatory heuristic**. Deliberately not an EN 45554 + or Regulation (EU) 2023/1669 index — those have prescribed methodologies this + does not implement, and presenting a heuristic as either would misstate it. +required: + - overall +properties: + overall: + type: number + minimum: 0 + maximum: 10 + example: 7.5 + criteria: + type: array + description: Per-criterion breakdown. Empty when only the overall score is known. + items: + $ref: ./RepairCriterion.yaml diff --git a/api/components/schemas/SealFormat.yaml b/api/components/schemas/SealFormat.yaml new file mode 100644 index 00000000..f3a06088 --- /dev/null +++ b/api/components/schemas/SealFormat.yaml @@ -0,0 +1,7 @@ +type: string +description: AdES format of a seal value. +enum: + - JADES + - PADES + - CADES + - XADES diff --git a/api/components/schemas/SealResponse.yaml b/api/components/schemas/SealResponse.yaml new file mode 100644 index 00000000..f3d47cf7 --- /dev/null +++ b/api/components/schemas/SealResponse.yaml @@ -0,0 +1,60 @@ +type: object +description: >- + The eIDAS seal, plus what is needed to check it — and an explicit statement of + what this node did **not** check. +required: + - format + - sealValue + - sealedAt + - placeholder + - currentJws + - currentPayloadHash + - coverage + - verification +properties: + format: + type: string + description: AdES format of `sealValue`. + example: CADES + sealValue: + type: string + description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. + sealedAt: + type: string + format: date-time + signingCertRef: + type: + - string + - 'null' + description: >- + Hex SHA-256 of the certificate the seal names as its signer, **as reported + by the seal** — read out of the CAdES, never verified. It answers *which* + certificate to ask about, not whether that certificate was qualified or on + the EU Trusted List. Null when the seal predates extraction or could not + be parsed. + placeholder: + type: boolean + description: True when this is a placeholder with no legal validity. + currentJws: + type: string + description: The passport's **current** compact JWS. + currentPayloadHash: + type: string + description: Hex SHA-256 of `currentJws`. + sealedPayloadHash: + type: + - string + - 'null' + description: >- + Hex SHA-256 this node **asked** the backend to seal. A record, not proof: + it says what was requested, while the validator's extracted message digest + says what the CAdES actually covers. The two agreeing is the cross-check. + Null for a seal restored from a backup or produced elsewhere. + coverage: + $ref: ./Coverage.yaml + verification: + type: string + description: >- + Stated, not implied: this node did not cryptographically validate the + CAdES. A detached CAdES must be checked by an independent AdES validator + against the EU Trusted List. diff --git a/api/components/schemas/SealSummaryResponse.yaml b/api/components/schemas/SealSummaryResponse.yaml new file mode 100644 index 00000000..5df8b55d --- /dev/null +++ b/api/components/schemas/SealSummaryResponse.yaml @@ -0,0 +1,24 @@ +type: object +description: >- + Operator-wide sealing state. Read `sealingConfigured` first: when it is false + every count is `0` because this node has no outbox, **not** because nothing is + outstanding. +required: + - unsealedPublished + - pending + - sealed + - exhausted + - sealingConfigured +properties: + unsealedPublished: + type: integer + description: Published passports carrying no seal at all. `0` is the healthy state. + pending: + type: integer + sealed: + type: integer + exhausted: + type: integer + description: Rows that gave up after exhausting their retries. + sealingConfigured: + type: boolean diff --git a/api/components/schemas/SealedEnvelope.yaml b/api/components/schemas/SealedEnvelope.yaml new file mode 100644 index 00000000..42215e13 --- /dev/null +++ b/api/components/schemas/SealedEnvelope.yaml @@ -0,0 +1,27 @@ +type: object +description: >- + An eIDAS electronic seal over the passport's full-payload signature. Check + `placeholder` before treating it as evidence — a placeholder envelope is + produced when no QTSP is configured and carries no legal validity. +required: + - format + - sealValue + - sealedAt + - placeholder +properties: + format: + $ref: ./SealFormat.yaml + sealValue: + type: string + description: Base64-encoded seal value as returned by the QTSP. + signingCertRef: + type: string + description: Reference to the signing certificate chain. + sealedAt: + type: string + format: date-time + placeholder: + type: boolean + description: >- + `true` when this envelope has **no** legal validity. Consumers must check + this flag rather than inferring validity from the envelope's presence. diff --git a/api/components/schemas/SectorData.yaml b/api/components/schemas/SectorData.yaml deleted file mode 100644 index 84eefe96..00000000 --- a/api/components/schemas/SectorData.yaml +++ /dev/null @@ -1,25 +0,0 @@ -type: object -description: | - Sector-specific data, **internally tagged** by a `sector` discriminator - (`#[serde(tag = "sector")]`) — e.g. - `{ "sector": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. - The remaining fields are sector-specific and validated against the - sector's versioned JSON schema. Sectors include `battery`, `textile`, - `steel`, `aluminium`, `tyre`, `electronics`, and others. -required: - - sector -discriminator: - propertyName: sector -properties: - sector: - type: string - example: battery -additionalProperties: true -example: - sector: battery - gtin: '09506000134352' - batteryChemistry: LFP - nominalVoltageV: 48 - nominalCapacityAh: 100 - expectedLifetimeCycles: 3000 - co2ePerUnitKg: 45.2 diff --git a/api/components/schemas/TransferCounts.yaml b/api/components/schemas/TransferCounts.yaml new file mode 100644 index 00000000..5a9d5081 --- /dev/null +++ b/api/components/schemas/TransferCounts.yaml @@ -0,0 +1,15 @@ +type: object +required: + - pending + - notified + - rejected + - stalled +properties: + pending: + type: integer + notified: + type: integer + rejected: + type: integer + stalled: + type: integer diff --git a/api/components/schemas/TransferView.yaml b/api/components/schemas/TransferView.yaml new file mode 100644 index 00000000..ff24da39 --- /dev/null +++ b/api/components/schemas/TransferView.yaml @@ -0,0 +1,22 @@ +type: object +description: One transfer-of-responsibility notification owed to the registry. +required: + - transferId + - status + - attempts + - stalled +properties: + transferId: + type: string + format: uuid + status: + type: string + enum: [pending, notified, rejected] + registryId: + type: string + message: + type: string + attempts: + type: integer + stalled: + type: boolean diff --git a/api/components/schemas/UpdateOperatorConfig.yaml b/api/components/schemas/UpdateOperatorConfig.yaml index cd992614..8f77c457 100644 --- a/api/components/schemas/UpdateOperatorConfig.yaml +++ b/api/components/schemas/UpdateOperatorConfig.yaml @@ -31,3 +31,11 @@ properties: type: integer featureFlags: type: object + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: >- + When this operator's identity was last verified against the EU registry. + Null when no verification has succeeded — never treat absence as verified. diff --git a/api/components/schemas/ValidateResponse.yaml b/api/components/schemas/ValidateResponse.yaml new file mode 100644 index 00000000..6e4de5be --- /dev/null +++ b/api/components/schemas/ValidateResponse.yaml @@ -0,0 +1,42 @@ +type: object +description: >- + The dry-run verdict. Two booleans rather than one, because create and publish + deliberately differ: a body can be creatable as a draft and not yet + publishable, and collapsing that into a single flag would hide the gap until + the caller tried to publish. +required: + - createValid + - productGroupDataValid +properties: + createValid: + type: boolean + description: >- + Always `true` on a 200 — a body create would reject gets back the + identical 422 create would have returned, not a paraphrase of it. + example: true + productGroupDataValid: + type: boolean + description: | + Whether the product group data would clear the publish-time schema gate. `true` + when no product group data is supplied, since publish only validates it when + present. + + **Not a publish verdict.** It reports one of publish's preconditions, and + publish applies others this route does not run: the registry-identity + requirement (a default facility and a primary operator identifier), which + needs operator state this route never reads; the category-mandatory + content gate, reachable only by attempting the lifecycle transition; and + the compliance gate, which needs a `placedOnMarketDate` and a stored + passport. `true` means "this body clears the schema gate", never "publish + will succeed" — the field was called `publishValid` and was renamed + because that name promised the latter. + example: false + detail: + type: + - string + - 'null' + description: >- + Why the product group data would be refused. Null when `productGroupDataValid` is true. + example: >- + cannot publish: no registered JSON Schema for product group 'furniture' — + publish requires a resolvable schema when product group data is present diff --git a/api/components/schemas/VerificationView.yaml b/api/components/schemas/VerificationView.yaml new file mode 100644 index 00000000..e65d5ac4 --- /dev/null +++ b/api/components/schemas/VerificationView.yaml @@ -0,0 +1,28 @@ +type: object +description: >- + The operator's verified-registry standing. Verified status ends when the + electronic identification means used expire, and at the latest three years + after verification; an operator that lets it lapse cannot register or amend + anything until it verifies again. Reported whether or not the queues are + configured. +required: + - current +properties: + current: + type: boolean + description: >- + False both when never verified and when lapsed — the registry refuses + either way, though they are different situations to act on. + verifiedAt: + type: string + format: date-time + description: Absent when never verified. + expiresAt: + type: string + format: date-time + description: >- + The three-year cap. The eID means may expire sooner, which this cannot + see, so it is an upper bound rather than a promise. + daysRemaining: + type: integer + description: Negative once lapsed. Absent when never verified. diff --git a/api/components/schemas/WebhookSubscription.yaml b/api/components/schemas/WebhookSubscription.yaml new file mode 100644 index 00000000..598c1cd8 --- /dev/null +++ b/api/components/schemas/WebhookSubscription.yaml @@ -0,0 +1,38 @@ +type: object +description: >- + A receiver subscription, redacted. The signing secret is never carried here — + it is returned exactly once from the create call and otherwise stays + server-side. +required: + - id + - url + - events + - active + - createdAt + - updatedAt +properties: + id: + type: string + format: uuid + url: + type: string + format: uri + description: Receiver URL — validated `https`, non-private host at creation. + events: + type: array + description: Subject filter — event type strings, or a single `*` for all events. + items: + type: string + active: + type: boolean + description: Removal is a soft `active = false`, never a hard delete. + description: + type: + - string + - 'null' + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time diff --git a/api/components/schemas/WhoamiResponse.yaml b/api/components/schemas/WhoamiResponse.yaml new file mode 100644 index 00000000..c8e54e53 --- /dev/null +++ b/api/components/schemas/WhoamiResponse.yaml @@ -0,0 +1,20 @@ +type: object +description: >- + What the presented credential is. Reports only what the caller already sent — + it reveals nothing about any other key, and the key's secret is never stored + in a recoverable form. +required: + - userId + - scope +properties: + userId: + type: string + description: The caller's identity, as authenticated. + scope: + $ref: ./ApiKeyScope.yaml + keyId: + type: string + format: uuid + description: >- + The key's row id — never the token. Absent for local-admin Basic auth, + which has no key row. diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json new file mode 100644 index 00000000..6e654e88 --- /dev/null +++ b/api/openapi.bundled.json @@ -0,0 +1,6823 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Odal Node API", + "version": "0.12.0", + "description": "**Sovereign Digital Product Passport Infrastructure**\n\nThe Odal API is split across two deployable services:\n\n- **Node** (`http://localhost:8001`) — the MVP service that bundles vault,\n identity, and integrator on a single port with sub-path routing:\n `/vault/*`, `/identity/*`, `/integrator/*`.\n- **Resolver** (`http://localhost:8003`) — standalone public resolver for\n QR code scans and supply-chain integrations. Unauthenticated.\n\nAll authenticated vault endpoints are versioned under `/vault/api/v1/`.\nPath parameters use OpenAPI 3.1 `{id}` brace syntax (aligned with\nAxum 0.8 routing).\n\nAuthentication uses `Authorization: Bearer odal_sk_...` API keys (SHA-256\nhashed, prefix-indexed) or HTTP Basic auth for local development.\n\nRegulation references:\n- EU Ecodesign for Sustainable Products Regulation (ESPR) 2024/1781\n- EU Battery Regulation 2023/1542\n- W3C DID Core Spec 1.0 / W3C VC Data Model 2.0\n", + "contact": { + "name": "Odal Support", + "email": "contact@odal-node.io", + "url": "https://docs.odal-node.io" + }, + "license": { + "name": "BSL-1.1", + "url": "https://mariadb.com/bsl11/" + }, + "x-legal-entity": "Odal Node" + }, + "servers": [ + { + "url": "http://localhost:8001", + "description": "Local development — odal-node (vault + identity public + integrator)" + }, + { + "url": "http://localhost:8002", + "description": "Local development — odal-identity (standalone; hosts the mTLS internal signing surface)" + }, + { + "url": "http://localhost:8003", + "description": "Local development — odal-resolver (public)" + } + ], + "tags": [ + { + "name": "DPP Management", + "description": "Create, read, update, list, and audit Digital Product Passports." + }, + { + "name": "DPP Lifecycle", + "description": "Lifecycle transitions — publish, suspend, archive." + }, + { + "name": "Scan Telemetry", + "description": "Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups." + }, + { + "name": "Registry", + "description": "EU-registry registration state — per-passport and operator-wide, plus the operator's verified-registry standing." + }, + { + "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": "Operator", + "description": "Operator configuration (branding, legal info, retention policy)." + }, + { + "name": "API Keys", + "description": "API key management — create, list, revoke." + }, + { + "name": "Plugins", + "description": "Signed product group-plugin hot-install — verify, persist, hot-swap (admin-only)." + }, + { + "name": "Webhooks", + "description": "Signed outbound event delivery — subscribe, list, remove, test." + }, + { + "name": "Facilities", + "description": "Manufacturing/processing facilities (ESPR Annex III) stamped onto new passports." + }, + { + "name": "Operator Identifiers", + "description": "Economic-operator identifiers (ESPR Art. 13) stamped onto new passports." + }, + { + "name": "Node", + "description": "Node setup/readiness state." + }, + { + "name": "Identity", + "description": "Public did:web identity — DID document, health, readiness. On the fused node these are served under `/identity/*`. The standalone identity service (:8002) serves them at the root — `/health`, `/ready`, `/.well-known/did.json`." + }, + { + "name": "Identity (internal)", + "description": "mTLS service-to-service signing/rotation. **Not exposed by the fused node**, which signs in-process — reachable only on the standalone identity service, and only from a client presenting `CN=odal-vault`." + }, + { + "name": "Integrator", + "description": "CSV/XLSX bulk import — templates, upload, async job polling." + }, + { + "name": "Public (Vault)", + "description": "Unauthenticated vault endpoints for inter-service communication." + }, + { + "name": "Credentialed Access", + "description": "Audience-scoped passport reads authenticated by a verifiable credential rather than an API key — repairers, market-surveillance authorities. Deliberately outside both `/public` (a public URL whose body varies by caller breaks caching and the meaning of `publicJwsSignature`) and `/api/v1` (API keys are the operator's own machine access; a repairer or authority holds a credential and no key)." + }, + { + "name": "Vault (internal)", + "description": "mTLS service-to-service telemetry ingestion (resolver → vault only)." + }, + { + "name": "Public Resolver", + "description": "Unauthenticated public endpoints for QR scan resolution." + }, + { + "name": "Health", + "description": "Health and readiness probes." + } + ], + "paths": { + "/vault/api/v1/dpp": { + "post": { + "operationId": "createDpp", + "summary": "Create a DPP", + "description": "Create a new Digital Product Passport in `draft` status.\nOnly `productName` (non-blank) and `manufacturer` (with `name`\nand `address`) are required. All other fields can be supplied\nlater via `PUT` before publishing.\n\nReturns the full passport record with a server-assigned UUID v7 `id`.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + }, + "example": { + "productName": "EcoCell Pro 48V", + "productGroup": "battery", + "manufacturer": { + "name": "EcoTech GmbH", + "address": "Hauptstraße 1, 10115 Berlin" + }, + "co2ePerUnit": 4.2, + "batchId": "BATCH-2026-04-001", + "productGroupData": { + "productGroup": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "nominalVoltageV": 48, + "nominalCapacityAh": 100, + "expectedLifetimeCycles": 3000, + "co2ePerUnitKg": 45.2 + } + } + } + } + }, + "responses": { + "201": { + "description": "DPP created in draft status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/dpp/validate": { + "post": { + "operationId": "validateDpp", + "summary": "Dry-run a passport body without creating it", + "description": "Runs exactly the validation `POST /vault/api/v1/dpp` runs, and persists\nnothing. The same code path serves both, so a preview and the real create\ncannot disagree.\n\n**Two verdicts, because create and publish deliberately differ.** Create is\nlenient about a product group with no resolvable JSON Schema — a draft is allowed\nto be incomplete — while publish fails closed on it, since a signed\npassport must have passed a real schema check. A body can therefore be\ncreatable but not yet clear the publish-time schema gate, and that gap is\nreported rather than hidden until publish is attempted.\n\nA body that create **would reject** gets back the identical `422` create\nwould have returned, not a paraphrase of it. A body create would accept\nreturns `200` with the two verdicts.\n\n**`productGroupDataValid` is not a publish verdict.** It reports one of publish's\npreconditions — the product group-data gates — and publish applies others this\nroute does not run:\n\n- the **registry identity** requirement (a default facility and a primary\n operator identifier), which needs operator state this route never reads;\n- the **category-mandatory content** gate, which is reachable only by\n attempting the lifecycle transition, so it cannot be previewed;\n- the **compliance** gate, which needs a `placedOnMarketDate` and a stored\n passport.\n\nA `productGroupDataValid: true` therefore means \"this body clears the schema\ngate\", never \"publish will succeed\". The field was called `publishValid`\nand was renamed because that name promised the latter.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The body is creatable. `productGroupDataValid` says whether its product group data\nwould also clear the publish-time schema gate.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/dpps": { + "get": { + "operationId": "listDpps", + "summary": "List DPPs", + "description": "Paginated list of DPPs for the authenticated operator.\nSupports filtering by status, free-text search across\n`productName`, `batchId`, and `manufacturer.name`, and an exact\n`facilityId` match (ESPR Annex III). A grouping filter, never an\nisolation boundary.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Free-text search across productName, batchId, manufacturer.name" + }, + { + "name": "status", + "in": "query", + "schema": { + "$ref": "#/components/schemas/PassportStatus" + } + }, + { + "name": "facilityId", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Exact match on the facility identifier stamped on the passport (see GET /facilities)." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Paginated list of DPPs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/dpp/by-identity": { + "get": { + "operationId": "findDppByIdentity", + "summary": "Find a DPP by exact compound identity", + "description": "Look up a passport by exact (product group, GTIN, batch) match, across\n`draft` and `active` statuses. Backs the import delta-matcher —\nnot intended as a general-purpose search (use `GET /dpps` for that).\n`batchId` omitted matches only passports with no batch set.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "product group", + "in": "query", + "required": true, + "schema": { + "type": "string", + "example": "battery" + } + }, + { + "name": "gtin", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "batchId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The matching DPP record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}": { + "get": { + "operationId": "getDpp", + "summary": "Get a DPP (write-side)", + "description": "Retrieve the full DPP record including drafts.\nScoped to the authenticated operator. For the public read\n(post-publish), use the resolver's `GET /dpp/{dppId}`.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Full DPP record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "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", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Free-form merge-patch. Supply only the fields being changed." + } + } + } + }, + "responses": { + "200": { + "description": "Updated DPP record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/publish": { + "post": { + "operationId": "publishDpp", + "summary": "Publish a DPP", + "description": "Transition a DPP from `draft` to `active`.\n\nOn success:\n1. `qrCodeUrl` is set to the passport's GS1 Digital Link carrier —\n `{resolverBase}/01/{gtin}/21/{serial}` for a trade item, else\n `{resolverBase}/dpp/{id}`\n2. The identity service signs the VC payload with Ed25519 (best-effort;\n signing failure does not block publish — `jwsSignature` may be null)\n3. `retentionLocked` is set to `true` permanently\n4. `status` = `active`, `publishedAt` = now\n5. A `dpp.published` NATS event is emitted (fire-after-commit)\n\nNo request body is required.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "DPP published successfully. Returns the full passport record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/suspend": { + "post": { + "operationId": "suspendDpp", + "summary": "Suspend a DPP", + "description": "Transition an `active` DPP to `suspended`.\nThe DPP becomes non-resolvable. The JWS signature is preserved.\nEmits a `dpp.suspended` NATS event.\n\nAn optional request body with a `reason` field can be provided.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "example": "Product recall — safety investigation pending" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "DPP suspended. Returns the full passport record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/archive": { + "post": { + "operationId": "archiveDpp", + "summary": "Archive a DPP", + "description": "Transition a DPP to `archived`. This is irreversible.\nArchived DPPs are immutable and retained for regulatory record-keeping.\nNo request body is required.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "DPP archived. Returns the full passport record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/eol": { + "post": { + "operationId": "declareDppEol", + "summary": "Declare a DPP end-of-life", + "description": "Transition a `published` or `suspended` DPP to `deactivated`\n(terminal). The record is retained, never deleted — the passport\noutlives the product. Destruction (`reason.kind: destroyed`) is only\nlawful with a recognised derogation from the unsold-goods destruction\nban (ESPR Art. 25 delegated act).\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EolRequest" + } + } + } + }, + "responses": { + "200": { + "description": "DPP deactivated. Returns the full passport record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/transfer/initiate": { + "post": { + "operationId": "initiateDppTransfer", + "summary": "Initiate a transfer of responsibility", + "description": "The outgoing operator signs a pending handover onto the passport's\ntransfer chain. Only a `published` DPP can be transferred. In the\nmanaged single-node model the caller supplies both the outgoing and\nincoming operator; the node signs on the outgoing operator's behalf.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferInitiateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Transfer initiated (pending acceptance).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferRecord" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/transfer/accept": { + "post": { + "operationId": "acceptDppTransfer", + "summary": "Accept a pending transfer of responsibility", + "description": "The incoming operator's signature completes a pending handover: the\noutgoing operator's signature is verified before the node countersigns\non the incoming operator's behalf, and the incoming operator becomes\nthe passport's current responsible operator.\n", + "tags": [ + "DPP Lifecycle" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Transfer completed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransferRecord" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "No pending transfer to accept for this DPP.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/history": { + "get": { + "operationId": "getDppHistory", + "summary": "Get DPP audit history", + "description": "Returns the chronological audit trail for a passport: creation,\nstatus transitions, field updates, etc. Unbounded — returns the full\ntrail with no pagination or limit.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "List of audit entries.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEntry" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/stats": { + "get": { + "operationId": "getDppScanStats", + "summary": "Per-passport scan telemetry", + "description": "Aggregate, privacy-safe resolution counts for one passport over a\ntrailing window. Scans and QR-image renders are reported as separate\nfields and are never summed — a render is label production, not a\nresolution. Nothing about the scanner (IP, agent, session) is collected\nor returned; the counters carry no such fields. Returns zeros for a\npassport that has never been scanned.\n", + "tags": [ + "Scan Telemetry" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + }, + { + "name": "days", + "in": "query", + "required": false, + "description": "Trailing window in days (default 30, clamped to 1..=730).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 730, + "default": 30 + } + } + ], + "responses": { + "200": { + "description": "Aggregate scan counts for the passport.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportScanStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/stats": { + "get": { + "operationId": "getOperatorScanStats", + "summary": "Operator-wide scan telemetry rollup", + "description": "Aggregate resolution counts across all of the operator's passports over\na trailing window — the \"your passports were resolved N times\" figure.\nScans and QR-image renders are separate; nothing about the scanner is\ncollected.\n", + "tags": [ + "Scan Telemetry" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "days", + "in": "query", + "required": false, + "description": "Trailing window in days (default 30, clamped to 1..=730).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 730, + "default": 30 + } + } + ], + "responses": { + "200": { + "description": "Operator-wide aggregate scan counts.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorScanStats" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/registry": { + "get": { + "operationId": "getDppRegistryStatus", + "summary": "EU-registry state for one passport", + "description": "What the EU registry knows about this passport: its queued registration,\nany handover notifications, and who is responsible for it now.\n\nRegistration is the legal obligation the rest of the system exists to\ndischarge, and this is where an operator sees whether it has been —\nthe state otherwise lives only in outbox tables, metrics and log lines.\n\n**Absent is not zero.** A deployment without the registry queues reports\n`configured: false` and omits the detail, rather than a row of zeros that\nwould read as \"everything is registered\".\n", + "tags": [ + "Registry" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Registry state for the passport.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportRegistryView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/registry": { + "get": { + "operationId": "getRegistryRollup", + "summary": "Operator-wide EU-registry rollup", + "description": "Registration and handover queue totals for this operator, plus its\nverified-registry standing.\n\nVerification is reported whether or not the queues are configured: verified\nstatus lapses at the latest three years after verification (sooner if the\nelectronic identification means expire), and a lapsed operator cannot\nregister or amend anything until it verifies again.\n\n**Absent is not zero** — see the per-passport route.\n", + "tags": [ + "Registry" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "Registry rollup for the operator.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistryRollupView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/verify-tree": { + "get": { + "operationId": "verifyDppTree", + "summary": "Recursively verify a passport's component tree (BOM)", + "description": "Walks the passport's `componentRefs` breadth-first, fetching each node\nand checking its public JWS against the pinned hash. Fails closed on\nevery ambiguity, bounded by a depth cap and a total-node cap; the report\nnames the path from the root to any broken node.\n\nIntegrity only: this proves each node's signed public view is unchanged\n(hash pin), not the cryptographic validity of the signature.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "The component-tree verification report.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreeReport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/seal": { + "get": { + "operationId": "getSealSummary", + "summary": "Operator-wide sealing state", + "description": "How many published passports carry no seal, plus the outbox totals\nbehind that number.\n\n`unsealedPublished` is the headline; the three row counts are context.\nThey answer different questions and can legitimately disagree: the\ncounts describe outbox **rows**, while the obligation is about\n**passports**. Enqueueing happens after the publish commits, so a crash\nin that window publishes a passport that no row will ever cover —\n`pending: 0, exhausted: 0` is therefore consistent with any number of\nunsealed passports, and a summary built on rows alone would report all\nclear. A repair sweep queues those passports on its next pass.\n\nA passport whose seal covers a *superseded* signature is not counted\nhere — it carries a seal, and that seal remains a valid attestation of\nthe signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal`\nreports that case per passport as `coverage`.\n\nWhen `sealingConfigured` is `false` no seal provider is selected, so\nevery count is `0` because this node has no outbox — not because it has\nnothing outstanding.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "Operator-wide sealing state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SealSummaryResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/seal": { + "get": { + "operationId": "getDppSeal", + "summary": "Fetch the passport's eIDAS qualified electronic seal", + "description": "Returns the qualified seal a QTSP applied to this passport, together with\nthe compact JWS it was taken over and that JWS's SHA-256 digest.\n\nThe seal has its own route because it is stripped from every audience\nview, public included: it covers the **full**-payload `jwsSignature`, so\nattaching it to a redacted body would hand the reader a proof that\nverifies against nothing they received.\n\n**This node does not validate the seal.** A detached CAdES must be\nchecked by an independent AdES validator against the EU Trusted List. A\nverdict from the node that bought the seal would attest nothing, so none\nis offered.\n\n`coverage` answers a narrower question that the node *can* answer, from\nits own records: `sealedPayloadHash` is the digest it asked the backend\nto seal, so a passport re-published after sealing shows as `superseded`\nwithout any AdES tooling. That is a record of what was requested, not\nproof of what the CAdES covers — the validator's extracted message\ndigest is the cross-check. A `superseded` seal remains valid for the\nsignature it does cover; a seal over the new signature has not landed\nyet.\n\n`404` when the passport has no seal — it may be unpublished, its seal may\nstill be queued, or the node may have no QTSP configured. An unsealed\npassport has no seal resource rather than an empty one.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "The qualified seal and the signature it attests to.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SealResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/lint": { + "post": { + "operationId": "relintDpp", + "summary": "Re-check plausibility-lint findings", + "description": "Recomputes the `dpp-rules` plausibility lint pack against the DPP's\ncurrent product group data and persists the refreshed `lintResult` (pack\nversion, findings, assessed-at timestamp). Findings are non-binding —\narithmetic and physical-plausibility checks distinct from binding\ncompliance rules — and never gate publish or any other transition.\n\nWorks regardless of DPP status, including `active` (published):\nre-checking does not retroactively affect the passport's JWS\nsignature, which is frozen over whatever `lintResult` looked like at\npublish time. No request body is required.\n", + "tags": [ + "DPP Management" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Lint findings refreshed. Returns the full passport record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/dpp/{dppId}/evidence": { + "post": { + "operationId": "generateDppEvidence", + "summary": "Generate and store a signed evidence dossier", + "description": "Assembles a self-contained, signed dossier proving a passport's full\nproof chain — both JWS signatures, DID document snapshots, the\nhash-chained audit trail, and (when present) the transfer chain and\nend-of-life record — and persists it. See\n`docs/architecture/EVIDENCE-DOSSIER.md` for the complete format\nspecification.\n\nAuthenticated tier only — the dossier's `fullView` member carries\nfull-view (non-redacted) passport data. Requires the passport to have\nbeen published at least once; a draft has no signature to export.\n", + "tags": [ + "Evidence Dossiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "201": { + "description": "The stored dossier record.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceDossierRecord" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + }, + "get": { + "operationId": "listDppEvidence", + "summary": "List stored evidence dossiers for a passport", + "description": "Summaries only (no document body), newest first.", + "tags": [ + "Evidence Dossiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "Stored dossier summaries.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EvidenceDossierSummary" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/evidence/{id}": { + "get": { + "operationId": "getEvidenceDossier", + "summary": "Fetch one stored dossier's document", + "description": "Returns the dossier document itself — the same shape `POST .../evidence` returns on generation, not the summary wrapper the list endpoint shows.", + "tags": [ + "Evidence Dossiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The evidence dossier document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceDossier" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/evidence/{id}/verify": { + "post": { + "operationId": "verifyStoredEvidenceDossier", + "summary": "Verify a stored dossier", + "description": "An integrity check against the stored dossier's own signatures and\nhash chains. A tamper is still a `200` response — the report's\n`checks` name which check failed.\n", + "tags": [ + "Evidence Dossiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "The verification report.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerificationReport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/evidence/verify": { + "post": { + "operationId": "verifyUploadedEvidenceDossier", + "summary": "Verify an uploaded dossier document", + "description": "Same checks as the stored-dossier verify endpoint, run against an uploaded document instead.", + "tags": [ + "Evidence Dossiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceDossier" + } + } + } + }, + "responses": { + "200": { + "description": "The verification report.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerificationReport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "422": { + "description": "Not a valid dossier — malformed JSON or an unrecognised field." + } + } + } + }, + "/vault/api/v1/operator": { + "get": { + "operationId": "getOperatorConfig", + "summary": "Get operator configuration", + "description": "Returns the calling operator's configuration (branding, legal info,\nretention settings). If no config has been saved yet, returns an\nempty default config rather than 404.\n", + "tags": [ + "Operator" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "Operator configuration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorConfig" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "patch": { + "operationId": "updateOperatorConfig", + "summary": "Update operator configuration", + "description": "Merge-patch the operator's configuration. Only supplied fields\nare changed.\n", + "tags": [ + "Operator" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOperatorConfig" + } + } + } + }, + "responses": { + "200": { + "description": "Updated operator configuration.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorConfig" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/api-keys": { + "get": { + "operationId": "listApiKeys", + "summary": "List active API keys", + "description": "Lists all active API keys for this deployment. Only the key prefix\nis returned — the full secret is shown once at creation time.\n", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "List of API keys.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "createApiKey", + "summary": "Create a new API key", + "description": "Generate a new API key. The response includes the full plain-text\n`secret` — this is the only time it will be shown. Store it securely.\n", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + } + }, + "responses": { + "201": { + "description": "API key created. The `secret` field is shown ONCE.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewApiKey" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/api-keys/{id}": { + "delete": { + "operationId": "revokeApiKey", + "summary": "Revoke an API key", + "description": "Soft-revokes a key. The record remains for audit but `isActive`\nis set to false. Subsequent auth attempts with this key will fail.\n", + "tags": [ + "API Keys" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Key revoked." + }, + "400": { + "description": "Invalid key ID format.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/plugins": { + "post": { + "operationId": "installPlugin", + "summary": "Install a signed product group plugin", + "description": "Verify, persist, and hot-swap a signed product group plugin — no node restart.\n\nThe node verifies the uploaded artifact's detached signature against its\npinned publisher key, gates the plugin's declared ABI, instantiate-smokes\nthe module, persists it (so a restart re-loads it), and atomically swaps\nit into service. Any rejection is fail-closed — the previously installed\nplugin keeps serving. Admin-scoped.\n\nBoth a portable `.wasm` (compiled on the node) and a precompiled `.cwasm`\n(loaded only if it matches this node's engine) are accepted.\n", + "tags": [ + "Plugins" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "wasm", + "sig" + ], + "properties": { + "wasm": { + "type": "string", + "format": "binary", + "description": "The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the product group when `productGroup` is omitted (`product group-.wasm`) and whether it is treated as precompiled (`.cwasm`).\n" + }, + "sig": { + "type": "string", + "format": "binary", + "description": "Detached Ed25519 signature over SHA-256 of the artifact bytes." + }, + "productGroup": { + "type": "string", + "description": "Product group key; derived from the filename if omitted.", + "example": "battery" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Plugin verified, persisted, and now serving.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstalledPlugin" + } + } + } + }, + "400": { + "description": "Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined)." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "A non-admin credential attempted to install a plugin." + }, + "422": { + "description": "The artifact was rejected — bad signature, incompatible ABI, or a non-instantiable/incompatible module." + }, + "501": { + "description": "This node has no plugin host configured; runtime install is unavailable." + } + } + } + }, + "/vault/api/v1/webhooks": { + "get": { + "operationId": "listWebhooks", + "summary": "List webhook subscriptions", + "description": "Lists webhook subscriptions (active and retired). The signing secret is\nnever returned here — only in the create response. Admin-scoped.\n", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "Subscriptions (secret redacted).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookSubscription" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "createWebhook", + "summary": "Create a webhook subscription", + "description": "Register a receiver URL (must be https and resolve to a public host,\nunless the node sets WEBHOOK_ALLOW_PRIVATE_TARGETS). The response includes\nthe `secret` — shown ONCE — used to verify the `X-Odal-Signature` header\non every delivery. Admin-scoped.\n", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewWebhookSubscription" + } + } + } + }, + "responses": { + "201": { + "description": "Subscription created. The `secret` field is shown ONCE.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatedWebhookResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/webhooks/{id}": { + "delete": { + "operationId": "removeWebhook", + "summary": "Remove a webhook subscription", + "description": "Soft-removes a subscription (`active` set to false). Deliveries already\nqueued still drain; no new events are enqueued. Admin-scoped.\n", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Subscription removed." + }, + "400": { + "description": "Invalid subscription id.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/webhooks/{id}/test": { + "post": { + "operationId": "testWebhook", + "summary": "Send a test delivery", + "description": "Enqueues a synthetic `dpp.webhook.test` delivery to the subscription so a\nreceiver can be proven end-to-end. Admin-scoped.\n", + "tags": [ + "Webhooks" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "202": { + "description": "Test delivery enqueued." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/node/state": { + "get": { + "operationId": "getNodeState", + "summary": "Node setup state", + "description": "Reports whether the node has been claimed (at least one active API key)\nand whether the operator identity is complete enough to publish. Used by\n`odal bootstrap` to stay idempotent.\n", + "tags": [ + "Node" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "Node setup state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodeState" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/whoami": { + "get": { + "operationId": "whoami", + "summary": "Identity and scope of the presented credential", + "description": "Echoes back what the caller's own credential is. A client cannot otherwise\ndiscover what it is allowed to do — a `read` key learns it is read-only by\nhaving a write rejected, which cannot be checked ahead of time.\n\nReports only what the caller already presented; it reveals nothing about\nany other key. `keyId` is the key's row id, never the token, and is absent\nfor local Basic auth, which has no key row.\n\nAvailable to every scope, including `read`.\n", + "tags": [ + "Node" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "The caller's identity and scope.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/vault/api/v1/facilities": { + "get": { + "operationId": "listFacilities", + "summary": "List facilities", + "description": "Lists the operator's facilities (ESPR Annex III). The `isDefault`\nfacility is stamped onto new passports. Requires an admin-scoped key.\n", + "tags": [ + "Facilities" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "List of facilities.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Facility" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "operationId": "addFacility", + "summary": "Add a facility", + "description": "Add a facility. The identifier is validated by scheme — a `gln` must\npass the GS1 mod-10 check digit. Requires an admin-scoped key.\n", + "tags": [ + "Facilities" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFacilityRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Facility created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Facility" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/facilities/{id}": { + "delete": { + "operationId": "removeFacility", + "summary": "Remove a facility", + "description": "Retires the facility (soft-delete): the row is kept as Annex III provenance for passports that already stamped its identifier — never hard-deleted. Requires an admin-scoped key.", + "tags": [ + "Facilities" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Facility removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/facilities/{id}/audit": { + "get": { + "operationId": "getFacilityAudit", + "summary": "Facility audit trail", + "description": "Append-only mutation history for one facility (added, retired,\nset-default), oldest first. Requires an admin-scoped key.\n", + "tags": [ + "Facilities" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of audit entries for this facility.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RegistryIdentityAudit" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/vault/api/v1/facilities/{id}/default": { + "post": { + "operationId": "setDefaultFacility", + "summary": "Set the default facility", + "description": "Makes this facility the sole default, stamped onto new passports. Requires an admin-scoped key.", + "tags": [ + "Facilities" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Default facility set." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/operator-identifiers": { + "get": { + "operationId": "listOperatorIdentifiers", + "summary": "List operator identifiers", + "description": "Lists the operator's economic-operator identifiers (ESPR Art. 13). The\n`isPrimary` identifier is stamped onto new passports. Admin scope required.\n", + "tags": [ + "Operator Identifiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "responses": { + "200": { + "description": "List of operator identifiers.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OperatorIdentifier" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + }, + "post": { + "operationId": "addOperatorIdentifier", + "summary": "Add an operator identifier", + "description": "Add an economic-operator identifier. Validated by scheme — LEI uses\nISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a country prefix.\nRequires an admin-scoped key.\n", + "tags": [ + "Operator Identifiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOperatorIdentifierRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Operator identifier created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OperatorIdentifier" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/vault/api/v1/operator-identifiers/{id}": { + "delete": { + "operationId": "removeOperatorIdentifier", + "summary": "Remove an operator identifier", + "description": "Retires the identifier (soft-delete): the row is kept as Art. 13 provenance for passports that already stamped its value — never hard-deleted. Requires an admin-scoped key.", + "tags": [ + "Operator Identifiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Operator identifier removed." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/api/v1/operator-identifiers/{id}/audit": { + "get": { + "operationId": "getOperatorIdentifierAudit", + "summary": "Operator-identifier audit trail", + "description": "Append-only mutation history for one operator identifier (added,\nretired, set-primary), oldest first. Requires an admin-scoped key.\n", + "tags": [ + "Operator Identifiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of audit entries for this operator identifier.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RegistryIdentityAudit" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/vault/api/v1/operator-identifiers/{id}/primary": { + "post": { + "operationId": "setPrimaryOperatorIdentifier", + "summary": "Set the primary operator identifier", + "description": "Makes this identifier the sole primary, stamped onto new passports. Requires an admin-scoped key.", + "tags": [ + "Operator Identifiers" + ], + "security": [ + { + "BearerApiKey": [] + }, + { + "BasicAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Primary operator identifier set." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/vault/public/dpp/{dppId}": { + "get": { + "operationId": "publicGetDpp", + "summary": "Public read of a published DPP", + "description": "Unauthenticated read of a published passport. Used by the resolver\nservice internally. Returns 404 for drafts, 410 for suspended.\n", + "tags": [ + "Public (Vault)" + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + }, + { + "name": "schema_view", + "in": "query", + "required": false, + "description": "Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed.", + "schema": { + "type": "string" + }, + "example": "2.0.0" + } + ], + "responses": { + "200": { + "description": "Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "404": { + "description": "Not found or not published.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "410": { + "description": "Passport has been suspended.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "422": { + "description": "The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no product group data).", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/vault/public/dpp/by-gtin/{gtin}": { + "get": { + "operationId": "publicGetDppByGtin", + "summary": "Public read of a published DPP by GTIN", + "description": "Unauthenticated read of the published passport for a GS1 GTIN-14.\nReturns 404 if no published passport matches the GTIN.\n", + "tags": [ + "Public (Vault)" + ], + "parameters": [ + { + "name": "gtin", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "GS1 GTIN-14 (14 digits, mod-10 check digit).", + "example": "09506000134352" + } + }, + { + "name": "schema_view", + "in": "query", + "required": false, + "description": "Request a read-time schema-upcast view (see the by-id read). The response becomes `{ passport, schemaView }`.", + "schema": { + "type": "string" + }, + "example": "2.0.0" + } + ], + "responses": { + "200": { + "description": "Published passport record. With `?schema_view`, the body is instead `{ passport, schemaView }`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "description": "The requested `schema_view` is unavailable.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/vault/credential/dpp/{dppId}": { + "get": { + "operationId": "readDppByCredential", + "summary": "Audience-scoped read of a published DPP", + "description": "Reads a published passport filtered to the caller's audience. No\n`X-DPP-Credential` header returns the same signed public view as\n`/public/dpp/{dppId}`. A verified credential returns the passport\nfiltered to that audience's disclosure classes (ESPR Art. 77(2)),\ncarrying the proof computed over that view. Credentialed reads are\nrecorded to the passport's audit trail; anonymous reads are not.\n\nReturns the public view (not an error) when credential verification\nis not configured on this node.\n", + "tags": [ + "Credentialed Access" + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + }, + { + "name": "X-DPP-Credential", + "in": "header", + "required": false, + "description": "A verifiable access credential. Absent means public access.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Passport filtered to the resolved audience.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + } + } + }, + "401": { + "description": "The presented credential failed verification.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "Not found or not published.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "410": { + "description": "This passport has been suspended.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/vault/internal/scan-batch": { + "post": { + "operationId": "ingestScanBatch", + "summary": "Flush a scan-telemetry batch (internal, mTLS)", + "description": "The mTLS-gated sink the public resolver flushes its in-memory\naggregate scan/QR-render counters to (`CN=odal-resolver` only). The\nresolver holds no operator API key and no database of its own.\n", + "tags": [ + "Vault (internal)" + ], + "security": [ + { + "MutualTLS": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanBatch" + } + } + } + }, + "responses": { + "204": { + "description": "Batch ingested." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/vault/health": { + "get": { + "operationId": "vaultHealth", + "summary": "Vault health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is up." + } + } + } + }, + "/vault/ready": { + "get": { + "operationId": "vaultReady", + "summary": "Vault readiness check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is ready (DB connection verified)." + } + } + } + }, + "/vault/api/v1/info": { + "get": { + "operationId": "vaultInfo", + "summary": "Service info", + "description": "Returns version and build information.", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service info.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VaultInfo" + } + } + } + } + } + } + }, + "/identity/.well-known/did.json": { + "get": { + "operationId": "getDidDocument", + "summary": "did:web DID document", + "description": "Serves the operator's `did:web` DID document. Public, unauthenticated.\n", + "tags": [ + "Identity" + ], + "responses": { + "200": { + "description": "The DID document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DidDocument" + } + } + } + } + } + } + }, + "/identity/health": { + "get": { + "operationId": "identityHealth", + "summary": "Identity health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is up." + } + } + } + }, + "/identity/ready": { + "get": { + "operationId": "identityReady", + "summary": "Identity readiness check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is ready." + } + } + } + }, + "/internal/sign": { + "servers": [ + { + "url": "http://localhost:8002", + "description": "odal-identity standalone (mTLS internal)" + } + ], + "post": { + "operationId": "internalSign", + "summary": "Sign a payload (internal, mTLS)", + "description": "Signs a base64-encoded canonical-JSON payload with the operator's\nEd25519 key (auto-provisioned on first use). Returns a compact JWS.\nService-to-service only — gated by mTLS (`CN=odal-vault`).\n", + "tags": [ + "Identity (internal)" + ], + "security": [ + { + "MutualTLS": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Signed payload.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignResponse" + } + } + } + }, + "400": { + "description": "Payload is not valid base64 or not valid JSON." + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/internal/verify": { + "servers": [ + { + "url": "http://localhost:8002", + "description": "odal-identity standalone (mTLS internal)" + } + ], + "post": { + "operationId": "internalVerify", + "summary": "Verify a JWS this service issued (internal, mTLS)", + "description": "Checks a compact JWS against the named operator's key *and* confirms\nit was signed over the given payload — a validly-signed JWS for\ndifferent content does not pass. Never errors on a signature that\nsimply fails to verify; that is `{ \"valid\": false }`, not a fault.\nService-to-service only — gated by mTLS (`CN=odal-vault`).\n", + "tags": [ + "Identity (internal)" + ], + "security": [ + { + "MutualTLS": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Verification result. Always `200` — an unverifiable signature is `{ \"valid\": false }`, not an error status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyResponse" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/internal/keys/rotate": { + "servers": [ + { + "url": "http://localhost:8002", + "description": "odal-identity standalone (mTLS internal)" + } + ], + "post": { + "operationId": "internalRotateKey", + "summary": "Rotate an operator signing key (internal, mTLS)", + "description": "Archives the current key (so prior signatures still verify), generates\na new primary key, and rebuilds the DID document. Service-to-service\nonly — gated by mTLS (`CN=odal-vault`).\n", + "tags": [ + "Identity (internal)" + ], + "security": [ + { + "MutualTLS": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Key rotated; returns the new fingerprint and DID document.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateResponse" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/integrator/health": { + "get": { + "operationId": "integratorHealth", + "summary": "Integrator health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is up." + } + } + } + }, + "/integrator/api/v1/templates/{productGroup}": { + "get": { + "operationId": "getImportTemplate", + "summary": "Download a CSV import template", + "description": "Returns the canonical CSV import template for a product group (`battery`,\n`textile`). `?format=xlsx` returns 501 (download the CSV and open it in\na spreadsheet app). Unauthenticated.\n", + "tags": [ + "Integrator" + ], + "parameters": [ + { + "name": "product group", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "battery" + } + }, + { + "name": "format", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "csv", + "xlsx" + ], + "default": "csv" + } + } + ], + "responses": { + "200": { + "description": "CSV template (Content-Disposition attachment).", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "No template for this product group." + }, + "501": { + "description": "XLSX export not yet implemented." + } + } + } + }, + "/integrator/api/v1/schemas": { + "get": { + "operationId": "listSectorSchemas", + "summary": "List product group schemas and their versions", + "description": "Every product group with a JSON Schema, the version a new passport is validated\nagainst (`current`), and every version a stored passport may legitimately\nrecord (`versions`). Unauthenticated.\n", + "tags": [ + "Integrator" + ], + "responses": { + "200": { + "description": "The available product group schemas.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "schemas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "productGroup": { + "type": "string", + "example": "battery" + }, + "current": { + "type": [ + "string", + "null" + ], + "example": "2.6.0" + }, + "versions": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "1.0.0", + "2.6.0" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "/integrator/api/v1/schemas/{productGroup}": { + "get": { + "operationId": "getCurrentSectorSchema", + "summary": "Fetch a product group's current JSON Schema", + "description": "The schema a passport created today is validated against, resolved through\nthe same registry the publish gate uses — never a copy, which would drift in\nthe direction where a body passes here and fails at publish. Unauthenticated.\n\nEvery `description` is omitted from the served document. Those fields make\nregulatory assertions that have not been verified against primary text, and\nserving them would turn developer-facing comments into a product surface.\nEverything that decides accept or reject — types, `enum`, `required`,\n`pattern`, bounds, `additionalProperties` — is served in full, so a client\ncan pre-validate a body and get the verdict the create route would give.\n", + "tags": [ + "Integrator" + ], + "parameters": [ + { + "name": "product group", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "battery" + } + } + ], + "responses": { + "200": { + "description": "The product group's current JSON Schema.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "404": { + "description": "No schema for this product group; the body names the known product groups.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/integrator/api/v1/schemas/{productGroup}/{version}": { + "get": { + "operationId": "getPinnedSectorSchema", + "summary": "Fetch a pinned version of a product group's JSON Schema", + "description": "A stored passport records the `schemaVersion` it was written under, so a\nclient holding one needs that exact schema rather than whatever is current.\nThe version may be given with or without a leading `v`. Unauthenticated.\n\nDescriptions are omitted, as on the current-schema route.\n", + "tags": [ + "Integrator" + ], + "parameters": [ + { + "name": "product group", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "battery" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "2.6.0" + } + } + ], + "responses": { + "200": { + "description": "The product group's JSON Schema at that version.", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "The version is not a semver string.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + }, + "404": { + "description": "No schema at that version; the body names what is available.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/integrator/api/v1/import/{productGroup}": { + "post": { + "operationId": "importFile", + "summary": "Bulk-import passports from a file", + "description": "Upload a CSV or XLSX file to create draft passports for a product group\n(`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's\n`Authorization: Bearer` token is validated and forwarded to the vault.\n\n- ≤ 100 valid rows → processed synchronously, `200` with results.\n- `> 100` valid rows → an async job is queued, `202` with a `jobId`.\n- `mode=dry_run` → validate only, `200` with the would-be results.\n\nEvery import — dry-run or apply, sync or async — mints a job id and\npersists a row-addressed report retrievable via the job-status\nendpoint, even when this endpoint's own response is synchronous.\n", + "tags": [ + "Integrator" + ], + "security": [ + { + "BearerApiKey": [] + } + ], + "parameters": [ + { + "name": "product group", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "battery" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "CSV or XLSX file." + }, + "mode": { + "type": "string", + "description": "\"dry_run\" to validate without creating records; any other value (or omitted) means apply." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Synchronous import (or dry-run) results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSyncResponse" + } + } + } + }, + "202": { + "description": "Async import job accepted — poll the job-status endpoint.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportAsyncResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "description": "Unknown product group." + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/integrator/api/v1/imports/{job_id}": { + "get": { + "operationId": "getImportJobStatus", + "summary": "Poll an async import job", + "description": "Returns the status and progress of an async import job. Requires the\nsame bearer auth as the import endpoint (validated against the vault).\n", + "tags": [ + "Integrator" + ], + "security": [ + { + "BearerApiKey": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Job status and progress.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid job ID format." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/dpp/{dppId}": { + "get": { + "operationId": "resolveDpp", + "summary": "Resolve a public DPP", + "description": "Resolve a published Digital Product Passport by ID. This endpoint\nis the target of QR code scans. No authentication required.\n\nEvery representation is built from the **signed** public payload, not\nthe live database row, so the body and the proof it carries agree by\nconstruction.\n\nContent negotiation via `Accept` header:\n- `application/json` / `application/ld+json` (default): JSON-LD\n passport data\n- `text/html`: the consumer-facing HTML passport page with operator\n branding (logo, colours)\n- `application/aas+json`: an IDTA Asset Administration Shell\n Environment (see below)\n\nAn absent, empty, `*/*`, `application/*`, `application/json` or\n`application/ld+json` header all reach the JSON-LD default. Only a\nheader naming something this route cannot produce gets `406`.\n\nResponses carry `Vary: Accept`.\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "DPP resolved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PassportResponse" + } + }, + "text/html": { + "schema": { + "type": "string", + "description": "Consumer-facing HTML passport page." + } + }, + "application/aas+json": { + "schema": { + "type": "object", + "description": "An IDTA Asset Administration Shell `Environment` — shells\nand submodels in one self-contained document.\n\n`conceptDescriptions` is **absent**, not empty. This node\ncoins no concept descriptions, and the metamodel constrains\nthat member to `minItems: 1`, so an empty array would make\nthe whole document invalid.\n\n**Public tier only.** The passport is filtered through the\ndisclosure seam before any AAS mapper sees it, so this door\nnever carries a field the JSON-LD door would withhold.\nRestricted and conformity-tier data require a credentialed\nchannel and a different projection.\n\n**Schema-valid, not conformance-certified.** Every\nEnvironment is validated in `dpp-core`'s CI against IDTA's\npublished AAS JSON Schemas for metamodel **3.0, 3.1 and\n3.2**, and must satisfy all three — no single revision is\nthe strictest, so the intersection is the only target that\nmeans \"loadable whichever revision your toolchain\nimplements\".\n\nThat establishes metamodel validity only: it is not a claim\nof IDTA conformance, and it asserts nothing about whether a\nsubmodel matches a published submodel template. Note also\nthat no AAS JSON Schema sets `additionalProperties`, so\nschema validity alone cannot rule out a member the metamodel\ndoes not define; `dpp-core` gates that separately.\n\n**Unsigned, and it says so in a header.** This is a derived\nrepresentation of the signed canonical public view, which is\nwhat `application/ld+json` returns for this same URL. The\npublic proof covers that payload, not this serialisation of\nit, so attaching the signature here would hand a verifier a\nproof that fails against the bytes it arrived with.\n\nEvery `200` therefore carries:\n\n```\nLink: <{resolverBase}/dpp/{dppId}>; rel=\"alternate\"; type=\"application/ld+json\"\n```\n\n`alternate` rather than `canonical`: the two representations\nshare one URL and are separated only by `Accept`, so a\n`canonical` relation would point this resource at itself.\nFollow the link with that `Accept` to obtain the signed\npayload and its proof.\n\n`resolverBase` is per-deployment (`RESOLVER_BASE_URL`,\ndefault `https://id.odal-node.io`). Error responses carry no\n`Link` — an error is not a representation of the passport.\n" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "406": { + "$ref": "#/components/responses/NotAcceptable" + } + } + } + }, + "/dpp/{dppId}/qr": { + "get": { + "operationId": "getDppQrCode", + "summary": "Get QR code for a DPP", + "description": "Returns a QR code image for the given DPP. The QR encodes the passport's\nGS1 Digital Link on the resolver's configured base\n(e.g. `https://id.odal-node.io/01/{gtin}/21/{serial}`).\nThis endpoint is on the **resolver** service (port 8003).\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "dppId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DppId" + } + } + ], + "responses": { + "200": { + "description": "QR code image.", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/01/{gtin}": { + "get": { + "operationId": "resolveByGtin", + "summary": "GS1 Digital Link resolver", + "description": "GS1-conformant resolver (GS1-CRSV1) keyed by GTIN-14. Behaviour depends\non the `linkType` query / `Accept` header:\n- default → `307` redirect to the HTML DPP page\n- `?linkType=gs1:pip` / `gs1:dpp` (and related) → `307` redirect to the DPP\n- `?linkType=linkset` or `Accept: application/linkset+json` → `200`\n RFC 9264 linkset\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "gtin", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "09506000134352" + } + }, + { + "name": "linkType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "e.g. linkset, gs1:pip, gs1:dpp" + } + ], + "responses": { + "200": { + "description": "RFC 9264 linkset (when a linkset is requested).", + "content": { + "application/linkset+json": { + "schema": { + "type": "object" + } + } + } + }, + "307": { + "description": "Redirect to the DPP page (`Location` header)." + }, + "404": { + "description": "No published DPP for this GTIN, or unknown link type." + } + } + } + }, + "/01/{gtin}/21/{serial}": { + "get": { + "operationId": "resolveByGtinSerial", + "summary": "GS1 Digital Link resolver — GTIN + serial", + "description": "A printed carrier may encode more than the GTIN — this node's own\npublisher emits `/01/{gtin}/21/{serial}` for a serialised trade item.\n**Resolution is keyed on the GTIN alone**: `serial` is accepted so no\nconformant carrier 404s for carrying more precision than the resolver\nindexes, but it is not looked up. Same behaviour as `/01/{gtin}`\notherwise (`linkType` / `Accept` negotiation).\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "gtin", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "09506000134352" + } + }, + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Accepted and ignored — not a resolution key." + }, + { + "name": "linkType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "e.g. linkset, gs1:pip, gs1:dpp" + } + ], + "responses": { + "200": { + "description": "RFC 9264 linkset (when a linkset is requested).", + "content": { + "application/linkset+json": { + "schema": { + "type": "object" + } + } + } + }, + "307": { + "description": "Redirect to the DPP page (`Location` header)." + }, + "404": { + "description": "No published DPP for this GTIN, or unknown link type." + } + } + } + }, + "/01/{gtin}/10/{batch}": { + "get": { + "operationId": "resolveByGtinBatch", + "summary": "GS1 Digital Link resolver — GTIN + batch/lot", + "description": "Accepts the batch/lot segment (AI 10) a carrier may include.\n**Resolution is keyed on the GTIN alone**: `batch` is accepted and\nignored, not looked up. Same behaviour as `/01/{gtin}` otherwise\n(`linkType` / `Accept` negotiation).\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "gtin", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "09506000134352" + } + }, + { + "name": "batch", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Accepted and ignored — not a resolution key." + }, + { + "name": "linkType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "e.g. linkset, gs1:pip, gs1:dpp" + } + ], + "responses": { + "200": { + "description": "RFC 9264 linkset (when a linkset is requested).", + "content": { + "application/linkset+json": { + "schema": { + "type": "object" + } + } + } + }, + "307": { + "description": "Redirect to the DPP page (`Location` header)." + }, + "404": { + "description": "No published DPP for this GTIN, or unknown link type." + } + } + } + }, + "/01/{gtin}/10/{batch}/21/{serial}": { + "get": { + "operationId": "resolveByGtinBatchSerial", + "summary": "GS1 Digital Link resolver — GTIN + batch/lot + serial", + "description": "The full shape this node's own carrier emits for a batched,\nserialised trade item. **Resolution is keyed on the GTIN alone**:\n`batch` and `serial` are accepted and ignored. Same behaviour as\n`/01/{gtin}` otherwise (`linkType` / `Accept` negotiation).\n", + "tags": [ + "Public Resolver" + ], + "parameters": [ + { + "name": "gtin", + "in": "path", + "required": true, + "schema": { + "type": "string", + "example": "09506000134352" + } + }, + { + "name": "batch", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Accepted and ignored — not a resolution key." + }, + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Accepted and ignored — not a resolution key." + }, + { + "name": "linkType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "e.g. linkset, gs1:pip, gs1:dpp" + } + ], + "responses": { + "200": { + "description": "RFC 9264 linkset (when a linkset is requested).", + "content": { + "application/linkset+json": { + "schema": { + "type": "object" + } + } + } + }, + "307": { + "description": "Redirect to the DPP page (`Location` header)." + }, + "404": { + "description": "No published DPP for this GTIN, or unknown link type." + } + } + } + }, + "/health": { + "get": { + "operationId": "resolverHealth", + "summary": "Resolver health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Resolver is up." + } + } + } + }, + "/ready": { + "get": { + "operationId": "resolverReady", + "summary": "Resolver readiness check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Resolver is ready." + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerApiKey": { + "type": "http", + "scheme": "bearer", + "description": "Machine-to-machine access via API keys. Keys are prefixed with\n`odal_sk_` and sent as `Authorization: Bearer odal_sk_...`.\n\nKeys are stored as SHA-256 hashes in PostgreSQL with a 12-character\nprefix index for fast lookup. The plain-text key is only returned\nonce at creation time via `POST /vault/api/v1/api-keys`.\n" + }, + "BasicAuth": { + "type": "http", + "scheme": "basic", + "description": "The operator's own bootstrap credential, from the `ADMIN_USERNAME` and\n`ADMIN_PASSWORD` environment variables. It mints the first API key on a\nfresh node — before any key exists — and is the lockout-recovery path\nafterwards, since it carries no `keyId` and so can revoke any key.\n\nActive in every environment where both variables are set; there is no\nproduction gate. Leave them unset once an API key exists if you do not\nwant the path available.\n\nReached only via the `Basic` scheme. A `Bearer` token is never matched\nagainst it, even one carrying the same `base64(user:pass)` payload.\n" + }, + "MutualTLS": { + "type": "mutualTLS", + "description": "Client-certificate (mTLS) authentication for service-to-service calls.\nThe standalone identity service accepts the internal `/internal/*`\nendpoints only from a client presenting `CN=odal-vault`. The fused\n`dpp-node` never exposes these over the network — it signs in-process.\n" + } + }, + "schemas": { + "ManufacturerInfo": { + "type": "object", + "required": [ + "name", + "address" + ], + "properties": { + "name": { + "type": "string", + "example": "EcoTech GmbH" + }, + "address": { + "type": "string", + "example": "Hauptstraße 1, 10115 Berlin" + }, + "didWebUrl": { + "type": "string", + "format": "uri", + "description": "did:web URL for the manufacturer", + "example": "https://ecotech.example.com/.well-known/did.json" + } + } + }, + "MaterialEntry": { + "type": "object", + "required": [ + "name", + "weightKg" + ], + "properties": { + "name": { + "type": "string", + "example": "Lithium carbonate" + }, + "weightKg": { + "type": "number", + "description": "Weight in kilograms", + "example": 12.5 + }, + "recycledPct": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage of recycled content (0.0–100.0)", + "example": 35 + }, + "countryOfOrigin": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "ISO 3166-1 alpha-2 country code", + "example": "DE" + } + } + }, + "ProductGroupData": { + "type": "object", + "description": "Product group-specific data, **internally tagged** by a `productGroup` discriminator\n(`#[serde(tag = \"product group\")]`) — e.g.\n`{ \"product group\": \"battery\", \"gtin\": \"…\", \"batteryChemistry\": \"LFP\", … }`.\nThe remaining fields are product group-specific and validated against the\nproduct group's versioned JSON schema. Sectors include `battery`, `textile`,\n`steel`, `aluminium`, `tyre`, `electronics`, and others.\n", + "required": [ + "productGroup" + ], + "discriminator": { + "propertyName": "product group" + }, + "properties": { + "productGroup": { + "type": "string", + "example": "battery" + } + }, + "additionalProperties": true, + "example": { + "productGroup": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "nominalVoltageV": 48, + "nominalCapacityAh": 100, + "expectedLifetimeCycles": 3000, + "co2ePerUnitKg": 45.2 + } + }, + "PassportRef": { + "type": "object", + "required": [ + "uri", + "publicJwsHash" + ], + "description": "A cross-operator reference to another passport: where to fetch it, and the lowercase-hex SHA-256 of that passport's public JWS, which pins the exact signed public view expected there.", + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "Resolvable https URI of the referenced passport.", + "example": "https://id.other-operator.example/dpp/0191b2c3-d4e5-7f80" + }, + "publicJwsHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Lowercase hex SHA-256 of the referenced passport's public JWS." + } + } + }, + "CreateRequest": { + "type": "object", + "required": [ + "productName", + "manufacturer" + ], + "description": "Request body for creating a new DPP. Only `productName` and\n`manufacturer` are required. All other fields are optional and\ncan be filled in later via `PUT` before publishing.\n", + "properties": { + "productName": { + "type": "string", + "description": "Human-readable product name. Must not be blank.", + "example": "EcoCell Pro 48V" + }, + "productGroup": { + "type": "string", + "description": "EU ESPR product group (the dispatch key), e.g. `battery`, `textile`,\n`electronics`. Optional — derived from `productGroupData` when omitted.\n", + "example": "battery" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerInfo" + }, + "materials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaterialEntry" + } + }, + "co2ePerUnit": { + "type": "number", + "minimum": 0, + "description": "CO₂ equivalent per unit, in kg. Must be finite and non-negative; anything else is rejected with `422`.\nSupplied here as a scalar and stored as a `CarbonFootprint` object, so the value echoed back on `PassportResponse` is `{ \"valueKg\": … }` rather than the bare number sent. The lifecycle stage and system boundary that make two figures comparable cannot be set through this route.", + "example": 4.2 + }, + "repairabilityScore": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU 2023/1669 — those have prescribed methodologies this does not implement. Anything outside 0–10 is rejected with `422`.\nSupplied here as a scalar and stored as a `RepairabilityScore` object, so the value echoed back on `PassportResponse` is `{ \"overall\": … }` rather than the bare number sent.", + "example": 7.5 + }, + "productGroupData": { + "description": "Optional at create; an explicit `null` is accepted and equivalent to omitting it. Publish validates it only when present.", + "anyOf": [ + { + "$ref": "#/components/schemas/ProductGroupData" + }, + { + "type": "null" + } + ] + }, + "batchId": { + "type": "string", + "description": "Optional batch or lot identifier", + "example": "BATCH-2026-04-001" + }, + "placedOnMarketDate": { + "type": "string", + "format": "date", + "description": "The date this product was placed on the EU market — the regulated\ntriggering event that fixes which law governs it.\n\nOptional, and omitting it is not neutral. A compliance determination\nwhose rule is phased by date has no answer without it: the node\nreports the missing fact rather than substituting today's date, which\nwould produce a determination that silently changes its own answer\nwhen a phase begins. For batteries this decides which EU 2023/1542\nArt. 8 minimum recycled shares apply.\n", + "example": "2026-03-14" + }, + "schemaVersion": { + "type": "string", + "description": "Product group schema version. Optional, and the only accepted value is the\nproduct group's **current** version — omitting it is equivalent. Any other\nvalue is rejected with `422`.\n\nIt is not the caller's to choose: the stored version selects the\ndisclosure table the passport's public view is filtered through and\nsigned under, and an older table classifies fewer fields, defaulting\nthe rest to public. The body is validated against the current schema\nin either case, so a differing declaration is already false about the\nbody it accompanies.\n", + "example": "2.6.0" + }, + "parentPassportRef": { + "$ref": "#/components/schemas/PassportRef" + }, + "componentRefs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PassportRef" + }, + "description": "Cross-operator references to this product's constituent passports (its bill of materials). Local cycles / over-depth are refused at create/update; immutable after publish." + }, + "commodityCode": { + "type": "string", + "description": "Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, no separators). Registration data the EU registry verifies against the ranges its product group permits.", + "example": "85076000" + } + } + }, + "DppId": { + "type": "string", + "format": "uuid", + "description": "UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs.", + "example": "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f" + }, + "CarbonFootprint": { + "type": "object", + "description": "A CO₂-equivalent figure with the LCA context needed to read it. A bare number is not comparable across products — the lifecycle stage and system boundary are what make two figures mean the same thing.", + "required": [ + "valueKg" + ], + "properties": { + "valueKg": { + "type": "number", + "description": "CO₂-equivalent value in kg per functional unit.", + "example": 45.2 + }, + "lifecycleStage": { + "type": "string", + "description": "LCA lifecycle stage this figure covers.", + "enum": [ + "cradle-to-gate", + "cradle-to-grave", + "cradle-to-cradle", + "gate-to-grave", + "other" + ] + }, + "systemBoundary": { + "type": "string", + "description": "LCA system-boundary standard used.", + "enum": [ + "EN-15804", + "ISO-14044", + "GHG-protocol", + "other" + ] + }, + "methodologyRef": { + "type": "string", + "description": "Citation for the methodology behind the figure." + }, + "performanceClass": { + "type": "string", + "maxLength": 8, + "description": "Performance class label as defined by the applicable delegated act (e.g. an A–G band). Free text because the banding is per product group.", + "example": "B" + } + } + }, + "RepairCriterion": { + "type": "object", + "description": "One weighted criterion contributing to a repairability score.", + "required": [ + "name", + "score", + "weight" + ], + "properties": { + "name": { + "type": "string", + "example": "disassembly_depth" + }, + "score": { + "type": "number" + }, + "weight": { + "type": "number" + } + } + }, + "RepairabilityScore": { + "type": "object", + "description": "Repairability as a **non-regulatory heuristic**. Deliberately not an EN 45554 or Regulation (EU) 2023/1669 index — those have prescribed methodologies this does not implement, and presenting a heuristic as either would misstate it.", + "required": [ + "overall" + ], + "properties": { + "overall": { + "type": "number", + "minimum": 0, + "maximum": 10, + "example": 7.5 + }, + "criteria": { + "type": "array", + "description": "Per-criterion breakdown. Empty when only the overall score is known.", + "items": { + "$ref": "#/components/schemas/RepairCriterion" + } + } + } + }, + "ComplianceStatus": { + "type": "string", + "description": "Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group plugin was loaded and nothing was assessed — it is not a pass.", + "enum": [ + "PASSTHROUGH_NO_VALIDATION", + "COMPLIANT", + "NON_COMPLIANT", + "NOT_ASSESSED", + "NOT_IMPLEMENTED" + ] + }, + "ComplianceFinding": { + "type": "object", + "description": "A single compliance finding. Severity is encoded by which array it lands in on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no separate severity field.", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable code.", + "example": "battery.recycled_content.cobalt_below_2031" + }, + "field": { + "type": "string", + "description": "JSON-pointer-style locator, or absent when the finding is not tied to a single field.", + "example": "/recycledContentCobaltPct" + }, + "message": { + "type": "string" + } + } + }, + "ComplianceResult": { + "type": "object", + "description": "The computed compliance determination. Part of the signed passport payload and immutable after retention lock. Absent until a determination has been computed — for example on a product group with no plugin loaded.", + "required": [ + "co2eScore", + "repairabilityIndex", + "recycledContentPct", + "complianceStatus" + ], + "properties": { + "co2eScore": { + "type": [ + "number", + "null" + ], + "description": "Calculated or manufacturer-supplied CO₂e score in kg." + }, + "repairabilityIndex": { + "type": [ + "number", + "null" + ], + "description": "Calculated or manufacturer-supplied repairability index (0.0–10.0)." + }, + "recycledContentPct": { + "type": [ + "number", + "null" + ] + }, + "complianceStatus": { + "$ref": "#/components/schemas/ComplianceStatus" + }, + "violations": { + "type": "array", + "description": "Binding findings — these block publish when the product group is in force. Empty for passthrough or not-assessed determinations.", + "items": { + "$ref": "#/components/schemas/ComplianceFinding" + } + }, + "warnings": { + "type": "array", + "description": "Advisory findings — surfaced but never blocking (e.g. thresholds not yet in force).", + "items": { + "$ref": "#/components/schemas/ComplianceFinding" + } + }, + "rulesetVersion": { + "type": "string", + "description": "Version of the resolved calculation ruleset, when one ran." + }, + "assessedAt": { + "type": "string", + "format": "date-time" + }, + "receipt": { + "type": "object", + "additionalProperties": true, + "description": "Calculation receipt (input hash, ruleset id and version, factor dataset version and table hash) for notified-body audit. Present only when a calculation actually ran." + } + } + }, + "LintSeverity": { + "type": "string", + "description": "Severity of a plausibility finding. Neither value blocks publish — lint is advisory by construction, unlike a compliance violation.", + "enum": [ + "WARNING", + "NOTICE" + ] + }, + "LintFinding": { + "type": "object", + "required": [ + "code", + "field", + "severity", + "message" + ], + "properties": { + "code": { + "type": "string", + "example": "mass.balance" + }, + "field": { + "type": "string", + "example": "/materials" + }, + "severity": { + "$ref": "#/components/schemas/LintSeverity" + }, + "message": { + "type": "string" + } + } + }, + "LintResult": { + "type": "object", + "description": "Non-binding plausibility findings — arithmetic and physical-plausibility checks, distinct from binding compliance rules. Never gates publish, and may be recomputed at any time after publish via `POST /dpp/{dppId}/lint` (unlike `complianceResult`, which is frozen into the signed payload).", + "required": [ + "packVersion", + "assessedAt" + ], + "properties": { + "packVersion": { + "type": "string", + "description": "Version of the lint pack that produced these findings.", + "example": "1.0.0" + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LintFinding" + } + }, + "assessedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PassportStatus": { + "type": "string", + "enum": [ + "draft", + "active", + "suspended", + "archived", + "superseded", + "deactivated" + ], + "description": "DPP lifecycle state. The domain model uses `Published` internally;\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" + }, + "FacilitySnapshot": { + "type": "object", + "description": "Annex III facility details copied by value into the passport at create time. Self-contained on purpose: the signed passport stays a complete record even after the operator retires the facility from their mutable registry.", + "required": [ + "scheme", + "value", + "name", + "country" + ], + "properties": { + "scheme": { + "type": "string", + "example": "gln" + }, + "value": { + "type": "string", + "example": "4012345000009" + }, + "name": { + "type": "string" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "ISO 3166-1 alpha-2 country code." + }, + "address": { + "type": "string" + } + } + }, + "SealFormat": { + "type": "string", + "description": "AdES format of a seal value.", + "enum": [ + "JADES", + "PADES", + "CADES", + "XADES" + ] + }, + "SealedEnvelope": { + "type": "object", + "description": "An eIDAS electronic seal over the passport's full-payload signature. Check `placeholder` before treating it as evidence — a placeholder envelope is produced when no QTSP is configured and carries no legal validity.", + "required": [ + "format", + "sealValue", + "sealedAt", + "placeholder" + ], + "properties": { + "format": { + "$ref": "#/components/schemas/SealFormat" + }, + "sealValue": { + "type": "string", + "description": "Base64-encoded seal value as returned by the QTSP." + }, + "signingCertRef": { + "type": "string", + "description": "Reference to the signing certificate chain." + }, + "sealedAt": { + "type": "string", + "format": "date-time" + }, + "placeholder": { + "type": "boolean", + "description": "`true` when this envelope has **no** legal validity. Consumers must check this flag rather than inferring validity from the envelope's presence." + } + } + }, + "PassportResponse": { + "type": "object", + "description": "The canonical Digital Product Passport record. Fields marked required are emitted on every read; the rest are omitted when unset rather than sent as null, so a consumer must treat absence and null as the same thing.", + "required": [ + "id", + "productName", + "productGroup", + "manufacturer", + "materials", + "status", + "schemaVersion", + "createdAt", + "updatedAt", + "retentionLocked", + "version" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/DppId" + }, + "batchId": { + "type": [ + "string", + "null" + ] + }, + "productName": { + "type": "string", + "example": "EcoCell Pro 48V" + }, + "productGroup": { + "type": "string", + "description": "EU ESPR product group — the delegated-act bucket selecting the applicable schema and plugin. Deliberately an open string, not a closed enum: adding a product group is a catalog manifest plus a schema, not a release, and a product group this build does not know still round-trips its wire tag verbatim.", + "example": "battery" + }, + "applicableInstruments": { + "type": "array", + "description": "The legal instruments this passport was issued under, fixed when the product was placed on the market and never recomputed afterwards.\n\nA set rather than a single value because acts accumulate: ESPR Art. 5(7) lets a group-specific delegated act supplement a horizontal one and the Regulation states no precedence rule, so the governing law is the union of the members' requirements.\n\nNot derivable from `productGroup`. A horizontal act can reach a product whose product group is not one the catalog models, so an entry may be asserted by the economic operator rather than resolved from the catalog — which `recorded` distinguishes. Read it as who asserted the entry, not as how much to trust it.", + "items": { + "type": "object", + "required": [ + "instrument", + "recorded" + ], + "properties": { + "instrument": { + "type": "string", + "description": "The instrument's catalog id.", + "example": "battery-reg-2023-1542" + }, + "recorded": { + "type": "string", + "enum": [ + "catalog", + "operator" + ], + "description": "`catalog` — resolved from the instrument catalog at issuance. `operator` — asserted by the economic operator placing the product on the market.", + "example": "catalog" + } + }, + "additionalProperties": false + } + }, + "granularity": { + "type": "string", + "enum": [ + "model", + "batch", + "item" + ], + "description": "The level this passport describes. Set by the applicable delegated act (ESPR Art. 9(2)(d)), so it is a property of the law rather than a choice made here, and absent while no adopted act has fixed one — which is every product group today. Absent is not `item`: the EU registry registers batteries per item, but that is the registry's operational position, not a level any act has set.", + "example": "item" + }, + "manufacturer": { + "$ref": "#/components/schemas/ManufacturerInfo" + }, + "materials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaterialEntry" + } + }, + "co2ePerUnit": { + "description": "CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not a bare number: a figure without its lifecycle stage and system boundary is not comparable to another product's.", + "anyOf": [ + { + "$ref": "#/components/schemas/CarbonFootprint" + }, + { + "type": "null" + } + ] + }, + "repairabilityScore": { + "description": "Non-regulatory repairability heuristic — see the schema's own note.", + "anyOf": [ + { + "$ref": "#/components/schemas/RepairabilityScore" + }, + { + "type": "null" + } + ] + }, + "complianceResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ComplianceResult" + } + ], + "description": "The computed compliance determination, attached at create/update. Part of the signed payload and immutable after retention lock. Absent until a determination has been computed." + }, + "lintResult": { + "allOf": [ + { + "$ref": "#/components/schemas/LintResult" + } + ], + "description": "Advisory plausibility findings. Absent until a lint pass has run, and recomputable after publish — unlike `complianceResult`." + }, + "productGroupData": { + "description": "Explicitly `null` — not omitted — for a passport whose product group data has not been supplied yet, which is every draft created without it. The field has no `skip_serializing_if`, so it is always present on the wire.", + "anyOf": [ + { + "$ref": "#/components/schemas/ProductGroupData" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/components/schemas/PassportStatus" + }, + "qrCodeUrl": { + "type": [ + "string", + "null" + ], + "format": "uri", + "description": "GS1 Digital Link the carrier (QR) encodes, set on publish: {resolverBase}/01/{gtin}/21/{serial} for a trade item, else {resolverBase}/dpp/{id}. resolverBase is per-deployment (RESOLVER_BASE_URL, default https://id.odal-node.io)." + }, + "jwsSignature": { + "type": [ + "string", + "null" + ], + "description": "Compact JWS (Ed25519) over the **full** canonical payload. Null until published." + }, + "publicJwsSignature": { + "type": "string", + "description": "Compact JWS over the **public (redacted) view**, so anyone can verify the public passport independently — the resolver checks this on the unauthenticated public route. Set at publish; absent for drafts." + }, + "disclosureSignatures": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Compact JWS signatures over the **non-public** redacted views, keyed by disclosure set (e.g. `public+restricted+individual`), never by audience name. Every audience receiving more than the public view needs a proof over *its* view: `publicJwsSignature` covers only the public payload and `jwsSignature` only the full one, so a reader handed a filtered body and either of those holds a signature that cannot verify against the bytes it received. Empty for drafts." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "publishedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "placedOnMarketDate": { + "type": "string", + "format": "date", + "description": "The date the product was placed on the EU market — the regulated triggering event fixing **which law governs it**, distinct from the three lifecycle dates above, none of which selects a rule. Staged EU obligations attach at placing on the market and do not move afterwards. Absence means the date was not declared; it is **not** licence to substitute the current date, and a determination depending on it has no answer." + }, + "schemaVersion": { + "type": "string", + "description": "Semantic version of the **product group** schema `productGroupData` was validated against. Scoped to `productGroupData` only — the envelope fields have no equivalent version and never will, because they are shared by every product group's stored documents. The envelope's compatibility rule is additive only, permanently.", + "example": "1.0.0" + }, + "retentionLocked": { + "type": "boolean", + "description": "Set to `true` permanently on first publish. Retention-locked\npassports must remain accessible for the EU ESPR retention period.\n" + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Monotonic version counter. `1` on first publish; incremented on the successor each time a new version supersedes this record." + }, + "supersedesId": { + "allOf": [ + { + "$ref": "#/components/schemas/DppId" + } + ], + "description": "The passport this record supersedes. Absent for first versions." + }, + "parentPassportRef": { + "allOf": [ + { + "$ref": "#/components/schemas/PassportRef" + } + ], + "description": "Cross-operator reference to the predecessor this passport derives from (second-life successor linkage)." + }, + "componentRefs": { + "type": "array", + "description": "Cross-operator references to the constituent passports this product is assembled from — its bill of materials. The inverse edge of `parentPassportRef`: these point down to many constituents, that points up to one predecessor.", + "items": { + "$ref": "#/components/schemas/PassportRef" + } + }, + "retentionUntil": { + "type": "string", + "format": "date-time", + "description": "Deadline by which this record must remain accessible, computed at publish from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) requires the delegated act to specify a period corresponding to at least the product's expected lifetime; Art. 11(e) restates it as an essential requirement, available including after the responsible operator's insolvency, liquidation or cessation of activity." + }, + "productId": { + "type": "string", + "format": "uuid", + "description": "Opaque link to an internal product-template record. Not a legal identifier." + }, + "commodityCode": { + "type": "string", + "description": "Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the product group does not call for one; this node will not invent a classification it cannot derive.", + "example": "85076000" + }, + "operatorIdentifier": { + "type": "string", + "description": "EORI or national economic-operator identifier for the responsible party (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). **Frozen at publish — this is the operator that published the passport, not necessarily the one responsible for it now.** A transfer of responsibility does not rewrite it and cannot: published content is immutable and covered by the signature over it. For current responsibility, read the transfer chain.", + "example": "DE123456789" + }, + "facility": { + "allOf": [ + { + "$ref": "#/components/schemas/FacilitySnapshot" + } + ], + "description": "Snapshot of the Annex III facility where this product was manufactured or processed, copied by value at create time." + }, + "seal": { + "allOf": [ + { + "$ref": "#/components/schemas/SealedEnvelope" + } + ], + "description": "The eIDAS electronic seal applied to this passport. Absent until a seal has been applied; check its `placeholder` flag rather than inferring validity from presence." + } + } + }, + "Problem": { + "type": "object", + "description": "RFC 7807 / RFC 9457 problem details. The shape\n`dpp-common::http_problem::Problem` produces, served as\n`application/problem+json`.\n\n`type` is derived from `title`, so each distinct `title` used across the\ncodebase is a stable catalogue key that clients may depend on.\n", + "required": [ + "type", + "title", + "status" + ], + "properties": { + "type": { + "type": "string", + "format": "uri", + "description": "Absolute URI identifying the problem type.", + "example": "https://problems.odal-node.io/not-found" + }, + "title": { + "type": "string", + "description": "Short human-readable summary of the problem type.", + "example": "Not Found" + }, + "status": { + "type": "integer", + "description": "The HTTP status code, mirroring the status line.", + "example": 404 + }, + "detail": { + "type": "string", + "description": "Human-readable explanation for this specific occurrence.", + "example": "No schema for product group 'nosuchsector'. Known product groups: aluminium, battery." + }, + "instance": { + "type": "string", + "format": "uri-reference", + "description": "URI reference identifying this specific occurrence." + } + } + }, + "ValidateResponse": { + "type": "object", + "description": "The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.", + "required": [ + "createValid", + "productGroupDataValid" + ], + "properties": { + "createValid": { + "type": "boolean", + "description": "Always `true` on a 200 — a body create would reject gets back the identical 422 create would have returned, not a paraphrase of it.", + "example": true + }, + "productGroupDataValid": { + "type": "boolean", + "description": "Whether the product group data would clear the publish-time schema gate. `true`\nwhen no product group data is supplied, since publish only validates it when\npresent.\n\n**Not a publish verdict.** It reports one of publish's preconditions, and\npublish applies others this route does not run: the registry-identity\nrequirement (a default facility and a primary operator identifier), which\nneeds operator state this route never reads; the category-mandatory\ncontent gate, reachable only by attempting the lifecycle transition; and\nthe compliance gate, which needs a `placedOnMarketDate` and a stored\npassport. `true` means \"this body clears the schema gate\", never \"publish\nwill succeed\" — the field was called `publishValid` and was renamed\nbecause that name promised the latter.\n", + "example": false + }, + "detail": { + "type": [ + "string", + "null" + ], + "description": "Why the product group data would be refused. Null when `productGroupDataValid` is true.", + "example": "cannot publish: no registered JSON Schema for product group 'furniture' — publish requires a resolvable schema when product group data is present" + } + } + }, + "PassportListResponse": { + "type": "object", + "description": "One page of passports, with the totals needed to page through them.", + "required": [ + "dpps", + "total", + "limit", + "skip" + ], + "properties": { + "dpps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PassportResponse" + } + }, + "total": { + "type": "integer", + "description": "Total matching the filter across every page — deliberately not the length of `dpps`." + }, + "limit": { + "type": "integer", + "description": "The page size actually applied, after clamping." + }, + "skip": { + "type": "integer", + "description": "The offset this page starts at." + } + } + }, + "DerogationRef": { + "type": "object", + "required": [ + "category" + ], + "description": "A recognised derogation from the ESPR Art. 25 destruction ban. The category list is fixed by the applicable delegated act; validated against that list at the engine boundary, not by this schema.", + "properties": { + "category": { + "type": "string", + "description": "The derogation category as named by the delegated act.", + "example": "health-and-safety" + }, + "actCitation": { + "type": [ + "string", + "null" + ], + "description": "The act/article this derogation is grounded in (e.g. an OJ/CELEX ref)." + } + } + }, + "DeactivationReason": { + "description": "Why a passport reached end-of-life, internally tagged by `kind`. Destruction alone requires a `derogation` citing the lawful basis.", + "oneOf": [ + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "recycled" + ] + } + } + }, + { + "type": "object", + "required": [ + "kind", + "derogation" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "destroyed" + ] + }, + "derogation": { + "$ref": "#/components/schemas/DerogationRef" + } + } + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "exported" + ] + } + } + }, + { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "lost" + ] + } + } + } + ], + "example": { + "kind": "recycled" + } + }, + "EolRequest": { + "type": "object", + "required": [ + "reason" + ], + "description": "Request body for declaring a passport end-of-life.", + "properties": { + "reason": { + "$ref": "#/components/schemas/DeactivationReason" + }, + "declaredBy": { + "type": [ + "string", + "null" + ], + "description": "DID of the declaring operator; defaults to the authenticated actor." + }, + "materialRecovery": { + "type": [ + "object", + "null" + ], + "description": "Optional recovered-material summary (Battery Annex XIII circularity)." + }, + "notes": { + "type": [ + "string", + "null" + ] + } + } + }, + "OperatorRole": { + "type": "string", + "description": "The role of an economic operator in the DPP supply chain.", + "enum": [ + "manufacturer", + "importer", + "distributor", + "authorisedRepresentative", + "remanufacturer", + "repurposer", + "preparerForReuse", + "repairer", + "recycler" + ] + }, + "ResponsibleOperator": { + "type": "object", + "required": [ + "did", + "name", + "role", + "country" + ], + "description": "An economic operator responsible for a DPP (ESPR \"responsible economic operator\").", + "properties": { + "did": { + "type": "string", + "example": "did:web:acme.example.com" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/OperatorRole" + }, + "euOperatorId": { + "type": [ + "string", + "null" + ], + "description": "EU-assigned economic operator identifier, if available." + }, + "euOperatorIdScheme": { + "type": [ + "string", + "null" + ], + "description": "Scheme euOperatorId is expressed in — \"vat\", \"lei\", \"eori\", \"duns\"." + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "description": "ISO 3166-1 alpha-2 country code of the operator's establishment." + } + } + }, + "TransferReason": { + "type": "string", + "description": "The reason for a transfer of DPP responsibility.", + "enum": [ + "sale", + "return", + "remanufacturing", + "repurposing", + "preparationForReuse", + "import", + "insolvencySuccession" + ] + }, + "TransferInitiateRequest": { + "type": "object", + "required": [ + "fromOperator", + "toOperator", + "reason" + ], + "properties": { + "fromOperator": { + "allOf": [ + { + "$ref": "#/components/schemas/ResponsibleOperator" + } + ], + "description": "The current (outgoing) responsible operator — must match the chain head." + }, + "toOperator": { + "allOf": [ + { + "$ref": "#/components/schemas/ResponsibleOperator" + } + ], + "description": "The incoming responsible operator taking over the DPP." + }, + "reason": { + "$ref": "#/components/schemas/TransferReason" + }, + "notes": { + "type": [ + "string", + "null" + ] + } + } + }, + "TransferRecord": { + "type": "object", + "required": [ + "transferId", + "passportId", + "fromOperator", + "toOperator", + "reason", + "initiatedAt" + ], + "description": "A single transfer-of-responsibility event, dual-signed by the outgoing and incoming operators.", + "properties": { + "transferId": { + "type": "string", + "format": "uuid" + }, + "passportId": { + "type": "string", + "format": "uuid" + }, + "fromOperator": { + "$ref": "#/components/schemas/ResponsibleOperator" + }, + "toOperator": { + "$ref": "#/components/schemas/ResponsibleOperator" + }, + "reason": { + "$ref": "#/components/schemas/TransferReason" + }, + "fromSignature": { + "type": [ + "string", + "null" + ], + "description": "Compact JWS from the outgoing operator, authorising the handover." + }, + "toSignature": { + "type": [ + "string", + "null" + ], + "description": "Compact JWS from the incoming operator, accepting responsibility." + }, + "initiatedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "rejectedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "cancelledAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "notes": { + "type": [ + "string", + "null" + ] + } + } + }, + "AuditEntry": { + "type": "object", + "required": [ + "id", + "passportId", + "actor", + "action", + "timestamp" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "passportId": { + "type": "string" + }, + "actor": { + "type": "string", + "example": "admin@example.com" + }, + "action": { + "type": "string", + "example": "published" + }, + "previousStatus": { + "type": [ + "string", + "null" + ] + }, + "newStatus": { + "type": [ + "string", + "null" + ] + }, + "metadata": { + "type": [ + "object", + "null" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "prevHash": { + "type": [ + "string", + "null" + ], + "description": "Hex SHA-256 of the previous entry's `entryHash`, or null for the first entry in a passport's chain. Together with `entryHash` this makes the trail append-only and tamper-evident: recomputing the chain detects any inserted, removed or edited entry." + }, + "entryHash": { + "type": [ + "string", + "null" + ], + "description": "Hex SHA-256 over this entry's canonical (RFC 8785) bytes including `prevHash`. Null only for entries written before the chain was introduced." + } + } + }, + "DailyScanCount": { + "type": "object", + "required": [ + "day", + "count" + ], + "properties": { + "day": { + "type": "string", + "format": "date" + }, + "count": { + "type": "integer" + } + } + }, + "PassportScanStats": { + "type": "object", + "description": "Per-passport scan aggregates over a trailing window. `totalScans` and `qrRenders` sit side by side and are never combined — a QR render is label production, not a resolution. Nothing about the scanner (IP, agent, session) is collected or returned; the counters carry no such fields.", + "required": [ + "windowDays", + "totalScans", + "scansHtml", + "scansJson", + "daily", + "qrRenders" + ], + "properties": { + "windowDays": { + "type": "integer" + }, + "totalScans": { + "type": "integer" + }, + "scansHtml": { + "type": "integer" + }, + "scansJson": { + "type": "integer" + }, + "daily": { + "type": "array", + "description": "Per-day scan totals, oldest first.", + "items": { + "$ref": "#/components/schemas/DailyScanCount" + } + }, + "qrRenders": { + "type": "integer" + } + } + }, + "OperatorScanStats": { + "type": "object", + "description": "Operator-wide scan rollup over a trailing window.", + "required": [ + "windowDays", + "totalScans", + "totalQrRenders", + "distinctPassportsScanned" + ], + "properties": { + "windowDays": { + "type": "integer" + }, + "totalScans": { + "type": "integer" + }, + "totalQrRenders": { + "type": "integer" + }, + "distinctPassportsScanned": { + "type": "integer" + } + } + }, + "RegistrationView": { + "type": "object", + "description": "One passport's registration, as the EU-registry queue holds it.", + "required": [ + "status", + "attempts", + "stalled" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending", + "submitted", + "registered", + "rejected", + "deactivated" + ] + }, + "registryId": { + "type": "string", + "description": "The registry's own record id, once it has issued one." + }, + "message": { + "type": "string", + "description": "The last thing the registry (or the drain) said about it." + }, + "attempts": { + "type": "integer" + }, + "stalled": { + "type": "boolean", + "description": "True once `attempts` reaches the drain threshold — the row is not going to succeed without someone looking at it." + }, + "statusIntent": { + "type": "string", + "description": "A status change owed to the registry, independent of the queue state. Nothing drains these: the registry publishes no status-push API, so they are held durably and reported rather than accumulating out of sight." + } + } + }, + "TransferView": { + "type": "object", + "description": "One transfer-of-responsibility notification owed to the registry.", + "required": [ + "transferId", + "status", + "attempts", + "stalled" + ], + "properties": { + "transferId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "notified", + "rejected" + ] + }, + "registryId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "attempts": { + "type": "integer" + }, + "stalled": { + "type": "boolean" + } + } + }, + "CurrentOperatorView": { + "type": "object", + "description": "The operator responsible for a passport **today**, derived from its transfer chain. Reported separately from the passport's own `operatorIdentifier`, which is the operator that *published* it — frozen at publish and covered by the signature, so a transfer does not rewrite it. For a passport that has changed hands the two differ, and that difference is a fact about the product.", + "required": [ + "did", + "name", + "country", + "transferCount" + ], + "properties": { + "did": { + "type": "string" + }, + "name": { + "type": "string" + }, + "country": { + "type": "string" + }, + "transferCount": { + "type": "integer", + "description": "Completed handovers this passport has been through." + } + } + }, + "PassportRegistryView": { + "type": "object", + "description": "EU-registry state for one passport. `configured: false` means this deployment has no registry queues at all — reported instead of a row of zeros, which would read as \"everything is registered\".", + "required": [ + "passportId", + "configured" + ], + "properties": { + "passportId": { + "type": "string" + }, + "configured": { + "type": "boolean" + }, + "registration": { + "allOf": [ + { + "$ref": "#/components/schemas/RegistrationView" + } + ], + "description": "Absent when the passport has never been published: it owes no registration, which is different from owing one that has not happened." + }, + "transfers": { + "type": "array", + "description": "Handover notifications recorded for this passport, newest first.", + "items": { + "$ref": "#/components/schemas/TransferView" + } + }, + "currentOperator": { + "allOf": [ + { + "$ref": "#/components/schemas/CurrentOperatorView" + } + ], + "description": "Absent when the passport has never been transferred, in which case its own `operatorIdentifier` is current." + } + } + }, + "VerificationView": { + "type": "object", + "description": "The operator's verified-registry standing. Verified status ends when the electronic identification means used expire, and at the latest three years after verification; an operator that lets it lapse cannot register or amend anything until it verifies again. Reported whether or not the queues are configured.", + "required": [ + "current" + ], + "properties": { + "current": { + "type": "boolean", + "description": "False both when never verified and when lapsed — the registry refuses either way, though they are different situations to act on." + }, + "verifiedAt": { + "type": "string", + "format": "date-time", + "description": "Absent when never verified." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "The three-year cap. The eID means may expire sooner, which this cannot see, so it is an upper bound rather than a promise." + }, + "daysRemaining": { + "type": "integer", + "description": "Negative once lapsed. Absent when never verified." + } + } + }, + "RegistrationCounts": { + "type": "object", + "required": [ + "pending", + "submitted", + "registered", + "rejected", + "deactivated", + "statusIntents", + "stalled", + "unregisteredPublished" + ], + "properties": { + "pending": { + "type": "integer" + }, + "submitted": { + "type": "integer" + }, + "registered": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "deactivated": { + "type": "integer" + }, + "statusIntents": { + "type": "integer", + "description": "Status changes owed to the registry that nothing drains." + }, + "stalled": { + "type": "integer", + "description": "Rows that have retried past the point of self-recovery." + }, + "unregisteredPublished": { + "type": "integer", + "description": "Published passports with **no** outbox row at all — they owe a registration nobody is tracking (published before the outbox existed, or lost to an older write path). Reported, not repaired: the queued payload is what a drain replays and there is none to rebuild, so fabricating a row would create an entry that can never drain." + } + } + }, + "TransferCounts": { + "type": "object", + "required": [ + "pending", + "notified", + "rejected", + "stalled" + ], + "properties": { + "pending": { + "type": "integer" + }, + "notified": { + "type": "integer" + }, + "rejected": { + "type": "integer" + }, + "stalled": { + "type": "integer" + } + } + }, + "RegistryRollupView": { + "type": "object", + "description": "Operator-wide EU-registry state. `configured: false` means this deployment has no registry queues; the counts are then omitted rather than reported as zero.", + "required": [ + "configured", + "verification" + ], + "properties": { + "configured": { + "type": "boolean" + }, + "verification": { + "$ref": "#/components/schemas/VerificationView" + }, + "registrations": { + "$ref": "#/components/schemas/RegistrationCounts" + }, + "transfers": { + "$ref": "#/components/schemas/TransferCounts" + } + } + }, + "TreeNodeReport": { + "type": "object", + "required": [ + "path", + "verified" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Component-ref URIs from the root down to this node." + }, + "verified": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ], + "enum": [ + "unreachable", + "notPublished", + "hashMismatch", + "cycle", + "depthExceeded", + "nodeCapExceeded", + "malformedRef" + ], + "description": "The failure reason when `verified` is false." + } + } + }, + "TreeReport": { + "type": "object", + "required": [ + "verified", + "nodes" + ], + "description": "Result of recursively verifying a passport's component tree.", + "properties": { + "verified": { + "type": "boolean", + "description": "True iff every visited node verified." + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TreeNodeReport" + } + } + } + }, + "SealSummaryResponse": { + "type": "object", + "description": "Operator-wide sealing state. Read `sealingConfigured` first: when it is false every count is `0` because this node has no outbox, **not** because nothing is outstanding.", + "required": [ + "unsealedPublished", + "pending", + "sealed", + "exhausted", + "sealingConfigured" + ], + "properties": { + "unsealedPublished": { + "type": "integer", + "description": "Published passports carrying no seal at all. `0` is the healthy state." + }, + "pending": { + "type": "integer" + }, + "sealed": { + "type": "integer" + }, + "exhausted": { + "type": "integer", + "description": "Rows that gave up after exhausting their retries." + }, + "sealingConfigured": { + "type": "boolean" + } + } + }, + "Coverage": { + "type": "string", + "description": "Whether the stored seal covers the passport's current signature. Answered from this node's record of what it *asked* to be sealed — weaker than a validator's verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.", + "enum": [ + "current", + "superseded", + "unknown" + ] + }, + "SealResponse": { + "type": "object", + "description": "The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check.", + "required": [ + "format", + "sealValue", + "sealedAt", + "placeholder", + "currentJws", + "currentPayloadHash", + "coverage", + "verification" + ], + "properties": { + "format": { + "type": "string", + "description": "AdES format of `sealValue`.", + "example": "CADES" + }, + "sealValue": { + "type": "string", + "description": "Base64 detached CAdES (`.p7s`) as returned by the QTSP." + }, + "sealedAt": { + "type": "string", + "format": "date-time" + }, + "signingCertRef": { + "type": [ + "string", + "null" + ], + "description": "Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed." + }, + "placeholder": { + "type": "boolean", + "description": "True when this is a placeholder with no legal validity." + }, + "currentJws": { + "type": "string", + "description": "The passport's **current** compact JWS." + }, + "currentPayloadHash": { + "type": "string", + "description": "Hex SHA-256 of `currentJws`." + }, + "sealedPayloadHash": { + "type": [ + "string", + "null" + ], + "description": "Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator's extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere." + }, + "coverage": { + "$ref": "#/components/schemas/Coverage" + }, + "verification": { + "type": "string", + "description": "Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List." + } + } + }, + "EvidenceDossierSummary": { + "type": "object", + "description": "Listing projection of a stored dossier — everything but the document.", + "required": [ + "id", + "passportId", + "actor", + "createdAt", + "docHash" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "passportId": { + "type": "string", + "format": "uuid" + }, + "actor": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "docHash": { + "type": "string" + } + } + }, + "DossierManifest": { + "type": "object", + "description": "Signed metadata binding every dossier member into one atomic,\ntamper-evident unit.\n", + "required": [ + "formatVersion", + "passportId", + "issuerDid", + "createdAt", + "nodeVersion", + "coreVersion", + "contentHashes" + ], + "properties": { + "formatVersion": { + "type": "string", + "example": "1" + }, + "passportId": { + "type": "string" + }, + "issuerDid": { + "type": "string", + "example": "did:web:node.example.com" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "nodeVersion": { + "type": "string" + }, + "rulesetVersion": { + "type": [ + "string", + "null" + ] + }, + "contentHashes": { + "type": "object", + "description": "member name -> hex SHA-256 of that member's JCS-canonical bytes.", + "additionalProperties": { + "type": "string" + } + }, + "coreVersion": { + "type": "string", + "description": "The `dpp-core` version this node was built against. Recorded alongside `nodeVersion` because the two move independently — the regulatory logic, schemas and disclosure policy behind a determination live in core, so a dossier naming only the node version cannot be traced back to the code that produced its verdict.", + "example": "0.18.0" + } + } + }, + "SignedLayer": { + "type": "object", + "description": "A JWS alongside the exact JSON payload it was signed over — embedded\ndirectly rather than left for a verifier to reconstruct.\n", + "required": [ + "payload", + "jws" + ], + "properties": { + "payload": { + "type": "object" + }, + "jws": { + "type": "string", + "description": "Compact EdDSA JWS." + } + } + }, + "EvidenceDossier": { + "type": "object", + "description": "A self-contained, signed snapshot of a passport's full proof chain,\npersisted by the node at generation time. Verification\n(`POST /evidence/{id}/verify`) is an integrity check of the stored\ndossier against its own signatures and hash chains. See\n`docs/architecture/EVIDENCE-DOSSIER.md` for the full specification,\nincluding why `calcReceipts`/`checkpoint` are always empty/`null` in\nformat v1.\n", + "required": [ + "manifest", + "manifestJws", + "fullView", + "publicView", + "didDocuments", + "auditEntries" + ], + "properties": { + "manifest": { + "$ref": "#/components/schemas/DossierManifest" + }, + "manifestJws": { + "type": "string" + }, + "fullView": { + "$ref": "#/components/schemas/SignedLayer" + }, + "publicView": { + "$ref": "#/components/schemas/SignedLayer" + }, + "didDocuments": { + "type": "object", + "description": "DID document snapshots, keyed by DID.", + "additionalProperties": { + "type": "object" + } + }, + "auditEntries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEntry" + } + }, + "transferChain": { + "type": [ + "object", + "null" + ], + "description": "Present iff the passport has ever changed responsible operator." + }, + "eolEvent": { + "type": [ + "object", + "null" + ], + "description": "Present iff the passport was declared end-of-life." + }, + "checkpoint": { + "type": [ + "object", + "null" + ], + "description": "Always `null` in format v1 — the signed-checkpoint layer is not yet built." + }, + "calcReceipts": { + "type": "array", + "description": "Always empty in format v1 — `dpp-calc` invocation is not yet wired end to end.", + "items": { + "type": "object" + } + }, + "componentGraph": { + "anyOf": [ + { + "$ref": "#/components/schemas/TreeReport" + }, + { + "type": "null" + } + ], + "description": "The recursive component-tree (bill-of-materials) verification report,\npresent iff the passport declares `componentRefs`. `null` for a unit\nwith no modelled sub-assemblies.\n\nGenerated at dossier-assembly time by walking the tree and pin-checking\neach node, then bound into `contentHashes` like every other member — so\na tampered report fails the dossier's `content_integrity` check rather\nthan passing as an unverifiable attachment.\n\nIntegrity only, the same caveat as the standalone `verify-tree` route:\nit proves each node's signed public view is unchanged against its\npinned hash, not the cryptographic validity of that node's signature.\n" + }, + "qualifiedSeal": { + "type": [ + "object", + "null" + ], + "description": "The passport's eIDAS qualified seal, present iff one has been\napplied. Carries the seal envelope plus `signedOverJws` and\n`payloadHash`, so a verifier holding only this dossier has both the\nCAdES and the preimage to check it against.\n\nIncluded because a dossier is what an authority is handed and the\nseal is its one member carrying an Art. 35(2) presumption — and\nbecause it is unreachable otherwise: the seal is stripped from\n`fullView` and `publicView` alike, since it covers the full-payload\nsignature rather than any redaction. Bound into `contentHashes` like\nevery other member. `null` when the seal is still queued.\n", + "properties": { + "seal": { + "type": "object", + "description": "The `SealedEnvelope` as persisted on the passport." + }, + "signedOverJws": { + "type": "string" + }, + "payloadHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + }, + "EvidenceDossierRecord": { + "type": "object", + "description": "A stored dossier snapshot — the dossier plus its persistence envelope.", + "required": [ + "id", + "passportId", + "actor", + "createdAt", + "docHash", + "dossier" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "passportId": { + "type": "string", + "format": "uuid" + }, + "actor": { + "type": "string", + "description": "Who requested generation." + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "docHash": { + "type": "string", + "description": "Hex SHA-256 of the JCS-canonicalised stored dossier document." + }, + "dossier": { + "$ref": "#/components/schemas/EvidenceDossier" + } + } + }, + "CheckResult": { + "type": "object", + "description": "Outcome of a single named verification check.", + "required": [ + "name", + "status" + ], + "properties": { + "name": { + "type": "string", + "example": "audit_chain" + }, + "status": { + "type": "string", + "enum": [ + "pass", + "fail", + "absent" + ] + }, + "detail": { + "type": "string", + "description": "Present when status is `fail` or `absent`." + } + } + }, + "VerificationReport": { + "type": "object", + "required": [ + "trustAnchorNote", + "checks" + ], + "properties": { + "trustAnchorNote": { + "type": "string", + "example": "trust anchored to the dossier's embedded DID-document snapshot dated 2026-07-10T00:00:00Z" + }, + "checks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CheckResult" + } + } + } + }, + "OperatorConfig": { + "type": "object", + "required": [ + "operatorId", + "legalName", + "address", + "country", + "contactEmail" + ], + "properties": { + "operatorId": { + "type": "string", + "example": "self_hosted" + }, + "legalName": { + "type": "string", + "example": "Odal Node GmbH" + }, + "tradeName": { + "type": [ + "string", + "null" + ] + }, + "address": { + "type": "string", + "example": "Johannes Strauss 12" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "DE" + }, + "contactEmail": { + "type": "string", + "format": "email", + "example": "contact@odal-node.io" + }, + "didWebUrl": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "productCategories": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "brandPrimary": { + "type": [ + "string", + "null" + ], + "description": "Primary brand colour (hex)", + "example": "#2E7D32" + }, + "brandSecondary": { + "type": [ + "string", + "null" + ] + }, + "brandLogoUrl": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "customDomain": { + "type": [ + "string", + "null" + ] + }, + "dataResidency": { + "type": "string", + "default": "EU" + }, + "retentionPolicyDays": { + "type": "integer", + "default": 3650 + }, + "featureFlags": { + "type": [ + "object", + "null" + ] + }, + "createdAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "updatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "registryVerifiedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified." + } + } + }, + "UpdateOperatorConfig": { + "type": "object", + "description": "Merge-patch update — only supply fields being changed.", + "properties": { + "legalName": { + "type": "string" + }, + "tradeName": { + "type": "string" + }, + "address": { + "type": "string" + }, + "country": { + "type": "string" + }, + "contactEmail": { + "type": "string" + }, + "didWebUrl": { + "type": "string" + }, + "productCategories": { + "type": "array", + "items": { + "type": "string" + } + }, + "brandPrimary": { + "type": "string" + }, + "brandSecondary": { + "type": "string" + }, + "brandLogoUrl": { + "type": "string" + }, + "customDomain": { + "type": "string" + }, + "dataResidency": { + "type": "string" + }, + "retentionPolicyDays": { + "type": "integer" + }, + "featureFlags": { + "type": "object" + }, + "registryVerifiedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified." + } + } + }, + "ApiKeyScope": { + "type": "string", + "description": "What an API key authorises. `admin` is the default when a key is minted without an explicit scope, so a key issued to an integration should name `read` or `write` deliberately.", + "enum": [ + "read", + "write", + "admin" + ] + }, + "ApiKey": { + "type": "object", + "required": [ + "id", + "name", + "keyPrefix", + "isActive", + "scope", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "CI pipeline" + }, + "keyPrefix": { + "type": "string", + "description": "First 12 characters of the key (e.g. 'odal_sk_abc1')", + "example": "odal_sk_abc1" + }, + "isActive": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expiresAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope" + } + } + }, + "CreateApiKeyRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Human-readable label for this key.", + "example": "CI pipeline" + }, + "expiresAt": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Optional expiration. Null = never expires." + }, + "scope": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiKeyScope" + } + ], + "description": "Defaults to `admin` when omitted. Name `read` or `write` explicitly for a key issued to an integration." + } + } + }, + "NewApiKey": { + "type": "object", + "required": [ + "key", + "secret" + ], + "properties": { + "key": { + "$ref": "#/components/schemas/ApiKey" + }, + "secret": { + "type": "string", + "description": "The full plain-text API key. Shown ONCE at creation time.\nStore securely — it cannot be retrieved again.\n", + "example": "odal_sk_abc123def456ghi789jkl012mno345pqr678" + } + } + }, + "InstalledPlugin": { + "type": "object", + "description": "What the node installed, reported back after a successful hot-swap.", + "required": [ + "productGroup", + "abiVersion" + ], + "properties": { + "productGroup": { + "type": "string", + "description": "Product group catalog key the plugin is bound to.", + "example": "battery" + }, + "abiVersion": { + "type": "string", + "description": "ABI version the plugin declared, formatted `major.minor`.", + "example": "1.0" + } + } + }, + "WebhookSubscription": { + "type": "object", + "description": "A receiver subscription, redacted. The signing secret is never carried here — it is returned exactly once from the create call and otherwise stays server-side.", + "required": [ + "id", + "url", + "events", + "active", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "url": { + "type": "string", + "format": "uri", + "description": "Receiver URL — validated `https`, non-private host at creation." + }, + "events": { + "type": "array", + "description": "Subject filter — event type strings, or a single `*` for all events.", + "items": { + "type": "string" + } + }, + "active": { + "type": "boolean", + "description": "Removal is a soft `active = false`, never a hard delete." + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "NewWebhookSubscription": { + "type": "object", + "description": "Input for creating a subscription. The signing secret is generated server-side and is never client-supplied, so it is not part of this body.", + "required": [ + "url", + "events" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Receiver URL. SSRF-validated before it is persisted." + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + } + } + }, + "CreatedWebhookResponse": { + "description": "The created subscription's fields, inlined, plus the signing secret. The secret is shown **once** — it is not recoverable from any later read.", + "allOf": [ + { + "$ref": "#/components/schemas/WebhookSubscription" + }, + { + "type": "object", + "required": [ + "secret" + ], + "properties": { + "secret": { + "type": "string", + "description": "Signing secret. Store it now; it is never shown again." + } + } + } + ] + }, + "NodeState": { + "type": "object", + "required": [ + "bootstrapped", + "operatorComplete" + ], + "properties": { + "bootstrapped": { + "type": "boolean", + "description": "True once at least one active API key exists (the node is claimed)." + }, + "operatorComplete": { + "type": "boolean", + "description": "True once the operator identity is complete enough to publish." + }, + "profile": { + "type": "string", + "enum": [ + "development", + "staging", + "production" + ], + "description": "The node's deployment profile. Absent on a standalone vault, which has no\ncomposition root to resolve one.\n" + }, + "trustMode": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": [ + "ghost", + "sandbox", + "live" + ] + }, + "description": "Each resolved trust port and the tier it operates at — `ghost` is a\nplaceholder with no real authority behind it, `sandbox` a real but\nnon-production service, `live` a production one.\n\nThis is the ghost-honesty signal: no surface may present a placeholder as\nreal. It is reported here rather than on the unauthenticated `/health`\nbecause which ports are degraded, and how, is a targeting signal — the\nsame reasoning that keeps `/metrics` off the public router.\n\nAbsent on a standalone vault, which resolves no trust ports.\n", + "example": { + "seal": "ghost", + "registry_sync": "sandbox" + } + }, + "rulesetVersion": { + "type": "string", + "description": "Version of the Compliance Current ruleset this node validates against, so\nthe rules a passport was checked under are observable. Absent on a\nstandalone vault.\n", + "example": "baseline" + } + } + }, + "WhoamiResponse": { + "type": "object", + "description": "What the presented credential is. Reports only what the caller already sent — it reveals nothing about any other key, and the key's secret is never stored in a recoverable form.", + "required": [ + "userId", + "scope" + ], + "properties": { + "userId": { + "type": "string", + "description": "The caller's identity, as authenticated." + }, + "scope": { + "$ref": "#/components/schemas/ApiKeyScope" + }, + "keyId": { + "type": "string", + "format": "uuid", + "description": "The key's row id — never the token. Absent for local-admin Basic auth, which has no key row." + } + } + }, + "Facility": { + "type": "object", + "required": [ + "id", + "name", + "identifierScheme", + "identifierValue", + "country", + "isDefault", + "createdAt" + ], + "description": "A manufacturing/processing facility (ESPR Annex III).", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "München Cell Plant" + }, + "identifierScheme": { + "type": "string", + "description": "Identifier scheme, e.g. \"gln\" or \"national\".", + "example": "gln" + }, + "identifierValue": { + "type": "string", + "example": "4012345000009" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "DE" + }, + "address": { + "type": [ + "string", + "null" + ] + }, + "isDefault": { + "type": "boolean", + "description": "The default facility is stamped onto new passports." + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateFacilityRequest": { + "type": "object", + "required": [ + "name", + "identifierScheme", + "identifierValue", + "country" + ], + "properties": { + "name": { + "type": "string" + }, + "identifierScheme": { + "type": "string", + "example": "gln" + }, + "identifierValue": { + "type": "string", + "description": "Validated by scheme — a \"gln\" must pass the GS1 mod-10 check digit.", + "example": "4012345000009" + }, + "country": { + "type": "string", + "minLength": 2, + "maxLength": 2, + "example": "DE" + }, + "address": { + "type": [ + "string", + "null" + ] + }, + "isDefault": { + "type": "boolean", + "default": false, + "description": "Make this the default facility on creation (unsets any previous default)." + } + } + }, + "RegistryIdentityAudit": { + "type": "object", + "required": [ + "id", + "operatorId", + "entityType", + "entityId", + "action", + "actor", + "ts" + ], + "description": "An immutable audit record for a registry-identity mutation (a facility per Annex III or an operator identifier per Art. 13). Append-only.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "operatorId": { + "type": "string" + }, + "entityType": { + "type": "string", + "enum": [ + "facility", + "operator_identifier" + ] + }, + "entityId": { + "type": "string", + "format": "uuid" + }, + "action": { + "type": "string", + "enum": [ + "added", + "retired", + "set_default", + "set_primary" + ] + }, + "actor": { + "type": "string", + "description": "user_id of the actor who performed the change." + }, + "snapshot": { + "type": [ + "object", + "null" + ], + "description": "The full record at the time of the action, for reconstruction." + }, + "ts": { + "type": "string", + "format": "date-time" + } + } + }, + "OperatorIdentifier": { + "type": "object", + "required": [ + "id", + "scheme", + "value", + "isPrimary", + "createdAt" + ], + "description": "An economic-operator identifier (ESPR Art. 13).", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scheme": { + "type": "string", + "description": "Identifier scheme, e.g. \"vat\", \"lei\", \"eori\", \"duns\".", + "example": "lei" + }, + "value": { + "type": "string", + "example": "5493001KJTIIGC8Y1R12" + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "isPrimary": { + "type": "boolean", + "description": "The primary identifier is stamped onto new passports." + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateOperatorIdentifierRequest": { + "type": "object", + "required": [ + "scheme", + "value" + ], + "properties": { + "scheme": { + "type": "string", + "description": "Identifier scheme, e.g. \"vat\", \"lei\", \"eori\", \"duns\".", + "example": "lei" + }, + "value": { + "type": "string", + "description": "Validated by scheme — LEI uses ISO 7064 MOD 97-10; DUNS is 9 digits; EORI/VAT require a 2-letter country prefix. Unknown schemes are accepted without structural verification.", + "example": "5493001KJTIIGC8Y1R12" + }, + "label": { + "type": [ + "string", + "null" + ] + }, + "isPrimary": { + "type": "boolean", + "default": false, + "description": "Make this the primary identifier on creation (unsets any previous primary)." + } + } + }, + "ScanVariant": { + "type": "string", + "enum": [ + "html", + "json" + ] + }, + "ScanCount": { + "type": "object", + "required": [ + "dppId", + "day", + "variant", + "count" + ], + "description": "One aggregated scan increment since the resolver's last flush.", + "properties": { + "dppId": { + "type": "string", + "description": "The resolved passport id, as an opaque string (validated at ingest)." + }, + "day": { + "type": "string", + "format": "date" + }, + "variant": { + "$ref": "#/components/schemas/ScanVariant" + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + }, + "QrRenderCount": { + "type": "object", + "required": [ + "dppId", + "day", + "count" + ], + "description": "One aggregated QR-render increment since the resolver's last flush.", + "properties": { + "dppId": { + "type": "string" + }, + "day": { + "type": "string", + "format": "date" + }, + "count": { + "type": "integer", + "minimum": 0 + } + } + }, + "ScanBatch": { + "type": "object", + "required": [ + "scans", + "qrRenders" + ], + "description": "The full flush payload the resolver sends to the vault.", + "properties": { + "scans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScanCount" + } + }, + "qrRenders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QrRenderCount" + } + } + } + }, + "VaultInfo": { + "type": "object", + "required": [ + "version", + "coreVersion", + "authMethods", + "features" + ], + "description": "Vault build/version metadata, for dashboard feature detection.", + "properties": { + "version": { + "type": "string", + "description": "This node's own dpp-vault crate version.", + "example": "0.11.0" + }, + "coreVersion": { + "type": "string", + "description": "The dpp-domain (dpp-core) version this build was compiled against.", + "example": "0.16.0" + }, + "authMethods": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Auth schemes the vault accepts. Currently a fixed list, not derived from live config — `local` is listed even when `ADMIN_USERNAME`/`ADMIN_PASSWORD` are unset.", + "example": [ + "api_key", + "local" + ] + }, + "features": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "passthrough_compliance" + ] + } + } + }, + "DidDocument": { + "type": "object", + "description": "A W3C DID Core `did:web` document. Lists the operator's Ed25519\nverification methods; rotated keys are retained (so historical\nsignatures still verify) and revoked keys are omitted.\n", + "example": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/ed25519-2020/v1" + ], + "id": "did:web:id.odal-node.io", + "verificationMethod": [ + { + "id": "did:web:id.odal-node.io#key-1", + "type": "JsonWebKey2020", + "controller": "did:web:id.odal-node.io", + "publicKeyJwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "" + } + } + ], + "authentication": [ + "did:web:id.odal-node.io#key-1" + ], + "assertionMethod": [ + "did:web:id.odal-node.io#key-1" + ] + } + }, + "SignRequest": { + "type": "object", + "required": [ + "operator_id", + "passport_id", + "payload" + ], + "description": "Internal signing request. Field names are snake_case (internal contract).", + "properties": { + "operator_id": { + "type": "string", + "description": "Operator whose key signs. Auto-provisioned on first use.", + "example": "self_hosted" + }, + "passport_id": { + "type": "string", + "description": "The passport id being signed (recorded in the JWS payload)." + }, + "payload": { + "type": "string", + "description": "Base64-encoded canonical JSON of the payload to sign." + } + } + }, + "SignResponse": { + "type": "object", + "required": [ + "jws_signature" + ], + "properties": { + "jws_signature": { + "type": "string", + "description": "Compact JWS (EdDSA over RFC 8785 canonical bytes)." + } + } + }, + "VerifyRequest": { + "type": "object", + "required": [ + "operator_id", + "jws", + "payload" + ], + "description": "Internal verification request. Field names are snake_case (internal contract).", + "properties": { + "operator_id": { + "type": "string", + "description": "Operator id whose key the signature is checked against.", + "example": "self_hosted" + }, + "jws": { + "type": "string", + "description": "The compact JWS to verify." + }, + "payload": { + "description": "The payload the caller expects the JWS to have been signed over." + } + } + }, + "VerifyResponse": { + "type": "object", + "required": [ + "valid" + ], + "properties": { + "valid": { + "type": "boolean", + "description": "True iff the signature verifies against the named operator's key AND was signed over exactly this payload." + } + } + }, + "RotateRequest": { + "type": "object", + "required": [ + "operator_id" + ], + "properties": { + "operator_id": { + "type": "string", + "example": "self_hosted" + } + } + }, + "RotateResponse": { + "type": "object", + "required": [ + "operator_id", + "new_key_id", + "fingerprint", + "rotated", + "did_document" + ], + "properties": { + "operator_id": { + "type": "string" + }, + "new_key_id": { + "type": "string", + "example": "did:web:id.odal-node.io#key-1" + }, + "fingerprint": { + "type": "string", + "description": "SHA-256 fingerprint (hex) of the new public key." + }, + "rotated": { + "type": "boolean" + }, + "did_document": { + "$ref": "#/components/schemas/DidDocument" + } + } + }, + "ImportCreatedEntry": { + "type": "object", + "required": [ + "row", + "passportId", + "status" + ], + "properties": { + "row": { + "type": "integer", + "description": "1-based row number from the uploaded file." + }, + "passportId": { + "type": "string" + }, + "status": { + "type": "string", + "example": "draft" + } + } + }, + "ImportErrorEntry": { + "type": "object", + "required": [ + "row", + "field", + "message" + ], + "properties": { + "row": { + "type": "integer" + }, + "field": { + "type": "string", + "description": "Column name, or \"vault\" / \"auth\" / \"internal\"." + }, + "message": { + "type": "string" + } + } + }, + "ImportUpdatedEntry": { + "type": "object", + "description": "One row that matched an existing passport and updated it.", + "required": [ + "row", + "passportId" + ], + "properties": { + "row": { + "type": "integer", + "description": "1-based row number in the uploaded file, excluding the header." + }, + "passportId": { + "type": "string", + "format": "uuid" + } + } + }, + "ImportSyncResponse": { + "type": "object", + "required": [ + "jobId", + "totalRows", + "successCount", + "errorCount", + "created", + "updated", + "errors" + ], + "description": "Returned for synchronous imports (≤ 100 valid rows) and dry runs.", + "properties": { + "jobId": { + "type": "string", + "format": "uuid", + "description": "The job this response's report was persisted under — retrievable later via the job-status endpoint." + }, + "totalRows": { + "type": "integer", + "description": "Rows read from the uploaded file, excluding the header." + }, + "successCount": { + "type": "integer", + "description": "Rows **not rejected**, counted once each. Deliberately not the size of `created` plus `updated`: a row whose record was already up to date, or which conflicted, writes nothing and is still not a failure. Always `0` for a dry run, which writes nothing at all." + }, + "errorCount": { + "type": "integer", + "description": "Rows **rejected**, counted once each. A row that fails several checks contributes one to this count and several entries to `errors`, so `errorCount` and `errors.length` differ and are not interchangeable." + }, + "created": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportCreatedEntry" + } + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportErrorEntry" + } + }, + "updated": { + "type": "array", + "description": "Rows that matched an existing passport and updated it. Counted in `successCount`, which is deliberately not `created.length + updated.length` — see that field.", + "items": { + "$ref": "#/components/schemas/ImportUpdatedEntry" + } + } + } + }, + "ImportAsyncResponse": { + "type": "object", + "required": [ + "jobId", + "status", + "totalRows" + ], + "description": "Returned for async imports (> 100 valid rows). Poll the job-status endpoint.", + "properties": { + "jobId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "example": "queued" + }, + "totalRows": { + "type": "integer" + } + } + }, + "JobStatusResponse": { + "type": "object", + "required": [ + "jobId", + "status", + "progress" + ], + "properties": { + "jobId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "processing", + "completed", + "failed" + ] + }, + "progress": { + "type": "object", + "properties": { + "processed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "result": { + "type": [ + "object", + "null" + ], + "description": "Populated on completion (created/errors) or failure (reason)." + }, + "report": { + "type": [ + "object", + "null" + ], + "description": "The row-addressed findings report — populated for every job, dry-run or apply, independent of `result`." + } + } + } + }, + "responses": { + "Unauthorized": { + "description": "Missing or invalid authentication credentials.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Missing or invalid Authorization header." + } + } + } + }, + "ValidationError": { + "description": "One or more fields failed validation.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/unprocessable-entity", + "title": "Unprocessable Entity", + "status": 422, + "detail": "productName must not be empty." + } + } + } + }, + "Forbidden": { + "description": "The credential is authenticated but lacks the required scope — e.g. a\n`write`/`read` key attempting an admin-only action (registry identity).\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "Registry-identity management requires an admin-scoped credential." + } + } + } + }, + "NotFound": { + "description": "Resource not found within the operator's scope.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/not-found", + "title": "Not Found", + "status": 404, + "detail": "DPP not found." + } + } + } + }, + "Conflict": { + "description": "State conflict — e.g. attempting to publish an already-published DPP\nor updating an archived DPP.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/conflict", + "title": "Conflict", + "status": 409, + "detail": "DPP is already published." + } + } + } + }, + "NotAcceptable": { + "description": "No representation matches the request's `Accept` header. The response\nbody names the media types this resource can produce.\n\nA passport carrying no GTIN — an unsold-goods report, or an untyped\nproduct group — also gets this for `application/aas+json`: it identifies no\ntrade item, so it has no AAS asset identity and therefore no AAS\nrepresentation.\n", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + }, + "example": { + "type": "https://problems.odal-node.io/not-acceptable", + "title": "Not Acceptable", + "status": 406, + "detail": "No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json." + } + } + } + } + } + } +} \ No newline at end of file diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index cb2f5795..4efd1937 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -46,6 +46,8 @@ tags: description: Lifecycle transitions — publish, suspend, archive. - name: Scan Telemetry description: Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups. + - name: Registry + description: EU-registry registration state — per-passport and operator-wide, plus the operator's verified-registry standing. - 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: Operator @@ -53,7 +55,7 @@ tags: - name: API Keys description: API key management — create, list, revoke. - name: Plugins - description: Signed sector-plugin hot-install — verify, persist, hot-swap (admin-only). + description: Signed product group-plugin hot-install — verify, persist, hot-swap (admin-only). - name: Webhooks description: Signed outbound event delivery — subscribe, list, remove, test. - name: Facilities @@ -103,14 +105,14 @@ paths: $ref: '#/components/schemas/CreateRequest' example: productName: EcoCell Pro 48V - sector: battery + productGroup: battery manufacturer: name: EcoTech GmbH address: Hauptstraße 1, 10115 Berlin co2ePerUnit: 4.2 batchId: BATCH-2026-04-001 - sectorData: - sector: battery + productGroupData: + productGroup: battery gtin: '09506000134352' batteryChemistry: LFP nominalVoltageV: 48 @@ -138,7 +140,7 @@ paths: cannot disagree. **Two verdicts, because create and publish deliberately differ.** Create is - lenient about a sector with no resolvable JSON Schema — a draft is allowed + lenient about a product group with no resolvable JSON Schema — a draft is allowed to be incomplete — while publish fails closed on it, since a signed passport must have passed a real schema check. A body can therefore be creatable but not yet clear the publish-time schema gate, and that gap is @@ -148,8 +150,8 @@ paths: would have returned, not a paraphrase of it. A body create would accept returns `200` with the two verdicts. - **`sectorDataValid` is not a publish verdict.** It reports one of publish's - preconditions — the sector-data gates — and publish applies others this + **`productGroupDataValid` is not a publish verdict.** It reports one of publish's + preconditions — the product group-data gates — and publish applies others this route does not run: - the **registry identity** requirement (a default facility and a primary @@ -159,7 +161,7 @@ paths: - the **compliance** gate, which needs a `placedOnMarketDate` and a stored passport. - A `sectorDataValid: true` therefore means "this body clears the schema + A `productGroupDataValid: true` therefore means "this body clears the schema gate", never "publish will succeed". The field was called `publishValid` and was renamed because that name promised the latter. tags: @@ -176,36 +178,12 @@ paths: responses: '200': description: | - The body is creatable. `sectorDataValid` says whether its sector data + The body is creatable. `productGroupDataValid` says whether its product group data would also clear the publish-time schema gate. content: application/json: schema: - type: object - required: - - createValid - - sectorDataValid - properties: - createValid: - type: boolean - description: Always `true` on a 200 — a rejected body returns 422. - example: true - sectorDataValid: - type: boolean - description: | - Whether the sector data would clear the publish-time schema - gate. `true` when no sector data is supplied, since publish - only validates it when present. Not a publish verdict — see - the endpoint description for the gates this does not run. - example: false - detail: - type: - - string - - 'null' - description: | - Why the sector data would be refused. `null` when - `sectorDataValid` is `true`. - example: 'cannot publish: no registered JSON Schema for sector ''furniture'' — publish requires a resolvable schema when sector data is present' + $ref: '#/components/schemas/ValidateResponse' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -261,24 +239,7 @@ paths: content: application/json: schema: - type: object - required: - - dpps - - total - - limit - - skip - properties: - dpps: - type: array - items: - $ref: '#/components/schemas/PassportResponse' - total: - type: integer - description: Total count (optionally filtered by status and/or facilityId) - limit: - type: integer - skip: - type: integer + $ref: '#/components/schemas/PassportListResponse' '401': $ref: '#/components/responses/Unauthorized' /vault/api/v1/dpp/by-identity: @@ -286,7 +247,7 @@ paths: operationId: findDppByIdentity summary: Find a DPP by exact compound identity description: | - Look up a passport by exact (sector, GTIN, batch) match, across + Look up a passport by exact (product group, GTIN, batch) match, across `draft` and `active` statuses. Backs the import delta-matcher — not intended as a general-purpose search (use `GET /dpps` for that). `batchId` omitted matches only passports with no batch set. @@ -296,7 +257,7 @@ paths: - BearerApiKey: [] - BasicAuth: [] parameters: - - name: sector + - name: product group in: query required: true schema: @@ -623,9 +584,9 @@ paths: '404': description: No pending transfer to accept for this DPP. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '422': $ref: '#/components/responses/ValidationError' /vault/api/v1/dpp/{dppId}/history: @@ -697,33 +658,7 @@ paths: content: application/json: schema: - type: object - properties: - windowDays: - type: integer - example: 30 - totalScans: - type: integer - example: 128 - scansHtml: - type: integer - example: 96 - scansJson: - type: integer - example: 32 - qrRenders: - type: integer - example: 4 - daily: - type: array - items: - type: object - properties: - day: - type: string - format: date - count: - type: integer + $ref: '#/components/schemas/PassportScanStats' '401': $ref: '#/components/responses/Unauthorized' /vault/api/v1/stats: @@ -756,20 +691,72 @@ paths: content: application/json: schema: - type: object - properties: - windowDays: - type: integer - example: 30 - totalScans: - type: integer - example: 4213 - totalQrRenders: - type: integer - example: 57 - distinctPassportsScanned: - type: integer - example: 312 + $ref: '#/components/schemas/OperatorScanStats' + '401': + $ref: '#/components/responses/Unauthorized' + /vault/api/v1/dpp/{dppId}/registry: + get: + operationId: getDppRegistryStatus + summary: EU-registry state for one passport + description: | + What the EU registry knows about this passport: its queued registration, + any handover notifications, and who is responsible for it now. + + Registration is the legal obligation the rest of the system exists to + discharge, and this is where an operator sees whether it has been — + the state otherwise lives only in outbox tables, metrics and log lines. + + **Absent is not zero.** A deployment without the registry queues reports + `configured: false` and omits the detail, rather than a row of zeros that + would read as "everything is registered". + tags: + - Registry + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: '#/components/schemas/DppId' + responses: + '200': + description: Registry state for the passport. + content: + application/json: + schema: + $ref: '#/components/schemas/PassportRegistryView' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + /vault/api/v1/registry: + get: + operationId: getRegistryRollup + summary: Operator-wide EU-registry rollup + description: | + Registration and handover queue totals for this operator, plus its + verified-registry standing. + + Verification is reported whether or not the queues are configured: verified + status lapses at the latest three years after verification (sooner if the + electronic identification means expire), and a lapsed operator cannot + register or amend anything until it verifies again. + + **Absent is not zero** — see the per-passport route. + tags: + - Registry + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + '200': + description: Registry rollup for the operator. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistryRollupView' '401': $ref: '#/components/responses/Unauthorized' /vault/api/v1/dpp/{dppId}/verify-tree: @@ -842,34 +829,7 @@ paths: content: application/json: schema: - type: object - required: - - unsealedPublished - - pending - - sealed - - exhausted - - sealingConfigured - properties: - unsealedPublished: - type: integer - format: int64 - description: Published passports carrying no seal at all. `0` is the healthy state. - example: 0 - pending: - type: integer - format: int64 - description: Outbox rows awaiting a sealing attempt. - sealed: - type: integer - format: int64 - description: Outbox rows whose seal is on the passport. - exhausted: - type: integer - format: int64 - description: Outbox rows that gave up after exhausting retries. - sealingConfigured: - type: boolean - description: False when no seal provider is configured, in which case every count above is `0` for that reason alone. + $ref: '#/components/schemas/SealSummaryResponse' '401': $ref: '#/components/responses/Unauthorized' /vault/api/v1/dpp/{dppId}/seal: @@ -919,85 +879,7 @@ paths: content: application/json: schema: - type: object - required: - - format - - sealValue - - sealedAt - - signingCertRef - - placeholder - - currentJws - - currentPayloadHash - - sealedPayloadHash - - coverage - - verification - properties: - format: - type: string - description: AdES format of `sealValue`. - example: CADES - sealValue: - type: string - description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. - sealedAt: - type: string - format: date-time - signingCertRef: - type: - - string - - 'null' - description: | - Hex SHA-256 of the certificate the seal names as its - signer, **as reported by the seal** — read out of the CAdES - structure, never verified. - - It answers *which* certificate to ask about, not whether - that certificate was qualified or on the EU Trusted List - when the seal was made; both are the independent - validator's question. `null` when the seal predates - extraction or could not be parsed. - pattern: ^[0-9a-f]{64}$ - placeholder: - type: boolean - description: | - `true` when this is a development placeholder with no legal - validity. A production node refuses to boot in that state. - currentJws: - type: string - description: The passport's current compact JWS. - currentPayloadHash: - type: string - description: | - Hex SHA-256 of `currentJws` — the digest a seal over this - passport's present signature would be taken over. - pattern: ^[0-9a-f]{64}$ - sealedPayloadHash: - type: - - string - - 'null' - description: | - Hex SHA-256 this node asked the backend to seal, from the - outbox row that bought `sealValue`. `null` when the node - holds no such row — a seal restored from a backup or - produced elsewhere. - pattern: ^[0-9a-f]{64}$ - coverage: - type: string - enum: - - current - - superseded - - unknown - description: | - Whether the stored seal covers the passport's current - signature, per this node's own records. - - `current` — the requested digest is the passport's current - one. `superseded` — the passport was re-published after - this seal was bought. `unknown` — no record; only the - external validator can answer. - verification: - type: string - description: What was and was not checked by this node. + $ref: '#/components/schemas/SealResponse' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -1008,7 +890,7 @@ paths: summary: Re-check plausibility-lint findings description: | Recomputes the `dpp-rules` plausibility lint pack against the DPP's - current sector data and persists the refreshed `lintResult` (pack + current product group data and persists the refreshed `lintResult` (pack version, findings, assessed-at timestamp). Findings are non-binding — arithmetic and physical-plausibility checks distinct from binding compliance rules — and never gate publish or any other transition. @@ -1316,9 +1198,9 @@ paths: '400': description: Invalid key ID format. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -1326,9 +1208,9 @@ paths: /vault/api/v1/plugins: post: operationId: installPlugin - summary: Install a signed sector plugin + summary: Install a signed product group plugin description: | - Verify, persist, and hot-swap a signed sector plugin — no node restart. + Verify, persist, and hot-swap a signed product group plugin — no node restart. The node verifies the uploaded artifact's detached signature against its pinned publisher key, gates the plugin's declared ABI, instantiate-smokes @@ -1357,14 +1239,14 @@ paths: type: string format: binary description: | - The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the sector when `sector` is omitted (`sector-.wasm`) and whether it is treated as precompiled (`.cwasm`). + The `.wasm` or precompiled `.cwasm` plugin artifact. Its filename determines the product group when `productGroup` is omitted (`product group-.wasm`) and whether it is treated as precompiled (`.cwasm`). sig: type: string format: binary description: Detached Ed25519 signature over SHA-256 of the artifact bytes. - sector: + productGroup: type: string - description: Sector key; derived from the filename if omitted. + description: Product group key; derived from the filename if omitted. example: battery responses: '201': @@ -1372,16 +1254,9 @@ paths: content: application/json: schema: - type: object - properties: - sector: - type: string - example: battery - abiVersion: - type: string - example: '1.1' + $ref: '#/components/schemas/InstalledPlugin' '400': - description: Malformed multipart body (missing `wasm`/`sig`, or the sector could not be determined). + description: Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined). '401': $ref: '#/components/responses/Unauthorized' '403': @@ -1410,29 +1285,7 @@ paths: schema: type: array items: - type: object - properties: - id: - type: string - format: uuid - url: - type: string - events: - type: array - items: - type: string - active: - type: boolean - description: - type: - - string - - 'null' - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time + $ref: '#/components/schemas/WebhookSubscription' '401': $ref: '#/components/responses/Unauthorized' post: @@ -1453,56 +1306,14 @@ paths: content: application/json: schema: - type: object - required: - - url - properties: - url: - type: string - format: uri - example: https://hooks.example.com/odal - events: - type: array - items: - type: string - description: Event subjects to receive, or ["*"] for all. Defaults to all. - example: - - dpp.passport.published - - dpp.passport.transferred - description: - type: string + $ref: '#/components/schemas/NewWebhookSubscription' responses: '201': description: Subscription created. The `secret` field is shown ONCE. content: application/json: schema: - type: object - properties: - id: - type: string - format: uuid - url: - type: string - events: - type: array - items: - type: string - active: - type: boolean - description: - type: - - string - - 'null' - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - secret: - type: string - description: Signing secret — shown once, store it securely. + $ref: '#/components/schemas/CreatedWebhookResponse' '401': $ref: '#/components/responses/Unauthorized' '422': @@ -1532,9 +1343,9 @@ paths: '400': description: Invalid subscription id. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '401': $ref: '#/components/responses/Unauthorized' '404': @@ -1612,37 +1423,7 @@ paths: content: application/json: schema: - type: object - required: - - userId - - scope - properties: - userId: - type: string - description: | - Caller identity — the API key's label, or the admin username - for local Basic auth. - example: svc-dashboard - scope: - type: string - enum: - - read - - write - - admin - description: | - What this credential authorises. `write` covers passport - create/update/lifecycle; `admin` additionally covers API-key - management and operator config. - example: read - keyId: - type: - - string - - 'null' - format: uuid - description: | - Id of the API key row that authenticated this request. - `null` for local Basic auth, which has no key row. - example: 0198f3a1-4c2e-7b3d-9f10-2a5c8e7d1b40 + $ref: '#/components/schemas/WhoamiResponse' '401': $ref: '#/components/responses/Unauthorized' /vault/api/v1/facilities: @@ -1942,7 +1723,7 @@ paths: - name: schema_view in: query required: false - description: Request a read-time schema-upcast view. Set to a newer sector schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed. + description: Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed. schema: type: string example: 2.0.0 @@ -1956,21 +1737,21 @@ paths: '404': description: Not found or not published. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '410': description: Passport has been suspended. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '422': - description: The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no sector data). + description: The requested `schema_view` is unavailable (no lens path, a downcast, or the passport has no product group data). content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' /vault/public/dpp/by-gtin/{gtin}: get: operationId: publicGetDppByGtin @@ -2007,9 +1788,9 @@ paths: '422': description: The requested `schema_view` is unavailable. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' /vault/credential/dpp/{dppId}: get: operationId: readDppByCredential @@ -2048,21 +1829,21 @@ paths: '401': description: The presented credential failed verification. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '404': description: Not found or not published. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' '410': description: This passport has been suspended. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' /vault/internal/scan-batch: post: operationId: ingestScanBatch @@ -2256,18 +2037,18 @@ paths: responses: '200': description: Service is up. - /integrator/api/v1/templates/{sector}: + /integrator/api/v1/templates/{productGroup}: get: operationId: getImportTemplate summary: Download a CSV import template description: | - Returns the canonical CSV import template for a sector (`battery`, + Returns the canonical CSV import template for a product group (`battery`, `textile`). `?format=xlsx` returns 501 (download the CSV and open it in a spreadsheet app). Unauthenticated. tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -2289,22 +2070,22 @@ paths: schema: type: string '404': - description: No template for this sector. + description: No template for this product group. '501': description: XLSX export not yet implemented. /integrator/api/v1/schemas: get: operationId: listSectorSchemas - summary: List sector schemas and their versions + summary: List product group schemas and their versions description: | - Every sector with a JSON Schema, the version a new passport is validated + Every product group with a JSON Schema, the version a new passport is validated against (`current`), and every version a stored passport may legitimately record (`versions`). Unauthenticated. tags: - Integrator responses: '200': - description: The available sector schemas. + description: The available product group schemas. content: application/json: schema: @@ -2315,7 +2096,7 @@ paths: items: type: object properties: - sector: + productGroup: type: string example: battery current: @@ -2330,10 +2111,10 @@ paths: example: - 1.0.0 - 2.6.0 - /integrator/api/v1/schemas/{sector}: + /integrator/api/v1/schemas/{productGroup}: get: operationId: getCurrentSectorSchema - summary: Fetch a sector's current JSON Schema + summary: Fetch a product group's current JSON Schema description: | The schema a passport created today is validated against, resolved through the same registry the publish gate uses — never a copy, which would drift in @@ -2348,7 +2129,7 @@ paths: tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -2356,21 +2137,21 @@ paths: example: battery responses: '200': - description: The sector's current JSON Schema. + description: The product group's current JSON Schema. content: application/json: schema: type: object '404': - description: No schema for this sector; the body names the known sectors. + description: No schema for this product group; the body names the known product groups. content: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /integrator/api/v1/schemas/{sector}/{version}: + /integrator/api/v1/schemas/{productGroup}/{version}: get: operationId: getPinnedSectorSchema - summary: Fetch a pinned version of a sector's JSON Schema + summary: Fetch a pinned version of a product group's JSON Schema description: | A stored passport records the `schemaVersion` it was written under, so a client holding one needs that exact schema rather than whatever is current. @@ -2380,7 +2161,7 @@ paths: tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -2394,7 +2175,7 @@ paths: example: 2.6.0 responses: '200': - description: The sector's JSON Schema at that version. + description: The product group's JSON Schema at that version. content: application/json: schema: @@ -2411,12 +2192,12 @@ paths: application/problem+json: schema: $ref: '#/components/schemas/Problem' - /integrator/api/v1/import/{sector}: + /integrator/api/v1/import/{productGroup}: post: operationId: importFile summary: Bulk-import passports from a file description: | - Upload a CSV or XLSX file to create draft passports for a sector + Upload a CSV or XLSX file to create draft passports for a product group (`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's `Authorization: Bearer` token is validated and forwarded to the vault. @@ -2432,7 +2213,7 @@ paths: security: - BearerApiKey: [] parameters: - - name: sector + - name: product group in: path required: true schema: @@ -2470,7 +2251,7 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '404': - description: Unknown sector. + description: Unknown product group. '422': $ref: '#/components/responses/ValidationError' /integrator/api/v1/imports/{job_id}: @@ -2891,32 +2672,32 @@ components: maximum: 100 description: Percentage of recycled content (0.0–100.0) example: 35 - originCountry: + countryOfOrigin: type: string minLength: 2 maxLength: 2 description: ISO 3166-1 alpha-2 country code example: DE - SectorData: + ProductGroupData: type: object description: | - Sector-specific data, **internally tagged** by a `sector` discriminator - (`#[serde(tag = "sector")]`) — e.g. - `{ "sector": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. - The remaining fields are sector-specific and validated against the - sector's versioned JSON schema. Sectors include `battery`, `textile`, + Product group-specific data, **internally tagged** by a `productGroup` discriminator + (`#[serde(tag = "product group")]`) — e.g. + `{ "product group": "battery", "gtin": "…", "batteryChemistry": "LFP", … }`. + The remaining fields are product group-specific and validated against the + product group's versioned JSON schema. Sectors include `battery`, `textile`, `steel`, `aluminium`, `tyre`, `electronics`, and others. required: - - sector + - productGroup discriminator: - propertyName: sector + propertyName: product group properties: - sector: + productGroup: type: string example: battery additionalProperties: true example: - sector: battery + productGroup: battery gtin: '09506000134352' batteryChemistry: LFP nominalVoltageV: 48 @@ -2953,11 +2734,11 @@ components: type: string description: Human-readable product name. Must not be blank. example: EcoCell Pro 48V - sector: + productGroup: type: string description: | - EU ESPR sector (the dispatch key), e.g. `battery`, `textile`, - `electronics`. Optional — derived from `sectorData` when omitted. + EU ESPR product group (the dispatch key), e.g. `battery`, `textile`, + `electronics`. Optional — derived from `productGroupData` when omitted. example: battery manufacturer: $ref: '#/components/schemas/ManufacturerInfo' @@ -2967,15 +2748,24 @@ components: $ref: '#/components/schemas/MaterialEntry' co2ePerUnit: type: number - description: CO₂ equivalent per unit in kg + minimum: 0 + description: |- + CO₂ equivalent per unit, in kg. Must be finite and non-negative; anything else is rejected with `422`. + Supplied here as a scalar and stored as a `CarbonFootprint` object, so the value echoed back on `PassportResponse` is `{ "valueKg": … }` rather than the bare number sent. The lifecycle stage and system boundary that make two figures comparable cannot be set through this route. example: 4.2 repairabilityScore: type: number minimum: 0 - maximum: 100 - description: Non-regulatory repairability indicator (0–100). NOT the EU 2023/1669 repairability class. Optional. - sectorData: - $ref: '#/components/schemas/SectorData' + maximum: 10 + description: |- + Non-regulatory repairability heuristic, **0–10**. Not EN 45554 or EU 2023/1669 — those have prescribed methodologies this does not implement. Anything outside 0–10 is rejected with `422`. + Supplied here as a scalar and stored as a `RepairabilityScore` object, so the value echoed back on `PassportResponse` is `{ "overall": … }` rather than the bare number sent. + example: 7.5 + productGroupData: + description: Optional at create; an explicit `null` is accepted and equivalent to omitting it. Publish validates it only when present. + anyOf: + - $ref: '#/components/schemas/ProductGroupData' + - type: 'null' batchId: type: string description: Optional batch or lot identifier @@ -2997,8 +2787,8 @@ components: schemaVersion: type: string description: | - Sector schema version. Optional, and the only accepted value is the - sector's **current** version — omitting it is equivalent. Any other + Product group schema version. Optional, and the only accepted value is the + product group's **current** version — omitting it is equivalent. Any other value is rejected with `422`. It is not the caller's to choose: the stored version selects the @@ -3015,11 +2805,194 @@ components: items: $ref: '#/components/schemas/PassportRef' description: Cross-operator references to this product's constituent passports (its bill of materials). Local cycles / over-depth are refused at create/update; immutable after publish. + commodityCode: + type: string + description: Customs tariff classification — HS-6, CN-8 or TARIC-10 (6, 8 or 10 digits, no separators). Registration data the EU registry verifies against the ranges its product group permits. + example: '85076000' DppId: type: string format: uuid description: UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs. example: 019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f + CarbonFootprint: + type: object + description: A CO₂-equivalent figure with the LCA context needed to read it. A bare number is not comparable across products — the lifecycle stage and system boundary are what make two figures mean the same thing. + required: + - valueKg + properties: + valueKg: + type: number + description: CO₂-equivalent value in kg per functional unit. + example: 45.2 + lifecycleStage: + type: string + description: LCA lifecycle stage this figure covers. + enum: + - cradle-to-gate + - cradle-to-grave + - cradle-to-cradle + - gate-to-grave + - other + systemBoundary: + type: string + description: LCA system-boundary standard used. + enum: + - EN-15804 + - ISO-14044 + - GHG-protocol + - other + methodologyRef: + type: string + description: Citation for the methodology behind the figure. + performanceClass: + type: string + maxLength: 8 + description: Performance class label as defined by the applicable delegated act (e.g. an A–G band). Free text because the banding is per product group. + example: B + RepairCriterion: + type: object + description: One weighted criterion contributing to a repairability score. + required: + - name + - score + - weight + properties: + name: + type: string + example: disassembly_depth + score: + type: number + weight: + type: number + RepairabilityScore: + type: object + description: Repairability as a **non-regulatory heuristic**. Deliberately not an EN 45554 or Regulation (EU) 2023/1669 index — those have prescribed methodologies this does not implement, and presenting a heuristic as either would misstate it. + required: + - overall + properties: + overall: + type: number + minimum: 0 + maximum: 10 + example: 7.5 + criteria: + type: array + description: Per-criterion breakdown. Empty when only the overall score is known. + items: + $ref: '#/components/schemas/RepairCriterion' + ComplianceStatus: + type: string + description: Overall compliance determination. `PASSTHROUGH_NO_VALIDATION` means no product group plugin was loaded and nothing was assessed — it is not a pass. + enum: + - PASSTHROUGH_NO_VALIDATION + - COMPLIANT + - NON_COMPLIANT + - NOT_ASSESSED + - NOT_IMPLEMENTED + ComplianceFinding: + type: object + description: A single compliance finding. Severity is encoded by which array it lands in on `ComplianceResult` — `violations` bind, `warnings` advise — so there is no separate severity field. + required: + - code + - message + properties: + code: + type: string + description: Stable machine-readable code. + example: battery.recycled_content.cobalt_below_2031 + field: + type: string + description: JSON-pointer-style locator, or absent when the finding is not tied to a single field. + example: /recycledContentCobaltPct + message: + type: string + ComplianceResult: + type: object + description: The computed compliance determination. Part of the signed passport payload and immutable after retention lock. Absent until a determination has been computed — for example on a product group with no plugin loaded. + required: + - co2eScore + - repairabilityIndex + - recycledContentPct + - complianceStatus + properties: + co2eScore: + type: + - number + - 'null' + description: Calculated or manufacturer-supplied CO₂e score in kg. + repairabilityIndex: + type: + - number + - 'null' + description: Calculated or manufacturer-supplied repairability index (0.0–10.0). + recycledContentPct: + type: + - number + - 'null' + complianceStatus: + $ref: '#/components/schemas/ComplianceStatus' + violations: + type: array + description: Binding findings — these block publish when the product group is in force. Empty for passthrough or not-assessed determinations. + items: + $ref: '#/components/schemas/ComplianceFinding' + warnings: + type: array + description: Advisory findings — surfaced but never blocking (e.g. thresholds not yet in force). + items: + $ref: '#/components/schemas/ComplianceFinding' + rulesetVersion: + type: string + description: Version of the resolved calculation ruleset, when one ran. + assessedAt: + type: string + format: date-time + receipt: + type: object + additionalProperties: true + description: Calculation receipt (input hash, ruleset id and version, factor dataset version and table hash) for notified-body audit. Present only when a calculation actually ran. + LintSeverity: + type: string + description: Severity of a plausibility finding. Neither value blocks publish — lint is advisory by construction, unlike a compliance violation. + enum: + - WARNING + - NOTICE + LintFinding: + type: object + required: + - code + - field + - severity + - message + properties: + code: + type: string + example: mass.balance + field: + type: string + example: /materials + severity: + $ref: '#/components/schemas/LintSeverity' + message: + type: string + LintResult: + type: object + description: Non-binding plausibility findings — arithmetic and physical-plausibility checks, distinct from binding compliance rules. Never gates publish, and may be recomputed at any time after publish via `POST /dpp/{dppId}/lint` (unlike `complianceResult`, which is frozen into the signed payload). + required: + - packVersion + - assessedAt + properties: + packVersion: + type: string + description: Version of the lint pack that produced these findings. + example: 1.0.0 + findings: + type: array + items: + $ref: '#/components/schemas/LintFinding' + assessedAt: + type: string + format: date-time PassportStatus: type: string enum: @@ -3027,6 +3000,8 @@ components: - active - suspended - archived + - superseded + - deactivated description: | DPP lifecycle state. The domain model uses `Published` internally; the wire format uses `active`. Deserialization accepts both. @@ -3034,16 +3009,80 @@ components: - `active`: published and signed; publicly resolvable via QR - `suspended`: temporarily hidden (recall, dispute); JWS preserved - `archived`: end-of-life; immutable; retained for regulatory record-keeping + - `superseded`: replaced by a newer passport version; terminal + - `deactivated`: end-of-life declared (recycled, destroyed under a + derogation, exported, or lost); terminal. The typed reason is carried by + the EOL event, not this field Valid transitions: draft → active, draft → archived, - active → suspended, active → archived, - suspended → active, suspended → archived. + active → suspended, active → archived, active → superseded, + active → deactivated, + suspended → active, suspended → archived, suspended → deactivated. + + `archived`, `superseded` and `deactivated` are terminal. + FacilitySnapshot: + type: object + description: 'Annex III facility details copied by value into the passport at create time. Self-contained on purpose: the signed passport stays a complete record even after the operator retires the facility from their mutable registry.' + required: + - scheme + - value + - name + - country + properties: + scheme: + type: string + example: gln + value: + type: string + example: '4012345000009' + name: + type: string + country: + type: string + minLength: 2 + maxLength: 2 + description: ISO 3166-1 alpha-2 country code. + address: + type: string + SealFormat: + type: string + description: AdES format of a seal value. + enum: + - JADES + - PADES + - CADES + - XADES + SealedEnvelope: + type: object + description: An eIDAS electronic seal over the passport's full-payload signature. Check `placeholder` before treating it as evidence — a placeholder envelope is produced when no QTSP is configured and carries no legal validity. + required: + - format + - sealValue + - sealedAt + - placeholder + properties: + format: + $ref: '#/components/schemas/SealFormat' + sealValue: + type: string + description: Base64-encoded seal value as returned by the QTSP. + signingCertRef: + type: string + description: Reference to the signing certificate chain. + sealedAt: + type: string + format: date-time + placeholder: + type: boolean + description: '`true` when this envelope has **no** legal validity. Consumers must check this flag rather than inferring validity from the envelope''s presence.' PassportResponse: type: object + description: The canonical Digital Product Passport record. Fields marked required are emitted on every read; the rest are omitted when unset rather than sent as null, so a consumer must treat absence and null as the same thing. required: - id - productName + - productGroup - manufacturer - materials - status @@ -3051,6 +3090,7 @@ components: - createdAt - updatedAt - retentionLocked + - version properties: id: $ref: '#/components/schemas/DppId' @@ -3061,6 +3101,44 @@ components: productName: type: string example: EcoCell Pro 48V + productGroup: + type: string + description: 'EU ESPR product group — the delegated-act bucket selecting the applicable schema and plugin. Deliberately an open string, not a closed enum: adding a product group is a catalog manifest plus a schema, not a release, and a product group this build does not know still round-trips its wire tag verbatim.' + example: battery + applicableInstruments: + type: array + description: |- + The legal instruments this passport was issued under, fixed when the product was placed on the market and never recomputed afterwards. + + A set rather than a single value because acts accumulate: ESPR Art. 5(7) lets a group-specific delegated act supplement a horizontal one and the Regulation states no precedence rule, so the governing law is the union of the members' requirements. + + Not derivable from `productGroup`. A horizontal act can reach a product whose product group is not one the catalog models, so an entry may be asserted by the economic operator rather than resolved from the catalog — which `recorded` distinguishes. Read it as who asserted the entry, not as how much to trust it. + items: + type: object + required: + - instrument + - recorded + properties: + instrument: + type: string + description: The instrument's catalog id. + example: battery-reg-2023-1542 + recorded: + type: string + enum: + - catalog + - operator + description: '`catalog` — resolved from the instrument catalog at issuance. `operator` — asserted by the economic operator placing the product on the market.' + example: catalog + additionalProperties: false + granularity: + type: string + enum: + - model + - batch + - item + description: 'The level this passport describes. Set by the applicable delegated act (ESPR Art. 9(2)(d)), so it is a property of the law rather than a choice made here, and absent while no adopted act has fixed one — which is every product group today. Absent is not `item`: the EU registry registers batteries per item, but that is the registry''s operational position, not a level any act has set.' + example: item manufacturer: $ref: '#/components/schemas/ManufacturerInfo' materials: @@ -3068,15 +3146,28 @@ components: items: $ref: '#/components/schemas/MaterialEntry' co2ePerUnit: - type: - - number - - 'null' + description: 'CO₂e per unit — manufacturer-supplied or engine-calculated. An object, not a bare number: a figure without its lifecycle stage and system boundary is not comparable to another product''s.' + anyOf: + - $ref: '#/components/schemas/CarbonFootprint' + - type: 'null' repairabilityScore: - type: - - number - - 'null' - sectorData: - $ref: '#/components/schemas/SectorData' + description: Non-regulatory repairability heuristic — see the schema's own note. + anyOf: + - $ref: '#/components/schemas/RepairabilityScore' + - type: 'null' + complianceResult: + allOf: + - $ref: '#/components/schemas/ComplianceResult' + description: The computed compliance determination, attached at create/update. Part of the signed payload and immutable after retention lock. Absent until a determination has been computed. + lintResult: + allOf: + - $ref: '#/components/schemas/LintResult' + description: Advisory plausibility findings. Absent until a lint pass has run, and recomputable after publish — unlike `complianceResult`. + productGroupData: + description: Explicitly `null` — not omitted — for a passport whose product group data has not been supplied yet, which is every draft created without it. The field has no `skip_serializing_if`, so it is always present on the wire. + anyOf: + - $ref: '#/components/schemas/ProductGroupData' + - type: 'null' status: $ref: '#/components/schemas/PassportStatus' qrCodeUrl: @@ -3089,7 +3180,15 @@ components: type: - string - 'null' - description: JWS compact serialisation (Ed25519). Null until published. + description: Compact JWS (Ed25519) over the **full** canonical payload. Null until published. + publicJwsSignature: + type: string + description: Compact JWS over the **public (redacted) view**, so anyone can verify the public passport independently — the resolver checks this on the unauthenticated public route. Set at publish; absent for drafts. + disclosureSignatures: + type: object + additionalProperties: + type: string + description: 'Compact JWS signatures over the **non-public** redacted views, keyed by disclosure set (e.g. `public+restricted+individual`), never by audience name. Every audience receiving more than the public view needs a proof over *its* view: `publicJwsSignature` covers only the public payload and `jwsSignature` only the full one, so a reader handed a filtered body and either of those holds a signature that cannot verify against the bytes it received. Empty for drafts.' createdAt: type: string format: date-time @@ -3101,28 +3200,153 @@ components: - string - 'null' format: date-time + placedOnMarketDate: + type: string + format: date + description: The date the product was placed on the EU market — the regulated triggering event fixing **which law governs it**, distinct from the three lifecycle dates above, none of which selects a rule. Staged EU obligations attach at placing on the market and do not move afterwards. Absence means the date was not declared; it is **not** licence to substitute the current date, and a determination depending on it has no answer. schemaVersion: type: string + description: Semantic version of the **product group** schema `productGroupData` was validated against. Scoped to `productGroupData` only — the envelope fields have no equivalent version and never will, because they are shared by every product group's stored documents. The envelope's compatibility rule is additive only, permanently. example: 1.0.0 retentionLocked: type: boolean description: | Set to `true` permanently on first publish. Retention-locked passports must remain accessible for the EU ESPR retention period. - ApiError: - type: object - required: - - error - - message - properties: - error: - type: string - example: VALIDATION_ERROR - message: - type: string - example: productName is required - DerogationRef: - type: object + version: + type: integer + minimum: 1 + description: Monotonic version counter. `1` on first publish; incremented on the successor each time a new version supersedes this record. + supersedesId: + allOf: + - $ref: '#/components/schemas/DppId' + description: The passport this record supersedes. Absent for first versions. + parentPassportRef: + allOf: + - $ref: '#/components/schemas/PassportRef' + description: Cross-operator reference to the predecessor this passport derives from (second-life successor linkage). + componentRefs: + type: array + description: 'Cross-operator references to the constituent passports this product is assembled from — its bill of materials. The inverse edge of `parentPassportRef`: these point down to many constituents, that points up to one predecessor.' + items: + $ref: '#/components/schemas/PassportRef' + retentionUntil: + type: string + format: date-time + description: Deadline by which this record must remain accessible, computed at publish from the product group's retention period. Regulation (EU) 2024/1781 Art. 9(2)(i) requires the delegated act to specify a period corresponding to at least the product's expected lifetime; Art. 11(e) restates it as an essential requirement, available including after the responsible operator's insolvency, liquidation or cessation of activity. + productId: + type: string + format: uuid + description: Opaque link to an internal product-template record. Not a legal identifier. + commodityCode: + type: string + description: Customs tariff classification — HS-6, CN-8 or TARIC-10. Absent where the product group does not call for one; this node will not invent a classification it cannot derive. + example: '85076000' + operatorIdentifier: + type: string + description: 'EORI or national economic-operator identifier for the responsible party (Regulation (EU) 2024/1781 Annex III(k); issuance mechanics in Art. 12). **Frozen at publish — this is the operator that published the passport, not necessarily the one responsible for it now.** A transfer of responsibility does not rewrite it and cannot: published content is immutable and covered by the signature over it. For current responsibility, read the transfer chain.' + example: DE123456789 + facility: + allOf: + - $ref: '#/components/schemas/FacilitySnapshot' + description: Snapshot of the Annex III facility where this product was manufactured or processed, copied by value at create time. + seal: + allOf: + - $ref: '#/components/schemas/SealedEnvelope' + description: The eIDAS electronic seal applied to this passport. Absent until a seal has been applied; check its `placeholder` flag rather than inferring validity from presence. + Problem: + type: object + description: | + RFC 7807 / RFC 9457 problem details. The shape + `dpp-common::http_problem::Problem` produces, served as + `application/problem+json`. + + `type` is derived from `title`, so each distinct `title` used across the + codebase is a stable catalogue key that clients may depend on. + required: + - type + - title + - status + properties: + type: + type: string + format: uri + description: Absolute URI identifying the problem type. + example: https://problems.odal-node.io/not-found + title: + type: string + description: Short human-readable summary of the problem type. + example: Not Found + status: + type: integer + description: The HTTP status code, mirroring the status line. + example: 404 + detail: + type: string + description: Human-readable explanation for this specific occurrence. + example: 'No schema for product group ''nosuchsector''. Known product groups: aluminium, battery.' + instance: + type: string + format: uri-reference + description: URI reference identifying this specific occurrence. + ValidateResponse: + type: object + description: 'The dry-run verdict. Two booleans rather than one, because create and publish deliberately differ: a body can be creatable as a draft and not yet publishable, and collapsing that into a single flag would hide the gap until the caller tried to publish.' + required: + - createValid + - productGroupDataValid + properties: + createValid: + type: boolean + description: Always `true` on a 200 — a body create would reject gets back the identical 422 create would have returned, not a paraphrase of it. + example: true + productGroupDataValid: + type: boolean + description: | + Whether the product group data would clear the publish-time schema gate. `true` + when no product group data is supplied, since publish only validates it when + present. + + **Not a publish verdict.** It reports one of publish's preconditions, and + publish applies others this route does not run: the registry-identity + requirement (a default facility and a primary operator identifier), which + needs operator state this route never reads; the category-mandatory + content gate, reachable only by attempting the lifecycle transition; and + the compliance gate, which needs a `placedOnMarketDate` and a stored + passport. `true` means "this body clears the schema gate", never "publish + will succeed" — the field was called `publishValid` and was renamed + because that name promised the latter. + example: false + detail: + type: + - string + - 'null' + description: Why the product group data would be refused. Null when `productGroupDataValid` is true. + example: 'cannot publish: no registered JSON Schema for product group ''furniture'' — publish requires a resolvable schema when product group data is present' + PassportListResponse: + type: object + description: One page of passports, with the totals needed to page through them. + required: + - dpps + - total + - limit + - skip + properties: + dpps: + type: array + items: + $ref: '#/components/schemas/PassportResponse' + total: + type: integer + description: Total matching the filter across every page — deliberately not the length of `dpps`. + limit: + type: integer + description: The page size actually applied, after clamping. + skip: + type: integer + description: The offset this page starts at. + DerogationRef: + type: object required: - category description: A recognised derogation from the ESPR Art. 25 destruction ban. The category list is fixed by the applicable delegated act; validated against that list at the engine boundary, not by this schema. @@ -3334,7 +3558,6 @@ components: required: - id - passportId - - operatorId - actor - action - timestamp @@ -3344,8 +3567,6 @@ components: format: uuid passportId: type: string - operatorId: - type: string actor: type: string example: admin@example.com @@ -3367,6 +3588,250 @@ components: timestamp: type: string format: date-time + prevHash: + type: + - string + - 'null' + description: 'Hex SHA-256 of the previous entry''s `entryHash`, or null for the first entry in a passport''s chain. Together with `entryHash` this makes the trail append-only and tamper-evident: recomputing the chain detects any inserted, removed or edited entry.' + entryHash: + type: + - string + - 'null' + description: Hex SHA-256 over this entry's canonical (RFC 8785) bytes including `prevHash`. Null only for entries written before the chain was introduced. + DailyScanCount: + type: object + required: + - day + - count + properties: + day: + type: string + format: date + count: + type: integer + PassportScanStats: + type: object + description: Per-passport scan aggregates over a trailing window. `totalScans` and `qrRenders` sit side by side and are never combined — a QR render is label production, not a resolution. Nothing about the scanner (IP, agent, session) is collected or returned; the counters carry no such fields. + required: + - windowDays + - totalScans + - scansHtml + - scansJson + - daily + - qrRenders + properties: + windowDays: + type: integer + totalScans: + type: integer + scansHtml: + type: integer + scansJson: + type: integer + daily: + type: array + description: Per-day scan totals, oldest first. + items: + $ref: '#/components/schemas/DailyScanCount' + qrRenders: + type: integer + OperatorScanStats: + type: object + description: Operator-wide scan rollup over a trailing window. + required: + - windowDays + - totalScans + - totalQrRenders + - distinctPassportsScanned + properties: + windowDays: + type: integer + totalScans: + type: integer + totalQrRenders: + type: integer + distinctPassportsScanned: + type: integer + RegistrationView: + type: object + description: One passport's registration, as the EU-registry queue holds it. + required: + - status + - attempts + - stalled + properties: + status: + type: string + enum: + - pending + - submitted + - registered + - rejected + - deactivated + registryId: + type: string + description: The registry's own record id, once it has issued one. + message: + type: string + description: The last thing the registry (or the drain) said about it. + attempts: + type: integer + stalled: + type: boolean + description: True once `attempts` reaches the drain threshold — the row is not going to succeed without someone looking at it. + statusIntent: + type: string + description: 'A status change owed to the registry, independent of the queue state. Nothing drains these: the registry publishes no status-push API, so they are held durably and reported rather than accumulating out of sight.' + TransferView: + type: object + description: One transfer-of-responsibility notification owed to the registry. + required: + - transferId + - status + - attempts + - stalled + properties: + transferId: + type: string + format: uuid + status: + type: string + enum: + - pending + - notified + - rejected + registryId: + type: string + message: + type: string + attempts: + type: integer + stalled: + type: boolean + CurrentOperatorView: + type: object + description: The operator responsible for a passport **today**, derived from its transfer chain. Reported separately from the passport's own `operatorIdentifier`, which is the operator that *published* it — frozen at publish and covered by the signature, so a transfer does not rewrite it. For a passport that has changed hands the two differ, and that difference is a fact about the product. + required: + - did + - name + - country + - transferCount + properties: + did: + type: string + name: + type: string + country: + type: string + transferCount: + type: integer + description: Completed handovers this passport has been through. + PassportRegistryView: + type: object + description: 'EU-registry state for one passport. `configured: false` means this deployment has no registry queues at all — reported instead of a row of zeros, which would read as "everything is registered".' + required: + - passportId + - configured + properties: + passportId: + type: string + configured: + type: boolean + registration: + allOf: + - $ref: '#/components/schemas/RegistrationView' + description: 'Absent when the passport has never been published: it owes no registration, which is different from owing one that has not happened.' + transfers: + type: array + description: Handover notifications recorded for this passport, newest first. + items: + $ref: '#/components/schemas/TransferView' + currentOperator: + allOf: + - $ref: '#/components/schemas/CurrentOperatorView' + description: Absent when the passport has never been transferred, in which case its own `operatorIdentifier` is current. + VerificationView: + type: object + description: The operator's verified-registry standing. Verified status ends when the electronic identification means used expire, and at the latest three years after verification; an operator that lets it lapse cannot register or amend anything until it verifies again. Reported whether or not the queues are configured. + required: + - current + properties: + current: + type: boolean + description: False both when never verified and when lapsed — the registry refuses either way, though they are different situations to act on. + verifiedAt: + type: string + format: date-time + description: Absent when never verified. + expiresAt: + type: string + format: date-time + description: The three-year cap. The eID means may expire sooner, which this cannot see, so it is an upper bound rather than a promise. + daysRemaining: + type: integer + description: Negative once lapsed. Absent when never verified. + RegistrationCounts: + type: object + required: + - pending + - submitted + - registered + - rejected + - deactivated + - statusIntents + - stalled + - unregisteredPublished + properties: + pending: + type: integer + submitted: + type: integer + registered: + type: integer + rejected: + type: integer + deactivated: + type: integer + statusIntents: + type: integer + description: Status changes owed to the registry that nothing drains. + stalled: + type: integer + description: Rows that have retried past the point of self-recovery. + unregisteredPublished: + type: integer + description: 'Published passports with **no** outbox row at all — they owe a registration nobody is tracking (published before the outbox existed, or lost to an older write path). Reported, not repaired: the queued payload is what a drain replays and there is none to rebuild, so fabricating a row would create an entry that can never drain.' + TransferCounts: + type: object + required: + - pending + - notified + - rejected + - stalled + properties: + pending: + type: integer + notified: + type: integer + rejected: + type: integer + stalled: + type: integer + RegistryRollupView: + type: object + description: 'Operator-wide EU-registry state. `configured: false` means this deployment has no registry queues; the counts are then omitted rather than reported as zero.' + required: + - configured + - verification + properties: + configured: + type: boolean + verification: + $ref: '#/components/schemas/VerificationView' + registrations: + $ref: '#/components/schemas/RegistrationCounts' + transfers: + $ref: '#/components/schemas/TransferCounts' TreeNodeReport: type: object required: @@ -3407,6 +3872,82 @@ components: type: array items: $ref: '#/components/schemas/TreeNodeReport' + SealSummaryResponse: + type: object + description: 'Operator-wide sealing state. Read `sealingConfigured` first: when it is false every count is `0` because this node has no outbox, **not** because nothing is outstanding.' + required: + - unsealedPublished + - pending + - sealed + - exhausted + - sealingConfigured + properties: + unsealedPublished: + type: integer + description: Published passports carrying no seal at all. `0` is the healthy state. + pending: + type: integer + sealed: + type: integer + exhausted: + type: integer + description: Rows that gave up after exhausting their retries. + sealingConfigured: + type: boolean + Coverage: + type: string + description: 'Whether the stored seal covers the passport''s current signature. Answered from this node''s record of what it *asked* to be sealed — weaker than a validator''s verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.' + enum: + - current + - superseded + - unknown + SealResponse: + type: object + description: The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check. + required: + - format + - sealValue + - sealedAt + - placeholder + - currentJws + - currentPayloadHash + - coverage + - verification + properties: + format: + type: string + description: AdES format of `sealValue`. + example: CADES + sealValue: + type: string + description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. + sealedAt: + type: string + format: date-time + signingCertRef: + type: + - string + - 'null' + description: Hex SHA-256 of the certificate the seal names as its signer, **as reported by the seal** — read out of the CAdES, never verified. It answers *which* certificate to ask about, not whether that certificate was qualified or on the EU Trusted List. Null when the seal predates extraction or could not be parsed. + placeholder: + type: boolean + description: True when this is a placeholder with no legal validity. + currentJws: + type: string + description: The passport's **current** compact JWS. + currentPayloadHash: + type: string + description: Hex SHA-256 of `currentJws`. + sealedPayloadHash: + type: + - string + - 'null' + description: 'Hex SHA-256 this node **asked** the backend to seal. A record, not proof: it says what was requested, while the validator''s extracted message digest says what the CAdES actually covers. The two agreeing is the cross-check. Null for a seal restored from a backup or produced elsewhere.' + coverage: + $ref: '#/components/schemas/Coverage' + verification: + type: string + description: 'Stated, not implied: this node did not cryptographically validate the CAdES. A detached CAdES must be checked by an independent AdES validator against the EU Trusted List.' EvidenceDossierSummary: type: object description: Listing projection of a stored dossier — everything but the document. @@ -3441,6 +3982,7 @@ components: - issuerDid - createdAt - nodeVersion + - coreVersion - contentHashes properties: formatVersion: @@ -3465,6 +4007,10 @@ components: description: member name -> hex SHA-256 of that member's JCS-canonical bytes. additionalProperties: type: string + coreVersion: + type: string + description: The `dpp-core` version this node was built against. Recorded alongside `nodeVersion` because the two move independently — the regulatory logic, schemas and disclosure policy behind a determination live in core, so a dossier naming only the node version cannot be traced back to the code that produced its verdict. + example: 0.18.0 SignedLayer: type: object description: | @@ -3717,6 +4263,12 @@ components: - string - 'null' format: date-time + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. UpdateOperatorConfig: type: object description: Merge-patch update — only supply fields being changed. @@ -3751,6 +4303,19 @@ components: type: integer featureFlags: type: object + registryVerifiedAt: + type: + - string + - 'null' + format: date-time + description: When this operator's identity was last verified against the EU registry. Null when no verification has succeeded — never treat absence as verified. + ApiKeyScope: + type: string + description: What an API key authorises. `admin` is the default when a key is minted without an explicit scope, so a key issued to an integration should name `read` or `write` deliberately. + enum: + - read + - write + - admin ApiKey: type: object required: @@ -3758,6 +4323,7 @@ components: - name - keyPrefix - isActive + - scope - createdAt properties: id: @@ -3785,6 +4351,8 @@ components: - string - 'null' format: date-time + scope: + $ref: '#/components/schemas/ApiKeyScope' CreateApiKeyRequest: type: object required: @@ -3800,6 +4368,10 @@ components: - 'null' format: date-time description: Optional expiration. Null = never expires. + scope: + allOf: + - $ref: '#/components/schemas/ApiKeyScope' + description: Defaults to `admin` when omitted. Name `read` or `write` explicitly for a key issued to an integration. NewApiKey: type: object required: @@ -3814,6 +4386,85 @@ components: The full plain-text API key. Shown ONCE at creation time. Store securely — it cannot be retrieved again. example: odal_sk_abc123def456ghi789jkl012mno345pqr678 + InstalledPlugin: + type: object + description: What the node installed, reported back after a successful hot-swap. + required: + - productGroup + - abiVersion + properties: + productGroup: + type: string + description: Product group catalog key the plugin is bound to. + example: battery + abiVersion: + type: string + description: ABI version the plugin declared, formatted `major.minor`. + example: '1.0' + WebhookSubscription: + type: object + description: A receiver subscription, redacted. The signing secret is never carried here — it is returned exactly once from the create call and otherwise stays server-side. + required: + - id + - url + - events + - active + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + url: + type: string + format: uri + description: Receiver URL — validated `https`, non-private host at creation. + events: + type: array + description: Subject filter — event type strings, or a single `*` for all events. + items: + type: string + active: + type: boolean + description: Removal is a soft `active = false`, never a hard delete. + description: + type: + - string + - 'null' + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + NewWebhookSubscription: + type: object + description: Input for creating a subscription. The signing secret is generated server-side and is never client-supplied, so it is not part of this body. + required: + - url + - events + properties: + url: + type: string + format: uri + description: Receiver URL. SSRF-validated before it is persisted. + events: + type: array + items: + type: string + description: + type: string + CreatedWebhookResponse: + description: The created subscription's fields, inlined, plus the signing secret. The secret is shown **once** — it is not recoverable from any later read. + allOf: + - $ref: '#/components/schemas/WebhookSubscription' + - type: object + required: + - secret + properties: + secret: + type: string + description: Signing secret. Store it now; it is never shown again. NodeState: type: object required: @@ -3864,6 +4515,22 @@ components: the rules a passport was checked under are observable. Absent on a standalone vault. example: baseline + WhoamiResponse: + type: object + description: What the presented credential is. Reports only what the caller already sent — it reveals nothing about any other key, and the key's secret is never stored in a recoverable form. + required: + - userId + - scope + properties: + userId: + type: string + description: The caller's identity, as authenticated. + scope: + $ref: '#/components/schemas/ApiKeyScope' + keyId: + type: string + format: uuid + description: The key's row id — never the token. Absent for local-admin Basic auth, which has no key row. Facility: type: object required: @@ -4219,41 +4886,6 @@ components: type: boolean did_document: $ref: '#/components/schemas/DidDocument' - Problem: - type: object - description: | - RFC 7807 / RFC 9457 problem details. The shape - `dpp-common::http_problem::Problem` produces, served as - `application/problem+json`. - - `type` is derived from `title`, so each distinct `title` used across the - codebase is a stable catalogue key that clients may depend on. - required: - - type - - title - - status - properties: - type: - type: string - format: uri - description: Absolute URI identifying the problem type. - example: https://problems.odal-node.io/not-found - title: - type: string - description: Short human-readable summary of the problem type. - example: Not Found - status: - type: integer - description: The HTTP status code, mirroring the status line. - example: 404 - detail: - type: string - description: Human-readable explanation for this specific occurrence. - example: 'No schema for sector ''nosuchsector''. Known sectors: aluminium, battery.' - instance: - type: string - format: uri-reference - description: URI reference identifying this specific occurrence. ImportCreatedEntry: type: object required: @@ -4283,6 +4915,19 @@ components: description: Column name, or "vault" / "auth" / "internal". message: type: string + ImportUpdatedEntry: + type: object + description: One row that matched an existing passport and updated it. + required: + - row + - passportId + properties: + row: + type: integer + description: 1-based row number in the uploaded file, excluding the header. + passportId: + type: string + format: uuid ImportSyncResponse: type: object required: @@ -4291,6 +4936,7 @@ components: - successCount - errorCount - created + - updated - errors description: Returned for synchronous imports (≤ 100 valid rows) and dry runs. properties: @@ -4315,6 +4961,11 @@ components: type: array items: $ref: '#/components/schemas/ImportErrorEntry' + updated: + type: array + description: Rows that matched an existing passport and updated it. Counted in `successCount`, which is deliberately not `created.length + updated.length` — see that field. + items: + $ref: '#/components/schemas/ImportUpdatedEntry' ImportAsyncResponse: type: object required: @@ -4369,59 +5020,77 @@ components: Unauthorized: description: Missing or invalid authentication credentials. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' example: - error: UNAUTHORIZED - message: Missing or invalid Authorization header. + type: https://problems.odal-node.io/unauthorized + title: Unauthorized + status: 401 + detail: Missing or invalid Authorization header. ValidationError: description: One or more fields failed validation. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/unprocessable-entity + title: Unprocessable Entity + status: 422 + detail: productName must not be empty. Forbidden: description: | The credential is authenticated but lacks the required scope — e.g. a `write`/`read` key attempting an admin-only action (registry identity). content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' example: - error: FORBIDDEN - message: Registry-identity management requires an admin-scoped credential. + type: https://problems.odal-node.io/forbidden + title: Forbidden + status: 403 + detail: Registry-identity management requires an admin-scoped credential. NotFound: description: Resource not found within the operator's scope. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' example: - error: NOT_FOUND - message: DPP not found. + type: https://problems.odal-node.io/not-found + title: Not Found + status: 404 + detail: DPP not found. Conflict: description: | State conflict — e.g. attempting to publish an already-published DPP or updating an archived DPP. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' + example: + type: https://problems.odal-node.io/conflict + title: Conflict + status: 409 + detail: DPP is already published. NotAcceptable: description: | No representation matches the request's `Accept` header. The response body names the media types this resource can produce. A passport carrying no GTIN — an unsold-goods report, or an untyped - sector — also gets this for `application/aas+json`: it identifies no + product group — also gets this for `application/aas+json`: it identifies no trade item, so it has no AAS asset identity and therefore no AAS representation. content: application/problem+json: schema: - $ref: '#/components/schemas/ApiError' + $ref: '#/components/schemas/Problem' example: - error: NOT_ACCEPTABLE - message: No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json. + type: https://problems.odal-node.io/not-acceptable + title: Not Acceptable + status: 406 + detail: No representation matches 'application/pdf'. This resource is available as text/html, application/ld+json, or application/aas+json. diff --git a/api/openapi.yaml b/api/openapi.yaml index d8d23070..eb392bee 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -104,6 +104,10 @@ paths: $ref: paths/vault_api_v1_dpp_{dppId}_stats.yaml /vault/api/v1/stats: $ref: paths/vault_api_v1_stats.yaml + /vault/api/v1/dpp/{dppId}/registry: + $ref: paths/vault_api_v1_dpp_{dppId}_registry.yaml + /vault/api/v1/registry: + $ref: paths/vault_api_v1_registry.yaml /vault/api/v1/dpp/{dppId}/verify-tree: $ref: paths/vault_api_v1_dpp_{dppId}_verify-tree.yaml /vault/api/v1/seal: @@ -182,16 +186,16 @@ paths: $ref: paths/internal_keys_rotate.yaml /integrator/health: $ref: paths/integrator_health.yaml - /integrator/api/v1/templates/{sector}: - $ref: paths/integrator_api_v1_templates_{sector}.yaml + /integrator/api/v1/templates/{productGroup}: + $ref: paths/integrator_api_v1_templates_{productGroup}.yaml /integrator/api/v1/schemas: $ref: paths/integrator_api_v1_schemas.yaml - /integrator/api/v1/schemas/{sector}: - $ref: paths/integrator_api_v1_schemas_{sector}.yaml - /integrator/api/v1/schemas/{sector}/{version}: - $ref: paths/integrator_api_v1_schemas_{sector}_{version}.yaml - /integrator/api/v1/import/{sector}: - $ref: paths/integrator_api_v1_import_{sector}.yaml + /integrator/api/v1/schemas/{productGroup}: + $ref: paths/integrator_api_v1_schemas_{productGroup}.yaml + /integrator/api/v1/schemas/{productGroup}/{version}: + $ref: paths/integrator_api_v1_schemas_{productGroup}_{version}.yaml + /integrator/api/v1/import/{productGroup}: + $ref: paths/integrator_api_v1_import_{productGroup}.yaml /integrator/api/v1/imports/{job_id}: $ref: paths/integrator_api_v1_imports_{job_id}.yaml /dpp/{dppId}: @@ -219,6 +223,10 @@ tags: description: >- Aggregate, privacy-safe resolution counts — per-passport and operator-wide rollups. + - name: Registry + description: >- + EU-registry registration state — per-passport and operator-wide, plus the + operator's verified-registry standing. - name: Evidence Dossiers description: >- Signed, self-contained evidence dossiers — generate, fetch, and verify @@ -228,7 +236,7 @@ tags: - name: API Keys description: API key management — create, list, revoke. - name: Plugins - description: Signed sector-plugin hot-install — verify, persist, hot-swap (admin-only). + description: Signed product group-plugin hot-install — verify, persist, hot-swap (admin-only). - name: Webhooks description: Signed outbound event delivery — subscribe, list, remove, test. - name: Facilities diff --git a/api/paths/integrator_api_v1_import_{sector}.yaml b/api/paths/integrator_api_v1_import_{productGroup}.yaml similarity index 92% rename from api/paths/integrator_api_v1_import_{sector}.yaml rename to api/paths/integrator_api_v1_import_{productGroup}.yaml index ceaf4910..88392af3 100644 --- a/api/paths/integrator_api_v1_import_{sector}.yaml +++ b/api/paths/integrator_api_v1_import_{productGroup}.yaml @@ -2,7 +2,7 @@ post: operationId: importFile summary: Bulk-import passports from a file description: | - Upload a CSV or XLSX file to create draft passports for a sector + Upload a CSV or XLSX file to create draft passports for a product group (`battery`, `textile`, `steel`, `aluminium`, `tyre`). The caller's `Authorization: Bearer` token is validated and forwarded to the vault. @@ -18,7 +18,7 @@ post: security: - BearerApiKey: [] parameters: - - name: sector + - name: product group in: path required: true schema: @@ -58,6 +58,6 @@ post: '401': $ref: ../components/responses/Unauthorized.yaml '404': - description: Unknown sector. + description: Unknown product group. '422': $ref: ../components/responses/ValidationError.yaml diff --git a/api/paths/integrator_api_v1_schemas.yaml b/api/paths/integrator_api_v1_schemas.yaml index a5d3bc13..e4111ff7 100644 --- a/api/paths/integrator_api_v1_schemas.yaml +++ b/api/paths/integrator_api_v1_schemas.yaml @@ -1,15 +1,15 @@ get: operationId: listSectorSchemas - summary: List sector schemas and their versions + summary: List product group schemas and their versions description: | - Every sector with a JSON Schema, the version a new passport is validated + Every product group with a JSON Schema, the version a new passport is validated against (`current`), and every version a stored passport may legitimately record (`versions`). Unauthenticated. tags: - Integrator responses: '200': - description: The available sector schemas. + description: The available product group schemas. content: application/json: schema: @@ -20,7 +20,7 @@ get: items: type: object properties: - sector: + productGroup: type: string example: battery current: diff --git a/api/paths/integrator_api_v1_schemas_{sector}.yaml b/api/paths/integrator_api_v1_schemas_{productGroup}.yaml similarity index 83% rename from api/paths/integrator_api_v1_schemas_{sector}.yaml rename to api/paths/integrator_api_v1_schemas_{productGroup}.yaml index 9a5e8a61..1c13a1a6 100644 --- a/api/paths/integrator_api_v1_schemas_{sector}.yaml +++ b/api/paths/integrator_api_v1_schemas_{productGroup}.yaml @@ -1,6 +1,6 @@ get: operationId: getCurrentSectorSchema - summary: Fetch a sector's current JSON Schema + summary: Fetch a product group's current JSON Schema description: | The schema a passport created today is validated against, resolved through the same registry the publish gate uses — never a copy, which would drift in @@ -15,7 +15,7 @@ get: tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -23,13 +23,13 @@ get: example: battery responses: '200': - description: The sector's current JSON Schema. + description: The product group's current JSON Schema. content: application/json: schema: type: object '404': - description: No schema for this sector; the body names the known sectors. + description: No schema for this product group; the body names the known product groups. content: application/problem+json: schema: diff --git a/api/paths/integrator_api_v1_schemas_{sector}_{version}.yaml b/api/paths/integrator_api_v1_schemas_{productGroup}_{version}.yaml similarity index 87% rename from api/paths/integrator_api_v1_schemas_{sector}_{version}.yaml rename to api/paths/integrator_api_v1_schemas_{productGroup}_{version}.yaml index 2386ebab..f6c5bf03 100644 --- a/api/paths/integrator_api_v1_schemas_{sector}_{version}.yaml +++ b/api/paths/integrator_api_v1_schemas_{productGroup}_{version}.yaml @@ -1,6 +1,6 @@ get: operationId: getPinnedSectorSchema - summary: Fetch a pinned version of a sector's JSON Schema + summary: Fetch a pinned version of a product group's JSON Schema description: | A stored passport records the `schemaVersion` it was written under, so a client holding one needs that exact schema rather than whatever is current. @@ -10,7 +10,7 @@ get: tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -24,7 +24,7 @@ get: example: 2.6.0 responses: '200': - description: The sector's JSON Schema at that version. + description: The product group's JSON Schema at that version. content: application/json: schema: diff --git a/api/paths/integrator_api_v1_templates_{sector}.yaml b/api/paths/integrator_api_v1_templates_{productGroup}.yaml similarity index 82% rename from api/paths/integrator_api_v1_templates_{sector}.yaml rename to api/paths/integrator_api_v1_templates_{productGroup}.yaml index c2179cca..fbaf5b0d 100644 --- a/api/paths/integrator_api_v1_templates_{sector}.yaml +++ b/api/paths/integrator_api_v1_templates_{productGroup}.yaml @@ -2,13 +2,13 @@ get: operationId: getImportTemplate summary: Download a CSV import template description: | - Returns the canonical CSV import template for a sector (`battery`, + Returns the canonical CSV import template for a product group (`battery`, `textile`). `?format=xlsx` returns 501 (download the CSV and open it in a spreadsheet app). Unauthenticated. tags: - Integrator parameters: - - name: sector + - name: product group in: path required: true schema: @@ -30,6 +30,6 @@ get: schema: type: string '404': - description: No template for this sector. + description: No template for this product group. '501': description: XLSX export not yet implemented. diff --git a/api/paths/vault_api_v1_api-keys_{id}.yaml b/api/paths/vault_api_v1_api-keys_{id}.yaml index a7b0f4f9..8da42722 100644 --- a/api/paths/vault_api_v1_api-keys_{id}.yaml +++ b/api/paths/vault_api_v1_api-keys_{id}.yaml @@ -22,9 +22,9 @@ delete: '400': description: Invalid key ID format. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '401': $ref: ../components/responses/Unauthorized.yaml '404': diff --git a/api/paths/vault_api_v1_dpp.yaml b/api/paths/vault_api_v1_dpp.yaml index ec185e65..bde49bd4 100644 --- a/api/paths/vault_api_v1_dpp.yaml +++ b/api/paths/vault_api_v1_dpp.yaml @@ -21,14 +21,14 @@ post: $ref: ../components/schemas/CreateRequest.yaml example: productName: EcoCell Pro 48V - sector: battery + productGroup: battery manufacturer: name: EcoTech GmbH address: Hauptstraße 1, 10115 Berlin co2ePerUnit: 4.2 batchId: BATCH-2026-04-001 - sectorData: - sector: battery + productGroupData: + productGroup: battery gtin: '09506000134352' batteryChemistry: LFP nominalVoltageV: 48 diff --git a/api/paths/vault_api_v1_dpp_by-identity.yaml b/api/paths/vault_api_v1_dpp_by-identity.yaml index cb9dfaa5..f8d32f85 100644 --- a/api/paths/vault_api_v1_dpp_by-identity.yaml +++ b/api/paths/vault_api_v1_dpp_by-identity.yaml @@ -2,7 +2,7 @@ get: operationId: findDppByIdentity summary: Find a DPP by exact compound identity description: | - Look up a passport by exact (sector, GTIN, batch) match, across + Look up a passport by exact (product group, GTIN, batch) match, across `draft` and `active` statuses. Backs the import delta-matcher — not intended as a general-purpose search (use `GET /dpps` for that). `batchId` omitted matches only passports with no batch set. @@ -12,7 +12,7 @@ get: - BearerApiKey: [] - BasicAuth: [] parameters: - - name: sector + - name: product group in: query required: true schema: diff --git a/api/paths/vault_api_v1_dpp_validate.yaml b/api/paths/vault_api_v1_dpp_validate.yaml index a8bbaad5..3d2dd395 100644 --- a/api/paths/vault_api_v1_dpp_validate.yaml +++ b/api/paths/vault_api_v1_dpp_validate.yaml @@ -7,7 +7,7 @@ post: cannot disagree. **Two verdicts, because create and publish deliberately differ.** Create is - lenient about a sector with no resolvable JSON Schema — a draft is allowed + lenient about a product group with no resolvable JSON Schema — a draft is allowed to be incomplete — while publish fails closed on it, since a signed passport must have passed a real schema check. A body can therefore be creatable but not yet clear the publish-time schema gate, and that gap is @@ -17,8 +17,8 @@ post: would have returned, not a paraphrase of it. A body create would accept returns `200` with the two verdicts. - **`sectorDataValid` is not a publish verdict.** It reports one of publish's - preconditions — the sector-data gates — and publish applies others this + **`productGroupDataValid` is not a publish verdict.** It reports one of publish's + preconditions — the product group-data gates — and publish applies others this route does not run: - the **registry identity** requirement (a default facility and a primary @@ -28,7 +28,7 @@ post: - the **compliance** gate, which needs a `placedOnMarketDate` and a stored passport. - A `sectorDataValid: true` therefore means "this body clears the schema + A `productGroupDataValid: true` therefore means "this body clears the schema gate", never "publish will succeed". The field was called `publishValid` and was renamed because that name promised the latter. tags: @@ -45,35 +45,12 @@ post: responses: '200': description: | - The body is creatable. `sectorDataValid` says whether its sector data + The body is creatable. `productGroupDataValid` says whether its product group data would also clear the publish-time schema gate. content: application/json: schema: - type: object - required: [createValid, sectorDataValid] - properties: - createValid: - type: boolean - description: Always `true` on a 200 — a rejected body returns 422. - example: true - sectorDataValid: - type: boolean - description: | - Whether the sector data would clear the publish-time schema - gate. `true` when no sector data is supplied, since publish - only validates it when present. Not a publish verdict — see - the endpoint description for the gates this does not run. - example: false - detail: - type: [string, 'null'] - description: | - Why the sector data would be refused. `null` when - `sectorDataValid` is `true`. - example: >- - cannot publish: no registered JSON Schema for sector - 'furniture' — publish requires a resolvable schema when - sector data is present + $ref: ../components/schemas/ValidateResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml '403': diff --git a/api/paths/vault_api_v1_dpp_{dppId}_lint.yaml b/api/paths/vault_api_v1_dpp_{dppId}_lint.yaml index 5ee526c7..eb65d849 100644 --- a/api/paths/vault_api_v1_dpp_{dppId}_lint.yaml +++ b/api/paths/vault_api_v1_dpp_{dppId}_lint.yaml @@ -3,7 +3,7 @@ post: summary: Re-check plausibility-lint findings description: | Recomputes the `dpp-rules` plausibility lint pack against the DPP's - current sector data and persists the refreshed `lintResult` (pack + current product group data and persists the refreshed `lintResult` (pack version, findings, assessed-at timestamp). Findings are non-binding — arithmetic and physical-plausibility checks distinct from binding compliance rules — and never gate publish or any other transition. diff --git a/api/paths/vault_api_v1_dpp_{dppId}_registry.yaml b/api/paths/vault_api_v1_dpp_{dppId}_registry.yaml new file mode 100644 index 00000000..77febc02 --- /dev/null +++ b/api/paths/vault_api_v1_dpp_{dppId}_registry.yaml @@ -0,0 +1,36 @@ +get: + operationId: getDppRegistryStatus + summary: EU-registry state for one passport + description: | + What the EU registry knows about this passport: its queued registration, + any handover notifications, and who is responsible for it now. + + Registration is the legal obligation the rest of the system exists to + discharge, and this is where an operator sees whether it has been — + the state otherwise lives only in outbox tables, metrics and log lines. + + **Absent is not zero.** A deployment without the registry queues reports + `configured: false` and omits the detail, rather than a row of zeros that + would read as "everything is registered". + tags: + - Registry + security: + - BearerApiKey: [] + - BasicAuth: [] + parameters: + - name: dppId + in: path + required: true + schema: + $ref: ../components/schemas/DppId.yaml + responses: + '200': + description: Registry state for the passport. + content: + application/json: + schema: + $ref: ../components/schemas/PassportRegistryView.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml + '404': + $ref: ../components/responses/NotFound.yaml diff --git a/api/paths/vault_api_v1_dpp_{dppId}_seal.yaml b/api/paths/vault_api_v1_dpp_{dppId}_seal.yaml index f48ef7cb..34f52aa0 100644 --- a/api/paths/vault_api_v1_dpp_{dppId}_seal.yaml +++ b/api/paths/vault_api_v1_dpp_{dppId}_seal.yaml @@ -44,85 +44,7 @@ get: content: application/json: schema: - type: object - required: - - format - - sealValue - - sealedAt - - signingCertRef - - placeholder - - currentJws - - currentPayloadHash - - sealedPayloadHash - - coverage - - verification - properties: - format: - type: string - description: AdES format of `sealValue`. - example: CADES - sealValue: - type: string - description: Base64 detached CAdES (`.p7s`) as returned by the QTSP. - sealedAt: - type: string - format: date-time - signingCertRef: - type: - - string - - 'null' - description: | - Hex SHA-256 of the certificate the seal names as its - signer, **as reported by the seal** — read out of the CAdES - structure, never verified. - - It answers *which* certificate to ask about, not whether - that certificate was qualified or on the EU Trusted List - when the seal was made; both are the independent - validator's question. `null` when the seal predates - extraction or could not be parsed. - pattern: ^[0-9a-f]{64}$ - placeholder: - type: boolean - description: | - `true` when this is a development placeholder with no legal - validity. A production node refuses to boot in that state. - currentJws: - type: string - description: The passport's current compact JWS. - currentPayloadHash: - type: string - description: | - Hex SHA-256 of `currentJws` — the digest a seal over this - passport's present signature would be taken over. - pattern: ^[0-9a-f]{64}$ - sealedPayloadHash: - type: - - string - - 'null' - description: | - Hex SHA-256 this node asked the backend to seal, from the - outbox row that bought `sealValue`. `null` when the node - holds no such row — a seal restored from a backup or - produced elsewhere. - pattern: ^[0-9a-f]{64}$ - coverage: - type: string - enum: - - current - - superseded - - unknown - description: | - Whether the stored seal covers the passport's current - signature, per this node's own records. - - `current` — the requested digest is the passport's current - one. `superseded` — the passport was re-published after - this seal was bought. `unknown` — no record; only the - external validator can answer. - verification: - type: string - description: What was and was not checked by this node. + $ref: ../components/schemas/SealResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml '404': diff --git a/api/paths/vault_api_v1_dpp_{dppId}_stats.yaml b/api/paths/vault_api_v1_dpp_{dppId}_stats.yaml index 5393eec7..430a73a7 100644 --- a/api/paths/vault_api_v1_dpp_{dppId}_stats.yaml +++ b/api/paths/vault_api_v1_dpp_{dppId}_stats.yaml @@ -34,32 +34,6 @@ get: content: application/json: schema: - type: object - properties: - windowDays: - type: integer - example: 30 - totalScans: - type: integer - example: 128 - scansHtml: - type: integer - example: 96 - scansJson: - type: integer - example: 32 - qrRenders: - type: integer - example: 4 - daily: - type: array - items: - type: object - properties: - day: - type: string - format: date - count: - type: integer + $ref: ../components/schemas/PassportScanStats.yaml '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_dpp_{dppId}_transfer_accept.yaml b/api/paths/vault_api_v1_dpp_{dppId}_transfer_accept.yaml index 0985dab7..83a00842 100644 --- a/api/paths/vault_api_v1_dpp_{dppId}_transfer_accept.yaml +++ b/api/paths/vault_api_v1_dpp_{dppId}_transfer_accept.yaml @@ -29,8 +29,8 @@ post: '404': description: No pending transfer to accept for this DPP. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '422': $ref: ../components/responses/ValidationError.yaml diff --git a/api/paths/vault_api_v1_dpps.yaml b/api/paths/vault_api_v1_dpps.yaml index f82943d2..d2df0503 100644 --- a/api/paths/vault_api_v1_dpps.yaml +++ b/api/paths/vault_api_v1_dpps.yaml @@ -48,23 +48,6 @@ get: content: application/json: schema: - type: object - required: - - dpps - - total - - limit - - skip - properties: - dpps: - type: array - items: - $ref: ../components/schemas/PassportResponse.yaml - total: - type: integer - description: Total count (optionally filtered by status and/or facilityId) - limit: - type: integer - skip: - type: integer + $ref: ../components/schemas/PassportListResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_plugins.yaml b/api/paths/vault_api_v1_plugins.yaml index fa8a8ce9..fcb92f7d 100644 --- a/api/paths/vault_api_v1_plugins.yaml +++ b/api/paths/vault_api_v1_plugins.yaml @@ -1,8 +1,8 @@ post: operationId: installPlugin - summary: Install a signed sector plugin + summary: Install a signed product group plugin description: | - Verify, persist, and hot-swap a signed sector plugin — no node restart. + Verify, persist, and hot-swap a signed product group plugin — no node restart. The node verifies the uploaded artifact's detached signature against its pinned publisher key, gates the plugin's declared ABI, instantiate-smokes @@ -32,16 +32,16 @@ post: format: binary description: > The `.wasm` or precompiled `.cwasm` plugin artifact. Its - filename determines the sector when `sector` is omitted - (`sector-.wasm`) and whether it is treated as precompiled + filename determines the product group when `productGroup` is omitted + (`product group-.wasm`) and whether it is treated as precompiled (`.cwasm`). sig: type: string format: binary description: Detached Ed25519 signature over SHA-256 of the artifact bytes. - sector: + productGroup: type: string - description: Sector key; derived from the filename if omitted. + description: Product group key; derived from the filename if omitted. example: battery responses: '201': @@ -49,17 +49,10 @@ post: content: application/json: schema: - type: object - properties: - sector: - type: string - example: battery - abiVersion: - type: string - example: '1.1' + $ref: ../components/schemas/InstalledPlugin.yaml '400': description: >- - Malformed multipart body (missing `wasm`/`sig`, or the sector could not + Malformed multipart body (missing `wasm`/`sig`, or the product group could not be determined). '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_registry.yaml b/api/paths/vault_api_v1_registry.yaml new file mode 100644 index 00000000..04c5ff56 --- /dev/null +++ b/api/paths/vault_api_v1_registry.yaml @@ -0,0 +1,27 @@ +get: + operationId: getRegistryRollup + summary: Operator-wide EU-registry rollup + description: | + Registration and handover queue totals for this operator, plus its + verified-registry standing. + + Verification is reported whether or not the queues are configured: verified + status lapses at the latest three years after verification (sooner if the + electronic identification means expire), and a lapsed operator cannot + register or amend anything until it verifies again. + + **Absent is not zero** — see the per-passport route. + tags: + - Registry + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + '200': + description: Registry rollup for the operator. + content: + application/json: + schema: + $ref: ../components/schemas/RegistryRollupView.yaml + '401': + $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_seal.yaml b/api/paths/vault_api_v1_seal.yaml index f8da961d..bcbb0081 100644 --- a/api/paths/vault_api_v1_seal.yaml +++ b/api/paths/vault_api_v1_seal.yaml @@ -33,37 +33,6 @@ get: content: application/json: schema: - type: object - required: - - unsealedPublished - - pending - - sealed - - exhausted - - sealingConfigured - properties: - unsealedPublished: - type: integer - format: int64 - description: >- - Published passports carrying no seal at all. `0` is the - healthy state. - example: 0 - pending: - type: integer - format: int64 - description: Outbox rows awaiting a sealing attempt. - sealed: - type: integer - format: int64 - description: Outbox rows whose seal is on the passport. - exhausted: - type: integer - format: int64 - description: Outbox rows that gave up after exhausting retries. - sealingConfigured: - type: boolean - description: >- - False when no seal provider is configured, in which case every - count above is `0` for that reason alone. + $ref: ../components/schemas/SealSummaryResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_stats.yaml b/api/paths/vault_api_v1_stats.yaml index dcfefe9e..8542119d 100644 --- a/api/paths/vault_api_v1_stats.yaml +++ b/api/paths/vault_api_v1_stats.yaml @@ -27,19 +27,6 @@ get: content: application/json: schema: - type: object - properties: - windowDays: - type: integer - example: 30 - totalScans: - type: integer - example: 4213 - totalQrRenders: - type: integer - example: 57 - distinctPassportsScanned: - type: integer - example: 312 + $ref: ../components/schemas/OperatorScanStats.yaml '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_api_v1_webhooks.yaml b/api/paths/vault_api_v1_webhooks.yaml index 59d09b31..db64dfe7 100644 --- a/api/paths/vault_api_v1_webhooks.yaml +++ b/api/paths/vault_api_v1_webhooks.yaml @@ -17,29 +17,7 @@ get: schema: type: array items: - type: object - properties: - id: - type: string - format: uuid - url: - type: string - events: - type: array - items: - type: string - active: - type: boolean - description: - type: - - string - - 'null' - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time + $ref: ../components/schemas/WebhookSubscription.yaml '401': $ref: ../components/responses/Unauthorized.yaml post: @@ -60,56 +38,14 @@ post: content: application/json: schema: - type: object - required: - - url - properties: - url: - type: string - format: uri - example: https://hooks.example.com/odal - events: - type: array - items: - type: string - description: Event subjects to receive, or ["*"] for all. Defaults to all. - example: - - dpp.passport.published - - dpp.passport.transferred - description: - type: string + $ref: ../components/schemas/NewWebhookSubscription.yaml responses: '201': description: Subscription created. The `secret` field is shown ONCE. content: application/json: schema: - type: object - properties: - id: - type: string - format: uuid - url: - type: string - events: - type: array - items: - type: string - active: - type: boolean - description: - type: - - string - - 'null' - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - secret: - type: string - description: Signing secret — shown once, store it securely. + $ref: ../components/schemas/CreatedWebhookResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml '422': diff --git a/api/paths/vault_api_v1_webhooks_{id}.yaml b/api/paths/vault_api_v1_webhooks_{id}.yaml index 8b659818..ff482785 100644 --- a/api/paths/vault_api_v1_webhooks_{id}.yaml +++ b/api/paths/vault_api_v1_webhooks_{id}.yaml @@ -22,9 +22,9 @@ delete: '400': description: Invalid subscription id. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '401': $ref: ../components/responses/Unauthorized.yaml '404': diff --git a/api/paths/vault_api_v1_whoami.yaml b/api/paths/vault_api_v1_whoami.yaml index 95cd17ad..3ee730aa 100644 --- a/api/paths/vault_api_v1_whoami.yaml +++ b/api/paths/vault_api_v1_whoami.yaml @@ -22,29 +22,6 @@ get: content: application/json: schema: - type: object - required: [userId, scope] - properties: - userId: - type: string - description: | - Caller identity — the API key's label, or the admin username - for local Basic auth. - example: svc-dashboard - scope: - type: string - enum: [read, write, admin] - description: | - What this credential authorises. `write` covers passport - create/update/lifecycle; `admin` additionally covers API-key - management and operator config. - example: read - keyId: - type: [string, 'null'] - format: uuid - description: | - Id of the API key row that authenticated this request. - `null` for local Basic auth, which has no key row. - example: 0198f3a1-4c2e-7b3d-9f10-2a5c8e7d1b40 + $ref: ../components/schemas/WhoamiResponse.yaml '401': $ref: ../components/responses/Unauthorized.yaml diff --git a/api/paths/vault_credential_dpp_{dppId}.yaml b/api/paths/vault_credential_dpp_{dppId}.yaml index 754b09cd..f084f670 100644 --- a/api/paths/vault_credential_dpp_{dppId}.yaml +++ b/api/paths/vault_credential_dpp_{dppId}.yaml @@ -35,18 +35,18 @@ get: '401': description: The presented credential failed verification. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '404': description: Not found or not published. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '410': description: This passport has been suspended. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml diff --git a/api/paths/vault_public_dpp_by-gtin_{gtin}.yaml b/api/paths/vault_public_dpp_by-gtin_{gtin}.yaml index f5eaee6d..852449bd 100644 --- a/api/paths/vault_public_dpp_by-gtin_{gtin}.yaml +++ b/api/paths/vault_public_dpp_by-gtin_{gtin}.yaml @@ -37,6 +37,6 @@ get: '422': description: The requested `schema_view` is unavailable. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml diff --git a/api/paths/vault_public_dpp_{dppId}.yaml b/api/paths/vault_public_dpp_{dppId}.yaml index e376653e..9fb2d5de 100644 --- a/api/paths/vault_public_dpp_{dppId}.yaml +++ b/api/paths/vault_public_dpp_{dppId}.yaml @@ -16,7 +16,7 @@ get: in: query required: false description: >- - Request a read-time schema-upcast view. Set to a newer sector schema + Request a read-time schema-upcast view. Set to a newer product group schema version (e.g. `2.0.0`); the response becomes `{ passport, schemaView }` — the canonical signed passport plus the derived view with lens provenance. The original is never re-signed. @@ -35,20 +35,20 @@ get: '404': description: Not found or not published. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '410': description: Passport has been suspended. content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml '422': description: >- The requested `schema_view` is unavailable (no lens path, a downcast, or - the passport has no sector data). + the passport has no product group data). content: - application/json: + application/problem+json: schema: - $ref: ../components/schemas/ApiError.yaml + $ref: ../components/schemas/Problem.yaml diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index 59eae3df..0232e8a9 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -102,7 +102,7 @@ pub enum Commands { #[command(subcommand)] command: WebhookCommands, }, - /// Install signed sector plugins (verified, persisted, hot-swapped) + /// Install signed product group plugins (verified, persisted, hot-swapped) Plugin { #[command(subcommand)] command: PluginCommands, @@ -420,7 +420,7 @@ pub enum OperatorIdCommands { #[derive(Subcommand)] pub enum PluginCommands { - /// Install a signed sector plugin. Uploads the `.wasm` and its sibling + /// Install a signed product group plugin. Uploads the `.wasm` and its sibling /// `.sig`; the node verifies the signature against its pinned publisher /// key, gates the ABI, persists it, and hot-swaps it into service — no restart. Install { diff --git a/cli/src/commands/plugin.rs b/cli/src/commands/plugin.rs index 1ef70783..faa23ebd 100644 --- a/cli/src/commands/plugin.rs +++ b/cli/src/commands/plugin.rs @@ -1,4 +1,4 @@ -//! `odal plugin install ` — upload a signed sector plugin for runtime +//! `odal plugin install ` — upload a signed product group plugin for runtime //! install (verified, persisted, and hot-swapped by the node). use anyhow::Result; @@ -9,8 +9,8 @@ pub async fn run_plugin_install(file: &str) -> Result<()> { let (client, cfg) = crate::http::load_client()?; let installed = action_plugin_install(file, &client, &cfg).await?; println!( - "Installed sector '{}' (ABI {}) — verified, persisted, and now serving.", - installed.sector, installed.abi_version + "Installed product_group '{}' (ABI {}) — verified, persisted, and now serving.", + installed.product_group, installed.abi_version ); Ok(()) } diff --git a/cli/src/console/menu/passports.rs b/cli/src/console/menu/passports.rs index 732e6660..8b1dfc97 100644 --- a/cli/src/console/menu/passports.rs +++ b/cli/src/console/menu/passports.rs @@ -396,7 +396,7 @@ impl std::fmt::Display for BrowseChoice { status_dot(&p.status), p.status, truncate_label(&p.product_name, 30), - p.sector, + p.product_group, p.batch.as_deref().unwrap_or("—"), ), BrowseChoice::NextPage => write!(f, "{}", style("→ Next page").cyan()), diff --git a/cli/src/core/passport/import.rs b/cli/src/core/passport/import.rs index 4c9eff29..cd9d3149 100644 --- a/cli/src/core/passport/import.rs +++ b/cli/src/core/passport/import.rs @@ -27,7 +27,7 @@ pub async fn action_import( // CSV/XLSX go through the integrator's bulk endpoint — the single validated // path that maps every column (Annex XIII fields, materials) and runs the // compliance determination server-side via the vault create handler. The - // sector is detected from the file's `sector` (or `productCategory`) column. + // product group is detected from the file's `product_group` (or `productCategory`) column. let bytes = std::fs::read(¶ms.file) .with_context(|| format!("Cannot read file: {}", params.file))?; let filename = std::path::Path::new(¶ms.file) @@ -35,9 +35,9 @@ pub async fn action_import( .and_then(|s| s.to_str()) .unwrap_or("upload.csv") .to_owned(); - let sector = detect_sector(&bytes).with_context(|| { + let product_group = detect_product_group(&bytes).with_context(|| { format!( - "Could not determine the sector for {}. Add a `sector` column \ + "Could not determine the product_group for {}. Add a `product_group` column \ (battery, textile, steel, aluminium, tyre).", params.file ) @@ -46,7 +46,7 @@ pub async fn action_import( if let Some(f) = progress { f(ProgressEvent::Started { total: None }); } - let url = format!("{}/api/v1/import/{}", cfg.integrator_url(), sector); + let url = format!("{}/api/v1/import/{}", cfg.integrator_url(), product_group); let (status, body) = client.upload_file(&url, &filename, bytes).await?; if let Some(f) = progress { f(ProgressEvent::Done); @@ -118,15 +118,15 @@ pub fn parse_json_payloads(content: &str) -> Result> { } } -/// Detect the import sector from a CSV's `sector` (or `productCategory`) column. +/// Detect the import product group from a CSV's `product_group` (or `productCategory`) column. /// -/// Returns `None` for non-UTF-8 input (e.g. XLSX) or when no recognisable sector +/// Returns `None` for non-UTF-8 input (e.g. XLSX) or when no recognisable product group /// column/value is present. Delimiter is auto-detected (comma / tab / semicolon). /// /// Uses a real CSV reader so a quoted field containing the delimiter (e.g. an /// address `"Street 1, City, DE"`) does not shift the column positions — a naive -/// `split` would mis-locate the sector value. -pub fn detect_sector(bytes: &[u8]) -> Option { +/// `split` would mis-locate the product group value. +pub fn detect_product_group(bytes: &[u8]) -> Option { let first_line = bytes.split(|&b| b == b'\n').next().unwrap_or(bytes); let delim = if first_line.contains(&b'\t') { b'\t' @@ -150,7 +150,7 @@ pub fn detect_sector(bytes: &[u8]) -> Option { .map(str::trim) .filter(|v| !v.is_empty()) }; - let raw = value_for("sector") + let raw = value_for("productGroup") .or_else(|| value_for("productCategory")) .or_else(|| value_for("product_category"))?; Some(raw.to_ascii_lowercase()) @@ -255,35 +255,35 @@ mod tests { use super::*; #[test] - fn detect_sector_reads_sector_column() { - let csv = b"productName,sector,gtin\nCell A,battery,09506000134352\n"; - assert_eq!(detect_sector(csv).as_deref(), Some("battery")); + fn detect_product_group_reads_product_group_column() { + let csv = b"productName,productGroup,gtin\nCell A,battery,09506000134352\n"; + assert_eq!(detect_product_group(csv).as_deref(), Some("battery")); } #[test] - fn detect_sector_falls_back_to_product_category() { + fn detect_product_group_falls_back_to_product_category() { let csv = b"productName,productCategory,gtin\nTee,TEXTILE,09506000134352\n"; - assert_eq!(detect_sector(csv).as_deref(), Some("textile")); + assert_eq!(detect_product_group(csv).as_deref(), Some("textile")); } #[test] - fn detect_sector_handles_semicolon_delimiter() { - let csv = b"productName;sector;gtin\nCell;battery;09506000134352\n"; - assert_eq!(detect_sector(csv).as_deref(), Some("battery")); + fn detect_product_group_handles_semicolon_delimiter() { + let csv = b"productName;productGroup;gtin\nCell;battery;09506000134352\n"; + assert_eq!(detect_product_group(csv).as_deref(), Some("battery")); } #[test] - fn detect_sector_none_without_sector_column() { + fn detect_product_group_none_without_product_group_column() { let csv = b"productName,gtin\nWidget,09506000134352\n"; - assert!(detect_sector(csv).is_none()); + assert!(detect_product_group(csv).is_none()); } #[test] - fn detect_sector_handles_quoted_comma_in_earlier_field() { + fn detect_product_group_handles_quoted_comma_in_earlier_field() { // Regression: a quoted address with embedded commas must not shift the - // sector column (the bug that produced "Unknown sector: '90.0'"). - let csv = b"productName,address,sector,gtin\n\ + // product group column (the bug that produced "Unknown product group: '90.0'"). + let csv = b"productName,address,productGroup,gtin\n\ Cell,\"Prenzlauer Berg 12, 10405 Berlin, DE\",battery,09506000134352\n"; - assert_eq!(detect_sector(csv).as_deref(), Some("battery")); + assert_eq!(detect_product_group(csv).as_deref(), Some("battery")); } } diff --git a/cli/src/core/passport/list.rs b/cli/src/core/passport/list.rs index dd895515..bb94dd7f 100644 --- a/cli/src/core/passport/list.rs +++ b/cli/src/core/passport/list.rs @@ -101,7 +101,7 @@ fn summary_from_doc(doc: &serde_json::Value) -> PassportSummary { PassportSummary { id: s("id"), product_name, - sector: s("sector"), + product_group: s("productGroup"), status: s("status"), batch, updated, @@ -132,7 +132,7 @@ mod tests { let doc = json!({ "id": "019ee576-ca26-7532-8d21-730f17e65ce8", "productName": "Example Linen Blouse", - "sector": "textile", + "productGroup": "textile", "status": "active", "batchId": "BATCH-SS26-004", "updatedAt": "2026-06-20T14:41:01.688Z" @@ -140,7 +140,7 @@ mod tests { let s = summary_from_doc(&doc); assert_eq!(s.id, "019ee576-ca26-7532-8d21-730f17e65ce8"); assert_eq!(s.product_name, "Example Linen Blouse"); - assert_eq!(s.sector, "textile"); + assert_eq!(s.product_group, "textile"); assert_eq!(s.status, "active"); assert_eq!(s.batch.as_deref(), Some("BATCH-SS26-004")); assert_eq!(s.updated, "2026-06-20 14:41"); @@ -151,7 +151,7 @@ mod tests { let s = summary_from_doc(&json!({ "id": "x", "status": "draft" })); assert_eq!(s.product_name, "(unnamed)"); assert!(s.batch.is_none()); - assert_eq!(s.sector, ""); + assert_eq!(s.product_group, ""); } #[test] diff --git a/cli/src/core/passport/validate.rs b/cli/src/core/passport/validate.rs index b79b44f5..6d5eb009 100644 --- a/cli/src/core/passport/validate.rs +++ b/cli/src/core/passport/validate.rs @@ -1,4 +1,4 @@ -//! Validate: fetch drafts and flag rows missing required sector-data fields. +//! Validate: fetch drafts and flag rows missing required product group-data fields. use anyhow::{Context, Result}; @@ -71,7 +71,7 @@ pub async fn action_validate_body( if !http_status.is_success() { return Ok(DryRunVerdict { create_valid: false, - sector_data_valid: false, + product_group_data_valid: false, detail: Some(describe_error(http_status, &body)), }); } @@ -83,8 +83,8 @@ pub async fn action_validate_body( .get("createValid") .and_then(|b| b.as_bool()) .unwrap_or(false), - sector_data_valid: v - .get("sectorDataValid") + product_group_data_valid: v + .get("productGroupDataValid") .and_then(|b| b.as_bool()) .unwrap_or(false), detail: v.get("detail").and_then(|d| d.as_str()).map(str::to_owned), @@ -94,14 +94,14 @@ pub async fn action_validate_body( pub fn find_issues(rec: &serde_json::Value) -> Vec { let mut issues = Vec::new(); - for field in &["productName", "sectorData"] { + for field in &["productName", "productGroupData"] { if rec.get(field).is_none() || rec[field].is_null() { issues.push(format!("missing {field}")); } } - if let Some(sd) = rec.get("sectorData").and_then(|v| v.as_object()) { - match sd.get("sector").and_then(|s| s.as_str()) { + if let Some(sd) = rec.get("productGroupData").and_then(|v| v.as_object()) { + match sd.get("productGroup").and_then(|s| s.as_str()) { Some("battery") => { for f in &[ "gtin", @@ -112,7 +112,7 @@ pub fn find_issues(rec: &serde_json::Value) -> Vec { "co2ePerUnitKg", ] { if sd.get(*f).is_none() { - issues.push(format!("sectorData.{f} missing")); + issues.push(format!("productGroupData.{f} missing")); } } } @@ -125,12 +125,12 @@ pub fn find_issues(rec: &serde_json::Value) -> Vec { "chemicalComplianceStandard", ] { if sd.get(*f).is_none() { - issues.push(format!("sectorData.{f} missing")); + issues.push(format!("productGroupData.{f} missing")); } } } _ => { - issues.push("unknown sector".into()); + issues.push("unknown product_group".into()); } } } @@ -147,8 +147,8 @@ mod tests { fn no_issues_for_complete_textile() { let rec = json!({ "productName": "T-Shirt", - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "gtin": "09506000134352", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], "countryOfOrigin": "DE", @@ -163,8 +163,8 @@ mod tests { fn no_issues_for_complete_battery() { let rec = json!({ "productName": "EV Battery", - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": "09876543210123", "batteryChemistry": "NMC", "nominalVoltageV": 3.7, @@ -178,28 +178,33 @@ mod tests { #[test] fn missing_product_name() { - let rec = json!({ "sectorData": { "sector": "textile" } }); + let rec = json!({ "productGroupData": { "productGroup": "textile" } }); assert!(find_issues(&rec).iter().any(|i| i.contains("productName"))); } #[test] fn missing_gtin() { - let rec = json!({ "productName": "Widget", "sectorData": { "sector": "battery" } }); + let rec = + json!({ "productName": "Widget", "productGroupData": { "productGroup": "battery" } }); assert!(find_issues(&rec).iter().any(|i| i.contains("gtin"))); } #[test] - fn missing_sector_data() { + fn missing_product_group_data() { let rec = json!({ "productName": "Widget" }); - assert!(find_issues(&rec).iter().any(|i| i.contains("sectorData"))); + assert!( + find_issues(&rec) + .iter() + .any(|i| i.contains("productGroupData")) + ); } #[test] fn textile_missing_gtin() { let rec = json!({ "productName": "T-Shirt", - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], "countryOfOrigin": "DE", "careInstructions": "wash", @@ -213,8 +218,8 @@ mod tests { fn textile_missing_fibre_composition() { let rec = json!({ "productName": "T-Shirt", - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "countryOfOrigin": "DE", "careInstructions": "wash", "chemicalComplianceStandard": "REACH" @@ -231,8 +236,8 @@ mod tests { fn battery_missing_chemistry() { let rec = json!({ "productName": "Battery", - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "nominalVoltageV": 3.7, "nominalCapacityAh": 50.0, "expectedLifetimeCycles": 2000, @@ -247,12 +252,13 @@ mod tests { } #[test] - fn unknown_sector_flagged() { - let rec = json!({ "productName": "Widget", "sectorData": { "sector": "alien" } }); + fn unknown_product_group_flagged() { + let rec = + json!({ "productName": "Widget", "productGroupData": { "productGroup": "alien" } }); assert!( find_issues(&rec) .iter() - .any(|i| i.contains("unknown sector")) + .any(|i| i.contains("unknown product_group")) ); } diff --git a/cli/src/core/plugin.rs b/cli/src/core/plugin.rs index 40721c4f..2c5a6472 100644 --- a/cli/src/core/plugin.rs +++ b/cli/src/core/plugin.rs @@ -1,4 +1,4 @@ -//! Sector-plugin administration via the node API — upload a signed artifact for +//! ProductGroup-plugin administration via the node API — upload a signed artifact for //! runtime install. Pure HTTP; the node owns verification and persistence. use anyhow::{Context, Result, bail}; @@ -11,7 +11,7 @@ use crate::{ /// An installed plugin as reported by the node. pub struct InstalledPlugin { - pub sector: String, + pub product_group: String, pub abi_version: String, } @@ -42,8 +42,8 @@ pub async fn action_plugin_install( } let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null); Ok(InstalledPlugin { - sector: v - .get("sector") + product_group: v + .get("productGroup") .and_then(|s| s.as_str()) .unwrap_or("?") .to_owned(), diff --git a/cli/src/core/types.rs b/cli/src/core/types.rs index 88be6f87..047eda32 100644 --- a/cli/src/core/types.rs +++ b/cli/src/core/types.rs @@ -152,7 +152,7 @@ pub struct ListParams { pub struct PassportSummary { pub id: String, pub product_name: String, - pub sector: String, + pub product_group: String, pub status: String, pub batch: Option, pub updated: String, @@ -235,12 +235,12 @@ pub struct WhoAmI { pub struct DryRunVerdict { /// Would `POST /api/v1/dpp` accept it? pub create_valid: bool, - /// Would the sector data clear the publish-time schema gates? + /// Would the product group data clear the publish-time schema gates? /// /// Deliberately not named for publish: it is one of publish's /// preconditions, not all of them. Registry identity and /// category-mandatory content also gate publish and are not checked here. - pub sector_data_valid: bool, + pub product_group_data_valid: bool, /// Why not, when either verdict is false. pub detail: Option, } diff --git a/cli/src/http.rs b/cli/src/http.rs index 6a17dc80..534375d4 100644 --- a/cli/src/http.rs +++ b/cli/src/http.rs @@ -142,7 +142,7 @@ impl OdalClient { /// Upload a file as `multipart/form-data` (field name `file`) to `url` with /// the client's credential — the shape the integrator's - /// `POST /api/v1/import/{sector}` expects. The filename is preserved so the + /// `POST /api/v1/import/{product group}` expects. The filename is preserved so the /// server can detect CSV vs XLSX. pub async fn upload_file( &self, @@ -165,7 +165,7 @@ impl OdalClient { } /// Upload a signed plugin as `multipart/form-data` — a `wasm` file part - /// (filename preserved so the node can derive the sector) plus a `sig` part + /// (filename preserved so the node can derive the product group) plus a `sig` part /// carrying the detached Ed25519 signature. Mirrors `POST /api/v1/plugins`. pub async fn install_plugin( &self, diff --git a/cli/src/stateless/render.rs b/cli/src/stateless/render.rs index dcaf50df..273ee179 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -174,7 +174,7 @@ pub fn render_whoami(who: &WhoAmI) { /// `odal validate ` — the dry-run verdict. /// -/// Both verdicts are always shown. Create is lenient about a sector with no +/// Both verdicts are always shown. Create is lenient about a product group with no /// resolvable schema and publish fails closed on it, so a body can be /// creatable and not yet publishable — collapsing the two into one line would /// hide that gap until the operator tried to publish. @@ -196,14 +196,14 @@ pub fn render_dry_run(verdict: &DryRunVerdict) { } ); // Deliberately not "would be accepted". The node's publish verdict is its - // sector-data schema gate alone; publish additionally requires registry + // product group-data schema gate alone; publish additionally requires registry // identity, and category-mandatory content for some product categories, // neither of which this preview runs. Reporting a pass here as acceptance // would promise more than the node checked. println!( - "{} sector {}", - mark(verdict.sector_data_valid), - if verdict.sector_data_valid { + "{} product_group {}", + mark(verdict.product_group_data_valid), + if verdict.product_group_data_valid { "clears the publish-time schema gate" } else { "would be refused at publish" @@ -212,7 +212,7 @@ pub fn render_dry_run(verdict: &DryRunVerdict) { if let Some(detail) = &verdict.detail { println!("\n{detail}"); } - if verdict.create_valid && verdict.sector_data_valid { + if verdict.create_valid && verdict.product_group_data_valid { println!( "\n{}", style( @@ -241,7 +241,7 @@ pub fn render_passport_list(page: &PassportPage) { "{:<10} {:<32} {:<9} {:<18} {}", r.status, truncate(&r.product_name, 32), - r.sector, + r.product_group, r.batch.as_deref().unwrap_or("—"), r.updated ); @@ -261,7 +261,7 @@ pub fn render_passport_details(doc: &serde_json::Value) { line("Product", s("productName").unwrap_or("—")); line("Status", s("status").unwrap_or("—")); - line("Sector", s("sector").unwrap_or("—")); + line("ProductGroup", s("productGroup").unwrap_or("—")); line("Batch / ref", s("batchId").unwrap_or("—")); if let Some(name) = doc .get("manufacturer") diff --git a/crates/dpp-common/src/plugin_admin.rs b/crates/dpp-common/src/plugin_admin.rs index ad4bbcf3..02c8faa7 100644 --- a/crates/dpp-common/src/plugin_admin.rs +++ b/crates/dpp-common/src/plugin_admin.rs @@ -13,8 +13,8 @@ use serde::Serialize; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct InstalledPlugin { - /// Sector catalog key the plugin is bound to (e.g. `"battery"`). - pub sector: String, + /// ProductGroup catalog key the plugin is bound to (e.g. `"battery"`). + pub product_group: String, /// ABI version the plugin declared, formatted `"major.minor"`. pub abi_version: String, } @@ -44,7 +44,7 @@ pub enum PluginInstallError { /// leaving the previous plugin serving on any rejection. pub trait PluginAdmin: Send + Sync { /// Install `artifact` (with detached signature `sig`, the raw 64-byte or - /// base64 Ed25519 signature over `SHA-256(artifact)`) for `sector`. + /// base64 Ed25519 signature over `SHA-256(artifact)`) for `product_group`. /// /// `precompiled` selects the artifact kind: `false` for a portable `.wasm` /// module (compiled on the node), `true` for a precompiled `.cwasm` (loaded @@ -56,7 +56,7 @@ pub trait PluginAdmin: Send + Sync { /// hot-swapped into service. On any failure the prior state is unchanged. fn install( &self, - sector: &str, + product_group: &str, artifact: Vec, sig: Vec, precompiled: bool, diff --git a/crates/dpp-dal/src/in_memory_repo.rs b/crates/dpp-dal/src/in_memory_repo.rs index c5e20492..9d14b784 100644 --- a/crates/dpp-dal/src/in_memory_repo.rs +++ b/crates/dpp-dal/src/in_memory_repo.rs @@ -80,6 +80,14 @@ impl PassportRepository for InMemoryPassportRepo { Ok(None) } + /// Answers nothing, for the same reason as `find_published_by_gtin` above: + /// the real lookup is a `LIKE` over `qrCodeUrl` with a numeric-only guard, + /// and approximating that here would make a test pass against behaviour the + /// database does not have. + async fn find_by_gtin_any_status(&self, _gtin: &str) -> Result, DppError> { + Ok(None) + } + async fn find_by_id_any_status(&self, id: PassportId) -> Result, DppError> { self.find_by_id(id).await } diff --git a/crates/dpp-dal/src/pg/repo_passport.rs b/crates/dpp-dal/src/pg/repo_passport.rs index da9e6802..479d0eb6 100644 --- a/crates/dpp-dal/src/pg/repo_passport.rs +++ b/crates/dpp-dal/src/pg/repo_passport.rs @@ -12,7 +12,7 @@ use sqlx::{Postgres, Row, Transaction}; use uuid::Uuid; use dpp_domain::{ - catalog::SectorCatalog, + catalog::ProductGroupCatalog, domain::{ error::DppError, passport::{Passport, PassportId}, @@ -34,52 +34,47 @@ use super::{PgDal, db_err}; /// while the `retention_locked` column stays `false`). Serialized (camelCase) /// names. /// -/// # Relationship to the `PassportRepository` default-impl guard in dpp-core +/// # Derived from core, never restated /// -/// This overrides that default, so it does not inherit it — a comment here used -/// to claim it "mirrors" core's list while being **three entries short of it**. -/// `operatorIdentifier`, `facility` and `parentPassportRef` are protected by -/// core and were not protected here, which on PostgreSQL — the only backend that -/// ships — meant they were writable through `PUT /dpp/{id}`. They are restored -/// below. +/// This backend overrides `patch_fields`, so it does not inherit core's default +/// guard — but it still owes callers that guard's contract. It therefore reads +/// `dpp_domain::PROTECTED_PATCH_FIELDS` and applies exactly the two divergences +/// declared below, rather than keeping a second list. /// -/// Two entries are **deliberately** different, and the divergence is the reason -/// this list exists rather than being deleted in favour of core's: +/// It used to keep one, and that list fell **three entries short** of core's: +/// `operatorIdentifier`, `facility` and `parentPassportRef` were protected by +/// core and not here, which on PostgreSQL — the only backend that ships — made +/// them writable through `PUT /dpp/{id}` and carried them into the signed +/// publish payload. A second list is a second thing to keep right, and nothing +/// was keeping it right: the one test covering this path asserted two keys, both +/// of which were in both lists the whole time. /// -/// - `sector` is protected here and not in core, because it backs a real column -/// this merge does not rewrite. -/// - `componentRefs` is protected in core and **not** here. Core protects the -/// lineage edges because patching them on a *published* passport would leave -/// the served body no longer verifying against its own signature. This path -/// accepts drafts only (`update` refuses any non-`Draft` status), and a draft -/// has no signature to break — so a bill of materials is editable while it is -/// still being assembled, which is the whole point of a draft. Its *downward* -/// sibling `parentPassportRef` is not editable, because nothing applies it: -/// it is stamped at create and read at verify. +/// The two divergences, and why each is deliberate: /// -/// `disclosureSignatures` is added for symmetry with the three proof fields -/// beside it. It was the only one of the four `audience_view` strips as proofs -/// that was missing — not exploitable today, because publish assigns the whole -/// map, but a missing rung on a ladder is worth putting back. -const PROTECTED_PATCH_FIELDS: [&str; 17] = [ - "id", - "sector", - "status", - "retentionLocked", - "retentionUntil", - "jwsSignature", - "publicJwsSignature", - "disclosureSignatures", - "seal", - "version", - "publishedAt", - "createdAt", - "supersedesId", - "schemaVersion", - "operatorIdentifier", - "facility", - "parentPassportRef", -]; +/// - **`product_group` is added here.** It backs a real scalar column that this JSONB +/// merge does not rewrite, so patching it in the doc would desync the two. +/// Core has no column to protect. +/// - **`componentRefs` is removed here.** Core protects the lineage edges +/// because patching them on a *published* passport would leave the served body +/// no longer verifying against its own signature. This path accepts drafts +/// only (`update` refuses any non-`Draft` status), and a draft has no +/// signature to break — a bill of materials is editable while it is still +/// being assembled, which is the point of a draft. Its *upward* sibling +/// `parentPassportRef` stays protected, because nothing applies it: it is +/// stamped at create and read at verify. +/// +/// `protected_patch_derivation_tests` holds both halves honest — the guard must +/// equal core's value plus/minus these entries, and an entry that no longer +/// diverges from core must be deleted rather than left as a stale exception. +const ADDED_HERE: [&str; 1] = ["productGroup"]; +const REMOVED_HERE: [&str; 1] = ["componentRefs"]; + +fn is_protected_patch_field(key: &str) -> bool { + if REMOVED_HERE.contains(&key) { + return false; + } + ADDED_HERE.contains(&key) || dpp_domain::PROTECTED_PATCH_FIELDS.contains(&key) +} /// Apply a passport update (scalar columns + `doc`) inside a caller-supplied /// transaction. Shared by [`PgPassportRepo::update`] and the transactional @@ -100,7 +95,7 @@ const PROTECTED_PATCH_FIELDS: [&str; 17] = [ /// /// `||` is a shallow merge at the top level: the struct wins on every key it /// models, and keys it does not model survive. That is exactly the -/// envelope/`sectorData` split — `sectorData` is fully modelled and versioned +/// envelope/`productGroupData` split — `productGroupData` is fully modelled and versioned /// through the lens chain, so replacing it wholesale is correct; the envelope is /// the axis with no such mechanism. /// @@ -118,7 +113,7 @@ pub(crate) async fn update_passport_in_tx( .map_err(|e| DppError::Internal(format!("serialize: {e}")))?; let res = sqlx::query( r#"UPDATE odal.passport SET - sector = $2->>'sector', + product_group = $2->>'product_group', status = COALESCE($2->>'status', status), retention_locked = COALESCE(($2->>'retentionLocked')::boolean, retention_locked), schema_version = COALESCE($2->>'schemaVersion', schema_version), @@ -140,12 +135,12 @@ pub(crate) async fn update_passport_in_tx( /// PostgreSQL implementation of [`PassportRepository`]. /// /// Each method serialises to/from the `doc JSONB` column. Scalar columns -/// (`sector`, `status`, `retention_locked`, …) are extracted from the JSON +/// (`product_group`, `status`, `retention_locked`, …) are extracted from the JSON /// and stored redundantly as real columns to support indexed queries. pub struct PgPassportRepo { dal: PgDal, lenses: LensRegistry, - catalog: SectorCatalog, + catalog: ProductGroupCatalog, } impl PgPassportRepo { @@ -154,7 +149,7 @@ impl PgPassportRepo { Self { dal, lenses: LensRegistry::new(), - catalog: SectorCatalog::new(), + catalog: ProductGroupCatalog::new(), } } @@ -162,11 +157,11 @@ impl PgPassportRepo { serde_json::to_value(passport).map_err(|e| DppError::Internal(format!("serialize: {e}"))) } - /// Deserialize a stored document, upcasting `sectorData` through the - /// registered lens chain first if it predates the sector's current + /// Deserialize a stored document, upcasting `productGroupData` through the + /// registered lens chain first if it predates the product group's current /// schema version — see `Passport::from_stored` in dpp-domain. Every /// passport read goes through this, not raw `serde_json::from_value`, so - /// a non-additive dpp-domain change to a persisted sector-data shape + /// a non-additive dpp-domain change to a persisted product group-data shape /// fails a specific document instead of every one at once. fn read_doc(&self, doc: serde_json::Value) -> Result { Passport::from_stored(doc, &self.lenses, &self.catalog) @@ -185,10 +180,10 @@ impl PassportRepository for PgPassportRepo { let mut tx = self.dal.begin().await?; sqlx::query( r#"INSERT INTO odal.passport - (id, sector, status, retention_locked, schema_version, + (id, product_group, status, retention_locked, schema_version, created_at, updated_at, published_at, doc) VALUES ($1, - $2->>'sector', + $2->>'product_group', COALESCE($2->>'status','draft'), COALESCE(($2->>'retentionLocked')::boolean, false), COALESCE($2->>'schemaVersion','1.0.0'), @@ -256,29 +251,58 @@ impl PassportRepository for PgPassportRepo { .transpose() } - /// Find a passport by exact compound identity (sector, GTIN, batch), + /// Find a passport by the GTIN in its `qrCodeUrl`, regardless of status. + /// + /// The by-GTIN counterpart of `find_by_id_any_status`, and it exists for the + /// same reason: a public route must be able to tell "no such GTIN" from + /// "that GTIN resolves to a suspended passport", because only the second is + /// a recall and only the second warrants `410 Gone`. Returning the passport + /// and leaving the lifecycle decision to the caller is what makes that + /// possible — storage says what is stored, not what is publicly visible. + /// + /// Same numeric-only guard as `find_published_by_gtin`: a `%` or `_` in an + /// untrusted value would otherwise widen the LIKE pattern and match an + /// arbitrary passport. + async fn find_by_gtin_any_status(&self, gtin: &str) -> Result, DppError> { + if gtin.is_empty() || !gtin.bytes().all(|b| b.is_ascii_digit()) { + return Ok(None); + } + let row = sqlx::query( + "SELECT doc FROM odal.passport \ + WHERE doc->>'qrCodeUrl' LIKE '%/01/' || $1 || '/%' \ + LIMIT 1", + ) + .bind(gtin) + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + row.map(|r| self.read_doc(r.get::("doc"))) + .transpose() + } + + /// Find a passport by exact compound identity (product group, GTIN, batch), /// across `Draft` and `Published` — backs the import delta-matcher. /// Indexed by `0019_passport_identity_index.sql`. GTIN is read from - /// `doc->'sectorData'->>'gtin'`: present for every sector except + /// `doc->'productGroupData'->>'gtin'`: present for every product group except /// `UnsoldGoods`/`Other`, which carry no GTIN field and so never match /// here — a discard-event report and an untyped catch-all, not a query bug. async fn find_by_identity( &self, identity: &ProductIdentity, ) -> Result, DppError> { - let sector_str = serde_json::to_value(&identity.sector) + let product_group_str = serde_json::to_value(&identity.product_group) .ok() .and_then(|v| v.as_str().map(str::to_owned)) - .ok_or_else(|| DppError::Internal("failed to serialise sector".into()))?; + .ok_or_else(|| DppError::Internal("failed to serialise product_group".into()))?; let row = sqlx::query( "SELECT doc FROM odal.passport \ WHERE status IN ('draft','active') \ - AND sector = $1 \ - AND doc->'sectorData'->>'gtin' = $2 \ + AND product_group = $1 \ + AND doc->'productGroupData'->>'gtin' = $2 \ AND doc->>'batchId' IS NOT DISTINCT FROM $3 \ LIMIT 1", ) - .bind(§or_str) + .bind(&product_group_str) .bind(&identity.gtin) .bind(identity.batch_id.as_deref()) .fetch_optional(self.dal.pool()) @@ -323,10 +347,10 @@ impl PassportRepository for PgPassportRepo { // does not rewrite, so patching them would bypass the state machine and // desync the doc from its enforcing column. if let Some(obj) = delta.as_object() { - let mut forbidden: Vec<&str> = PROTECTED_PATCH_FIELDS - .iter() - .copied() - .filter(|k| obj.contains_key(*k)) + let mut forbidden: Vec<&str> = obj + .keys() + .map(String::as_str) + .filter(|k| is_protected_patch_field(k)) .collect(); if !forbidden.is_empty() { forbidden.sort_unstable(); @@ -447,3 +471,51 @@ impl PassportRepository for PgPassportRepo { Ok(total.max(0) as u64) } } + +#[cfg(test)] +mod protected_patch_derivation_tests { + use super::{ADDED_HERE, REMOVED_HERE, is_protected_patch_field}; + + /// The backend's guard must equal core's, plus/minus exactly the declared + /// divergences — and it is checked against core's live value, not a copy. + /// + /// This is the test the hand-typed list never had. Its predecessor asserted + /// two keys (`retentionLocked`, `status`), both of which were present in + /// both lists the whole time, so the three entries that actually drifted + /// were covered by nothing. + #[test] + fn guard_equals_core_plus_declared_divergences() { + for key in dpp_domain::PROTECTED_PATCH_FIELDS { + let expected = !REMOVED_HERE.contains(key); + assert_eq!( + is_protected_patch_field(key), + expected, + "core protects `{key}`; this backend must too unless it is in REMOVED_HERE" + ); + } + for key in ADDED_HERE { + assert!( + is_protected_patch_field(key), + "`{key}` is declared as added here but is not protected" + ); + } + } + + /// A divergence that no longer diverges is a stale exception — it reads as a + /// deliberate difference while being none, and hides the next real one. + #[test] + fn declared_divergences_are_real() { + for key in ADDED_HERE { + assert!( + !dpp_domain::PROTECTED_PATCH_FIELDS.contains(&key), + "`{key}` is in ADDED_HERE but core already protects it — drop the entry" + ); + } + for key in REMOVED_HERE { + assert!( + dpp_domain::PROTECTED_PATCH_FIELDS.contains(&key), + "`{key}` is in REMOVED_HERE but core does not protect it — drop the entry" + ); + } + } +} diff --git a/crates/dpp-dal/tests/passport_doc_compat.rs b/crates/dpp-dal/tests/passport_doc_compat.rs index f0d81fb3..d5337f9f 100644 --- a/crates/dpp-dal/tests/passport_doc_compat.rs +++ b/crates/dpp-dal/tests/passport_doc_compat.rs @@ -8,11 +8,11 @@ //! field, a rename with no lens for it) can still make an already-stored //! document unreadable, forever, for every node that upgrades over old data. //! -//! This test freezes one real, valid `doc` per shipped sector schema version +//! This test freezes one real, valid `doc` per shipped product group schema version //! and asserts it still reads through that same path under the `dpp-domain` //! version this workspace currently builds against. It will not catch a bump //! that is already in `Cargo.lock` when the fixture is added — it only -//! catches the *next* one. Add a fixture here whenever a sector's +//! catches the *next* one. Add a fixture here whenever a product group's //! `schema_version` moves, captured from a real document, before bumping //! `dpp-domain`. //! @@ -30,14 +30,27 @@ //! Pure filesystem + in-memory check — no Docker/Postgres required, runs in //! the fast `cargo nextest run --workspace` gate. //! +//! # ⚠️ This guard is currently vacuous, and that is not a quiet state +//! +//! Every frozen document predates the `sector` → `productGroup` envelope rename +//! and is now listed in [`UNREADABLE_FIXTURES`]. Nothing here reads, so nothing +//! here is being checked: the test passes because each failure is documented, +//! not because the read path works. +//! +//! It regains its teeth the moment one current-shape document is captured — from +//! a real create, not hand-authored, or it is only evidence that the current code +//! agrees with itself. Capture one per shipped product-group schema version and +//! delete the corresponding row from [`UNREADABLE_FIXTURES`]; the old files stay +//! on disk as the record of what the break was. +//! //! # Layout //! -//! One directory per sector, one file per frozen schema version — +//! One directory per product group, one file per frozen schema version — //! `{catalog_key}/v{version}.json`, mirroring -//! `dpp-core/crates/dpp-domain/schemas/{sector}/v{version}.json` exactly, so +//! `dpp-core/crates/dpp-domain/schemas/{product group}/v{version}.json` exactly, so //! a reader who knows one convention already knows the other. A flat -//! `{sector}_{version}.json` naming was tried first and abandoned: it does not -//! scale past a handful of sectors before every sector's versions interleave +//! `{product group}_{version}.json` naming was tried first and abandoned: it does not +//! scale past a handful of product groups before every product group's versions interleave //! in one listing. use std::collections::BTreeSet; @@ -45,7 +58,7 @@ use std::fs; use std::path::{Path, PathBuf}; use dpp_domain::Passport; -use dpp_domain::catalog::SectorCatalog; +use dpp_domain::catalog::ProductGroupCatalog; use dpp_domain::schemas::lens::LensRegistry; /// Every frozen fixture, paired with the catalog key its parent directory @@ -57,18 +70,18 @@ fn collect_fixtures() -> Vec<(String, PathBuf)> { .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.is_dir()) - .flat_map(|sector_dir| { - let sector = sector_dir + .flat_map(|product_group_dir| { + let product_group = product_group_dir .file_name() .and_then(|n| n.to_str()) - .expect("sector directory name is valid UTF-8") + .expect("product_group directory name is valid UTF-8") .to_owned(); - fs::read_dir(§or_dir) - .unwrap_or_else(|e| panic!("read {sector_dir:?}: {e}")) + fs::read_dir(&product_group_dir) + .unwrap_or_else(|e| panic!("read {product_group_dir:?}: {e}")) .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|ext| ext == "json")) - .map(move |path| (sector.clone(), path)) + .map(move |path| (product_group.clone(), path)) .collect::>() }) .collect(); @@ -147,26 +160,78 @@ const UNREADABLE_FIXTURES: &[(&str, &str)] = &[ upgrading would mean inventing a regulatory classification the operator never \ stated.", ), + ( + "aluminium/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "battery/v2.6.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "construction/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "detergent/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "electronics/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "electronics/v1.2.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "furniture/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "steel/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "textile/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "textile/v1.2.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "toy/v1.1.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "tyre/v1.0.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), + ( + "unsold-goods/v1.0.0.json", + "The envelope keys `sector` and `sectorData` were renamed to `productGroup` \n and `productGroupData`. `productGroup` is required, so a document of this \n shape is refused loudly rather than read with the field silently missing. \n Deliberate and permanent: the term the Regulation defines is product \n group, and a read-only alias would have kept both spellings alive in a \n codebase whose whole problem was two names for one concept. No lens \n bridges it, and none is wanted.", + ), ]; -/// The documented-unreadable list, as `sector/file.json` keys. +/// The documented-unreadable list, as `product_group/file.json` keys. fn unreadable_key(path: &Path) -> String { let file = path .file_name() .and_then(|n| n.to_str()) .unwrap_or_default(); - let sector = path + let product_group = path .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or_default(); - format!("{sector}/{file}") + format!("{product_group}/{file}") } #[test] fn every_frozen_passport_doc_still_reads() { let lenses = LensRegistry::new(); - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); let fixtures = collect_fixtures(); assert!( !fixtures.is_empty(), @@ -178,7 +243,7 @@ fn every_frozen_passport_doc_still_reads() { // lens later bridges one of these, this is what says so. let mut unexpectedly_readable = Vec::new(); - for (sector, path) in &fixtures { + for (product_group, path) in &fixtures { let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let value: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}")); @@ -198,11 +263,11 @@ fn every_frozen_passport_doc_still_reads() { Ok(passport) => { // A fixture that parses into the wrong document (e.g. an // empty object matching every field's default) would pass - // silently — pin it to the sector its directory claims. - let actual = passport.sector.catalog_key(); - if actual != sector { + // silently — pin it to the product group its directory claims. + let actual = passport.product_group.catalog_key(); + if actual != product_group { failures.push(format!( - "{}: expected sector `{sector}` from its directory, deserialised as `{actual}`", + "{}: expected product_group `{product_group}` from its directory, deserialised as `{actual}`", path.display() )); } @@ -249,12 +314,12 @@ fn every_frozen_passport_doc_still_reads() { #[test] fn no_frozen_doc_loses_an_envelope_key_unrecorded() { let lenses = LensRegistry::new(); - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); let retired: BTreeSet<&str> = RETIRED_ENVELOPE_KEYS.iter().map(|(k, _)| *k).collect(); let fixtures = collect_fixtures(); let mut failures = Vec::new(); - for (_sector, path) in &fixtures { + for (_product_group, path) in &fixtures { let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let stored: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}")); diff --git a/crates/dpp-dal/tests/passport_key_literal_drift.rs b/crates/dpp-dal/tests/passport_key_literal_drift.rs new file mode 100644 index 00000000..78bfad48 --- /dev/null +++ b/crates/dpp-dal/tests/passport_key_literal_drift.rs @@ -0,0 +1,171 @@ +//! Passport JSON key literals → core drift guard. +//! +//! Pure filesystem check — no Docker/Postgres required, so it runs in the fast +//! `cargo nextest run --workspace` gate. +//! +//! Every `doc->'x'` / `doc->>'x'` in the DAL's SQL, and in the migrations' +//! index expressions, addresses a `Passport` JSON key by a **string literal**. +//! A literal has no relationship to the field it names: rename the field in +//! dpp-core and the query still parses, still runs, and silently returns NULL +//! for every row. No error, no failing test, no log line — the column just +//! becomes empty. +//! +//! SQL cannot read a Rust constant, so these literals cannot be replaced by +//! consumption. What they *can* do is check themselves against the vocabulary +//! core publishes as [`dpp_domain::PASSPORT_WIRE_KEYS`], which core proves +//! complete against a fully-populated `Passport`. That makes the literals a +//! checked copy rather than an independent guess. +//! +//! Nested paths (`doc->'productGroupData'->>'gtin'`) are checked at the first hop +//! only: `productGroupData` is a `Passport` key, `gtin` belongs to the product group payload +//! and is versioned through the lens chain, which is a different contract with +//! its own machinery. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Migrations whose key literals are **history**, paired with the migration that +/// replaced them. +/// +/// The migration set is append-only — `sqlx::migrate!` checksums every file, so +/// editing an applied one makes a node that has already run it refuse to boot. +/// A migration that built an index over a key that has since been renamed +/// therefore keeps naming the old key forever, and nothing can be done to the +/// file about it. +/// +/// Exempting the **file**, never the key, is what keeps this honest: the old +/// name stays a failure everywhere else, so a live query that still uses it is +/// caught exactly as before. Each row must name its replacement, so the claim +/// "this is superseded" is checkable rather than asserted — and a row whose +/// replacement is missing is a row that should not be here. +const SUPERSEDED_MIGRATIONS: &[(&str, &str)] = &[( + "0019_passport_identity_index.sql", + "0032_product_group_rename.sql — the identity index is dropped and rebuilt \ + there over `productGroupData`, after the envelope key was renamed from \ + `sectorData`.", +)]; + +/// Every first-hop `doc->`/`doc->>` key literal found under a directory, mapped +/// to the files it appears in. +fn key_literals_under(dir: &Path, extension: &str) -> BTreeMap> { + let mut found: BTreeMap> = BTreeMap::new(); + let mut stack = vec![dir.to_path_buf()]; + let mut files: Vec = Vec::new(); + + while let Some(path) = stack.pop() { + let Ok(entries) = fs::read_dir(&path) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let p = entry.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x == extension) { + files.push(p); + } + } + } + + for file in files { + let Ok(body) = fs::read_to_string(&file) else { + continue; + }; + let name = file + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + + // Scan for `doc->` / `doc->>` followed by a single-quoted key. + let mut rest = body.as_str(); + while let Some(at) = rest.find("doc->") { + rest = &rest[at + "doc->".len()..]; + let rest_trimmed = rest.strip_prefix('>').unwrap_or(rest); + let Some(open) = rest_trimmed.find('\'') else { + break; + }; + // Only whitespace may sit between the arrow and the literal; + // anything else means this was not a direct key access. + if !rest_trimmed[..open].trim().is_empty() { + continue; + } + let after = &rest_trimmed[open + 1..]; + let Some(close) = after.find('\'') else { break }; + let key = after[..close].to_owned(); + if !key.is_empty() { + found.entry(key).or_default().push(name.clone()); + } + rest = &after[close..]; + } + } + found +} + +#[test] +fn every_passport_key_literal_is_a_real_wire_key() { + let dal_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let migrations = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ops/pg"); + + let mut all = key_literals_under(&dal_src, "rs"); + for (key, files) in key_literals_under(&migrations, "sql") { + let live: Vec = files + .into_iter() + .filter(|f| !SUPERSEDED_MIGRATIONS.iter().any(|(name, _)| name == f)) + .collect(); + if !live.is_empty() { + all.entry(key).or_default().extend(live); + } + } + + assert!( + !all.is_empty(), + "found no doc->'key' literals at all — the scanner is matching nothing, \ + which is indistinguishable from a passing gate" + ); + + let unknown: Vec = all + .iter() + .filter(|(key, _)| !dpp_domain::PASSPORT_WIRE_KEYS.contains(&key.as_str())) + .map(|(key, files)| { + let mut files = files.clone(); + files.sort(); + files.dedup(); + format!(" `{key}` in {}", files.join(", ")) + }) + .collect(); + + assert!( + unknown.is_empty(), + "these SQL literals address passport JSON keys that `Passport` does not emit:\n{}\n\n\ + Each one silently returns NULL for every row. Either the field was renamed in dpp-core \ + and these were not, or the key never existed. Check against \ + `dpp_domain::PASSPORT_WIRE_KEYS`.", + unknown.join("\n") + ); +} + +/// Every superseded migration names a replacement that exists. +/// +/// Without this, [`SUPERSEDED_MIGRATIONS`] is a list of files the gate has been +/// told to stop looking at, on nothing but the assertion that something else +/// covers them. A missing replacement means the exemption is hiding a live +/// defect rather than recording history. +#[test] +fn every_superseded_migration_names_a_replacement_that_exists() { + let migrations = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ops/pg"); + for (superseded, reason) in SUPERSEDED_MIGRATIONS { + assert!( + migrations.join(superseded).exists(), + "{superseded} is exempted but does not exist" + ); + let replacement = reason + .split_whitespace() + .next() + .expect("a reason names its replacement first"); + assert!( + migrations.join(replacement).exists(), + "{superseded} claims to be superseded by {replacement}, which does not exist" + ); + } +} diff --git a/crates/dpp-dal/tests/passport_read_upcast.rs b/crates/dpp-dal/tests/passport_read_upcast.rs index 0f2c1760..e2446be4 100644 --- a/crates/dpp-dal/tests/passport_read_upcast.rs +++ b/crates/dpp-dal/tests/passport_read_upcast.rs @@ -10,7 +10,7 @@ //! Docker/Postgres required. use dpp_domain::Passport; -use dpp_domain::catalog::SectorCatalog; +use dpp_domain::catalog::ProductGroupCatalog; use dpp_domain::domain::error::DppError; use dpp_domain::schemas::lens::LensRegistry; use serde_json::json; @@ -18,7 +18,7 @@ use serde_json::json; fn old_textile_doc() -> serde_json::Value { json!({ "id": "019f3aa5-579d-73c1-a3e6-a8002df5e06b", - "sector": "textile", + "productGroup": "textile", "status": "draft", "batchId": null, "version": 1, @@ -26,8 +26,8 @@ fn old_textile_doc() -> serde_json::Value { "updatedAt": "2026-07-01T00:00:00Z", "materials": [], "qrCodeUrl": null, - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "gtin": "09506000134352", "careInstructions": "Hand wash cold.", "fibreComposition": [{ "pct": 100.0, "fibre": "linen" }], @@ -47,29 +47,35 @@ fn old_textile_doc() -> serde_json::Value { #[test] fn a_document_written_under_the_old_country_key_reads_successfully() { let lenses = LensRegistry::new(); - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); let passport = Passport::from_stored(old_textile_doc(), &lenses, &catalog) .expect("the registered textile 1.1.0 -> 1.2.0 lens must bridge this document"); - let Some(dpp_domain::domain::sector::SectorData::Textile(textile)) = passport.sector_data + let Some(dpp_domain::domain::product_group::ProductGroupData::Textile(textile)) = + passport.product_group_data else { - panic!("expected textile sector data"); + panic!("expected textile product_group data"); }; assert_eq!(textile.country_of_origin, "BD"); } #[test] fn a_document_no_lens_can_bridge_fails_typed_not_panicked() { - // Same document, but recorded at 1.0.0 — today's registry has no lens - // leaving textile 1.0.0 at all, so this cannot be upgraded. Must come - // back as a distinguishable, typed refusal, not a raw serde panic and not - // a silent pass-through. + // Same document, recorded at a version the registry has no lens leaving. + // Must come back as a distinguishable, typed refusal — not a raw serde + // panic, and not a silent pass-through. + // + // This used to use 1.0.0, which dpp-core has since bridged: it added a + // textile 1.0.0 -> 1.1.0 lens precisely so a v1.0.0 document has a path to + // the current schema. The behaviour under test is the refusal, not that one + // particular version is unreachable, so it now names a version that has + // never existed rather than one core has since rescued. let mut doc = old_textile_doc(); - doc["schemaVersion"] = json!("1.0.0"); + doc["schemaVersion"] = json!("0.9.0"); let lenses = LensRegistry::new(); - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); let err = Passport::from_stored(doc, &lenses, &catalog) .expect_err("no lens chain reaches the current textile schema from 1.0.0"); diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index 3aab6dd9..19465745 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -33,8 +33,10 @@ use dpp_domain::{ domain::{ gtin::Gtin, passport::{FacilitySnapshot, ManufacturerInfo, Passport, PassportId}, + product_group::{ + BatteryChemistry, BatteryData, BatteryType, ProductGroup, ProductGroupData, + }, product_identity::ProductIdentity, - sector::{BatteryChemistry, BatteryData, BatteryType, Sector, SectorData}, status::PassportStatus, }, ports::passport_repo::PassportRepository, @@ -55,7 +57,9 @@ fn make_passport() -> Passport { id: PassportId::new(), batch_id: Some("LOT-PG-1".into()), product_name: "PG Parity Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), @@ -66,7 +70,7 @@ fn make_passport() -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Draft, qr_code_url: None, jws_signature: None, @@ -91,15 +95,15 @@ fn make_passport() -> Passport { } } -/// A battery passport carrying real `sectorData` (with `gtin`) and `status`, +/// A battery passport carrying real `productGroupData` (with `gtin`) and `status`, /// for the identity-lookup test — `make_passport()` deliberately leaves -/// `sector_data: None`, which `find_by_identity` can never match. +/// `product_group_data: None`, which `find_by_identity` can never match. fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus) -> Passport { let mut p = make_passport(); p.id = PassportId::new(); p.batch_id = batch.map(str::to_owned); p.status = status; - p.sector_data = Some(SectorData::Battery(Box::new(BatteryData { + p.product_group_data = Some(ProductGroupData::Battery(Box::new(BatteryData { gtin: Gtin::parse(gtin).expect("valid test gtin"), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 3.2, @@ -198,7 +202,7 @@ async fn t1_roundtrip_parity() { let found = repo.find_by_id(id).await.expect("find").expect("some"); assert_eq!(found.product_name, "PG Parity Battery"); - assert_eq!(found.sector, Sector::Battery); + assert_eq!(found.product_group, ProductGroup::Battery); let mut updated = found.clone(); updated.product_name = "PG Parity Battery v2".into(); @@ -530,7 +534,7 @@ async fn t8_app_role_can_read_every_table() { } } -// T9 — find_by_identity matches an exact (sector, gtin, batch) across both +// T9 — find_by_identity matches an exact (product group, gtin, batch) across both // Draft and Published, ignores non-matching rows, and does so via // 0019_passport_identity_index.sql rather than a sequential scan. #[tokio::test] @@ -561,7 +565,7 @@ async fn t9_find_by_identity_matches_draft_and_published_via_index() { repo.create(published).await.expect("create published"); let draft_identity = ProductIdentity { - sector: Sector::Battery, + product_group: ProductGroup::Battery, gtin: "09506000134352".into(), batch_id: Some("BATCH-D".into()), }; @@ -574,7 +578,7 @@ async fn t9_find_by_identity_matches_draft_and_published_via_index() { // batch_id: None must match only passports with no batch set — not "any batch". let published_identity = ProductIdentity { - sector: Sector::Battery, + product_group: ProductGroup::Battery, gtin: "01234567890128".into(), batch_id: None, }; @@ -586,7 +590,7 @@ async fn t9_find_by_identity_matches_draft_and_published_via_index() { assert_eq!(found.id, published_id); let no_match = ProductIdentity { - sector: Sector::Battery, + product_group: ProductGroup::Battery, gtin: "01234567890128".into(), batch_id: Some("WRONG-BATCH".into()), }; @@ -601,8 +605,8 @@ async fn t9_find_by_identity_matches_draft_and_published_via_index() { let plan_rows = sqlx::query( "EXPLAIN SELECT doc FROM odal.passport \ WHERE status IN ('draft','active') \ - AND sector = 'battery' \ - AND doc->'sectorData'->>'gtin' = '09506000134352' \ + AND product_group = 'battery' \ + AND doc->'productGroupData'->>'gtin' = '09506000134352' \ AND doc->>'batchId' IS NOT DISTINCT FROM 'BATCH-D' \ LIMIT 1", ) diff --git a/crates/dpp-dal/tests/pg_seal_outbox.rs b/crates/dpp-dal/tests/pg_seal_outbox.rs index c53456ed..b13c0c4a 100644 --- a/crates/dpp-dal/tests/pg_seal_outbox.rs +++ b/crates/dpp-dal/tests/pg_seal_outbox.rs @@ -27,7 +27,7 @@ use chrono::Utc; use dpp_dal::pg::{PgPassportRepo, PgSealOutboxRepo}; use dpp_dal::test_harness::start_pg; use dpp_domain::domain::passport::{ManufacturerInfo, Passport, PassportId}; -use dpp_domain::domain::sector::Sector; +use dpp_domain::domain::product_group::ProductGroup; use dpp_domain::domain::status::PassportStatus; use dpp_domain::ports::passport_repo::PassportRepository; use dpp_domain::ports::seal::{SealFormat, SealedEnvelope}; @@ -41,7 +41,9 @@ fn published_passport(jws: &str) -> Passport { id: PassportId::new(), batch_id: None, product_name: "Seal Test Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), @@ -52,7 +54,7 @@ fn published_passport(jws: &str) -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Published, qr_code_url: None, jws_signature: Some(jws.to_owned()), diff --git a/crates/dpp-dal/tests/retention_guard_drift.rs b/crates/dpp-dal/tests/retention_guard_drift.rs new file mode 100644 index 00000000..592b7537 --- /dev/null +++ b/crates/dpp-dal/tests/retention_guard_drift.rs @@ -0,0 +1,114 @@ +//! Retention-guard → core drift guard. +//! +//! Pure filesystem check — no Docker/Postgres required, so it runs in the fast +//! `cargo nextest run --workspace` gate. +//! +//! The `odal.passport_retention_guard` trigger decides which passport keys may +//! change once `retention_locked` is set. That is a domain statement, and core +//! owns it as [`dpp_domain::RETENTION_MUTABLE_FIELDS`]. SQL cannot read a Rust +//! constant, so the trigger necessarily restates the list — this test is what +//! makes that restatement a *checked copy* rather than a second source of truth. +//! +//! # Why this is needed +//! +//! The array has been re-typed in full **five times** (`0004`, `0011`, `0018`, +//! `0027`, `0028`), once for each field that became mutable after publish — +//! `publicJwsSignature`, `lintResult`, `disclosureSignatures`, `seal`. Nothing +//! signalled when the next one was due. A new post-publish-mutable field in core +//! simply failed at runtime, on a published record, with `ODAL_RETENTION` — the +//! first time anything tried to write it. Sealing is exactly that path, and +//! `seal` reached `mutable_keys` only in the same migration that introduced the +//! seal outbox. +//! +//! Now: add a field to `RETENTION_MUTABLE_FIELDS` and this test fails until a +//! migration redefines the trigger to match. + +use std::fs; +use std::path::Path; + +/// Extract the `mutable_keys` array literal from the newest migration that +/// defines one. `CREATE OR REPLACE FUNCTION` means the highest-numbered file +/// wins at apply time, so that is the definition in force. +fn mutable_keys_in_force() -> (String, Vec) { + let migrations_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ops/pg"); + let mut files: Vec<_> = fs::read_dir(&migrations_dir) + .expect("read ops/pg") + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "sql")) + .collect(); + files.sort(); + + let newest = files + .iter() + .rev() + .find_map(|p| { + let body = fs::read_to_string(p).ok()?; + body.contains("mutable_keys").then_some((p.clone(), body)) + }) + .expect("no migration defines mutable_keys — has the retention guard been removed?"); + + let (path, body) = newest; + let start = body + .find("mutable_keys") + .and_then(|i| body[i..].find("ARRAY[").map(|j| i + j + "ARRAY[".len())) + .expect("mutable_keys is not followed by an ARRAY[ literal"); + let end = start + + body[start..] + .find(']') + .expect("unterminated ARRAY[ literal in the retention guard"); + + let keys = body[start..end] + .split(',') + .map(|s| s.trim().trim_matches('\'').trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + + ( + path.file_name().unwrap().to_string_lossy().into_owned(), + keys, + ) +} + +/// The trigger in force must permit exactly what core says is mutable. +#[test] +fn retention_trigger_matches_core() { + let (file, sql_keys) = mutable_keys_in_force(); + + let mut expected: Vec<&str> = dpp_domain::RETENTION_MUTABLE_FIELDS.to_vec(); + let mut actual: Vec<&str> = sql_keys.iter().map(String::as_str).collect(); + expected.sort_unstable(); + actual.sort_unstable(); + + let missing: Vec<&&str> = expected.iter().filter(|k| !actual.contains(k)).collect(); + let extra: Vec<&&str> = actual.iter().filter(|k| !expected.contains(k)).collect(); + + assert!( + missing.is_empty(), + "{file}'s mutable_keys is missing {missing:?}, which dpp-core says may change after \ + retention lock.\n\nWriting one of those on a published passport will fail at runtime \ + with ODAL_RETENTION. Add a migration that redefines odal.passport_retention_guard with \ + the full list." + ); + assert!( + extra.is_empty(), + "{file}'s mutable_keys permits {extra:?}, which dpp-core does not list as mutable after \ + retention lock.\n\nEither the key is stale (a renamed or removed field, in which case \ + the trigger is silently allowing nothing) or core's list is wrong. The trigger must not \ + be the more permissive of the two." + ); +} + +/// The array must not carry duplicates — a repeated key is the signature of a +/// hand-edit that appended instead of replacing, which is how this list grew. +#[test] +fn retention_trigger_keys_are_unique() { + let (file, keys) = mutable_keys_in_force(); + let mut seen = std::collections::BTreeSet::new(); + for key in &keys { + assert!( + seen.insert(key), + "{file} lists `{key}` twice in mutable_keys" + ); + } +} diff --git a/crates/dpp-identity/src/handlers/rotate_key.rs b/crates/dpp-identity/src/handlers/rotate_key.rs index 2dffb345..62158b3c 100644 --- a/crates/dpp-identity/src/handlers/rotate_key.rs +++ b/crates/dpp-identity/src/handlers/rotate_key.rs @@ -9,20 +9,37 @@ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use dpp_common::http_problem; -use serde::Deserialize; -use serde_json::json; +use serde::{Deserialize, Serialize}; use dpp_vc::did_builder; use crate::state::AppState; /// Request body for the key rotation endpoint. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct RotateRequest { /// The operator whose active signing key is to be rotated. pub operator_id: String, } +/// Response body for the key rotation endpoint. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/RotateResponse` against it. snake_case, like the +/// rest of this internal service-to-service surface. +#[derive(Debug, Serialize)] +pub struct RotateResponse { + pub operator_id: String, + /// The new primary verification method, e.g. `did:web:…#key-1`. + pub new_key_id: String, + /// SHA-256 fingerprint (hex) of the new public key. + pub fingerprint: String, + pub rotated: bool, + /// The rebuilt DID document, carrying the new primary key and every + /// archived one (so signatures issued before the rotation still verify). + pub did_document: serde_json::Value, +} + /// Rotate the Ed25519 signing key for an operator. /// /// Workflow: @@ -78,13 +95,15 @@ pub async fn rotate_key_handler( ( StatusCode::OK, - Json(json!({ - "operator_id": body.operator_id, - "new_key_id": format!("{}#key-1", did_document["id"].as_str().unwrap_or("")), - "fingerprint": new_key.fingerprint, - "rotated": true, - "did_document": did_document - })), + Json(RotateResponse { + new_key_id: format!("{}#key-1", did_document["id"].as_str().unwrap_or("")), + operator_id: body.operator_id, + // `KeyEntry` implements `Drop` (zeroizing), so the field is cloned + // rather than moved out. + fingerprint: new_key.fingerprint.clone(), + rotated: true, + did_document, + }), ) .into_response() } @@ -94,6 +113,7 @@ mod tests { use std::sync::Arc; use dpp_crypto::jws::{signer, verifier}; + use serde_json::json; use super::*; use crate::state::AppState; diff --git a/crates/dpp-identity/src/handlers/sign.rs b/crates/dpp-identity/src/handlers/sign.rs index 13abb089..f01c1a5f 100644 --- a/crates/dpp-identity/src/handlers/sign.rs +++ b/crates/dpp-identity/src/handlers/sign.rs @@ -5,8 +5,7 @@ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use base64::Engine; use dpp_common::http_problem; -use serde::Deserialize; -use serde_json::json; +use serde::{Deserialize, Serialize}; use dpp_crypto::jws::signer; @@ -26,7 +25,7 @@ pub(crate) fn is_valid_operator_id(id: &str) -> bool { } /// Request body for the signing endpoint. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SignRequest { /// Operator id whose key is used for signing. Auto-provisioned on first use. pub operator_id: String, @@ -36,6 +35,22 @@ pub struct SignRequest { pub payload: String, } +/// Response body for the signing endpoint. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/SignResponse` against it. +/// +/// Note the field is `jws_signature`, not `jwsSignature`: this internal +/// service-to-service surface is snake_case, unlike the public `/api/v1` one. +/// That is the shape already on the wire and the shape the spec records; it is +/// not changed here, because renaming it would break the vault client for a +/// consistency the internal surface never claimed. +#[derive(Debug, Serialize)] +pub struct SignResponse { + /// Compact JWS (EdDSA over RFC 8785 canonical bytes). + pub jws_signature: String, +} + /// `POST /internal/sign` — sign a base64-encoded payload with the operator's Ed25519 key. /// /// Auto-provisions a signing key for `operator_id` if one does not yet exist. @@ -78,7 +93,7 @@ pub async fn sign_handler( } match signer::sign(&state.store, &body.operator_id, &payload_value) { - Ok(jws) => (StatusCode::OK, Json(json!({"jws_signature": jws}))).into_response(), + Ok(jws) => (StatusCode::OK, Json(SignResponse { jws_signature: jws })).into_response(), Err(e) => { tracing::error!(operator_id = %body.operator_id, error = %e, "signing failed"); http_problem::internal_error(e.to_string()).into_response() diff --git a/crates/dpp-identity/src/handlers/verify.rs b/crates/dpp-identity/src/handlers/verify.rs index b35fbbe2..45787637 100644 --- a/crates/dpp-identity/src/handlers/verify.rs +++ b/crates/dpp-identity/src/handlers/verify.rs @@ -6,8 +6,7 @@ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use base64::Engine; use dpp_common::http_problem; -use serde::Deserialize; -use serde_json::json; +use serde::{Deserialize, Serialize}; use dpp_crypto::jws::signer; @@ -15,7 +14,7 @@ use super::sign::is_valid_operator_id; use crate::state::AppState; /// Request body for the verification endpoint. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct VerifyRequest { /// Operator id whose key the signature is checked against. pub operator_id: String, @@ -25,6 +24,17 @@ pub struct VerifyRequest { pub payload: serde_json::Value, } +/// Response body for the verification endpoint. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/VerifyResponse` against it. +#[derive(Debug, Serialize)] +pub struct VerifyResponse { + /// True iff the signature verifies against the named operator's key **and** + /// was signed over exactly this payload. + pub valid: bool, +} + /// `POST /internal/verify` — check a JWS against the operator's key *and* /// confirm it was signed over `payload`. /// @@ -45,7 +55,7 @@ pub async fn verify_handler( // No key on file for this operator: cannot be a signature we issued. if !state.store.has_key(&body.operator_id) { - return (StatusCode::OK, Json(json!({"valid": false}))).into_response(); + return (StatusCode::OK, Json(VerifyResponse { valid: false })).into_response(); } // A structurally malformed signature segment (bad base64, wrong decoded @@ -64,7 +74,9 @@ pub async fn verify_handler( ( StatusCode::OK, - Json(json!({"valid": sig_valid && payload_matches})), + Json(VerifyResponse { + valid: sig_valid && payload_matches, + }), ) .into_response() } diff --git a/crates/dpp-integrator/src/domain/fields.rs b/crates/dpp-integrator/src/domain/fields.rs index 3fb79575..ca5d77b2 100644 --- a/crates/dpp-integrator/src/domain/fields.rs +++ b/crates/dpp-integrator/src/domain/fields.rs @@ -1,4 +1,4 @@ -//! Field extraction helpers shared by every sector's row validator. +//! Field extraction helpers shared by every product group's row validator. use std::collections::HashMap; @@ -9,11 +9,11 @@ use super::request::RowError; /// Parse `gtin` (when present) into the validated [`Gtin`] newtype, pushing a /// `RowError` instead if it is not a structurally valid GS1 GTIN-14 (14 digits + -/// mod-10 check digit). Shared by the sector importers so steel/aluminium/tyre +/// mod-10 check digit). Shared by the product group importers so steel/aluminium/tyre /// validate the checksum the same way the battery importer already does — a bad /// checksum must not pass through the pipeline unchecked. /// -/// Returns the parsed value rather than validating and discarding it: the sector +/// Returns the parsed value rather than validating and discarding it: the product group /// structs now hold `Gtin`, and parsing once here means a call site cannot end up /// re-parsing a string it has already proven valid. pub(super) fn parse_gtin( @@ -46,7 +46,7 @@ fn normalize_key(key: &str) -> String { } /// Look up a field tolerantly: exact key first, then a case/separator-insensitive -/// match. This lets **every** sector validator accept both camelCase and +/// match. This lets **every** product group validator accept both camelCase and /// snake_case headers (`manufacturerName` ≡ `manufacturer_name`) with no per-field /// alias lists. Semantically-different headers (e.g. `manufacturerCountry` vs a /// full `manufacturer_address`) still need explicit aliases via [`aliased`]. diff --git a/crates/dpp-integrator/src/domain/matcher.rs b/crates/dpp-integrator/src/domain/matcher.rs index eb28ce74..e21940e2 100644 --- a/crates/dpp-integrator/src/domain/matcher.rs +++ b/crates/dpp-integrator/src/domain/matcher.rs @@ -1,5 +1,5 @@ //! Delta-import matcher: classifies each valid row against existing passports -//! by exact compound identity (sector, GTIN, batch), so a re-uploaded sheet +//! by exact compound identity (product group, GTIN, batch), so a re-uploaded sheet //! updates what changed instead of creating duplicates. //! //! Classification names what should happen to each row @@ -49,14 +49,14 @@ pub struct Classification { /// excludes `co2ePerUnit`/`repairabilityScore`: `CreatePassportRequest` /// carries these as bare numbers but the persisted `Passport` carries them as /// computed objects (e.g. `CarbonFootprint`), so comparing them directly -/// would always report a spurious change. `sectorData` already carries the +/// would always report a spurious change. `productGroupData` already carries the /// source values these are usually derived from. const COMPARABLE_FIELDS: &[&str] = &[ "productName", - "sector", + "productGroup", "manufacturer", "materials", - "sectorData", + "productGroupData", "batchId", ]; @@ -81,17 +81,17 @@ pub fn comparable_fingerprint(value: &serde_json::Value) -> String { } /// Derive the compound identity from a not-yet-created request, or `None` if -/// its sector carries no GTIN (mirrors `ProductIdentity::from_passport`, but +/// its product group carries no GTIN (mirrors `ProductIdentity::from_passport`, but /// operates on the pre-create request shape). pub fn identity_from_request(req: &CreatePassportRequest) -> Option { - let sector = req.sector.clone().or_else(|| { - req.sector_data + let product_group = req.product_group.clone().or_else(|| { + req.product_group_data .as_ref() - .map(dpp_domain::domain::sector::SectorData::sector) + .map(dpp_domain::domain::product_group::ProductGroupData::product_group) })?; - let gtin = req.sector_data.as_ref()?.gtin()?.to_owned(); + let gtin = req.product_group_data.as_ref()?.gtin()?.to_owned(); Some(ProductIdentity { - sector, + product_group, gtin, batch_id: req.batch_id.clone(), }) @@ -99,7 +99,7 @@ pub fn identity_from_request(req: &CreatePassportRequest) -> Option, + /// EU ESPR product group (dispatch key). The vault also derives it from `productGroupData`. + pub product_group: Option, pub manufacturer: ManufacturerInfo, /// Bill of materials parsed from `material_N_*` columns. The vault stores /// these on the passport; they are not silently dropped at import. @@ -30,7 +30,7 @@ pub struct CreatePassportRequest { pub materials: Option>, pub co2e_per_unit: Option, pub repairability_score: Option, - pub sector_data: Option, + pub product_group_data: Option, pub batch_id: Option, pub schema_version: Option, } diff --git a/crates/dpp-integrator/src/domain/validate/aluminium.rs b/crates/dpp-integrator/src/domain/validate/aluminium.rs index 441e7759..57e26a4a 100644 --- a/crates/dpp-integrator/src/domain/validate/aluminium.rs +++ b/crates/dpp-integrator/src/domain/validate/aluminium.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use dpp_domain::domain::{ passport::ManufacturerInfo, - sector::{AluminiumData, ProductionRoute, Sector, SectorData}, + product_group::{AluminiumData, ProductGroup, ProductGroupData, ProductionRoute}, }; use crate::domain::fields::{optional_f64, optional_str, parse_gtin, require_f64, require_str}; @@ -47,7 +47,7 @@ pub fn validate_aluminium_row( Ok(CreatePassportRequest { product_name: product_name .expect("field verified present by errors.is_empty() guard above"), - sector: Some(Sector::Aluminium), + product_group: Some(ProductGroup::Aluminium), manufacturer: ManufacturerInfo { name: manufacturer_name .expect("field verified present by errors.is_empty() guard above"), @@ -58,7 +58,7 @@ pub fn validate_aluminium_row( materials: None, co2e_per_unit: None, repairability_score: None, - sector_data: Some(SectorData::Aluminium(AluminiumData { + product_group_data: Some(ProductGroupData::Aluminium(AluminiumData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), alloy_grade: alloy_grade .expect("field verified present by errors.is_empty() guard above"), @@ -103,16 +103,16 @@ mod tests { fn valid_aluminium_row_produces_request() { let row = aluminium_row(); let req = validate_aluminium_row(&row, 1).expect("valid aluminium row"); - assert_eq!(req.sector, Some(Sector::Aluminium)); - match req.sector_data.unwrap() { - SectorData::Aluminium(d) => { + assert_eq!(req.product_group, Some(ProductGroup::Aluminium)); + match req.product_group_data.unwrap() { + ProductGroupData::Aluminium(d) => { assert_eq!(d.recycled_content_pct, 75.0); assert!(matches!( d.production_route, ProductionRoute::SecondaryRecycled )); } - _ => panic!("expected aluminium sector data"), + _ => panic!("expected aluminium product_group data"), } } } diff --git a/crates/dpp-integrator/src/domain/validate/battery.rs b/crates/dpp-integrator/src/domain/validate/battery.rs index f04fe18c..30dd120d 100644 --- a/crates/dpp-integrator/src/domain/validate/battery.rs +++ b/crates/dpp-integrator/src/domain/validate/battery.rs @@ -5,8 +5,9 @@ use std::collections::HashMap; use dpp_domain::domain::{ gtin::Gtin, passport::ManufacturerInfo, - sector::{ - BatteryChemistry, BatteryData, BatteryType, CarbonFootprintClass, Sector, SectorData, + product_group::{ + BatteryChemistry, BatteryData, BatteryType, CarbonFootprintClass, ProductGroup, + ProductGroupData, }, }; @@ -128,7 +129,7 @@ pub fn validate_battery_row( serde_json::from_value(serde_json::Value::String(chemistry_raw)) .unwrap_or(BatteryChemistry::Other); - let battery_data = SectorData::Battery(Box::new(BatteryData { + let battery_data = ProductGroupData::Battery(Box::new(BatteryData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), battery_chemistry: battery_chemistry_parsed, nominal_voltage_v: nominal_voltage_v @@ -221,7 +222,7 @@ pub fn validate_battery_row( Ok(CreatePassportRequest { product_name: product_name .expect("field verified present by errors.is_empty() guard above"), - sector: Some(Sector::Battery), + product_group: Some(ProductGroup::Battery), manufacturer: ManufacturerInfo { name: manufacturer_name .expect("field verified present by errors.is_empty() guard above"), @@ -232,7 +233,7 @@ pub fn validate_battery_row( materials: (!materials.is_empty()).then_some(materials), co2e_per_unit: None, // vault derives from BatteryData repairability_score, - sector_data: Some(battery_data), + product_group_data: Some(battery_data), batch_id, schema_version: None, }) @@ -269,14 +270,14 @@ mod tests { let row = battery_row(); let req = validate_battery_row(&row, 1).expect("valid row should succeed"); assert_eq!(req.product_name, "EV Battery 48V"); - assert_eq!(req.sector, Some(Sector::Battery)); - match req.sector_data.unwrap() { - SectorData::Battery(b) => { + assert_eq!(req.product_group, Some(ProductGroup::Battery)); + match req.product_group_data.unwrap() { + ProductGroupData::Battery(b) => { assert_eq!(b.gtin.as_str(), "09506000134352"); assert_eq!(b.battery_chemistry, BatteryChemistry::Lfp); assert_eq!(b.nominal_voltage_v, 48.0); } - _ => panic!("expected battery sector data"), + _ => panic!("expected battery product_group data"), } } @@ -362,15 +363,15 @@ mod tests { assert_eq!(materials[0].country_of_origin.as_deref(), Some("CN")); // Extended battery fields carried through (no longer dropped). - match req.sector_data.unwrap() { - SectorData::Battery(b) => { + match req.product_group_data.unwrap() { + ProductGroupData::Battery(b) => { assert_eq!(b.recycled_content_lithium_pct, Some(12.5)); assert_eq!(b.recycled_content_cobalt_pct, Some(0.0)); assert_eq!(b.recycled_content_nickel_pct, Some(0.0)); assert_eq!(b.state_of_health_pct, Some(100.0)); assert_eq!(b.rated_capacity_kwh, Some(4.8)); } - _ => panic!("expected battery sector data"), + _ => panic!("expected battery product_group data"), } } } diff --git a/crates/dpp-integrator/src/domain/validate/mod.rs b/crates/dpp-integrator/src/domain/validate/mod.rs index 444576d8..e802b213 100644 --- a/crates/dpp-integrator/src/domain/validate/mod.rs +++ b/crates/dpp-integrator/src/domain/validate/mod.rs @@ -1,4 +1,4 @@ -//! Sector dispatch for row-level validation. +//! ProductGroup dispatch for row-level validation. mod aluminium; mod battery; @@ -16,35 +16,35 @@ use std::collections::HashMap; use super::request::{CreatePassportRequest, RowError}; -/// Sector keys with a row validator wired up — the single list both the -/// pre-upload sector check and [`validate_row`] read, so the two cannot -/// silently drift apart. Not every catalog sector has a validator yet +/// ProductGroup keys with a row validator wired up — the single list both the +/// pre-upload product group check and [`validate_row`] read, so the two cannot +/// silently drift apart. Not every catalog product group has a validator yet /// (electronics, construction, toy, furniture, detergent, and unsold-goods /// bulk import are not covered) — that gap is real, not an oversight, and -/// callers must not assume this list is exhaustive over all sectors. +/// callers must not assume this list is exhaustive over all product groups. pub const SUPPORTED_SECTORS: &[&str] = &["battery", "textile", "steel", "aluminium", "tyre"]; -/// Row-level validation failure: either the sector has no validator at all, +/// Row-level validation failure: either the product group has no validator at all, /// or the row itself failed field validation. Kept as a distinct, typed case /// rather than an `unreachable!()` at the call site. pub enum RowValidationError { - UnsupportedSector, + UnsupportedProductGroup, Invalid(Vec), } -/// Dispatch a raw row to its sector's validator. +/// Dispatch a raw row to its product group's validator. pub fn validate_row( - sector: &str, + product_group: &str, row: &HashMap, row_num: usize, ) -> Result { - let result = match sector { + let result = match product_group { "battery" => validate_battery_row(row, row_num), "textile" => validate_textile_row(row, row_num), "steel" => validate_steel_row(row, row_num), "aluminium" => validate_aluminium_row(row, row_num), "tyre" => validate_tyre_row(row, row_num), - _ => return Err(RowValidationError::UnsupportedSector), + _ => return Err(RowValidationError::UnsupportedProductGroup), }; result.map_err(RowValidationError::Invalid) } diff --git a/crates/dpp-integrator/src/domain/validate/steel.rs b/crates/dpp-integrator/src/domain/validate/steel.rs index f4135552..ab5cc496 100644 --- a/crates/dpp-integrator/src/domain/validate/steel.rs +++ b/crates/dpp-integrator/src/domain/validate/steel.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use dpp_domain::domain::{ passport::ManufacturerInfo, - sector::{ProductionRoute, Sector, SectorData, SteelData}, + product_group::{ProductGroup, ProductGroupData, ProductionRoute, SteelData}, }; use crate::domain::fields::{optional_f64, optional_str, parse_gtin, require_f64, require_str}; @@ -47,7 +47,7 @@ pub fn validate_steel_row( Ok(CreatePassportRequest { product_name: product_name .expect("field verified present by errors.is_empty() guard above"), - sector: Some(Sector::Steel), + product_group: Some(ProductGroup::Steel), manufacturer: ManufacturerInfo { name: manufacturer_name .expect("field verified present by errors.is_empty() guard above"), @@ -58,7 +58,7 @@ pub fn validate_steel_row( materials: None, co2e_per_unit: None, repairability_score: None, - sector_data: Some(SectorData::Steel(SteelData { + product_group_data: Some(ProductGroupData::Steel(SteelData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), co2e_per_tonne_steel: co2e .expect("field verified present by errors.is_empty() guard above"), @@ -103,13 +103,13 @@ mod tests { fn valid_steel_row_produces_request() { let row = steel_row(); let req = validate_steel_row(&row, 1).expect("valid steel row"); - assert_eq!(req.sector, Some(Sector::Steel)); - match req.sector_data.unwrap() { - SectorData::Steel(d) => { + assert_eq!(req.product_group, Some(ProductGroup::Steel)); + match req.product_group_data.unwrap() { + ProductGroupData::Steel(d) => { assert_eq!(d.co2e_per_tonne_steel, 1.85); assert!(matches!(d.production_route, ProductionRoute::ElectricArc)); } - _ => panic!("expected steel sector data"), + _ => panic!("expected steel product_group data"), } } diff --git a/crates/dpp-integrator/src/domain/validate/textile.rs b/crates/dpp-integrator/src/domain/validate/textile.rs index 51a13c5e..72356c38 100644 --- a/crates/dpp-integrator/src/domain/validate/textile.rs +++ b/crates/dpp-integrator/src/domain/validate/textile.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use dpp_domain::domain::{ passport::ManufacturerInfo, - sector::{FibreEntry, Sector, SectorData, TextileData}, + product_group::{FibreEntry, ProductGroup, ProductGroupData, TextileData}, }; use crate::domain::fields::{optional_f64, parse_gtin, require_str}; @@ -61,7 +61,7 @@ pub fn validate_textile_row( return Err(errors); } - let textile_data = SectorData::Textile(Box::new(TextileData { + let textile_data = ProductGroupData::Textile(Box::new(TextileData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), fibre_composition: fibres.expect("field verified present by errors.is_empty() guard above"), country_of_origin: country_of_origin @@ -96,7 +96,7 @@ pub fn validate_textile_row( Ok(CreatePassportRequest { product_name: product_name .expect("field verified present by errors.is_empty() guard above"), - sector: Some(Sector::Textile), + product_group: Some(ProductGroup::Textile), manufacturer: ManufacturerInfo { name: manufacturer_name .expect("field verified present by errors.is_empty() guard above"), @@ -107,7 +107,7 @@ pub fn validate_textile_row( materials: None, co2e_per_unit: carbon_footprint, repairability_score: repair_score, - sector_data: Some(textile_data), + product_group_data: Some(textile_data), batch_id, schema_version: None, }) @@ -175,19 +175,19 @@ mod tests { let row = textile_row(); let req = validate_textile_row(&row, 1).expect("valid row should succeed"); assert_eq!(req.product_name, "Organic Cotton Tee"); - match req.sector_data.unwrap() { - SectorData::Textile(t) => { + match req.product_group_data.unwrap() { + ProductGroupData::Textile(t) => { assert_eq!(t.gtin.as_str(), "09506000134352"); assert_eq!(t.fibre_composition.len(), 1); assert_eq!(t.fibre_composition[0].fibre, "cotton"); } - _ => panic!("expected textile sector data"), + _ => panic!("expected textile product_group data"), } } - /// Regression: textile was the one sector validator that skipped the GTIN + /// Regression: textile was the one product group validator that skipped the GTIN /// checksum, so a malformed GTIN passed straight through the import - /// pipeline unchecked while every other sector already rejected it. + /// pipeline unchecked while every other product group already rejected it. #[test] fn textile_row_bad_gtin_checksum_returns_error() { let mut row = textile_row(); diff --git a/crates/dpp-integrator/src/domain/validate/tyre.rs b/crates/dpp-integrator/src/domain/validate/tyre.rs index d9781ab6..7aace4e5 100644 --- a/crates/dpp-integrator/src/domain/validate/tyre.rs +++ b/crates/dpp-integrator/src/domain/validate/tyre.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use dpp_domain::domain::{ passport::ManufacturerInfo, - sector::{Sector, SectorData, TyreData}, + product_group::{ProductGroup, ProductGroupData, TyreData}, }; use crate::domain::fields::{optional_f64, optional_str, parse_gtin, require_f64, require_str}; @@ -45,7 +45,7 @@ pub fn validate_tyre_row( Ok(CreatePassportRequest { product_name: product_name .expect("field verified present by errors.is_empty() guard above"), - sector: Some(Sector::Tyre), + product_group: Some(ProductGroup::Tyre), manufacturer: ManufacturerInfo { name: manufacturer_name .expect("field verified present by errors.is_empty() guard above"), @@ -56,7 +56,7 @@ pub fn validate_tyre_row( materials: None, co2e_per_unit: co2e, repairability_score: None, - sector_data: Some(SectorData::Tyre(TyreData { + product_group_data: Some(ProductGroupData::Tyre(TyreData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), tyre_class: tyre_class .expect("field verified present by errors.is_empty() guard above"), @@ -102,13 +102,13 @@ mod tests { fn valid_tyre_row_produces_request() { let row = tyre_row(); let req = validate_tyre_row(&row, 1).expect("valid tyre row"); - assert_eq!(req.sector, Some(Sector::Tyre)); - match req.sector_data.unwrap() { - SectorData::Tyre(d) => { + assert_eq!(req.product_group, Some(ProductGroup::Tyre)); + match req.product_group_data.unwrap() { + ProductGroupData::Tyre(d) => { assert_eq!(d.tyre_class, "C1"); assert_eq!(d.external_rolling_noise_db, 68.0); } - _ => panic!("expected tyre sector data"), + _ => panic!("expected tyre product_group data"), } } } diff --git a/crates/dpp-integrator/src/handlers/import.rs b/crates/dpp-integrator/src/handlers/import.rs index 7a3d827d..c56e01dd 100644 --- a/crates/dpp-integrator/src/handlers/import.rs +++ b/crates/dpp-integrator/src/handlers/import.rs @@ -1,4 +1,4 @@ -//! `POST /api/v1/import/{sector}` — CSV/XLSX bulk import with sync and async paths. +//! `POST /api/v1/import/{product group}` — CSV/XLSX bulk import with sync and async paths. use axum::{ Json, @@ -105,7 +105,7 @@ pub struct AsyncImportResponse { // ─── Handler ───────────────────────────────────────────────────────────────── -/// `POST /api/v1/import/{sector}` +/// `POST /api/v1/import/{product group}` /// /// Accepts a `multipart/form-data` upload with the following fields: /// - `file` — CSV or XLSX file (required) @@ -120,17 +120,18 @@ pub struct AsyncImportResponse { /// /// The caller's `Authorization: Bearer` JWT is forwarded to the vault service. pub async fn import_file( - Path(sector): Path, + Path(product_group): Path, State(state): State, request_headers: HeaderMap, mut multipart: Multipart, ) -> impl IntoResponse { - // Validate sector early - if !validate::SUPPORTED_SECTORS.contains(§or.as_str()) { - metrics::counter!("import_rejections_total", "reason" => "unknown_sector").increment(1); + // Validate product group early + if !validate::SUPPORTED_SECTORS.contains(&product_group.as_str()) { + metrics::counter!("import_rejections_total", "reason" => "unknown_product_group") + .increment(1); return Problem::new(StatusCode::NOT_FOUND, "Not Found") .with_detail(format!( - "Unknown sector: '{sector}'. Valid values: {}.", + "Unknown product_group: '{product_group}'. Valid values: {}.", validate::SUPPORTED_SECTORS.join(", ") )) .into_response(); @@ -263,7 +264,7 @@ pub async fn import_file( for (i, raw_row) in raw_rows.iter().enumerate() { let row_num = i + 1; // 1-based for user-facing messages - match validate::validate_row(§or, raw_row, row_num) { + match validate::validate_row(&product_group, raw_row, row_num) { Ok(req) => valid_rows.push((row_num, req)), Err(RowValidationError::Invalid(errs)) => { for e in errs { @@ -275,14 +276,14 @@ pub async fn import_file( } } // The pre-upload SUPPORTED_SECTORS check above already rejected any - // sector that would land here — kept as a real, typed branch rather + // product group that would land here — kept as a real, typed branch rather // than `unreachable!()` so this stays correct if the two checks // ever move apart. - Err(RowValidationError::UnsupportedSector) => { - metrics::counter!("import_rejections_total", "reason" => "unknown_sector") + Err(RowValidationError::UnsupportedProductGroup) => { + metrics::counter!("import_rejections_total", "reason" => "unknown_product_group") .increment(1); return Problem::new(StatusCode::NOT_FOUND, "Not Found") - .with_detail(format!("Unknown sector: '{sector}'.")) + .with_detail(format!("Unknown product_group: '{product_group}'.")) .into_response(); } } @@ -309,10 +310,10 @@ pub async fn import_file( let mut lint_findings: std::collections::HashMap> = std::collections::HashMap::new(); for (row_num, req) in &valid_rows { - let Some(ref sd) = req.sector_data else { + let Some(ref sd) = req.product_group_data else { continue; }; - let findings = dpp_domain::lint_sector_data(sd, chrono::Utc::now()); + let findings = dpp_domain::lint_product_group_data(sd, chrono::Utc::now()); if !findings.is_empty() { lint_findings.insert( *row_num, @@ -330,7 +331,7 @@ pub async fn import_file( } // Delta-matcher: classify each valid row against existing passports by - // exact identity (sector, GTIN, batch). Apply's write path below reads + // exact identity (product group, GTIN, batch). Apply's write path below reads // this same map back to decide create/update/skip per row. let classifications = matcher::classify_batch( &valid_rows, @@ -715,10 +716,10 @@ mod tests { buf } - fn import_request(sector: &str, body: Vec) -> Request { + fn import_request(product_group: &str, body: Vec) -> Request { Request::builder() .method("POST") - .uri(format!("/api/v1/import/{sector}")) + .uri(format!("/api/v1/import/{product_group}")) .header("authorization", "Bearer test-token") .header("content-type", "multipart/form-data; boundary=X") .body(Body::from(body)) @@ -808,7 +809,7 @@ mod tests { #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct IdentityParams { - sector: String, + product_group: String, gtin: String, #[serde(default)] batch_id: Option, @@ -824,11 +825,11 @@ mod tests { .unwrap() .iter() .find(|p| { - p.get("sectorData") - .and_then(|sd| sd.get("sector")) + p.get("productGroupData") + .and_then(|sd| sd.get("productGroup")) .and_then(|s| s.as_str()) - == Some(q.sector.as_str()) - && p.get("sectorData") + == Some(q.product_group.as_str()) + && p.get("productGroupData") .and_then(|sd| sd.get("gtin")) .and_then(|g| g.as_str()) == Some(q.gtin.as_str()) @@ -969,7 +970,7 @@ mod tests { let mut passports = mock.passports.lock().unwrap(); let published = passports .iter_mut() - .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .find(|p| p["productGroupData"]["gtin"].as_str() == Some(published_gtin.as_str())) .expect("BATCH-3 passport must exist after v1 import"); published["status"] = serde_json::json!("active"); } @@ -1069,7 +1070,7 @@ mod tests { let mut passports = mock.passports.lock().unwrap(); let published = passports .iter_mut() - .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .find(|p| p["productGroupData"]["gtin"].as_str() == Some(published_gtin.as_str())) .expect("BATCH-3 passport must exist after v1 import"); published["status"] = serde_json::json!("active"); } @@ -1232,7 +1233,7 @@ mod tests { let mut passports = mock.passports.lock().unwrap(); let published = passports .iter_mut() - .find(|p| p["sectorData"]["gtin"].as_str() == Some(published_gtin.as_str())) + .find(|p| p["productGroupData"]["gtin"].as_str() == Some(published_gtin.as_str())) .expect("BATCH-3 passport must exist after v1 import"); published["status"] = serde_json::json!("active"); } @@ -1403,8 +1404,8 @@ mod tests { } #[tokio::test] - async fn unknown_sector_is_rejected_before_any_vault_call() { - // Intentionally a dead vault URL: the sector check must reject this + async fn unknown_product_group_is_rejected_before_any_vault_call() { + // Intentionally a dead vault URL: the product group check must reject this // before auth or parsing ever contact it. let state = AppState { vault_client: Arc::new(VaultHttpClient::new("http://127.0.0.1:1")), @@ -1415,7 +1416,7 @@ mod tests { let body = multipart_body("X", "x.csv", "a,b\n1,2\n", None); let resp = app - .oneshot(import_request("not-a-real-sector", body)) + .oneshot(import_request("not-a-real-product_group", body)) .await .unwrap(); assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND); diff --git a/crates/dpp-integrator/src/handlers/job_status.rs b/crates/dpp-integrator/src/handlers/job_status.rs index eb71a23f..193b30bb 100644 --- a/crates/dpp-integrator/src/handlers/job_status.rs +++ b/crates/dpp-integrator/src/handlers/job_status.rs @@ -7,10 +7,37 @@ use axum::{ response::IntoResponse, }; use dpp_common::http_problem; +use serde::Serialize; use uuid::Uuid; use crate::{handlers::import::extract_bearer_token, infra::job_store::JobStatus, state::AppState}; +/// How far an async import job has progressed. +#[derive(Debug, Serialize)] +pub struct JobProgress { + pub processed: usize, + pub total: usize, +} + +/// Response body for the job-status endpoint. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/JobStatusResponse` against it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JobStatusResponse { + pub job_id: Uuid, + /// `queued`, `processing`, `completed`, or `failed`. + pub status: String, + pub progress: JobProgress, + /// Populated on completion (created/errors) or failure (reason); `null` + /// while the job is still queued or processing. + pub result: serde_json::Value, + /// The row-addressed findings report — populated for every job, dry-run or + /// apply, independent of `result`. + pub report: serde_json::Value, +} + /// `GET /api/v1/imports/{job_id}` /// /// Returns the status and progress of an async import job. Requires the same @@ -58,16 +85,16 @@ pub async fn get_job_status( let report_json = serde_json::to_value(&job.report).unwrap_or(serde_json::Value::Null); ( StatusCode::OK, - Json(serde_json::json!({ - "jobId": job.id, - "status": status_str, - "progress": { - "processed": job.processed, - "total": job.total_rows + Json(JobStatusResponse { + job_id: job.id, + status: status_str.to_owned(), + progress: JobProgress { + processed: job.processed, + total: job.total_rows, }, - "result": result_json, - "report": report_json - })), + result: result_json, + report: report_json, + }), ) .into_response() } diff --git a/crates/dpp-integrator/src/handlers/schemas.rs b/crates/dpp-integrator/src/handlers/schemas.rs index 525a71f8..74cbebd8 100644 --- a/crates/dpp-integrator/src/handlers/schemas.rs +++ b/crates/dpp-integrator/src/handlers/schemas.rs @@ -1,4 +1,4 @@ -//! `GET /api/v1/schemas[/{sector}[/{version}]]` — serve the sector JSON Schemas +//! `GET /api/v1/schemas[/{product group}[/{version}]]` — serve the product group JSON Schemas //! an SDK needs to build a passport body before it posts one. //! //! # Why this exists @@ -33,7 +33,7 @@ //! `required`, patterns, bounds — and no unaudited regulatory claim leaves the //! node. //! -//! `title` is kept: they are short labels ("Odal Node — Battery Sector Data +//! `title` is kept: they are short labels ("Odal Node — Battery ProductGroup Data //! (v2.6.0)"), not assertions. use axum::{ @@ -43,34 +43,34 @@ use axum::{ response::{IntoResponse, Response}, }; use dpp_common::http_problem; -use dpp_domain::catalog::SectorCatalog; +use dpp_domain::catalog::ProductGroupCatalog; use dpp_domain::schemas::VersionedSchemaRegistry; use serde_json::{Value, json}; /// `GET /api/v1/schemas` /// -/// Every sector with a schema, and the versions it serves. `current` is the +/// Every product group with a schema, and the versions it serves. `current` is the /// version a new passport is written against; `versions` is everything a stored /// passport may legitimately record. pub async fn list_schemas() -> Response { let registry = VersionedSchemaRegistry::new(); - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); - let mut sectors: Vec<&str> = registry.sectors(); - sectors.sort_unstable(); + let mut product_groups: Vec<&str> = registry.product_groups(); + product_groups.sort_unstable(); - let entries: Vec = sectors + let entries: Vec = product_groups .into_iter() - .map(|sector| { + .map(|product_group| { let mut versions: Vec = registry - .versions_for(sector) + .versions_for(product_group) .into_iter() .map(ToString::to_string) .collect(); versions.sort(); json!({ - "sector": sector, - "current": catalog.current_schema_version(sector), + "productGroup": product_group, + "current": catalog.current_schema_version(product_group), "versions": versions, }) }) @@ -79,29 +79,29 @@ pub async fn list_schemas() -> Response { (StatusCode::OK, Json(json!({ "schemas": entries }))).into_response() } -/// `GET /api/v1/schemas/{sector}` +/// `GET /api/v1/schemas/{product group}` /// -/// The sector's current schema — the one a passport created today is validated +/// The product group's current schema — the one a passport created today is validated /// against. -pub async fn get_current_schema(Path(sector): Path) -> Response { - let catalog = SectorCatalog::new(); - let Some(version) = catalog.current_schema_version(§or) else { - return unknown_sector(§or); +pub async fn get_current_schema(Path(product_group): Path) -> Response { + let catalog = ProductGroupCatalog::new(); + let Some(version) = catalog.current_schema_version(&product_group) else { + return unknown_product_group(&product_group); }; - serve(§or, version) + serve(&product_group, version) } -/// `GET /api/v1/schemas/{sector}/{version}` +/// `GET /api/v1/schemas/{product group}/{version}` /// /// A pinned version. A stored passport records the `schemaVersion` it was /// written under, so an SDK holding one needs to keep fetching that exact /// schema rather than whatever is current. -pub async fn get_pinned_schema(Path((sector, version)): Path<(String, String)>) -> Response { - serve(§or, version.trim_start_matches('v')) +pub async fn get_pinned_schema(Path((product_group, version)): Path<(String, String)>) -> Response { + serve(&product_group, version.trim_start_matches('v')) } -/// Resolve one `(sector, version)` from the registry and serve it, prose removed. -fn serve(sector: &str, version: &str) -> Response { +/// Resolve one `(product group, version)` from the registry and serve it, prose removed. +fn serve(product_group: &str, version: &str) -> Response { let registry = VersionedSchemaRegistry::new(); let Ok(parsed) = version.parse() else { return http_problem::bad_request(format!( @@ -109,15 +109,15 @@ fn serve(sector: &str, version: &str) -> Response { )) .into_response(); }; - let Some(raw) = registry.get(sector, &parsed) else { - return unknown_version(sector, version); + let Some(raw) = registry.get(product_group, &parsed) else { + return unknown_version(product_group, version); }; // An embedded schema parsed at boot in the registry, so this cannot fail in // practice; a 500 is still the honest answer if it ever does. let Ok(mut schema) = serde_json::from_str::(raw) else { return http_problem::internal_error(format!( - "the schema for {sector} v{version} could not be read" + "the schema for {product_group} v{version} could not be read" )) .into_response(); }; @@ -126,30 +126,30 @@ fn serve(sector: &str, version: &str) -> Response { (StatusCode::OK, Json(schema)).into_response() } -fn unknown_sector(sector: &str) -> Response { - let catalog = SectorCatalog::new(); +fn unknown_product_group(product_group: &str) -> Response { + let catalog = ProductGroupCatalog::new(); let mut known: Vec<&str> = catalog.keys(); known.sort_unstable(); http_problem::not_found(format!( - "No schema for sector '{sector}'. Known sectors: {}.", + "No schema for product_group '{product_group}'. Known product_groups: {}.", known.join(", ") )) .into_response() } -fn unknown_version(sector: &str, version: &str) -> Response { +fn unknown_version(product_group: &str, version: &str) -> Response { let registry = VersionedSchemaRegistry::new(); let mut versions: Vec = registry - .versions_for(sector) + .versions_for(product_group) .into_iter() .map(ToString::to_string) .collect(); versions.sort(); if versions.is_empty() { - return unknown_sector(sector); + return unknown_product_group(product_group); } http_problem::not_found(format!( - "No schema for sector '{sector}' at version '{version}'. Available: {}.", + "No schema for product_group '{product_group}' at version '{version}'. Available: {}.", versions.join(", ") )) .into_response() @@ -271,15 +271,15 @@ mod tests { #[test] fn no_embedded_schema_keeps_a_description_after_stripping() { let registry = VersionedSchemaRegistry::new(); - for (sector, version) in registry.list() { - let raw = registry.get(sector, version).expect("just listed"); + for (product_group, version) in registry.list() { + let raw = registry.get(product_group, version).expect("just listed"); let mut schema: Value = serde_json::from_str(raw).unwrap(); strip_descriptions(&mut schema); assert!( !serde_json::to_string(&schema) .unwrap() .contains("\"description\""), - "{sector} v{version} still carries a description keyword after stripping" + "{product_group} v{version} still carries a description keyword after stripping" ); } } diff --git a/crates/dpp-integrator/src/handlers/templates.rs b/crates/dpp-integrator/src/handlers/templates.rs index ac695918..2b9d1c3a 100644 --- a/crates/dpp-integrator/src/handlers/templates.rs +++ b/crates/dpp-integrator/src/handlers/templates.rs @@ -1,4 +1,4 @@ -//! `GET /api/v1/templates/{sector}` — serve the canonical CSV import template for a sector. +//! `GET /api/v1/templates/{product group}` — serve the canonical CSV import template for a product group. use axum::{ extract::{Path, Query}, @@ -21,12 +21,12 @@ pub struct TemplateQuery { pub format: Option, } -/// `GET /api/v1/templates/{sector}[?format=csv|xlsx]` +/// `GET /api/v1/templates/{product group}[?format=csv|xlsx]` /// -/// Returns the canonical import CSV template for the requested sector. +/// Returns the canonical import CSV template for the requested product group. /// XLSX download is not yet implemented (returns 501). pub async fn get_template( - Path(sector): Path, + Path(product_group): Path, Query(query): Query, ) -> Response { let format = query.format.as_deref().unwrap_or("csv"); @@ -40,7 +40,7 @@ pub async fn get_template( .into_response(); } - let (content, filename): (&str, &str) = match sector.as_str() { + let (content, filename): (&str, &str) = match product_group.as_str() { "battery" => (BATTERY_TEMPLATE, "odal-battery-template.csv"), "textile" => (TEXTILE_TEMPLATE, "odal-textile-template.csv"), "steel" => (STEEL_TEMPLATE, "odal-steel-template.csv"), @@ -50,7 +50,7 @@ pub async fn get_template( return ( StatusCode::NOT_FOUND, format!( - "No template available for sector: '{sector}'. Valid values: battery, \ + "No template available for product_group: '{product_group}'. Valid values: battery, \ textile, steel, aluminium, tyre." ), ) @@ -78,7 +78,7 @@ pub async fn get_template( } /// Golden-pairing test: each shipped template's own example rows must be -/// accepted by that sector's row validator. Without this, a validator's +/// accepted by that product group's row validator. Without this, a validator's /// required-field list can silently drift away from the header set the /// template actually ships (or vice versa) with nothing catching it. #[cfg(test)] @@ -88,15 +88,18 @@ mod template_validator_pairing { }; use crate::domain::{csv_parser, validate}; - fn assert_all_rows_validate(sector: &str, csv: &str) { + fn assert_all_rows_validate(product_group: &str, csv: &str) { let rows = csv_parser::parse_csv(csv.as_bytes()).expect("template must parse as CSV"); - assert!(!rows.is_empty(), "{sector} template has no example rows"); + assert!( + !rows.is_empty(), + "{product_group} template has no example rows" + ); for (i, row) in rows.iter().enumerate() { let row_num = i + 1; if let Err(validate::RowValidationError::Invalid(errs)) = - validate::validate_row(sector, row, row_num) + validate::validate_row(product_group, row, row_num) { - panic!("{sector} template row {row_num} failed validation: {errs:?}"); + panic!("{product_group} template row {row_num} failed validation: {errs:?}"); } } } diff --git a/crates/dpp-integrator/src/infra/vault_client.rs b/crates/dpp-integrator/src/infra/vault_client.rs index 2465ca0f..492083bd 100644 --- a/crates/dpp-integrator/src/infra/vault_client.rs +++ b/crates/dpp-integrator/src/infra/vault_client.rs @@ -148,18 +148,21 @@ impl VaultHttpClient { } } - /// Look up a passport by exact compound identity (sector, GTIN, batch). + /// Look up a passport by exact compound identity (product group, GTIN, batch). /// `Ok(None)` means no passport matches — not an error. pub async fn find_by_identity( &self, identity: &ProductIdentity, auth_token: &str, ) -> Result, VaultClientError> { - let sector = serde_json::to_value(&identity.sector) + let product_group = serde_json::to_value(&identity.product_group) .ok() .and_then(|v| v.as_str().map(str::to_owned)) .unwrap_or_default(); - let mut params = vec![("sector", sector), ("gtin", identity.gtin.clone())]; + let mut params = vec![ + ("productGroup", product_group), + ("gtin", identity.gtin.clone()), + ]; if let Some(ref batch_id) = identity.batch_id { params.push(("batchId", batch_id.clone())); } @@ -263,7 +266,7 @@ mod tests { fn sample_request() -> CreatePassportRequest { CreatePassportRequest { product_name: "Test Widget".into(), - sector: None, + product_group: None, manufacturer: ManufacturerInfo { name: "Acme".into(), address: "1 Main St".into(), @@ -272,7 +275,7 @@ mod tests { materials: None, co2e_per_unit: None, repairability_score: None, - sector_data: None, + product_group_data: None, batch_id: None, schema_version: None, } diff --git a/crates/dpp-integrator/src/router.rs b/crates/dpp-integrator/src/router.rs index 41e3fbf8..54b748d6 100644 --- a/crates/dpp-integrator/src/router.rs +++ b/crates/dpp-integrator/src/router.rs @@ -33,15 +33,21 @@ const IMPORT_BODY_LIMIT: usize = 5 * 1024 * 1024; pub fn build(state: AppState) -> Router { Router::new() .route("/health", get(health::health_handler)) - .route("/api/v1/templates/{sector}", get(templates::get_template)) + .route( + "/api/v1/templates/{productGroup}", + get(templates::get_template), + ) .route("/api/v1/schemas", get(schemas::list_schemas)) - .route("/api/v1/schemas/{sector}", get(schemas::get_current_schema)) .route( - "/api/v1/schemas/{sector}/{version}", + "/api/v1/schemas/{productGroup}", + get(schemas::get_current_schema), + ) + .route( + "/api/v1/schemas/{productGroup}/{version}", get(schemas::get_pinned_schema), ) .route( - "/api/v1/import/{sector}", + "/api/v1/import/{productGroup}", post(import::import_file).layer(DefaultBodyLimit::max(IMPORT_BODY_LIMIT)), ) .route("/api/v1/imports/{job_id}", get(job_status::get_job_status)) @@ -126,12 +132,12 @@ mod tests { } } - /// An unknown sector and an unknown version are told apart, and both name + /// An unknown product group and an unknown version are told apart, and both name /// what is available rather than only refusing. #[tokio::test] async fn schema_routes_refuse_helpfully() { for (uri, status) in [ - ("/api/v1/schemas/nosuchsector", StatusCode::NOT_FOUND), + ("/api/v1/schemas/nosuchproduct_group", StatusCode::NOT_FOUND), ("/api/v1/schemas/battery/9.9.9", StatusCode::NOT_FOUND), ( "/api/v1/schemas/battery/not-semver", diff --git a/crates/dpp-node/src/boot/trust.rs b/crates/dpp-node/src/boot/trust.rs index 3639073f..6e816610 100644 --- a/crates/dpp-node/src/boot/trust.rs +++ b/crates/dpp-node/src/boot/trust.rs @@ -22,7 +22,7 @@ pub fn build_and_enforce( NodeProfile::from_env(), vec![ // Required, and the one whose absence is hardest to notice from - // outside: a node with no sector plugins evaluates nothing, returns + // outside: a node with no product group plugins evaluates nothing, returns // a passthrough determination that carries no violations, and so // sails through the publish gate that exists to stop a // non-compliant in-force passport being signed. The passport records diff --git a/crates/dpp-node/src/config.rs b/crates/dpp-node/src/config.rs index 3aa6a7a1..27a5a227 100644 --- a/crates/dpp-node/src/config.rs +++ b/crates/dpp-node/src/config.rs @@ -62,7 +62,7 @@ pub struct NodeConfig { /// Tracing/logging level, e.g. `"info"` or `"debug,odal=trace"`. pub log_level: String, - /// Path to the directory containing `*.wasm` sector plugin files. + /// Path to the directory containing `*.wasm` product group plugin files. pub plugins_dir: String, /// Bind address for the **private** Prometheus metrics listener (`GET /metrics`). diff --git a/crates/dpp-node/src/infra/registry/mapping.rs b/crates/dpp-node/src/infra/registry/mapping.rs index 79e4b222..9197bbcd 100644 --- a/crates/dpp-node/src/infra/registry/mapping.rs +++ b/crates/dpp-node/src/infra/registry/mapping.rs @@ -226,7 +226,7 @@ impl RegistrySyncPort for EuRegistrySync { did: (request.operator_identifier_scheme == "did") .then(|| request.operator_identifier.clone()), }, - sector: request.product_category.clone(), + product_group: request.product_category.clone(), schema_version: request.schema_version.clone(), digital_link_url: request.data_carrier_uri.clone(), published_at: request.published_at.unwrap_or_else(Utc::now), diff --git a/crates/dpp-node/src/plugins.rs b/crates/dpp-node/src/plugins.rs index 1b64cb07..98e45fd6 100644 --- a/crates/dpp-node/src/plugins.rs +++ b/crates/dpp-node/src/plugins.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Result; -use dpp_domain::{PassthroughRegistry, SectorCatalog, ports::plugin_host_port::PluginHost}; +use dpp_domain::{PassthroughRegistry, ProductGroupCatalog, ports::plugin_host_port::PluginHost}; use dpp_plugin_host::{ WasmPluginHost, loader::{LoadedPlugin, discover_plugins}, @@ -16,7 +16,7 @@ use dpp_vault::domain::compliance::CalcBatteryStrategy; /// /// Returns an `Arc` that implements `PluginHost` from core. /// If `plugins_dir` does not exist or is empty, the host boots with zero plugins; -/// the compliance engine falls back to `PassthroughRegistry` for each sector. +/// the compliance engine falls back to `PassthroughRegistry` for each product group. pub fn boot(plugins_dir: &str) -> Result> { let engine = build_engine().map_err(|e| anyhow::anyhow!("{e}"))?; let dir = Path::new(plugins_dir); @@ -60,17 +60,17 @@ pub fn boot(plugins_dir: &str) -> Result> { let mut loaded: Vec = Vec::with_capacity(discovered.len()); - for (sector_key, path) in discovered { - match LoadedPlugin::from_file(&engine, &path, §or_key, trusted_key.as_ref()) { + for (product_group_key, path) in discovered { + match LoadedPlugin::from_file(&engine, &path, &product_group_key, trusted_key.as_ref()) { Ok(plugin) => { - tracing::info!(sector = %sector_key, path = %path.display(), "plugin loaded"); - loaded.push(sector_key.clone()); - host.register(sector_key, plugin); + tracing::info!(product_group = %product_group_key, path = %path.display(), "plugin loaded"); + loaded.push(product_group_key.clone()); + host.register(product_group_key, plugin); } // Fail the boot rather than skip. This used to be a `warn!`, which // meant a plugin whose signature did not verify — tampered, corrupt, // or signed by the wrong key — silently turned into "no rules for - // this sector", and `compute` then returned a passthrough + // this product group", and `compute` then returned a passthrough // determination that the publish gate waves through because it // carries no violations. The line above about refusing unsigned // plugins was doing half a job: it closed "no key configured" and @@ -78,30 +78,30 @@ pub fn boot(plugins_dir: &str) -> Result> { // // A file is in `PLUGINS_DIR` because an operator put it there. That // it will not load is a misconfiguration, never a reason to serve a - // sector with no rules — so it stops the boot, where an operator + // product group with no rules — so it stops the boot, where an operator // sees it, instead of degrading a determination they will not. Err(e) => { anyhow::bail!( - "plugin for sector '{sector_key}' at {} failed to load: {e}\n\ + "plugin for product_group '{product_group_key}' at {} failed to load: {e}\n\ Refusing to boot: a plugin that cannot be verified or instantiated \ - would leave this sector with no compliance rules, and a passport \ + would leave this product_group with no compliance rules, and a passport \ published under it would carry a passthrough determination. Remove \ - the file to run without this sector's rules deliberately.", + the file to run without this product_group's rules deliberately.", path.display() ); } } } - report_sectors_without_plugins(&SectorCatalog::new(), &loaded); + report_product_groups_without_plugins(&ProductGroupCatalog::new(), &loaded); Ok(host) } -/// Say which catalogued sectors are running with no plugin. +/// Say which catalogued product groups are running with no plugin. /// /// Passthrough is a legitimate configuration, so this does not refuse to boot. -/// But a sector with no plugin and a sector whose plugin found nothing wrong +/// But a product group with no plugin and a product group whose plugin found nothing wrong /// produce the same thing — a determination with no findings — so from the /// outside they are indistinguishable. Left unsaid, "no violations" reads as /// "checked and clean" when it may mean "never checked". @@ -110,7 +110,7 @@ pub fn boot(plugins_dir: &str) -> Result> { /// release pipeline, so a gap there is a misconfiguration worth noticing, and /// `info` is where it would be missed. A node deliberately running a subset in /// development sees one line at boot and can ignore it. -fn report_sectors_without_plugins(catalog: &SectorCatalog, loaded: &[String]) { +fn report_product_groups_without_plugins(catalog: &ProductGroupCatalog, loaded: &[String]) { let mut missing: Vec<&str> = catalog .keys() .into_iter() @@ -120,26 +120,26 @@ fn report_sectors_without_plugins(catalog: &SectorCatalog, loaded: &[String]) { if missing.is_empty() { tracing::info!( - sectors = catalog.len(), - "every catalogued sector has a plugin loaded" + product_groups = catalog.len(), + "every catalogued product_group has a plugin loaded" ); return; } tracing::warn!( - sectors = %missing.join(", "), + product_groups = %missing.join(", "), count = missing.len(), - "no plugin loaded for these catalogued sectors; passports in them take the \ - passthrough path — declared values are carried verbatim, with no sector \ + "no plugin loaded for these catalogued product_groups; passports in them take the \ + passthrough path — declared values are carried verbatim, with no product_group \ validation and no findings" ); } -/// The registry that serves a sector with no Wasm plugin loaded for it. +/// The registry that serves a product group with no Wasm plugin loaded for it. /// /// `PassthroughRegistry::new` ships the Apache-2.0 strategies; `register` -/// replaces one by sector key, which is the documented way a host substitutes a -/// sector's behaviour without reimplementing the registry. Battery is +/// replaces one by product group key, which is the documented way a host substitutes a +/// product group's behaviour without reimplementing the registry. Battery is /// substituted because `CalcBatteryStrategy` mints a `CalculationReceipt` — the /// ruleset id, version and assessment timestamp that make a determination /// evidence rather than an opinion — which the passthrough strategy does not @@ -148,8 +148,8 @@ fn report_sectors_without_plugins(catalog: &SectorCatalog, loaded: &[String]) { /// # This does not displace the battery plugin /// /// `WasmPluginHost::compute` dispatches to a plugin whenever one is loaded for -/// the sector and reaches this registry only when none is. So on a node with -/// `sector-battery.wasm` installed — which is every node `compliance_trust` +/// the product group and reaches this registry only when none is. So on a node with +/// `product-group-battery.wasm` installed — which is every node `compliance_trust` /// rates above `Ghost` — the strategy registered here does not run, and the /// plugin's findings are what a battery passport carries. The two are not /// interchangeable: the plugin checks the Commission's per-category data-point @@ -175,20 +175,32 @@ fn fallback_registry() -> PassthroughRegistry { /// invariant exists to prevent, on the one port that decides whether a passport /// was checked against EU rules at all. /// -/// - `Ghost` — no plugin for any in-force sector. Nothing is evaluated. -/// - `Sandbox` — some in-force sectors have rules and some do not. A real +/// - `Ghost` — no plugin for any in-force product group. Nothing is evaluated. +/// - `Sandbox` — some in-force product groups have rules and some do not. A real /// determination is possible, but not for every product this node may publish. -/// - `Live` — every in-force sector in the catalog has a plugin loaded. +/// - `Live` — every in-force product group in the catalog has a plugin loaded. /// -/// Sectors that are **not** in force are deliberately not counted: their +/// ProductGroups that are **not** in force are deliberately not counted: their /// determinations are gated to non-binding by `gate_determination` regardless of /// what a plugin returns, so a missing plugin there changes nothing a consumer /// could rely on. pub fn compliance_trust(host: &WasmPluginHost) -> TrustMode { - let catalog = SectorCatalog::new(); - let in_force: Vec<&str> = catalog.in_force().iter().map(|d| d.key.as_str()).collect(); + let catalog = ProductGroupCatalog::new(); + let instruments = dpp_domain::InstrumentCatalog::new(); + // A product group counts only when some in-force act reaching it actually + // requires a passport. Being in force is not enough on its own: an act can + // bind a group while imposing no passport, and a missing plugin there + // changes nothing a consumer could rely on. + let in_force: Vec<&str> = catalog + .all() + .iter() + .map(|d| d.key.as_str()) + .filter(|key| { + !instruments.determinable_for(key).is_empty() && instruments.passport_required_for(key) + }) + .collect(); - // No in-force sector is an empty conjunction, which would make `all()` true + // No in-force product group is an empty conjunction, which would make `all()` true // and report Live on a node evaluating nothing. Treat it as Ghost: whatever // this node is doing, it is not applying an in-force rule. if in_force.is_empty() { @@ -222,9 +234,9 @@ mod tests { use super::*; #[test] - fn a_sector_with_no_plugin_is_named() { - let catalog = SectorCatalog::new(); - // Every catalogued sector but the first is missing a plugin. + fn a_product_group_with_no_plugin_is_named() { + let catalog = ProductGroupCatalog::new(); + // Every catalogued product group but the first is missing a plugin. let keys: Vec = catalog.keys().into_iter().map(str::to_owned).collect(); let loaded = vec![keys[0].clone()]; @@ -237,15 +249,15 @@ mod tests { assert_eq!( missing.len(), keys.len() - 1, - "every sector except the one loaded must be reported" + "every product_group except the one loaded must be reported" ); assert!( !missing.contains(&keys[0].as_str()), - "the loaded sector must not be reported as missing" + "the loaded product_group must not be reported as missing" ); // The reporting path itself must not panic on either branch. - report_sectors_without_plugins(&catalog, &loaded); - report_sectors_without_plugins(&catalog, &keys); + report_product_groups_without_plugins(&catalog, &loaded); + report_product_groups_without_plugins(&catalog, &keys); } #[test] @@ -307,7 +319,11 @@ mod tests { std::fs::create_dir_all(&tmp).unwrap(); // Contents are irrelevant: the signing-policy gate fires before any // attempt to compile the module. - std::fs::write(tmp.join("sector-evil.wasm"), b"\0asm not-a-real-module").unwrap(); + std::fs::write( + tmp.join("product-group-evil.wasm"), + b"\0asm not-a-real-module", + ) + .unwrap(); let result = boot(tmp.to_str().unwrap()); @@ -323,7 +339,7 @@ mod tests { ); } - /// A minimal, real (compilable) sector plugin: just enough to satisfy + /// A minimal, real (compilable) product group plugin: just enough to satisfy /// `LoadedPlugin::from_file`'s `describe()` ABI-compatibility check. fn minimal_plugin_wasm() -> Vec { let describe_off = 4096u32; @@ -363,7 +379,11 @@ mod tests { let tmp = std::env::temp_dir().join(format!("odal-n5-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&tmp).unwrap(); - std::fs::write(tmp.join("sector-battery.wasm"), minimal_plugin_wasm()).unwrap(); + std::fs::write( + tmp.join("product-group-battery.wasm"), + minimal_plugin_wasm(), + ) + .unwrap(); let result = boot(tmp.to_str().unwrap()); @@ -378,7 +398,7 @@ mod tests { } /// A discovered plugin that will not load stops the boot rather than - /// downgrading its sector to no rules. Uses the dev opt-in so the *signing* + /// downgrading its product group to no rules. Uses the dev opt-in so the *signing* /// gate passes and the failure comes from the load itself — which is the /// case that used to be a `warn!`: a file present, permitted, and not /// runnable. @@ -394,7 +414,7 @@ mod tests { std::fs::create_dir_all(&tmp).unwrap(); // Well-formed enough to be discovered, not a valid module. std::fs::write( - tmp.join("sector-battery.wasm"), + tmp.join("product-group-battery.wasm"), b"\0asm\x01\x00\x00\x00garbage", ) .unwrap(); @@ -405,12 +425,12 @@ mod tests { unsafe { std::env::remove_var("ALLOW_UNSIGNED_PLUGINS") }; let err = match result { - Ok(_) => panic!("an unloadable plugin must not boot into a passthrough sector"), + Ok(_) => panic!("an unloadable plugin must not boot into a passthrough product_group"), Err(e) => e.to_string(), }; assert!( err.contains("failed to load") && err.contains("battery"), - "the error must name the sector and the cause, got: {err}" + "the error must name the product_group and the cause, got: {err}" ); } @@ -428,18 +448,34 @@ mod tests { assert_eq!(compliance_trust(&host), TrustMode::Ghost); } + /// The product groups `compliance_trust` counts — those an in-force act + /// reaches *and* requires a passport for. Mirrors the predicate in + /// `compliance_trust` so a test cannot drift from the thing it tests. + fn counted_product_groups() -> Vec { + let catalog = ProductGroupCatalog::new(); + let instruments = dpp_domain::InstrumentCatalog::new(); + catalog + .all() + .iter() + .map(|d| d.key.clone()) + .filter(|key| { + !instruments.determinable_for(key).is_empty() + && instruments.passport_required_for(key) + }) + .collect() + } + /// Partial coverage is `Sandbox`: a real determination is possible, but not /// for every product this node may publish. Collapsing it into `Live` would - /// let a node claim rules it has for one sector as rules it has for all. + /// let a node claim rules it has for one product group as rules it has for all. #[test] #[serial_test::serial] fn partial_in_force_coverage_is_sandbox_not_live() { if std::env::var("PLUGIN_SIGNING_KEY").is_ok() { return; } - let catalog = SectorCatalog::new(); - let in_force = catalog.in_force(); - // Meaningless unless the catalog has more than one in-force sector — + let in_force = counted_product_groups(); + // Meaningless unless the catalog has more than one in-force product group — // with exactly one, "partial" and "complete" are the same state. if in_force.len() < 2 { return; @@ -448,9 +484,9 @@ mod tests { let tmp = std::env::temp_dir().join(format!("odal-ct2-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&tmp).unwrap(); - let one = in_force[0].key.clone(); + let one = in_force[0].clone(); std::fs::write( - tmp.join(format!("sector-{one}.wasm")), + tmp.join(format!("product-group-{one}.wasm")), minimal_plugin_wasm(), ) .unwrap(); @@ -463,39 +499,39 @@ mod tests { assert_eq!( compliance_trust(&host), TrustMode::Sandbox, - "{one} is covered but the other {} in-force sector(s) are not", + "{one} is covered but the other {} in-force product_group(s) are not", in_force.len() - 1 ); } - /// The tier is computed over **in-force** sectors only. A provisional - /// sector's determination is gated to non-binding regardless of what a + /// The tier is computed over **in-force** product groups only. A provisional + /// product group's determination is gated to non-binding regardless of what a /// plugin returns, so a missing plugin there changes nothing a consumer /// could rely on — and counting it would make `Live` unreachable for no /// safety gain. #[test] - fn provisional_sectors_do_not_affect_the_tier() { - let catalog = SectorCatalog::new(); + fn provisional_product_groups_do_not_affect_the_tier() { + let catalog = ProductGroupCatalog::new(); assert!( - !catalog.provisional().is_empty(), - "fixture assumption: the catalog carries provisional sectors" + catalog.all().len() > counted_product_groups().len(), + "fixture assumption: some product group is not counted — either no act reaches it in force, or none requires a passport for it" ); let tmp = std::env::temp_dir().join(format!("odal-ct3-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&tmp).unwrap(); let host = boot(tmp.to_str().unwrap()).unwrap(); std::fs::remove_dir_all(&tmp).ok(); - // Ghost because no *in-force* sector is covered — not because of the + // Ghost because no *in-force* product group is covered — not because of the // provisional ones, which are simply not counted either way. assert_eq!(compliance_trust(&host), TrustMode::Ghost); } // ── fallback registry ──────────────────────────────────────────────────── - use dpp_domain::domain::sector::{BatteryData, SectorData}; + use dpp_domain::domain::product_group::{BatteryData, ProductGroupData}; use dpp_domain::ports::compliance::ComplianceRegistry; /// An EV battery, which Art. 8 reaches by category alone. - fn ev_battery() -> SectorData { + fn ev_battery() -> ProductGroupData { let battery: BatteryData = serde_json::from_value(serde_json::json!({ "gtin": "09506000134352", "batteryChemistry": "NMC", @@ -506,7 +542,7 @@ mod tests { "ratedCapacityKwh": 80.0 })) .expect("a minimal battery deserialises"); - SectorData::Battery(Box::new(battery)) + ProductGroupData::Battery(Box::new(battery)) } fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate { diff --git a/crates/dpp-node/tests/openapi_contract.rs b/crates/dpp-node/tests/openapi_contract.rs new file mode 100644 index 00000000..d979f2b6 --- /dev/null +++ b/crates/dpp-node/tests/openapi_contract.rs @@ -0,0 +1,2179 @@ +//! The OpenAPI description is checked against the code that implements it. +//! +//! `api/` is hand-authored. Before this test the only gate on it was +//! `just openapi-check`, which bundles the tree and runs Redocly's linter — +//! both of which read *only* the spec. Nothing opened a `.rs` file, so a field +//! added to a struct, a variant added to an enum, or a route added to a router +//! never made the spec fail. Drift was not a risk being managed; it was +//! guaranteed, and it accumulated (see `docs/` for the audit that found it). +//! +//! Three things are checked, and every one of them fails loudly rather than +//! skipping: +//! +//! 1. **Object schemas** — the property set of each schema equals the key set +//! `serde` actually emits for a maximally-populated instance of the type +//! behind it. A field the server sends and the spec omits is a defect; so is +//! a property the spec promises and the server never sends. Both directions +//! fail. +//! 2. **Enum schemas** — the `enum` list equals the wire strings the Rust enum +//! serialises to. A status the server can return and the spec does not list +//! breaks every generated client that models it as a closed enum. +//! 3. **Route coverage** — the paths in the spec equal the routes actually +//! registered by every deployable `openapi.yaml` names in `servers`: the +//! assembled node, the standalone resolver, and the standalone identity +//! service's mTLS signing surface. No exception list — see +//! `identity_standalone_surface` for why one would be the wrong shape. +//! +//! ## Why maximal instances built from exhaustive struct literals +//! +//! Ground truth here is what `serde` emits, not what a parser thinks the source +//! says: `#[serde(flatten)]`, `rename`, `skip_serializing_if` and custom +//! `Serialize` impls all mean the wire shape is not readable off the field list. +//! Serialising a real value is the only honest answer. +//! +//! The fixtures below deliberately use **exhaustive struct literals** — no +//! `..Default::default()` on any type whose schema is checked. That is what makes +//! this gate self-maintaining: adding a field to such a struct fails to compile +//! here until the fixture sets it, and once the fixture sets it the schema check +//! fails until the spec documents it. A fixture that could silently omit a new +//! field would leave exactly the hole this test exists to close. +//! +//! Every `Option` is `Some` and every collection is non-empty for the same +//! reason — under `skip_serializing_if` a default-ish instance simply does not +//! emit the field, and the test would pass by not looking. +//! +//! ## Why the JSON bundle +//! +//! This reads `api/openapi.bundled.json`, generated beside the YAML bundle by +//! `just openapi-bundle` and committed with it. `serde_json` is already a +//! dependency; the alternative was adding a YAML parser, and the only +//! established one (`serde_yaml`) is unmaintained — a supply-chain cost to this +//! workspace's `cargo audit` gate paid purely so a test could read a file the +//! build already knows how to emit in a format it can read. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; +use serde_json::{Value, json}; +use uuid::Uuid; + +// ── Spec access ──────────────────────────────────────────────────────────── + +/// The committed bundle, embedded at compile time so a missing or moved spec is +/// a build failure rather than a test that silently reads nothing. +const SPEC_JSON: &str = include_str!("../../../api/openapi.bundled.json"); + +fn spec() -> Value { + serde_json::from_str(SPEC_JSON).expect("api/openapi.bundled.json is not valid JSON") +} + +fn schemas(spec: &Value) -> &serde_json::Map { + spec["components"]["schemas"] + .as_object() + .expect("components.schemas is not an object") +} + +/// Follow a local `$ref` (`#/components/schemas/Name`) to the schema it names. +/// +/// Only local refs occur: the bundle is a single self-contained document by +/// construction, so an external ref would mean the bundling step changed and is +/// worth failing on rather than silently resolving to nothing. +fn resolve<'a>(spec: &'a Value, schema: &'a Value) -> &'a Value { + match schema.get("$ref").and_then(Value::as_str) { + None => schema, + Some(pointer) => { + let name = pointer + .strip_prefix("#/components/schemas/") + .unwrap_or_else(|| panic!("unexpected non-local $ref in the bundle: {pointer}")); + schemas(spec) + .get(name) + .unwrap_or_else(|| panic!("$ref points at a schema that does not exist: {pointer}")) + } + } +} + +/// Walk a schema and every branch it composes with, applying `visit` to each. +/// +/// `allOf` is composition, not decoration: a schema written as +/// `allOf: [$ref Base, {properties: {extra}}]` declares `Base`'s properties plus +/// `extra`, and reading only the top-level `properties` of such a schema finds +/// nothing at all. Without this the gate would report every field of a composed +/// response as undocumented — a false failure that invites someone to "fix" it +/// by flattening a schema that was right. +fn walk_composed(spec: &Value, schema: &Value, visit: &mut impl FnMut(&Value)) { + let schema = resolve(spec, schema); + visit(schema); + if let Some(branches) = schema.get("allOf").and_then(Value::as_array) { + for branch in branches { + walk_composed(spec, branch, visit); + } + } +} + +/// Property names a schema declares, including through `allOf` composition. +fn spec_properties(spec: &Value, schema: &Value) -> BTreeSet { + let mut out = BTreeSet::new(); + walk_composed(spec, schema, &mut |s| { + if let Some(props) = s.get("properties").and_then(Value::as_object) { + out.extend(props.keys().cloned()); + } + }); + out +} + +/// Every property a schema declares, mapped to its own sub-schema, including +/// through `allOf` composition. Later branches do not override earlier ones — +/// a name declared twice in a composition is a spec bug, not a merge. +fn schema_property_map(spec: &Value, schema: &Value) -> BTreeMap { + let mut out = BTreeMap::new(); + walk_composed(spec, schema, &mut |s| { + if let Some(props) = s.get("properties").and_then(Value::as_object) { + for (k, v) in props { + out.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + }); + out +} + +/// Names a schema marks required, including through `allOf` composition. +fn spec_required(spec: &Value, schema: &Value) -> BTreeSet { + let mut out = BTreeSet::new(); + walk_composed(spec, schema, &mut |s| { + if let Some(req) = s.get("required").and_then(Value::as_array) { + out.extend(req.iter().filter_map(Value::as_str).map(str::to_owned)); + } + }); + out +} + +/// Top-level keys a serialised instance actually carries. +fn wire_keys(value: &Value) -> BTreeSet { + value + .as_object() + .expect("fixture did not serialise to a JSON object") + .keys() + .cloned() + .collect() +} + +fn joined(set: &BTreeSet) -> String { + set.iter().cloned().collect::>().join(", ") +} + +// ── Timestamps ───────────────────────────────────────────────────────────── +// +// Fixed rather than `Utc::now()` so a failure message is stable and diffable. + +fn ts() -> DateTime { + Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap() +} + +fn date() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 1, 2).unwrap() +} + +fn uuid() -> Uuid { + Uuid::nil() +} + +// ── Registry ─────────────────────────────────────────────────────────────── + +/// A schema whose shape is checked against a serialised instance. +struct ObjectCase { + /// Schema name in `components/schemas`. + name: &'static str, + /// A maximally-populated instance, serialised. + value: Value, +} + +/// A schema whose `enum` list is checked against the wire strings the Rust type +/// serialises to. +struct EnumCase { + name: &'static str, + /// Every variant, serialised. Built by serialising the actual values, so a + /// renamed variant fails here rather than being transcribed wrongly twice. + variants: Vec, +} + +/// Schemas deliberately not shape-checked, each with the reason. Anything not in +/// this list and not in a case above fails `every_schema_is_covered` — there is +/// no silent skip. +const UNCHECKED: &[(&str, &str)] = &[ + ( + "DppId", + "a bare `type: string, format: uuid`, not an object with properties", + ), + ( + "DidDocument", + "a W3C DID document passed through as opaque JSON; the node does not \ + model it as a struct and must not, since it round-trips documents it \ + did not author", + ), + ( + "ProductGroupData", + "deliberately open (`additionalProperties: true`, discriminated by \ + `product_group`). The per-product-group payloads are described by the versioned JSON \ + Schemas served from `/integrator/api/v1/schemas/{productGroup}`, which is the \ + authoritative description; duplicating them into OpenAPI would create a \ + second copy to drift", + ), +]; + +fn object_cases() -> Vec { + let mut cases = Vec::new(); + + macro_rules! case { + ($name:literal, $value:expr) => { + cases.push(ObjectCase { + name: $name, + value: serde_json::to_value(&$value).expect(concat!( + "fixture for ", + $name, + " failed to serialise" + )), + }); + }; + } + + // ── dpp-core: the passport aggregate and its parts ──────────────────── + case!("PassportResponse", fixtures::passport()); + case!("ManufacturerInfo", fixtures::manufacturer()); + case!("MaterialEntry", fixtures::material()); + case!("PassportRef", fixtures::passport_ref()); + case!("DerogationRef", fixtures::derogation_ref()); + case!("FacilitySnapshot", fixtures::facility_snapshot()); + case!("CarbonFootprint", fixtures::carbon_footprint()); + case!("RepairabilityScore", fixtures::repairability_score()); + case!("RepairCriterion", fixtures::repair_criterion()); + case!("ComplianceResult", fixtures::compliance_result()); + case!("ComplianceFinding", fixtures::compliance_finding()); + case!("LintResult", fixtures::lint_result()); + case!("LintFinding", fixtures::lint_finding()); + case!("SealedEnvelope", fixtures::sealed_envelope()); + case!("ResponsibleOperator", fixtures::responsible_operator()); + case!("TransferRecord", fixtures::transfer_record()); + + // ── dpp-types: platform records ─────────────────────────────────────── + case!("ApiKey", fixtures::api_key()); + case!("NewApiKey", fixtures::new_api_key()); + case!("CreateApiKeyRequest", fixtures::create_api_key_request()); + case!("AuditEntry", fixtures::audit_entry()); + case!("Facility", fixtures::facility()); + case!("CreateFacilityRequest", fixtures::create_facility_request()); + case!("OperatorIdentifier", fixtures::operator_identifier()); + case!( + "CreateOperatorIdentifierRequest", + fixtures::create_operator_identifier_request() + ); + case!("RegistryIdentityAudit", fixtures::registry_identity_audit()); + case!("OperatorConfig", fixtures::operator_config()); + case!("UpdateOperatorConfig", fixtures::update_operator_config()); + + // ── dpp-types: the evidence dossier ─────────────────────────────────── + case!("SignedLayer", fixtures::signed_layer()); + case!("DossierManifest", fixtures::dossier_manifest()); + case!("EvidenceDossier", fixtures::dossier()); + case!("EvidenceDossierRecord", fixtures::dossier_record()); + case!("EvidenceDossierSummary", fixtures::dossier_summary()); + case!("CheckResult", fixtures::check_result()); + case!("VerificationReport", fixtures::verification_report()); + + // ── dpp-common ──────────────────────────────────────────────────────── + case!("Problem", fixtures::problem()); + case!("ScanBatch", fixtures::scan_batch()); + case!("ScanCount", fixtures::scan_count()); + case!("QrRenderCount", fixtures::qr_render_count()); + + // ── dpp-vault: request and response bodies ──────────────────────────── + case!("CreateRequest", fixtures::create_request()); + case!("ValidateResponse", fixtures::validate_response()); + case!("PassportListResponse", fixtures::passport_list_response()); + case!("WhoamiResponse", fixtures::whoami_response()); + case!("PassportScanStats", fixtures::passport_scan_stats()); + case!("OperatorScanStats", fixtures::operator_scan_stats()); + case!("DailyScanCount", fixtures::daily_scan_count()); + case!("SealResponse", fixtures::seal_response()); + case!("SealSummaryResponse", fixtures::seal_summary_response()); + case!("InstalledPlugin", fixtures::installed_plugin()); + case!("WebhookSubscription", fixtures::webhook_subscription()); + case!( + "NewWebhookSubscription", + fixtures::new_webhook_subscription() + ); + case!( + "CreatedWebhookResponse", + fixtures::created_webhook_response() + ); + case!("EolRequest", fixtures::eol_request()); + case!("NodeState", fixtures::node_state()); + case!("VaultInfo", fixtures::vault_info()); + case!( + "TransferInitiateRequest", + fixtures::transfer_initiate_request() + ); + case!("TreeReport", fixtures::tree_report()); + case!("TreeNodeReport", fixtures::tree_node_report()); + case!("RegistrationView", fixtures::registration_view()); + case!("TransferView", fixtures::transfer_view()); + case!("CurrentOperatorView", fixtures::current_operator_view()); + case!("PassportRegistryView", fixtures::passport_registry_view()); + case!("VerificationView", fixtures::verification_view()); + case!("RegistrationCounts", fixtures::registration_counts()); + case!("TransferCounts", fixtures::transfer_counts()); + case!("RegistryRollupView", fixtures::registry_rollup_view()); + + // ── dpp-identity: the internal signing surface ──────────────────────── + case!("SignRequest", fixtures::sign_request()); + case!("SignResponse", fixtures::sign_response()); + case!("VerifyRequest", fixtures::verify_request()); + case!("VerifyResponse", fixtures::verify_response()); + case!("RotateRequest", fixtures::rotate_request()); + case!("RotateResponse", fixtures::rotate_response()); + + // ── dpp-integrator: bulk import ─────────────────────────────────────── + case!("ImportSyncResponse", fixtures::import_sync_response()); + case!("ImportAsyncResponse", fixtures::import_async_response()); + case!("ImportCreatedEntry", fixtures::import_created_entry()); + case!("ImportUpdatedEntry", fixtures::import_updated_entry()); + case!("ImportErrorEntry", fixtures::import_error_entry()); + case!("JobStatusResponse", fixtures::job_status_response()); + + cases +} + +fn enum_cases() -> Vec { + fn wire(values: &[T]) -> Vec { + values + .iter() + .map(|v| { + serde_json::to_value(v) + .expect("enum variant failed to serialise") + .as_str() + .expect("enum variant did not serialise to a string") + .to_owned() + }) + .collect() + } + + vec![ + EnumCase { + name: "PassportStatus", + variants: wire(&fixtures::all_passport_statuses()), + }, + EnumCase { + name: "OperatorRole", + variants: wire(&fixtures::all_operator_roles()), + }, + EnumCase { + name: "TransferReason", + variants: wire(&fixtures::all_transfer_reasons()), + }, + EnumCase { + name: "ScanVariant", + variants: wire(&fixtures::all_scan_variants()), + }, + EnumCase { + name: "ApiKeyScope", + variants: wire(&fixtures::all_api_key_scopes()), + }, + EnumCase { + name: "ComplianceStatus", + variants: wire(&fixtures::all_compliance_statuses()), + }, + EnumCase { + name: "LintSeverity", + variants: wire(&fixtures::all_lint_severities()), + }, + EnumCase { + name: "SealFormat", + variants: wire(&fixtures::all_seal_formats()), + }, + EnumCase { + name: "Coverage", + variants: wire(&fixtures::all_coverages()), + }, + ] +} + +/// `DeactivationReason` is an internally-tagged enum whose spec is a `oneOf` of +/// per-`kind` objects, so neither the object nor the enum check fits it. Its +/// discriminator values are checked instead. +fn deactivation_reason_kinds() -> Vec { + fixtures::all_deactivation_reasons() + .iter() + .map(|r| { + serde_json::to_value(r).expect("DeactivationReason failed to serialise")["kind"] + .as_str() + .expect("DeactivationReason has no `kind` discriminator") + .to_owned() + }) + .collect() +} + +// ── The dpp-core repin tripwire ──────────────────────────────────────────── + +/// The `dpp-core` version this contract was last verified against **by hand**. +/// +/// Bump only after re-checking the enums listed below against the released +/// crate. Bumping it to make a red build green is the one thing that breaks +/// this gate. +const CORE_VERSION_VERIFIED: &str = "0.18.0"; + +/// Enums whose variants this test cannot enumerate, and so cannot gate. +/// +/// Listed here so the failure message can name them, rather than leaving the +/// reader to work out what "re-check the enums" means. +const UNENUMERABLE_CORE_ENUMS: &[&str] = &[ + "PassportStatus", + "OperatorRole", + "TransferReason", + "DeactivationReason", + "ComplianceStatus", + "LifecycleStage", + "SystemBoundary", +]; + +/// A `dpp-core` repin must not pass silently. +/// +/// Everything else in this file survives a repin on its own: a field added to a +/// core struct fails the fixture's compile, a renamed wire key fails the schema +/// check, a changed field type fails the type check. **Enum variants do not.** +/// +/// Every enum in `dpp-domain` is `#[non_exhaustive]`, which is correct for a +/// published crate but means a consumer cannot enumerate one from outside. The +/// variant lists in `fixtures` are therefore written by hand, and a hand-written +/// list does not stop compiling when core grows a variant — it compiles, the +/// enum check passes, and the new value ships undocumented. That is precisely +/// how `PassportStatus` came to omit `superseded` and `deactivated`, two states +/// the server returns and the description called impossible. +/// +/// So the repin itself is the trigger. Changing the pin fails this test, and +/// clearing it means going and looking. +/// +/// **This is a stopgap.** The real fix belongs in `dpp-core`: a +/// `pub const ALL: &'static [Self]` on each enum, exactly as `SealFormat::ALL` +/// already does for the same stated reason. Once those land and the engine +/// repins, the fixtures read `ALL` directly, the hand-written lists go, and this +/// tripwire can go with them. +#[test] +fn a_dpp_core_repin_forces_the_enums_to_be_rechecked() { + assert_eq!( + dpp_domain::VERSION, + CORE_VERSION_VERIFIED, + "\n\ndpp-core moved from {CORE_VERSION_VERIFIED} to {}, and this contract was verified \ + against {CORE_VERSION_VERIFIED}.\n\n\ + Struct changes are already covered — a new field on a core struct fails to compile in \ + `fixtures`, and a renamed or retyped one fails the schema checks. This test exists for \ + the one thing that is NOT covered: enum variants.\n\n\ + `dpp-domain`'s enums are `#[non_exhaustive]`, so this crate cannot enumerate them. The \ + variant lists in `fixtures` are hand-written and will keep compiling — and keep passing \ + — after core adds a variant, shipping it undocumented.\n\n\ + Re-check each of these against the new release, add any new variant to its fixture list \ + AND to its schema under api/components/schemas/, then bump CORE_VERSION_VERIFIED:\n {}\n\n\ + Do not bump it first.\n", + dpp_domain::VERSION, + UNENUMERABLE_CORE_ENUMS.join("\n ") + ); +} + +// ── Declared type vs emitted type ────────────────────────────────────────── + +/// The JSON type a value actually has, in OpenAPI's vocabulary. +/// +/// `integer` is reported for a whole number because OpenAPI distinguishes it +/// from `number` even though JSON does not — a float documented as `integer` is +/// a real mismatch for a generated client that types the field as an int. +fn json_type_of(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + Value::Number(n) => { + if n.is_i64() || n.is_u64() { + "integer" + } else { + "number" + } + } + } +} + +/// The set of types a property schema declares, following `$ref`, `allOf` and +/// `anyOf` so a `$ref`-ed scalar (an enum, a formatted string) is still typed. +/// +/// `None` means the schema declares no type this can read — a bare composition +/// with no typed branch — and the property is skipped rather than guessed at. +fn declared_types(spec: &Value, prop: &Value) -> Option> { + let mut out = BTreeSet::new(); + + fn collect(spec: &Value, node: &Value, out: &mut BTreeSet) { + let node = resolve(spec, node); + match node.get("type") { + Some(Value::String(t)) => { + out.insert(t.clone()); + } + Some(Value::Array(ts)) => { + out.extend(ts.iter().filter_map(Value::as_str).map(str::to_owned)); + } + _ => {} + } + for key in ["allOf", "anyOf", "oneOf"] { + if let Some(branches) = node.get(key).and_then(Value::as_array) { + for branch in branches { + collect(spec, branch, out); + } + } + } + } + + collect(spec, prop, &mut out); + if out.is_empty() { None } else { Some(out) } +} + +/// Whether an emitted value satisfies a declared type set. +/// +/// `number` admits a whole number: JSON has one numeric type, and a field +/// documented `number` whose fixture value happens to be `7` is not a defect. +/// The reverse is not true — `integer` does not admit a fractional value. +fn type_matches(declared: &BTreeSet, actual: &str) -> bool { + if declared.contains(actual) { + return true; + } + actual == "integer" && declared.contains("number") +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[test] +fn object_schemas_match_the_types_behind_them() { + let spec = spec(); + let schemas = schemas(&spec); + let mut failures: Vec = Vec::new(); + + for case in object_cases() { + let Some(schema) = schemas.get(case.name) else { + failures.push(format!( + "{}: registered in this test but absent from the spec", + case.name + )); + continue; + }; + + let documented = spec_properties(&spec, schema); + let emitted = wire_keys(&case.value); + + let undocumented: BTreeSet = emitted.difference(&documented).cloned().collect(); + let phantom: BTreeSet = documented.difference(&emitted).cloned().collect(); + + if !undocumented.is_empty() { + failures.push(format!( + "{}: the server emits fields the spec does not document: {}", + case.name, + joined(&undocumented) + )); + } + if !phantom.is_empty() { + failures.push(format!( + "{}: the spec documents fields the server never emits: {}", + case.name, + joined(&phantom) + )); + } + + // A `required` property the server cannot emit is worse than an + // undocumented one: a conforming client is entitled to reject the + // response outright. + let impossible: BTreeSet = spec_required(&spec, schema) + .difference(&emitted) + .cloned() + .collect(); + if !impossible.is_empty() { + failures.push(format!( + "{}: the spec marks fields REQUIRED that the server never emits: {}", + case.name, + joined(&impossible) + )); + } + + // Matching names are not a matching contract. `co2ePerUnit` and + // `repairabilityScore` were both documented as bare numbers long after + // they became objects, and a name-only check passes that happily — a + // generated client would type them as `f64` and fail to parse every + // response. Compare the declared type against the type the fixture + // actually serialises to. + let props = schema_property_map(&spec, schema); + let emitted_obj = case + .value + .as_object() + .expect("fixture did not serialise to a JSON object"); + for (name, prop_schema) in &props { + let Some(actual_value) = emitted_obj.get(name) else { + continue; // absence is already reported above + }; + let Some(declared) = declared_types(&spec, prop_schema) else { + continue; // untyped composition — nothing to compare + }; + let actual = json_type_of(actual_value); + if !type_matches(&declared, actual) { + failures.push(format!( + "{}.{name}: the spec says `{}`, the server sends `{actual}`", + case.name, + declared.iter().cloned().collect::>().join(" | ") + )); + } + } + } + + assert!( + failures.is_empty(), + "OpenAPI object schemas disagree with the types that implement them:\n {}\n\n\ + Fix the spec under api/components/schemas/ (then `just openapi-bundle`), \ + or fix the type. Do not edit the fixtures to match a wrong spec.", + failures.join("\n ") + ); +} + +#[test] +fn enum_schemas_list_every_variant_the_server_can_emit() { + let spec = spec(); + let schemas = schemas(&spec); + let mut failures: Vec = Vec::new(); + + let mut check = |name: &str, emitted: Vec| { + let Some(schema) = schemas.get(name) else { + failures.push(format!( + "{name}: registered in this test but absent from the spec" + )); + return; + }; + let documented: BTreeSet = schema + .get("enum") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + let emitted: BTreeSet = emitted.into_iter().collect(); + + let undocumented: BTreeSet = emitted.difference(&documented).cloned().collect(); + let phantom: BTreeSet = documented.difference(&emitted).cloned().collect(); + + if !undocumented.is_empty() { + failures.push(format!( + "{name}: the server can emit values the spec does not list: {} \ + (a client modelling this as a closed enum fails on them)", + joined(&undocumented) + )); + } + if !phantom.is_empty() { + failures.push(format!( + "{name}: the spec lists values the server never emits: {}", + joined(&phantom) + )); + } + }; + + for case in enum_cases() { + check(case.name, case.variants); + } + + assert!( + failures.is_empty(), + "OpenAPI enum schemas disagree with the Rust enums behind them:\n {}", + failures.join("\n ") + ); +} + +#[test] +fn deactivation_reason_documents_every_kind() { + let spec = spec(); + let schema = &schemas(&spec)["DeactivationReason"]; + let documented: BTreeSet = schema["oneOf"] + .as_array() + .expect("DeactivationReason is not a oneOf") + .iter() + .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str()) + .map(str::to_owned) + .collect(); + let emitted: BTreeSet = deactivation_reason_kinds().into_iter().collect(); + + assert_eq!( + documented, + emitted, + "DeactivationReason `kind` discriminators disagree.\n spec: {}\n code: {}", + joined(&documented), + joined(&emitted) + ); +} + +#[test] +fn every_schema_is_covered() { + let spec = spec(); + let declared: BTreeSet = schemas(&spec).keys().cloned().collect(); + + let mut covered: BTreeSet = object_cases() + .into_iter() + .map(|c| c.name.to_owned()) + .collect(); + covered.extend(enum_cases().into_iter().map(|c| c.name.to_owned())); + covered.insert("DeactivationReason".to_owned()); + covered.extend(UNCHECKED.iter().map(|(n, _)| (*n).to_owned())); + + let unchecked: BTreeSet = declared.difference(&covered).cloned().collect(); + assert!( + unchecked.is_empty(), + "these schemas are in the spec but nothing checks them against code: {}\n\n\ + Add a case to `object_cases`/`enum_cases`, or an entry to `UNCHECKED` \ + with the reason it cannot be checked. A schema no test covers is a \ + schema free to drift.", + joined(&unchecked) + ); + + let stale: BTreeSet = covered.difference(&declared).cloned().collect(); + assert!( + stale.is_empty(), + "these names are registered here but no longer exist in the spec: {}", + joined(&stale) + ); +} + +// ── Documented bounds vs enforced bounds ─────────────────────────────────── + +/// A documented numeric bound is a promise about what the server accepts, and +/// nothing was checking it. +/// +/// `CreateRequest.repairabilityScore` was documented `minimum: 0, maximum: 100` +/// with a description saying "0–100", while the validator enforced `0..=10` and +/// rejected anything above with a 422. Every check up to this point passed: the +/// property existed, was named right, and was typed right. A client trusting +/// the description would send 55 and be refused. +/// +/// This does not restate the bound — restating it is how the two got out of +/// step. It reads `minimum`/`maximum` from the spec and drives the **real** +/// validator at those exact values, so the spec is only green when the code +/// agrees with it at the boundary. +#[test] +fn documented_numeric_bounds_are_the_bounds_actually_enforced() { + use dpp_vault::handlers::create::validate_create_request; + + let spec = spec(); + let schema = &schemas(&spec)["CreateRequest"]; + let props = schema_property_map(&spec, schema); + + // Each numeric field of CreateRequest, with a way to set it on a body that + // is otherwise valid. Adding a bounded field without adding it here is + // caught below. + type Setter = fn(&mut dpp_vault::handlers::create::CreateRequest, f64); + let numeric: &[(&str, Setter)] = &[ + ("co2ePerUnit", |b, v| b.co2e_per_unit = Some(v)), + ("repairabilityScore", |b, v| b.repairability_score = Some(v)), + ]; + + let accepts = |set: Setter, v: f64| -> bool { + let mut body = fixtures::minimal_create_request(); + set(&mut body, v); + validate_create_request(&body).is_none() + }; + + let mut failures = Vec::new(); + let mut checked = BTreeSet::new(); + + for (name, set) in numeric { + let Some(prop) = props.get(*name) else { + failures.push(format!("CreateRequest.{name}: no longer in the spec")); + continue; + }; + let min = prop.get("minimum").and_then(Value::as_f64); + let max = prop.get("maximum").and_then(Value::as_f64); + if min.is_none() && max.is_none() { + continue; + } + checked.insert((*name).to_owned()); + + // Step just outside by a hair relative to the bound's own magnitude, so + // this works for a bound of 10 and a bound of 730 alike. + let nudge = |v: f64| v.abs().max(1.0) * 1e-6; + + if let Some(min) = min { + if !accepts(*set, min) { + failures.push(format!( + "CreateRequest.{name}: spec says minimum {min}, but the validator rejects it" + )); + } + if accepts(*set, min - nudge(min)) { + failures.push(format!( + "CreateRequest.{name}: spec says minimum {min}, but the validator accepts \ + values below it" + )); + } + } + if let Some(max) = max { + if !accepts(*set, max) { + failures.push(format!( + "CreateRequest.{name}: spec says maximum {max}, but the validator REJECTS it \ + — the documented range is wider than the enforced one" + )); + } + if accepts(*set, max + nudge(max)) { + failures.push(format!( + "CreateRequest.{name}: spec says maximum {max}, but the validator accepts \ + values above it — the documented range is narrower than the enforced one" + )); + } + } + } + + // A bounded property nothing drives is a bound nobody is checking. + let bounded: BTreeSet = props + .iter() + .filter(|(_, p)| p.get("minimum").is_some() || p.get("maximum").is_some()) + .map(|(k, _)| k.clone()) + .collect(); + let undriven: BTreeSet = bounded.difference(&checked).cloned().collect(); + if !undriven.is_empty() { + failures.push(format!( + "CreateRequest declares bounds on properties this test does not drive: {} — add them \ + to `numeric` above", + joined(&undriven) + )); + } + + assert!( + failures.is_empty(), + "documented bounds disagree with the validator:\n {}", + failures.join("\n ") + ); +} + +// ── Response-body coverage ───────────────────────────────────────────────── + +/// `application/json` success responses that legitimately describe no Rust type +/// of ours, with the reason. Everything else must name a schema. +const INLINE_JSON_RESPONSE_ALLOWED: &[(&str, &str)] = &[ + ( + "/integrator/api/v1/schemas", + "returns the product_group schema registry's own listing of JSON Schema \ + documents — data about schemas, not a serialised domain type", + ), + ( + "/integrator/api/v1/schemas/{productGroup}", + "returns a JSON Schema document verbatim. Describing a meta-schema's \ + shape in OpenAPI would restate JSON Schema itself", + ), + ( + "/integrator/api/v1/schemas/{productGroup}/{version}", + "as above, pinned to a version", + ), +]; + +/// Every JSON success body must be a named schema, because only a named schema +/// is checked against a Rust type. +/// +/// This is what closes the gap the other tests leave: they prove every *named* +/// schema matches its type and every route is documented, but an endpoint whose +/// response is written as an anonymous inline object satisfies both while its +/// body is described by nothing and verified by nothing. Ten endpoints were in +/// that state — including the passport list, the seal status, and `whoami` — +/// and their inline shapes had no mechanism keeping them true. +/// +/// Non-JSON bodies are out of scope by construction: `text/html`, `image/png`, +/// `text/csv`, and the external-standard payloads (`application/aas+json`, +/// `application/linkset+json`) are not serialised from a type this workspace +/// owns. +#[test] +fn every_json_success_response_names_a_schema() { + let spec = spec(); + let allowed: BTreeSet<&str> = INLINE_JSON_RESPONSE_ALLOWED + .iter() + .map(|(p, _)| *p) + .collect(); + + let mut failures = Vec::new(); + let mut allowlist_used: BTreeSet<&str> = BTreeSet::new(); + + let paths = spec["paths"].as_object().expect("paths is not an object"); + for (path, item) in paths { + let Some(ops) = item.as_object() else { + continue; + }; + for (method, op) in ops { + let Some(responses) = op.get("responses").and_then(Value::as_object) else { + continue; + }; + for (code, response) in responses { + if !code.starts_with('2') { + continue; + } + let Some(json_body) = response + .get("content") + .and_then(Value::as_object) + .and_then(|c| c.get("application/json")) + else { + continue; + }; + let Some(schema) = json_body.get("schema") else { + continue; + }; + + // A named schema, an array of one, or a composition over one. + let named = schema.get("$ref").is_some() + || schema.get("items").is_some_and(|i| i.get("$ref").is_some()) + || schema + .get("allOf") + .and_then(Value::as_array) + .is_some_and(|b| b.iter().any(|x| x.get("$ref").is_some())); + + if named { + continue; + } + if allowed.contains(path.as_str()) { + allowlist_used.insert(path.as_str()); + continue; + } + failures.push(format!( + "{} {path} [{code}] describes its JSON body inline — nothing checks that \ + shape against the code", + method.to_uppercase() + )); + } + } + } + + // An allowlist entry for a response that no longer exists (or is now named) + // is a stale excuse; it should be deleted rather than left to cover + // something else later. + let stale: BTreeSet<&str> = allowed.difference(&allowlist_used).copied().collect(); + if !stale.is_empty() { + failures.push(format!( + "INLINE_JSON_RESPONSE_ALLOWED names paths that no longer have an inline JSON \ + response: {}", + stale.into_iter().collect::>().join(", ") + )); + } + + assert!( + failures.is_empty(), + "JSON success responses that name no schema:\n {}\n\n\ + Give the handler a named response type, add a schema for it under \ + api/components/schemas/, `$ref` it here, and register it in `object_cases` \ + — that is what puts the body under the contract test.", + failures.join("\n ") + ); +} + +// ── Route coverage ───────────────────────────────────────────────────────── + +/// Router sources, embedded at compile time so they cannot go stale relative to +/// the code that is actually built. +mod routers { + pub const VAULT: &str = include_str!("../../dpp-vault/src/router.rs"); + pub const IDENTITY: &str = include_str!("../../dpp-identity/src/router.rs"); + pub const INTEGRATOR: &str = include_str!("../../dpp-integrator/src/router.rs"); + pub const RESOLVER: &str = include_str!("../../dpp-resolver/src/router.rs"); + pub const NODE: &str = include_str!("../src/router.rs"); +} + +/// Everything before the file's `#[cfg(test)]` module. +/// +/// Router files mount throwaway routes inside their own unit tests (the CORS +/// test in `dpp-vault` registers `/credential/dpp/{id}`), and those are not part +/// of the served surface. Scanning them would report a documented route as +/// undocumented because a test spelled its path parameter differently. +fn without_tests(src: &str) -> &str { + match src.find("#[cfg(test)]") { + Some(at) => &src[..at], + None => src, + } +} + +/// Extract the path literal of every `.route("…"` in `src`. +/// +/// Route registration is a fixed, single-form construct — `.route("` followed +/// by a string literal — so scanning for it is exact, unlike reading field +/// shapes off a struct. Multi-line `.route(\n "…"` is handled by matching the +/// literal after the token rather than requiring it on the same line. +fn routes_in(src: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut rest = without_tests(src); + while let Some(at) = rest.find(".route(") { + rest = &rest[at + ".route(".len()..]; + let Some(open) = rest.find('"') else { break }; + // Only whitespace may separate the paren from the literal; anything else + // means this was not a literal route registration. + if !rest[..open].trim().is_empty() { + continue; + } + let after = &rest[open + 1..]; + let Some(close) = after.find('"') else { break }; + out.insert(after[..close].to_owned()); + rest = &after[close..]; + } + out +} + +/// The section of `dpp-vault`'s router between two markers, so each group of +/// routes can be given the prefix it is actually nested under. +fn section<'a>(src: &'a str, from: &str, to: Option<&str>) -> &'a str { + let start = src + .find(from) + .unwrap_or_else(|| panic!("marker {from:?} not found in router")); + let rest = &src[start..]; + match to { + Some(end) => { + &rest[..rest + .find(end) + .unwrap_or_else(|| panic!("marker {end:?} not found"))] + } + None => rest, + } +} + +fn prefixed(prefix: &str, routes: BTreeSet) -> BTreeSet { + routes + .into_iter() + .map(|r| { + if prefix.is_empty() { + r + } else { + format!("{prefix}{r}") + } + }) + .collect() +} + +/// Every path the assembled node serves. +/// +/// The node mounts `dpp_identity_service::router::build_public` — deliberately +/// not `build` — so the `/internal/*` signing routes are not part of this +/// surface. The vault signs in-process; there is no network-reachable signing +/// endpoint on a node. +fn node_surface() -> BTreeSet { + let vault_authenticated = section( + routers::VAULT, + "let authenticated =", + Some("let internal ="), + ); + let vault_internal = section(routers::VAULT, "let internal =", Some("let cors_layer =")); + let vault_public = section(routers::VAULT, "let cors_layer =", None); + + let identity_public = section(routers::IDENTITY, "pub fn build_public", None); + + let mut paths = BTreeSet::new(); + paths.extend(prefixed("/vault/api/v1", routes_in(vault_authenticated))); + paths.extend(prefixed("/vault/internal", routes_in(vault_internal))); + paths.extend(prefixed("/vault", routes_in(vault_public))); + paths.extend(prefixed("/identity", routes_in(identity_public))); + paths.extend(prefixed("/integrator", routes_in(routers::INTEGRATOR))); + // The node's own root routes, minus the `.nest` calls (which carry no + // `.route(` literal and so are already excluded). + paths.extend(routes_in(routers::NODE)); + paths +} + +/// The resolver is a separate deployable, mounted at the root of its own host. +fn resolver_surface() -> BTreeSet { + routes_in(routers::RESOLVER) +} + +/// The mTLS internal signing surface, served only when `dpp-identity` runs as +/// its own process (`servers[1]`, port 8002). +/// +/// These three routes live in `build()` and not in `build_public()`, which is +/// why the node does not serve them — it signs in-process instead. They are +/// still part of the described API, because `openapi.yaml` declares standalone +/// identity as one of its three servers. +/// +/// Modelled as a real surface rather than an exception list on purpose. An +/// exception list is where "missing" hides: every entry is a route nothing +/// checks, justified once by a comment that no longer has to stay true. Reading +/// them out of `build()` means adding a fourth internal route fails this test +/// the same as any other undocumented route. +fn identity_standalone_surface() -> BTreeSet { + // `build()` registers only the internal routes directly; it composes the + // public ones via `build_public(state).merge(internal)`, which carries no + // `.route(` literal. Slicing to `build_public` therefore yields exactly the + // routes the node does not mount. + let internal_only = section( + routers::IDENTITY, + "pub fn build", + Some("pub fn build_public"), + ); + routes_in(internal_only) +} + +#[test] +fn every_route_is_documented_and_every_documented_path_exists() { + let spec = spec(); + let documented: BTreeSet = spec["paths"] + .as_object() + .expect("paths is not an object") + .keys() + .cloned() + .collect(); + + // The union of every deployable `openapi.yaml` names in `servers`. + let mut served = node_surface(); + served.extend(resolver_surface()); + served.extend(identity_standalone_surface()); + + let undocumented: BTreeSet = served.difference(&documented).cloned().collect(); + let phantom: BTreeSet = documented.difference(&served).cloned().collect(); + + let mut failures = Vec::new(); + if !undocumented.is_empty() { + failures.push(format!( + "routes the code serves that the spec does not document: {}", + joined(&undocumented) + )); + } + if !phantom.is_empty() { + failures.push(format!( + "paths the spec documents that nothing serves: {}", + joined(&phantom) + )); + } + + assert!( + failures.is_empty(), + "the OpenAPI paths and the registered routes disagree:\n {}", + failures.join("\n ") + ); +} + +// ── Fixtures ─────────────────────────────────────────────────────────────── +// +// Every struct literal here is exhaustive on purpose — see the module docs. Do +// not reach for `..Default::default()`: it would let a new field slip past this +// gate without a compile error, which is the whole failure mode being closed. + +mod fixtures { + use super::*; + + use dpp_common::{ + http_problem::Problem, + plugin_admin::InstalledPlugin, + scan::{QrRenderCount, ScanBatch, ScanCount, ScanVariant}, + }; + use dpp_domain::domain::{ + commodity_code::CommodityCode, + compliance::{ComplianceFinding, ComplianceResult, ComplianceStatus}, + eol::{DeactivationReason, DerogationRef}, + lint::{LintFinding, LintResult, LintSeverity}, + passport::{ + FacilitySnapshot, ManufacturerInfo, MaterialEntry, Passport, PassportId, PassportRef, + }, + product_group::{ + CarbonFootprint, CarbonFootprintClass, LifecycleStage, ProductGroup, RepairCriterion, + RepairabilityScore, SystemBoundary, + }, + seal::{SealFormat, SealedEnvelope}, + status::PassportStatus, + transfer::{ + OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, + }, + }; + use dpp_types::{ + api_key::{ApiKey, ApiKeyScope, CreateApiKeyRequest, NewApiKey}, + audit::AuditEntry, + evidence::{ + CheckResult, CheckStatus, DossierManifest, DossierV1, EvidenceDossierRecord, + EvidenceDossierSummary, SignedLayer, VerificationReport, + }, + operator::{OperatorConfig, UpdateOperatorConfig}, + registry_identity::{ + CreateFacilityRequest, CreateOperatorIdentifierRequest, Facility, OperatorIdentifier, + RegistryIdentityAudit, + }, + scan::{DailyScanCount, OperatorScanStats, PassportScanStats}, + trust::{NodeProfile, NodeTrustReport, TrustMode, TrustPort}, + webhook::{NewWebhookSubscription, WebhookSubscription}, + }; + + use dpp_identity_service::handlers::{ + rotate_key::{RotateRequest, RotateResponse}, + sign::{SignRequest, SignResponse}, + verify::{VerifyRequest, VerifyResponse}, + }; + use dpp_integrator::handlers::{ + import::{AsyncImportResponse, CreatedEntry, ErrorEntry, SyncImportResponse, UpdatedEntry}, + job_status::{JobProgress, JobStatusResponse}, + }; + use dpp_vault::{ + domain::verify::{NodeReport, RefUnverifiable, TreeReport}, + handlers::{ + create::CreateRequest, + eol::EolRequest, + info::VaultInfo, + list::PassportListResponse, + node_state::NodeState, + registry_status::{ + CurrentOperatorView, PassportRegistryView, RegistrationCounts, RegistrationView, + RegistryRollupView, TransferCounts, TransferView, VerificationView, + }, + seal::{Coverage, SealResponse, SealSummaryResponse}, + transfer::TransferInitiateRequest, + validate::ValidateResponse, + webhooks::CreatedWebhookResponse, + whoami::WhoamiResponse, + }, + }; + + // ── dpp-core ────────────────────────────────────────────────────────── + + pub fn manufacturer() -> ManufacturerInfo { + ManufacturerInfo { + name: "Nordwerk GmbH".into(), + address: "Hauptstrasse 1, 10115 Berlin, DE".into(), + did_web_url: Some("did:web:nordwerk.example".into()), + } + } + + pub fn material() -> MaterialEntry { + MaterialEntry { + name: "Lithium carbonate".into(), + weight_kg: 12.5, + recycled_pct: Some(35.0), + country_of_origin: Some("DE".into()), + } + } + + pub fn passport_ref() -> PassportRef { + PassportRef { + uri: "https://id.example/dpp/019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + public_jws_hash: "b1946ac92492d2347c6235b4d2611184".into(), + } + } + + pub fn facility_snapshot() -> FacilitySnapshot { + FacilitySnapshot { + scheme: "gln".into(), + value: "4012345000009".into(), + name: "Werk Nord".into(), + country: "DE".into(), + address: Some("Werkstrasse 4, 21079 Hamburg, DE".into()), + } + } + + pub fn derogation_ref() -> DerogationRef { + DerogationRef { + category: "safety".into(), + act_citation: Some("Regulation (EU) 2024/1781, Art. 25(3)".into()), + } + } + + pub fn all_deactivation_reasons() -> Vec { + vec![ + DeactivationReason::Recycled, + DeactivationReason::Destroyed { + derogation: derogation_ref(), + }, + DeactivationReason::Exported, + DeactivationReason::Lost, + ] + } + + pub fn responsible_operator() -> ResponsibleOperator { + ResponsibleOperator { + did: "did:web:nordwerk.example".into(), + name: "Nordwerk GmbH".into(), + role: OperatorRole::Manufacturer, + eu_operator_id: Some("DE123456789".into()), + eu_operator_id_scheme: Some("eori".into()), + country: "DE".into(), + } + } + + pub fn transfer_record() -> TransferRecord { + TransferRecord { + transfer_id: uuid(), + passport_id: PassportId::new(), + from_operator: responsible_operator(), + to_operator: responsible_operator(), + reason: TransferReason::Sale, + from_signature: Some("eyJhbGciOiJFZERTQSJ9..aaa".into()), + to_signature: Some("eyJhbGciOiJFZERTQSJ9..bbb".into()), + initiated_at: ts(), + completed_at: Some(ts()), + rejected_at: Some(ts()), + cancelled_at: Some(ts()), + notes: Some("Sold to distributor".into()), + } + } + + pub fn all_passport_statuses() -> Vec { + vec![ + PassportStatus::Draft, + PassportStatus::Published, + PassportStatus::Suspended, + PassportStatus::Archived, + PassportStatus::Superseded, + PassportStatus::Deactivated, + ] + } + + pub fn all_operator_roles() -> Vec { + vec![ + OperatorRole::Manufacturer, + OperatorRole::Importer, + OperatorRole::Distributor, + OperatorRole::AuthorisedRepresentative, + OperatorRole::Remanufacturer, + OperatorRole::Repurposer, + OperatorRole::PreparerForReuse, + OperatorRole::Repairer, + OperatorRole::Recycler, + ] + } + + pub fn all_transfer_reasons() -> Vec { + vec![ + TransferReason::Sale, + TransferReason::Return, + TransferReason::Remanufacturing, + TransferReason::Repurposing, + TransferReason::PreparationForReuse, + TransferReason::Import, + TransferReason::InsolvencySuccession, + ] + } + + pub fn carbon_footprint() -> CarbonFootprint { + CarbonFootprint { + value_kg: 45.2, + lifecycle_stage: Some(LifecycleStage::CradleToGate), + system_boundary: Some(SystemBoundary::En15804), + methodology_ref: Some("EN 15804:2012+A2:2019".into()), + performance_class: Some(CarbonFootprintClass::new("B").expect("valid class label")), + } + } + + pub fn repair_criterion() -> RepairCriterion { + RepairCriterion { + name: "disassembly_depth".into(), + score: 8.0, + weight: 0.4, + } + } + + pub fn repairability_score() -> RepairabilityScore { + RepairabilityScore { + overall: 7.5, + criteria: vec![repair_criterion()], + } + } + + pub fn compliance_finding() -> ComplianceFinding { + ComplianceFinding::new( + "battery.recycled_content.cobalt_below_2031", + "/recycledContentCobaltPct", + "below the 2031 threshold", + ) + } + + pub fn lint_finding() -> LintFinding { + LintFinding { + code: "mass.balance".into(), + field: "/materials".into(), + severity: LintSeverity::Warning, + message: "material mass exceeds product mass".into(), + } + } + + pub fn all_api_key_scopes() -> Vec { + vec![ApiKeyScope::Read, ApiKeyScope::Write, ApiKeyScope::Admin] + } + + pub fn all_compliance_statuses() -> Vec { + vec![ + ComplianceStatus::PassthroughNoValidation, + ComplianceStatus::Compliant, + ComplianceStatus::NonCompliant, + ComplianceStatus::NotAssessed, + ComplianceStatus::NotImplemented, + ] + } + + pub fn all_lint_severities() -> Vec { + vec![LintSeverity::Warning, LintSeverity::Notice] + } + + pub fn all_seal_formats() -> Vec { + SealFormat::ALL.to_vec() + } + + pub fn compliance_result() -> ComplianceResult { + ComplianceResult { + co2e_score: Some(45.2), + repairability_index: Some(7.5), + recycled_content_pct: Some(35.0), + compliance_status: ComplianceStatus::PassthroughNoValidation, + violations: vec![compliance_finding()], + warnings: vec![compliance_finding()], + ruleset_version: Some("2026.1.0".into()), + assessed_at: Some(ts()), + receipt: Some(json!({ "inputHash": "abc" })), + } + } + + pub fn lint_result() -> LintResult { + LintResult { + pack_version: "1.0.0".into(), + findings: vec![lint_finding()], + assessed_at: ts(), + } + } + + pub fn sealed_envelope() -> SealedEnvelope { + SealedEnvelope { + format: SealFormat::Cades, + seal_value: "MIIB...".into(), + signing_cert_ref: Some("urn:cert:1".into()), + sealed_at: ts(), + placeholder: false, + } + } + + /// A `Passport` with every optional field populated, so the serialised form + /// carries every key the type is capable of emitting. + pub fn passport() -> Passport { + let mut disclosure_signatures = BTreeMap::new(); + disclosure_signatures.insert( + "public+restricted".to_owned(), + "eyJhbGciOiJFZERTQSJ9..ccc".to_owned(), + ); + + Passport { + id: PassportId::new(), + batch_id: Some("LOT-2026-001".into()), + product_name: "EcoCell Pro 48V".into(), + product_group: ProductGroup::Textile, + // Both must be *populated*, not defaulted: each is + // `skip_serializing_if`, so an empty vec or a `None` emits nothing, + // the fixture stops exercising the field, and the spec is never + // asked to document it. A field that serialises away is a field this + // gate cannot see. + applicable_instruments: vec![dpp_domain::InstrumentRef::from_catalog("espr")], + granularity: Some(dpp_domain::Granularity::Item), + manufacturer: manufacturer(), + materials: vec![material()], + co2e_per_unit: Some(CarbonFootprint::from_kg(45.2)), + repairability_score: Some(RepairabilityScore::from_scalar(7.5)), + compliance_result: Some(compliance_result()), + lint_result: Some(lint_result()), + product_group_data: None, + status: PassportStatus::Published, + qr_code_url: Some("https://id.example/01/09506000134352/21/ABC".into()), + jws_signature: Some("eyJhbGciOiJFZERTQSJ9..ddd".into()), + public_jws_signature: Some("eyJhbGciOiJFZERTQSJ9..eee".into()), + disclosure_signatures, + created_at: ts(), + updated_at: ts(), + published_at: Some(ts()), + placed_on_market_date: Some(date()), + schema_version: "1.0.0".into(), + retention_locked: true, + version: 2, + supersedes_id: Some(PassportId::new()), + parent_passport_ref: Some(passport_ref()), + component_refs: vec![passport_ref()], + retention_until: Some(ts()), + product_id: Some(uuid()), + commodity_code: Some(CommodityCode::parse("85076000").expect("valid CN-8")), + operator_identifier: Some("DE123456789".into()), + facility: Some(facility_snapshot()), + seal: Some(sealed_envelope()), + } + } + + // ── dpp-types ───────────────────────────────────────────────────────── + + pub fn api_key() -> ApiKey { + ApiKey { + id: uuid(), + name: "CI pipeline".into(), + key_prefix: "odal_sk_abc1".into(), + is_active: true, + scope: ApiKeyScope::Admin, + created_at: ts(), + last_used_at: Some(ts()), + expires_at: Some(ts()), + } + } + + pub fn new_api_key() -> NewApiKey { + NewApiKey { + key: api_key(), + secret: "odal_sk_abc123def456".into(), + } + } + + pub fn create_api_key_request() -> CreateApiKeyRequest { + CreateApiKeyRequest { + name: "CI pipeline".into(), + expires_at: Some(ts()), + scope: Some(ApiKeyScope::Admin), + } + } + + pub fn audit_entry() -> AuditEntry { + AuditEntry { + id: uuid(), + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + actor: "admin@example.com".into(), + action: "published".into(), + previous_status: Some("draft".into()), + new_status: Some("active".into()), + metadata: Some(json!({ "note": "first publish" })), + timestamp: ts(), + prev_hash: Some("0".repeat(64)), + entry_hash: Some("1".repeat(64)), + } + } + + pub fn facility() -> Facility { + Facility { + id: uuid(), + name: "Werk Nord".into(), + identifier_scheme: "gln".into(), + identifier_value: "4012345000009".into(), + country: "DE".into(), + address: Some("Werkstrasse 4, 21079 Hamburg, DE".into()), + is_default: true, + created_at: ts(), + } + } + + pub fn create_facility_request() -> CreateFacilityRequest { + CreateFacilityRequest { + name: "Werk Nord".into(), + identifier_scheme: "gln".into(), + identifier_value: "4012345000009".into(), + country: "DE".into(), + address: Some("Werkstrasse 4, 21079 Hamburg, DE".into()), + is_default: true, + } + } + + pub fn operator_identifier() -> OperatorIdentifier { + OperatorIdentifier { + id: uuid(), + scheme: "eori".into(), + value: "DE123456789".into(), + label: Some("Primary EORI".into()), + is_primary: true, + created_at: ts(), + } + } + + pub fn create_operator_identifier_request() -> CreateOperatorIdentifierRequest { + CreateOperatorIdentifierRequest { + scheme: "eori".into(), + value: "DE123456789".into(), + label: Some("Primary EORI".into()), + is_primary: true, + } + } + + pub fn registry_identity_audit() -> RegistryIdentityAudit { + RegistryIdentityAudit { + id: uuid(), + operator_id: "standalone".into(), + entity_type: "facility".into(), + entity_id: uuid(), + action: "created".into(), + actor: "admin@example.com".into(), + snapshot: Some(json!({ "name": "Werk Nord" })), + ts: ts(), + } + } + + pub fn operator_config() -> OperatorConfig { + OperatorConfig { + operator_id: "standalone".into(), + legal_name: "Nordwerk GmbH".into(), + trade_name: Some("Nordwerk".into()), + address: "Hauptstrasse 1, 10115 Berlin, DE".into(), + country: "DE".into(), + contact_email: "compliance@nordwerk.example".into(), + did_web_url: Some("did:web:nordwerk.example".into()), + product_categories: Some(vec!["battery".into()]), + brand_primary: Some("#0A5".into()), + brand_secondary: Some("#083".into()), + brand_logo_url: Some("https://nordwerk.example/logo.svg".into()), + custom_domain: Some("dpp.nordwerk.example".into()), + data_residency: "eu".into(), + retention_policy_days: 3650, + feature_flags: Some(json!({ "passthroughCompliance": true })), + registry_verified_at: Some(ts()), + created_at: Some(ts()), + updated_at: Some(ts()), + } + } + + pub fn update_operator_config() -> UpdateOperatorConfig { + UpdateOperatorConfig { + legal_name: Some("Nordwerk GmbH".into()), + trade_name: Some("Nordwerk".into()), + address: Some("Hauptstrasse 1, 10115 Berlin, DE".into()), + country: Some("DE".into()), + contact_email: Some("compliance@nordwerk.example".into()), + did_web_url: Some("did:web:nordwerk.example".into()), + product_categories: Some(vec!["battery".into()]), + brand_primary: Some("#0A5".into()), + brand_secondary: Some("#083".into()), + brand_logo_url: Some("https://nordwerk.example/logo.svg".into()), + custom_domain: Some("dpp.nordwerk.example".into()), + data_residency: Some("eu".into()), + retention_policy_days: Some(3650), + feature_flags: Some(json!({ "passthroughCompliance": true })), + registry_verified_at: Some(ts()), + } + } + + // ── Evidence dossier ────────────────────────────────────────────────── + + pub fn signed_layer() -> SignedLayer { + SignedLayer { + payload: json!({ "id": "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f" }), + jws: "eyJhbGciOiJFZERTQSJ9..fff".into(), + } + } + + pub fn dossier_manifest() -> DossierManifest { + let mut content_hashes = BTreeMap::new(); + content_hashes.insert("fullView".to_owned(), "2".repeat(64)); + DossierManifest { + format_version: "1".into(), + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + issuer_did: "did:web:node.example".into(), + created_at: ts(), + node_version: "0.12.0".into(), + core_version: "0.18.0".into(), + ruleset_version: Some("2026.1.0".into()), + content_hashes, + } + } + + pub fn dossier() -> DossierV1 { + let mut did_documents = BTreeMap::new(); + did_documents.insert( + "did:web:node.example".to_owned(), + json!({ "id": "did:web:node.example" }), + ); + DossierV1 { + manifest: dossier_manifest(), + manifest_jws: "eyJhbGciOiJFZERTQSJ9..ggg".into(), + full_view: signed_layer(), + public_view: signed_layer(), + did_documents, + audit_entries: vec![audit_entry()], + transfer_chain: Some(TransferChain { + passport_id: PassportId::new(), + original_operator: responsible_operator(), + transfers: vec![transfer_record()], + }), + eol_event: Some(json!({ "kind": "recycled" })), + checkpoint: Some(json!({})), + calc_receipts: vec![json!({})], + component_graph: Some(json!({})), + qualified_seal: Some(json!({})), + } + } + + pub fn check_result() -> CheckResult { + CheckResult { + name: "audit_chain".into(), + status: CheckStatus::Fail("hash mismatch at entry 3".into()), + } + } + + pub fn verification_report() -> VerificationReport { + VerificationReport { + trust_anchor_note: "verified against did:web:node.example".into(), + checks: vec![check_result()], + } + } + + // ── dpp-common ──────────────────────────────────────────────────────── + + pub fn problem() -> Problem { + Problem { + problem_type: "https://problems.example/validation-error".into(), + title: "Validation failed".into(), + status: 422, + detail: Some("productName must not be empty".into()), + instance: Some("/vault/api/v1/dpp".into()), + } + } + + pub fn scan_count() -> ScanCount { + ScanCount { + dpp_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + day: date(), + variant: ScanVariant::Html, + count: 42, + } + } + + pub fn qr_render_count() -> QrRenderCount { + QrRenderCount { + dpp_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + day: date(), + count: 7, + } + } + + pub fn scan_batch() -> ScanBatch { + ScanBatch { + scans: vec![scan_count()], + qr_renders: vec![qr_render_count()], + } + } + + pub fn all_scan_variants() -> Vec { + vec![ScanVariant::Html, ScanVariant::Json] + } + + pub fn dossier_record() -> EvidenceDossierRecord { + EvidenceDossierRecord { + id: uuid(), + passport_id: PassportId::new(), + actor: "admin@example.com".into(), + created_at: ts(), + doc_hash: "3".repeat(64), + dossier: dossier(), + } + } + + pub fn dossier_summary() -> EvidenceDossierSummary { + EvidenceDossierSummary { + id: uuid(), + passport_id: PassportId::new(), + actor: "admin@example.com".into(), + created_at: ts(), + doc_hash: "3".repeat(64), + } + } + + // ── dpp-vault: request and response bodies ──────────────────────────── + + pub fn create_request() -> CreateRequest { + CreateRequest { + product_name: "EcoCell Pro 48V".into(), + product_group: Some(ProductGroup::Textile), + manufacturer: manufacturer(), + materials: Some(vec![material()]), + co2e_per_unit: Some(45.2), + repairability_score: Some(7.5), + product_group_data: None, + batch_id: Some("LOT-2026-001".into()), + placed_on_market_date: Some(date()), + schema_version: Some("1.0.0".into()), + commodity_code: Some("85076000".into()), + parent_passport_ref: Some(passport_ref()), + component_refs: vec![passport_ref()], + } + } + + pub fn eol_request() -> EolRequest { + EolRequest { + reason: DeactivationReason::Recycled, + declared_by: Some("admin@example.com".into()), + material_recovery: Some(json!({ "cobaltKg": 1.2 })), + notes: Some("Sent to certified recycler".into()), + } + } + + /// `trust` is a flattened `serde_json::Value` on the response type, but the + /// value is produced by `NodeTrustReport::posture_json` — so it is built + /// here through that, not hand-written. A hand-written literal is only ever + /// a second guess at the shape: this fixture originally carried + /// `"trustMode": "full"`, a string, while the real posture emits an object + /// keyed by port. The type check caught it. + pub fn node_state() -> NodeState { + let report = NodeTrustReport::new( + NodeProfile::Production, + vec![ + TrustPort { + port: "seal", + mode: TrustMode::Ghost, + required: true, + }, + TrustPort { + port: "registry_sync", + mode: TrustMode::Sandbox, + required: false, + }, + ], + ); + NodeState { + bootstrapped: true, + operator_complete: true, + trust: Some(report.posture_json()), + ruleset_version: Some("2026.1.0".into()), + } + } + + pub fn vault_info() -> VaultInfo { + VaultInfo::current() + } + + pub fn transfer_initiate_request() -> TransferInitiateRequest { + TransferInitiateRequest { + from_operator: responsible_operator(), + to_operator: responsible_operator(), + reason: TransferReason::Sale, + notes: Some("Sold to distributor".into()), + } + } + + pub fn tree_node_report() -> NodeReport { + NodeReport { + path: vec!["root".into(), "cell".into()], + verified: false, + reason: Some(RefUnverifiable::HashMismatch), + } + } + + pub fn tree_report() -> TreeReport { + TreeReport { + verified: false, + nodes: vec![tree_node_report()], + } + } + + // ── dpp-identity ────────────────────────────────────────────────────── + + pub fn sign_request() -> SignRequest { + SignRequest { + operator_id: "standalone".into(), + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + payload: "eyJpZCI6IngifQ==".into(), + } + } + + pub fn sign_response() -> SignResponse { + SignResponse { + jws_signature: "eyJhbGciOiJFZERTQSJ9..hhh".into(), + } + } + + pub fn verify_request() -> VerifyRequest { + VerifyRequest { + operator_id: "standalone".into(), + jws: "eyJhbGciOiJFZERTQSJ9..hhh".into(), + payload: json!({ "id": "x" }), + } + } + + pub fn verify_response() -> VerifyResponse { + VerifyResponse { valid: true } + } + + pub fn rotate_request() -> RotateRequest { + RotateRequest { + operator_id: "standalone".into(), + } + } + + pub fn rotate_response() -> RotateResponse { + RotateResponse { + operator_id: "standalone".into(), + new_key_id: "did:web:node.example#key-1".into(), + fingerprint: "4".repeat(64), + rotated: true, + did_document: json!({ "id": "did:web:node.example" }), + } + } + + // ── dpp-integrator ──────────────────────────────────────────────────── + + // ── dpp-vault: read and telemetry responses ─────────────────────────── + + /// The smallest body `validate_create_request` accepts — only the two + /// genuinely required fields. Used to probe one constraint at a time + /// without another field's rule deciding the outcome. + /// + /// Deliberately *not* the maximal `create_request()` fixture: that one sets + /// every field, so a rejection could come from any of them and the probe + /// would be measuring the wrong rule. + pub fn minimal_create_request() -> CreateRequest { + CreateRequest { + product_name: "EcoCell Pro 48V".into(), + product_group: None, + manufacturer: manufacturer(), + materials: None, + co2e_per_unit: None, + repairability_score: None, + product_group_data: None, + batch_id: None, + placed_on_market_date: None, + schema_version: None, + commodity_code: None, + parent_passport_ref: None, + component_refs: Vec::new(), + } + } + + pub fn validate_response() -> ValidateResponse { + ValidateResponse { + create_valid: true, + product_group_data_valid: false, + detail: Some("no registered JSON Schema for product_group 'furniture'".into()), + } + } + + pub fn passport_list_response() -> PassportListResponse { + PassportListResponse { + dpps: vec![passport()], + total: 42, + limit: 20, + skip: 0, + } + } + + pub fn whoami_response() -> WhoamiResponse { + WhoamiResponse { + user_id: "ci-pipeline".into(), + scope: ApiKeyScope::Admin, + key_id: Some(uuid()), + } + } + + pub fn daily_scan_count() -> DailyScanCount { + DailyScanCount { + day: date(), + count: 12, + } + } + + pub fn passport_scan_stats() -> PassportScanStats { + PassportScanStats { + window_days: 30, + total_scans: 128, + scans_html: 96, + scans_json: 32, + daily: vec![daily_scan_count()], + qr_renders: 4, + } + } + + pub fn operator_scan_stats() -> OperatorScanStats { + OperatorScanStats { + window_days: 30, + total_scans: 1024, + total_qr_renders: 64, + distinct_passports_scanned: 12, + } + } + + pub fn seal_response() -> SealResponse { + SealResponse { + format: "CADES".into(), + seal_value: "MIIB...".into(), + sealed_at: ts(), + signing_cert_ref: Some("5".repeat(64)), + placeholder: false, + current_jws: "eyJhbGciOiJFZERTQSJ9..iii".into(), + current_payload_hash: "6".repeat(64), + sealed_payload_hash: Some("6".repeat(64)), + coverage: Coverage::Current, + // A `&'static str` constant on the response type; the fixture only + // needs a value of the right shape for the key set. + verification: "not validated by this node", + } + } + + pub fn seal_summary_response() -> SealSummaryResponse { + SealSummaryResponse { + unsealed_published: 0, + pending: 2, + sealed: 40, + exhausted: 0, + sealing_configured: true, + } + } + + pub fn all_coverages() -> Vec { + vec![Coverage::Current, Coverage::Superseded, Coverage::Unknown] + } + + pub fn installed_plugin() -> InstalledPlugin { + InstalledPlugin { + product_group: "battery".into(), + abi_version: "1.0".into(), + } + } + + pub fn webhook_subscription() -> WebhookSubscription { + WebhookSubscription { + id: uuid(), + url: "https://hooks.example.com/odal".into(), + events: vec!["dpp.passport.published".into()], + active: true, + description: Some("Production receiver".into()), + created_at: ts(), + updated_at: ts(), + } + } + + pub fn new_webhook_subscription() -> NewWebhookSubscription { + NewWebhookSubscription { + url: "https://hooks.example.com/odal".into(), + events: vec!["dpp.passport.published".into()], + description: Some("Production receiver".into()), + } + } + + pub fn created_webhook_response() -> CreatedWebhookResponse { + CreatedWebhookResponse { + subscription: webhook_subscription(), + secret: "whsec_abc123".into(), + } + } + + // ── dpp-vault: EU-registry state ────────────────────────────────────── + + pub fn registration_view() -> RegistrationView { + RegistrationView { + status: "submitted", + registry_id: Some("EUDPP-2026-0001".into()), + message: Some("accepted for processing".into()), + attempts: 2, + stalled: false, + status_intent: Some("deactivated"), + } + } + + pub fn transfer_view() -> TransferView { + TransferView { + transfer_id: uuid(), + status: "notified", + registry_id: Some("EUDPP-T-0001".into()), + message: Some("acknowledged".into()), + attempts: 1, + stalled: false, + } + } + + pub fn current_operator_view() -> CurrentOperatorView { + CurrentOperatorView { + did: "did:web:nordwerk.example".into(), + name: "Nordwerk GmbH".into(), + country: "DE".into(), + transfer_count: 1, + } + } + + pub fn passport_registry_view() -> PassportRegistryView { + PassportRegistryView { + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + configured: true, + registration: Some(registration_view()), + transfers: vec![transfer_view()], + current_operator: Some(current_operator_view()), + } + } + + pub fn verification_view() -> VerificationView { + VerificationView { + current: true, + verified_at: Some(ts()), + expires_at: Some(ts()), + days_remaining: Some(365), + } + } + + pub fn registration_counts() -> RegistrationCounts { + RegistrationCounts { + pending: 1, + submitted: 2, + registered: 3, + rejected: 0, + deactivated: 0, + status_intents: 1, + stalled: 0, + unregistered_published: 0, + } + } + + pub fn transfer_counts() -> TransferCounts { + TransferCounts { + pending: 1, + notified: 2, + rejected: 0, + stalled: 0, + } + } + + pub fn registry_rollup_view() -> RegistryRollupView { + RegistryRollupView { + configured: true, + verification: verification_view(), + registrations: Some(registration_counts()), + transfers: Some(transfer_counts()), + } + } + + pub fn import_updated_entry() -> UpdatedEntry { + UpdatedEntry { + row: 4, + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + } + } + + pub fn import_created_entry() -> CreatedEntry { + CreatedEntry { + row: 2, + passport_id: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f".into(), + status: "draft".into(), + } + } + + pub fn import_error_entry() -> ErrorEntry { + ErrorEntry { + row: 3, + field: "productName".into(), + message: "must not be empty".into(), + } + } + + pub fn import_sync_response() -> SyncImportResponse { + SyncImportResponse { + job_id: uuid().to_string(), + total_rows: 3, + success_count: 2, + error_count: 1, + created: vec![import_created_entry()], + updated: vec![import_updated_entry()], + errors: vec![import_error_entry()], + } + } + + pub fn import_async_response() -> AsyncImportResponse { + AsyncImportResponse { + job_id: uuid().to_string(), + status: "queued".into(), + total_rows: 500, + } + } + + pub fn job_status_response() -> JobStatusResponse { + JobStatusResponse { + job_id: uuid(), + status: "completed".into(), + progress: JobProgress { + processed: 3, + total: 3, + }, + result: json!({ "created": [] }), + report: json!({ "rows": [] }), + } + } +} diff --git a/crates/dpp-node/tests/registry_outbox.rs b/crates/dpp-node/tests/registry_outbox.rs index ff35a63d..bad542fe 100644 --- a/crates/dpp-node/tests/registry_outbox.rs +++ b/crates/dpp-node/tests/registry_outbox.rs @@ -37,7 +37,7 @@ use dpp_domain::{ DppError, domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_group::ProductGroup, status::PassportStatus, }, ports::{ @@ -68,7 +68,9 @@ fn draft_passport() -> Passport { id: PassportId::new(), batch_id: Some("LOT-OUTBOX-1".into()), product_name: "Outbox Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), @@ -79,7 +81,7 @@ fn draft_passport() -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Draft, qr_code_url: None, jws_signature: None, @@ -339,7 +341,7 @@ async fn insert_clobbered_row( ) -> uuid::Uuid { let id = uuid::Uuid::now_v7(); sqlx::query( - r#"INSERT INTO odal.passport (id, sector, status, schema_version, doc) + r#"INSERT INTO odal.passport (id, product_group, status, schema_version, doc) VALUES ($1, 'battery', 'suspended', '2.0.0', '{}'::jsonb)"#, ) .bind(id) diff --git a/crates/dpp-node/tests/s3_archive.rs b/crates/dpp-node/tests/s3_archive.rs index 2dc1862f..1f7a684d 100644 --- a/crates/dpp-node/tests/s3_archive.rs +++ b/crates/dpp-node/tests/s3_archive.rs @@ -14,7 +14,7 @@ use chrono::Utc; use dpp_domain::{ domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_group::ProductGroup, status::PassportStatus, }, ports::archive::ArchivePort, @@ -57,7 +57,9 @@ fn make_passport() -> Passport { id: PassportId::new(), batch_id: None, product_name: "Test Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "Test Co".into(), address: "Berlin, DE".into(), @@ -68,7 +70,7 @@ fn make_passport() -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Published, qr_code_url: None, jws_signature: Some("test.jws.sig".into()), diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 896025ba..6753f48e 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -44,7 +44,7 @@ use sha2::{Digest, Sha256}; use dpp_dal::pg::{PgAuditRepo, PgPassportRepo, PgSealOutboxRepo}; use dpp_dal::test_harness::start_pg; use dpp_domain::domain::passport::{ManufacturerInfo, Passport, PassportId}; -use dpp_domain::domain::sector::Sector; +use dpp_domain::domain::product_group::ProductGroup; use dpp_domain::domain::status::PassportStatus; use dpp_domain::ports::compliance::ComplianceRegistry; use dpp_domain::ports::passport_repo::PassportRepository; @@ -157,7 +157,9 @@ fn draft_passport() -> Passport { id: PassportId::new(), batch_id: Some("LOT-SEAL-SIM-1".into()), product_name: "Seal Simulation Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "Odal Simulation GmbH".into(), address: "Skopje, MK".into(), @@ -168,7 +170,7 @@ fn draft_passport() -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - // A battery passport cannot publish with no sector data at all — the + // A battery passport cannot publish with no product group data at all — the // mandatory-content gate refuses it before asking which fields are // missing. Portable is outside the Commission guidance's scope, so this // carries the schema minimum: the subject here is the seal outbox and @@ -178,9 +180,9 @@ fn draft_passport() -> Passport { // derives no `Default` on purpose — a new Annex field should break every // literal and force a decision — and this test has no opinion about any // of the sixty fields it would then have to name. - sector_data: Some( + product_group_data: Some( serde_json::from_value(serde_json::json!({ - "sector": "battery", + "productGroup": "battery", "gtin": "09506000134352", "batteryType": "portable", "batteryChemistry": "LFP", @@ -188,7 +190,7 @@ fn draft_passport() -> Passport { "nominalCapacityAh": 2.5, "co2ePerUnitKg": 1.8 })) - .expect("valid battery sector data"), + .expect("valid battery product_group data"), ), status: PassportStatus::Draft, qr_code_url: None, @@ -208,7 +210,7 @@ fn draft_passport() -> Passport { retention_until: None, product_id: None, commodity_code: None, - // Battery is an in-force sector, so publish refuses without the Annex III + // Battery is an in-force product group, so publish refuses without the Annex III // registry identity. Set here because the simulation has no operator // config to backfill from — the gate itself is correct and stays armed. operator_identifier: Some("LEI:529900T8BM49AURSDO55".into()), diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index e5d27833..2c485dc8 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -409,8 +409,8 @@ async fn route_inventory_matches_assembled_router() { "productName": "Route Inventory Battery", "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", "batteryType": "portable", @@ -588,8 +588,8 @@ async fn publish_battery( "productName": product_name, "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": gtin, "batteryChemistry": "LFP", "batteryType": "portable", @@ -1167,12 +1167,12 @@ async fn unauthenticated_request_increments_auth_failures_total() { ); } -/// Phase-3 metric (RT2-1 surface): an import with an unknown sector must increment -/// `import_rejections_total{reason="unknown_sector"}`. A valid multipart +/// Phase-3 metric (RT2-1 surface): an import with an unknown product group must increment +/// `import_rejections_total{reason="unknown_product_group"}`. A valid multipart /// content-type is sent so the `Multipart` extractor constructs; the handler's -/// sector check returns 404 before the body is read. +/// product group check returns 404 before the body is read. #[tokio::test(flavor = "multi_thread")] -async fn unknown_sector_import_increments_import_rejections_total() { +async fn unknown_product_group_import_increments_import_rejections_total() { let handle = prometheus_handle(); let (base, _container) = start_db_and_node().await; @@ -1192,7 +1192,7 @@ async fn unknown_sector_import_increments_import_rejections_total() { "import_rejections_total not found in Prometheus output:\n{output}" ); assert!( - output.contains(r#"reason="unknown_sector""#), - "import_rejections_total unknown_sector-reason not found:\n{output}" + output.contains(r#"reason="unknown_product_group""#), + "import_rejections_total unknown_product_group-reason not found:\n{output}" ); } diff --git a/crates/dpp-node/tests/snapshot_outbox.rs b/crates/dpp-node/tests/snapshot_outbox.rs index 5e35855d..d1df1a05 100644 --- a/crates/dpp-node/tests/snapshot_outbox.rs +++ b/crates/dpp-node/tests/snapshot_outbox.rs @@ -21,7 +21,7 @@ use dpp_domain::{ DppError, domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_group::ProductGroup, status::PassportStatus, }, ports::passport_repo::PassportRepository, @@ -189,7 +189,9 @@ fn passport(status: PassportStatus) -> Passport { id, batch_id: None, product_name: product_name.into(), - sector: Sector::Textile, + product_group: ProductGroup::Textile, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "Drain Test GmbH".into(), address: "Berlin, DE".into(), @@ -200,7 +202,7 @@ fn passport(status: PassportStatus) -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status, qr_code_url: None, jws_signature: Some("full.jws.signature".into()), diff --git a/crates/dpp-node/tests/transfer_outbox.rs b/crates/dpp-node/tests/transfer_outbox.rs index fc057517..10fd41d0 100644 --- a/crates/dpp-node/tests/transfer_outbox.rs +++ b/crates/dpp-node/tests/transfer_outbox.rs @@ -27,7 +27,7 @@ use dpp_dal::test_harness::start_pg; use dpp_domain::{ domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_group::ProductGroup, status::PassportStatus, transfer::{ OperatorRole, ResponsibleOperator, TransferChain, TransferReason, TransferRecord, @@ -44,7 +44,9 @@ fn published_passport() -> Passport { id: PassportId::new(), batch_id: None, product_name: "Transferred Battery".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), @@ -55,7 +57,7 @@ fn published_passport() -> Passport { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Published, qr_code_url: None, jws_signature: None, diff --git a/crates/dpp-plugin-host/benches/wasm_invoke.rs b/crates/dpp-plugin-host/benches/wasm_invoke.rs index 265b917f..4c9be226 100644 --- a/crates/dpp-plugin-host/benches/wasm_invoke.rs +++ b/crates/dpp-plugin-host/benches/wasm_invoke.rs @@ -76,28 +76,28 @@ fn wasm_benchmarks(c: &mut Criterion) { }); } -/// Build the real `sector-battery` plugin to wasm32-wasip1 and return its path. +/// Build the real `product-group-battery` plugin to wasm32-wasip1 and return its path. /// /// Returns `None` (after a warning) if the build fails — e.g. the wasm32-wasip1 /// target is not installed — so the WAT floor benches still run. Now that the /// host wires sandboxed WASI, this real plugin actually instantiates; the WAT /// benches measure the host round-trip floor, these measure battery logic. fn build_battery_wasm() -> Option { - // dpp-engine/crates/dpp-plugin-host → Odal-Node → dpp-core/plugins/sector-battery - let plugin_dir = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../dpp-core/plugins/sector-battery"); + // dpp-engine/crates/dpp-plugin-host → Odal-Node → dpp-core/plugins/product-group-battery + let plugin_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../dpp-core/plugins/product-group-battery"); match Command::new(env!("CARGO")) .current_dir(&plugin_dir) .args(["build", "--release", "--target", "wasm32-wasip1"]) .status() { Ok(s) if s.success() => { - let wasm = plugin_dir.join("target/wasm32-wasip1/release/sector_battery.wasm"); + let wasm = plugin_dir.join("target/wasm32-wasip1/release/product_group_battery.wasm"); wasm.is_file().then_some(wasm) } _ => { eprintln!( - "skipping battery_* benches: failed to build sector-battery.wasm \ + "skipping battery_* benches: failed to build product-group-battery.wasm \ (is the wasm32-wasip1 target installed? `rustup target add wasm32-wasip1`)" ); None @@ -105,7 +105,7 @@ fn build_battery_wasm() -> Option { } } -/// Benchmarks against the real `sector-battery` plugin (EU Battery Regulation +/// Benchmarks against the real `product-group-battery` plugin (EU Battery Regulation /// logic), so §1.3 can be quoted as a battery figure rather than the WAT floor. fn battery_benchmarks(c: &mut Criterion) { let Some(wasm) = build_battery_wasm() else { diff --git a/crates/dpp-plugin-host/src/host.rs b/crates/dpp-plugin-host/src/host.rs index f5bff1d5..672a539c 100644 --- a/crates/dpp-plugin-host/src/host.rs +++ b/crates/dpp-plugin-host/src/host.rs @@ -1,5 +1,5 @@ -//! `WasmPluginHost` — the `ComplianceRegistry` impl, per-sector dispatch, and -//! passthrough fallback when no plugin is loaded for a sector. +//! `WasmPluginHost` — the `ComplianceRegistry` impl, per-product group dispatch, and +//! passthrough fallback when no plugin is loaded for a product group. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -8,8 +8,8 @@ use std::sync::{Arc, RwLock}; use chrono::NaiveDate; use dpp_common::plugin_admin::{InstalledPlugin, PluginAdmin, PluginInstallError}; use dpp_domain::{ - PassthroughRegistry, SectorCatalog, - domain::sector::{Sector, SectorData}, + InstrumentCatalog, PassthroughRegistry, + domain::product_group::{ProductGroup, ProductGroupData}, ports::{ compliance::{ ComplianceError, ComplianceErrorKind, ComplianceFinding, ComplianceRegistry, @@ -37,16 +37,16 @@ struct InstallConfig { plugins_dir: PathBuf, } -/// Thread-safe registry of loaded Wasm sector plugins. +/// Thread-safe registry of loaded Wasm product group plugins. /// /// The node boots this at startup, scanning `/plugins/*.wasm`. /// Requests are dispatched here; fallback to `PassthroughRegistry` when -/// no plugin is loaded for the requested sector. +/// no plugin is loaded for the requested product group. pub struct WasmPluginHost { plugins: Arc>>>, /// Runtime install capability; `None` on a passthrough/test host. install: Option, - /// Serves any sector with no loaded plugin. + /// Serves any product group with no loaded plugin. /// /// This used to be a hard-coded `ComplianceResult::passthrough()`, which /// meant `PassthroughRegistry` — and therefore the whole `ComplianceStrategy` @@ -86,12 +86,12 @@ impl WasmPluginHost { } } - /// Replace the registry that serves sectors with no loaded plugin. + /// Replace the registry that serves product groups with no loaded plugin. /// - /// The per-sector `ComplianceStrategy` seam is reached through here: a build - /// that computes a real determination for one sector registers its strategy + /// The per-product group `ComplianceStrategy` seam is reached through here: a build + /// that computes a real determination for one product group registers its strategy /// on a `PassthroughRegistry` and hands the result to this, leaving every - /// other sector on the passthrough it already had. + /// other product group on the passthrough it already had. #[must_use] pub fn with_fallback(mut self, fallback: Arc) -> Self { self.fallback = fallback; @@ -99,28 +99,28 @@ impl WasmPluginHost { } /// Register a plugin that was loaded by `loader::load_plugin`. - pub fn register(&self, sector_key: String, plugin: LoadedPlugin) { + pub fn register(&self, product_group_key: String, plugin: LoadedPlugin) { self.plugins .write() .unwrap() - .insert(sector_key, Arc::new(plugin)); + .insert(product_group_key, Arc::new(plugin)); } - /// Fetch the plugin bound to `sector_key`, cloning its `Arc` out from under a + /// Fetch the plugin bound to `product_group_key`, cloning its `Arc` out from under a /// momentary read lock so the (potentially long) Wasm invocation runs without /// holding the registry lock. This is what lets a [`reload_plugin`] swap and /// in-flight invocations proceed concurrently. /// /// [`reload_plugin`]: Self::reload_plugin - pub fn get_plugin(&self, sector_key: &str) -> Option> { - self.plugins.read().unwrap().get(sector_key).cloned() + pub fn get_plugin(&self, product_group_key: &str) -> Option> { + self.plugins.read().unwrap().get(product_group_key).cloned() } - /// Atomically swap in a freshly loaded plugin, keyed on its own sector. + /// Atomically swap in a freshly loaded plugin, keyed on its own product group. /// /// The swap only affects invocations that *begin* after it returns; an /// invocation already running holds its own `Arc` to the previous instance - /// and completes normally (last-good continuity). Returns the sector key that + /// and completes normally (last-good continuity). Returns the product group key that /// was (re)bound. /// /// Callers build the replacement via [`LoadedPlugin::from_file`] first — @@ -128,12 +128,12 @@ impl WasmPluginHost { /// module by calling `describe()`. A rejected artifact errors there and never /// reaches this method, so the previously bound plugin keeps serving. pub fn reload_plugin(&self, plugin: LoadedPlugin) -> String { - let sector_key = plugin.sector_key.clone(); + let product_group_key = plugin.product_group_key.clone(); self.plugins .write() .unwrap() - .insert(sector_key.clone(), Arc::new(plugin)); - sector_key + .insert(product_group_key.clone(), Arc::new(plugin)); + product_group_key } /// Verify a signed artifact, persist it, and hot-swap it into service — @@ -147,7 +147,7 @@ impl WasmPluginHost { /// previously installed plugin keeps serving. pub fn install_plugin( &self, - sector: &str, + product_group: &str, artifact: Vec, sig: Vec, precompiled: bool, @@ -157,17 +157,17 @@ impl WasmPluginHost { .as_ref() .ok_or(PluginInstallError::NotSupported)?; - // Guard the sector key before it becomes a filename: it is interpolated - // verbatim into `sector-{sector}.wasm`, so an admin-supplied value like + // Guard the product group key before it becomes a filename: it is interpolated + // verbatim into `product-group-{product group}.wasm`, so an admin-supplied value like // `../evil` would escape the plugins directory (a path-traversal write). - // Sector catalog keys are lowercase kebab-case; reject anything else. - if sector.is_empty() - || !sector + // ProductGroup catalog keys are lowercase kebab-case; reject anything else. + if product_group.is_empty() + || !product_group .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { return Err(PluginInstallError::Rejected(format!( - "invalid sector key '{sector}' — expected lowercase letters, digits, and '-'" + "invalid product_group key '{product_group}' — expected lowercase letters, digits, and '-'" ))); } @@ -178,7 +178,7 @@ impl WasmPluginHost { // A precompiled artifact is persisted as `.cwasm` so `discover_plugins` // and `from_file` treat it as AOT (deserialize) rather than compile. let ext = if precompiled { "cwasm" } else { "wasm" }; - let file_stem = format!("sector-{sector}"); + let file_stem = format!("product-group-{product_group}"); // Stage inside the plugins dir so the final promotion is a same-filesystem // rename. `verify_plugin_signature` derives the `.sig` path by appending @@ -195,7 +195,7 @@ impl WasmPluginHost { let loaded = LoadedPlugin::from_file( &cfg.engine, &staged_artifact, - sector, + product_group, cfg.trusted_key.as_ref(), ) .map_err(|e| { @@ -217,20 +217,20 @@ impl WasmPluginHost { } let _ = std::fs::remove_dir_all(&staging); - // Retire any stale opposite-format artifact for this sector so a restart - // discovers exactly one file per sector (never a `.wasm` and a `.cwasm`). + // Retire any stale opposite-format artifact for this product group so a restart + // discovers exactly one file per product group (never a `.wasm` and a `.cwasm`). let stale_ext = if precompiled { "wasm" } else { "cwasm" }; let _ = std::fs::remove_file(cfg.plugins_dir.join(format!("{file_stem}.{stale_ext}"))); let _ = std::fs::remove_file(cfg.plugins_dir.join(format!("{file_stem}.{stale_ext}.sig"))); let abi = loaded.capabilities.abi_version; let report = InstalledPlugin { - sector: sector.to_owned(), + product_group: product_group.to_owned(), abi_version: format!("{}.{}", abi.major, abi.minor), }; self.reload_plugin(loaded); tracing::info!( - sector = %report.sector, + product_group = %report.product_group, abi = %report.abi_version, precompiled, "Wasm plugin installed and hot-swapped" @@ -250,13 +250,13 @@ impl WasmPluginHost { /// The returned value is structurally validated to be a non-null JSON object. pub fn generate_passport_payload( &self, - sector: &Sector, - data: &SectorData, + product_group: &ProductGroup, + data: &ProductGroupData, ) -> Result { - let key = sector.catalog_key(); + let key = product_group.catalog_key(); let plugin = self.get_plugin(key).ok_or_else(|| ComplianceError { - kind: ComplianceErrorKind::UnknownSector, - message: format!("no Wasm plugin loaded for sector '{key}'"), + kind: ComplianceErrorKind::UnknownProductGroup, + message: format!("no Wasm plugin loaded for product_group '{key}'"), })?; let input = enrich_input( @@ -291,10 +291,10 @@ impl WasmPluginHost { } // Host-side backstop mirroring compute()'s determination gate: a - // provisional (not-in-force) sector can never surface a binding + // provisional (not-in-force) product group can never surface a binding // compliance claim, even if the plugin ignores the advisory __isInForce // flag and injects one into its generated output. - if !catalog().is_in_force(key) + if !passport_determinable(key) && let Some(obj) = payload.as_object_mut() { obj.remove("complianceStatus"); @@ -314,12 +314,12 @@ impl Default for WasmPluginHost { impl PluginAdmin for WasmPluginHost { fn install( &self, - sector: &str, + product_group: &str, artifact: Vec, sig: Vec, precompiled: bool, ) -> Result { - self.install_plugin(sector, artifact, sig, precompiled) + self.install_plugin(product_group, artifact, sig, precompiled) } } @@ -338,20 +338,20 @@ fn staging_dir_name() -> String { } impl PluginHost for WasmPluginHost { - fn has_plugin(&self, sector_key: &str) -> bool { - self.plugins.read().unwrap().contains_key(sector_key) + fn has_plugin(&self, product_group_key: &str) -> bool { + self.plugins.read().unwrap().contains_key(product_group_key) } - #[tracing::instrument(skip(self, data), fields(sector = %sector_key))] + #[tracing::instrument(skip(self, data), fields(product_group = %product_group_key))] fn compute( &self, - sector_key: &str, - data: &SectorData, + product_group_key: &str, + data: &ProductGroupData, ) -> Result { - let key = sector_key; + let key = product_group_key; let plugin = self.get_plugin(key).ok_or_else(|| ComplianceError { - kind: ComplianceErrorKind::UnknownSector, - message: format!("no Wasm plugin loaded for sector '{key}'"), + kind: ComplianceErrorKind::UnknownProductGroup, + message: format!("no Wasm plugin loaded for product_group '{key}'"), })?; let input = enrich_input( @@ -374,30 +374,30 @@ impl PluginHost for WasmPluginHost { if msg.starts_with("fuel exhausted") { metrics::counter!( "plugin_fuel_exhausted_total", - "sector" => key.to_owned() + "productGroup" => key.to_owned() ) .increment(1); tracing::warn!( code = dpp_common::event_codes::PLUGIN_FUEL_EXHAUSTED, - sector = %key, + product_group = %key, "Wasm plugin exhausted fuel budget" ); } if msg.starts_with("memory cap exceeded") { metrics::counter!( "plugin_mem_capped_total", - "sector" => key.to_owned() + "productGroup" => key.to_owned() ) .increment(1); tracing::warn!( code = dpp_common::event_codes::PLUGIN_MEM_CAPPED, - sector = %key, + product_group = %key, "Wasm plugin hit memory cap" ); } metrics::counter!( "plugin_invocations_total", - "sector" => key.to_owned(), + "productGroup" => key.to_owned(), "outcome" => "error" ) .increment(1); @@ -408,14 +408,14 @@ impl PluginHost for WasmPluginHost { } }; - // Enforce regulatory status centrally: a provisional sector can never + // Enforce regulatory status centrally: a provisional product group can never // surface a binding determination, regardless of what the plugin returns. result.compliance_status = - gate_determination(catalog().is_in_force(key), result.compliance_status); + gate_determination(passport_determinable(key), result.compliance_status); metrics::counter!( "plugin_invocations_total", - "sector" => key.to_owned(), + "productGroup" => key.to_owned(), "outcome" => "ok" ) .increment(1); @@ -426,45 +426,46 @@ impl PluginHost for WasmPluginHost { /// `ComplianceRegistry` impl allows wiring `WasmPluginHost` directly into `PassportService`. /// -/// When a plugin is loaded for the sector, it is invoked. Otherwise the sector is +/// When a plugin is loaded for the product group, it is invoked. Otherwise the product group is /// served by the fallback registry — `PassthroughRegistry` by default, which -/// routes through that sector's `ComplianceStrategy` if one is registered and +/// routes through that product group's `ComplianceStrategy` if one is registered and /// otherwise returns a bare passthrough. Either way the *status* is /// `PassthroughNoValidation`; the difference is that a strategy lifts the -/// sector's declared metrics into the result's sector-agnostic fields, which a +/// product group's declared metrics into the result's product group-agnostic fields, which a /// hard-coded passthrough cannot do because it never sees the payload. impl ComplianceRegistry for WasmPluginHost { fn compute( &self, - sector_key: &str, - data: &SectorData, + product_group_key: &str, + data: &ProductGroupData, law_in_force_on: Option, ) -> Result { - if self.has_plugin(sector_key) { + if self.has_plugin(product_group_key) { // The plugin path does not take the date as an argument: a plugin - // receives the sector payload as JSON and reads + // receives the product group payload as JSON and reads // `placedOnMarketDate` from it directly, because that is the only // channel the Wasm ABI has. The date is passed on the host path, // where the caller holds the passport envelope and the guest does // not. - PluginHost::compute(self, sector_key, data) + PluginHost::compute(self, product_group_key, data) } else { - self.fallback.compute(sector_key, data, law_in_force_on) + self.fallback + .compute(product_group_key, data, law_in_force_on) } } } /// Inject host-side metadata into the plugin input before dispatch. /// -/// `__isInForce` tells the plugin whether the sector regulation is currently +/// `__isInForce` tells the plugin whether the product group regulation is currently /// active (in-force) so it can apply strict thresholds vs. provisional behaviour. /// The key uses camelCase to match the rest of the JSON field convention. -pub(crate) fn enrich_input(input: Value, sector_key: &str) -> Value { +pub(crate) fn enrich_input(input: Value, product_group_key: &str) -> Value { match input { Value::Object(mut m) => { m.insert( "__isInForce".into(), - catalog().is_in_force(sector_key).into(), + passport_determinable(product_group_key).into(), ); Value::Object(m) } @@ -472,10 +473,26 @@ pub(crate) fn enrich_input(input: Value, sector_key: &str) -> Value { } } -/// Process-wide sector catalog (manifests parsed once) for status gating. -fn catalog() -> &'static SectorCatalog { - static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); - CATALOG.get_or_init(SectorCatalog::new) +/// Process-wide instrument catalog (manifests parsed once) for status gating. +fn instruments() -> &'static InstrumentCatalog { + static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); + CATALOG.get_or_init(InstrumentCatalog::new) +} + +/// Whether any in-force act reaching `product_group` requires a passport, which +/// is what gates a *binding passport determination*. +/// +/// Both halves are load-bearing and neither implies the other. An act can bind a +/// product group today while imposing no passport at all — ESPR Arts. 24-25 — and +/// an act can bind while its passport duty is discharged through another system +/// under Art. 9(4)(b), which is the position of the ecodesign and energy +/// labelling pair for mobile devices. Gating on "is it in force" alone is exactly +/// how a binding claim came to be asserted against an obligation that does not +/// exist, for a product group that is in force and owes no passport. +fn passport_determinable(product_group: &str) -> bool { + let catalog = instruments(); + !catalog.determinable_for(product_group).is_empty() + && catalog.passport_required_for(product_group) } /// Convert a `PluginResult` into a `ComplianceResult` for the core compliance port. diff --git a/crates/dpp-plugin-host/src/lib.rs b/crates/dpp-plugin-host/src/lib.rs index acebd90a..808f0c62 100644 --- a/crates/dpp-plugin-host/src/lib.rs +++ b/crates/dpp-plugin-host/src/lib.rs @@ -1,8 +1,8 @@ -//! Wasmtime-based Wasm plugin host — loads sector plugins and dispatches compliance work. +//! Wasmtime-based Wasm plugin host — loads product group plugins and dispatches compliance work. //! -//! `WasmPluginHost` implements both `PluginHost` (for dispatch-by-sector) and +//! `WasmPluginHost` implements both `PluginHost` (for dispatch-by-product group) and //! `ComplianceRegistry` (the port wired into `PassportService`). When no plugin -//! is loaded for a sector it falls back to `PassthroughRegistry` behaviour. +//! is loaded for a product group it falls back to `PassthroughRegistry` behaviour. //! //! # Sandbox guarantees //! diff --git a/crates/dpp-plugin-host/src/loader/discover.rs b/crates/dpp-plugin-host/src/loader/discover.rs index 6785e140..c1296c6c 100644 --- a/crates/dpp-plugin-host/src/loader/discover.rs +++ b/crates/dpp-plugin-host/src/loader/discover.rs @@ -1,14 +1,14 @@ -//! Plugin discovery — find `.wasm` files and derive their sector key. +//! Plugin discovery — find `.wasm` files and derive their product group key. use std::path::Path; use anyhow::Result; /// Discover all `.wasm` and precompiled `.cwasm` files in `plugins_dir` and -/// return (sector_key, path) pairs. +/// return (product_group_key, path) pairs. /// -/// The sector key is the file stem, e.g. `sector-textile.wasm` → `"textile"` -/// (and likewise `sector-battery.cwasm` → `"battery"`). +/// The product group key is the file stem, e.g. `product-group-textile.wasm` → `"textile"` +/// (and likewise `product-group-battery.cwasm` → `"battery"`). pub fn discover_plugins(plugins_dir: &Path) -> Result> { let mut found = Vec::new(); if !plugins_dir.exists() { @@ -24,7 +24,7 @@ pub fn discover_plugins(plugins_dir: &Path) -> Result anyhow::Error { } } -/// A compiled, in-memory sector plugin ready to be instantiated per-request. +/// A compiled, in-memory product group plugin ready to be instantiated per-request. pub struct LoadedPlugin { engine: Engine, module: Module, @@ -49,7 +49,7 @@ pub struct LoadedPlugin { /// dominant per-invocation cost. `instantiate` takes `&self`, so one /// linker safely serves any number of concurrent instantiations. linker: Linker, - pub sector_key: String, + pub product_group_key: String, /// Capability declaration cached from `describe()` at load time. /// Used to configure per-invocation resource limits without an extra /// Wasm round-trip. @@ -69,7 +69,7 @@ impl LoadedPlugin { pub fn from_file( engine: &Engine, path: &Path, - sector_key: &str, + product_group_key: &str, trusted_key: Option<&VerifyingKey>, ) -> Result { if let Some(key) = trusted_key { @@ -77,7 +77,7 @@ impl LoadedPlugin { tracing::warn!( code = event_codes::PLUGIN_REFUSED, path = %path.display(), - sector = sector_key, + product_group = product_group_key, error = %e, "Wasm plugin refused — signature verification failed" ); @@ -134,7 +134,7 @@ impl LoadedPlugin { } let module = if is_precompiled { - tracing::info!(path = %path.display(), sector = sector_key, "loading precompiled plugin (.cwasm)"); + tracing::info!(path = %path.display(), product_group = product_group_key, "loading precompiled plugin (.cwasm)"); // Read the bytes and deserialize from memory rather than // `deserialize_file` (which mmaps and would keep the file open — on // Windows that blocks the install's promote-by-rename step). @@ -155,7 +155,7 @@ impl LoadedPlugin { })? } } else { - tracing::info!(path = %path.display(), sector = sector_key, "compiling Wasm plugin"); + tracing::info!(path = %path.display(), product_group = product_group_key, "compiling Wasm plugin"); Module::from_file(engine, path) .map_err(|e| anyhow::anyhow!("failed to compile {}: {e}", path.display()))? }; @@ -178,7 +178,7 @@ impl LoadedPlugin { Ok(caps) => caps, Err(e) => { tracing::warn!( - sector = sector_key, + product_group = product_group_key, error = %e, "plugin missing describe() — using host defaults for resource limits" ); @@ -195,7 +195,7 @@ impl LoadedPlugin { }; tracing::debug!( - sector = sector_key, + product_group = product_group_key, abi_major = capabilities.abi_version.major, abi_minor = capabilities.abi_version.minor, "plugin describe() cached" @@ -214,12 +214,12 @@ impl LoadedPlugin { tracing::warn!( code = event_codes::PLUGIN_REFUSED, path = %path.display(), - sector = sector_key, + product_group = product_group_key, report = ?compat, "Wasm plugin refused — ABI incompatible with host" ); return Err(anyhow::anyhow!( - "plugin '{sector_key}' refused — ABI incompatible with host: {compat:?}" + "plugin '{product_group_key}' refused — ABI incompatible with host: {compat:?}" )); } @@ -227,7 +227,7 @@ impl LoadedPlugin { engine: engine.clone(), module, linker, - sector_key: sector_key.to_owned(), + product_group_key: product_group_key.to_owned(), capabilities, }) } @@ -572,10 +572,10 @@ mod tests { /// Load a plugin without a trusted key — the development path. Opts into /// unsigned loading explicitly, mirroring what a real dev/CI deploy must do. - fn load_unsigned(engine: &Engine, path: &Path, sector: &str) -> Result { + fn load_unsigned(engine: &Engine, path: &Path, product_group: &str) -> Result { // Safe: every caller sets the same value, so concurrent sets are benign. unsafe { std::env::set_var("ALLOW_UNSIGNED_PLUGINS", "true") }; - LoadedPlugin::from_file(engine, path, sector, None) + LoadedPlugin::from_file(engine, path, product_group, None) } #[test] diff --git a/crates/dpp-plugin-host/src/runtime.rs b/crates/dpp-plugin-host/src/runtime.rs index 1cbb9752..71ef6c52 100644 --- a/crates/dpp-plugin-host/src/runtime.rs +++ b/crates/dpp-plugin-host/src/runtime.rs @@ -11,7 +11,7 @@ pub const DEFAULT_FUEL: u64 = 10_000_000; /// Default memory cap per plugin instance (64 MiB). pub const DEFAULT_MEMORY_CAP_BYTES: usize = 64 * 1024 * 1024; -/// Build a sandboxed wasmtime `Engine` for loading sector plugins. +/// Build a sandboxed wasmtime `Engine` for loading product group plugins. /// /// - Cranelift ahead-of-time compilation for predictable latency. /// - Fuel metering enabled — guests are killed after `DEFAULT_FUEL` instructions. @@ -71,7 +71,7 @@ impl ResourceLimiter for HostState { // with a huge element count forces a large host-side allocation that // fuel counts as only one instruction — a resource-limit bypass distinct // from the (capped) linear-memory path. Ok(false) makes `table.grow` - // return -1 (the Wasm denial signal). Sector plugins use tiny indirect + // return -1 (the Wasm denial signal). ProductGroup plugins use tiny indirect // tables, so this ceiling is generous. Ok(desired <= MAX_TABLE_ELEMENTS) } diff --git a/crates/dpp-plugin-host/src/tests.rs b/crates/dpp-plugin-host/src/tests.rs index 63afce0b..1772d6c8 100644 --- a/crates/dpp-plugin-host/src/tests.rs +++ b/crates/dpp-plugin-host/src/tests.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::sync::Arc; use dpp_domain::{ - domain::sector::{FibreEntry, Sector, SectorData, TextileData}, + domain::product_group::{FibreEntry, ProductGroup, ProductGroupData, TextileData}, ports::compliance::{ComplianceRegistry, ComplianceStatus}, }; use dpp_plugin_traits::{ @@ -16,7 +16,7 @@ use crate::loader::LoadedPlugin; use crate::runtime::build_engine; use crate::{WasmPluginHost, plugin_result_to_compliance}; -// Sector → catalog key mapping is `Sector::catalog_key()` in dpp-core (tested there). +// ProductGroup → catalog key mapping is `ProductGroup::catalog_key()` in dpp-core (tested there). // ── plugin_result_to_compliance() ──────────────────────────────────────── @@ -94,18 +94,18 @@ fn empty_host_has_no_plugins() { } #[test] -fn empty_host_reports_no_sector_plugin() { +fn empty_host_reports_no_product_group_plugin() { use dpp_domain::ports::plugin_host_port::PluginHost; let host = WasmPluginHost::new(); - assert!(!host.has_plugin(Sector::Battery.catalog_key())); - assert!(!host.has_plugin(Sector::Textile.catalog_key())); - assert!(!host.has_plugin(Sector::Steel.catalog_key())); + assert!(!host.has_plugin(ProductGroup::Battery.catalog_key())); + assert!(!host.has_plugin(ProductGroup::Textile.catalog_key())); + assert!(!host.has_plugin(ProductGroup::Steel.catalog_key())); } #[test] fn empty_host_compliance_returns_passthrough() { let host = WasmPluginHost::new(); - let data = SectorData::Textile(Box::new(TextileData { + let data = ProductGroupData::Textile(Box::new(TextileData { gtin: dpp_domain::Gtin::parse("09506000134352").unwrap(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), @@ -138,7 +138,8 @@ fn empty_host_compliance_returns_passthrough() { pef_score: None, })); let result = - ComplianceRegistry::compute(&host, Sector::Textile.catalog_key(), &data, None).unwrap(); + ComplianceRegistry::compute(&host, ProductGroup::Textile.catalog_key(), &data, None) + .unwrap(); assert_eq!( result.compliance_status, ComplianceStatus::PassthroughNoValidation @@ -152,11 +153,11 @@ fn default_trait_creates_empty_host() { assert!(!host.has_any_plugin()); } -/// A sector with no loaded plugin is served by the fallback registry, not by a +/// A product group with no loaded plugin is served by the fallback registry, not by a /// passthrough result the host invents. /// /// The host used to return `ComplianceResult::passthrough()` inline for this -/// case, so `PassthroughRegistry` — and the whole per-sector `ComplianceStrategy` +/// case, so `PassthroughRegistry` — and the whole per-product group `ComplianceStrategy` /// seam it dispatches through — never ran in the node, whatever `dpp-domain` /// documented. /// @@ -164,7 +165,7 @@ fn default_trait_creates_empty_host() { /// precedence over the fallback — needs a real module and is /// `register_plugin_and_compute_via_host` in the integration tier. #[test] -fn a_sector_without_a_plugin_is_served_by_the_fallback_registry() { +fn a_product_group_without_a_plugin_is_served_by_the_fallback_registry() { use dpp_domain::ports::compliance::{ComplianceError, ComplianceResult}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -172,8 +173,8 @@ fn a_sector_without_a_plugin_is_served_by_the_fallback_registry() { impl ComplianceRegistry for CountingRegistry { fn compute( &self, - _sector_key: &str, - _data: &SectorData, + _product_group_key: &str, + _data: &ProductGroupData, _law_in_force_on: Option, ) -> Result { self.0.fetch_add(1, Ordering::SeqCst); @@ -187,10 +188,10 @@ fn a_sector_without_a_plugin_is_served_by_the_fallback_registry() { let calls = Arc::new(AtomicUsize::new(0)); let host = WasmPluginHost::new().with_fallback(Arc::new(CountingRegistry(Arc::clone(&calls)))); - let data = SectorData::other(serde_json::json!({ "sector": "quantum-widget" })) - .expect("an unmodelled sector tag builds SectorData::Other"); + let data = ProductGroupData::other(serde_json::json!({ "productGroup": "quantum-widget" })) + .expect("an unmodelled product_group tag builds ProductGroupData::Other"); let result = ComplianceRegistry::compute(&host, "quantum-widget", &data, None) - .expect("fallback must serve the sector"); + .expect("fallback must serve the product_group"); assert_eq!( calls.load(Ordering::SeqCst), @@ -227,8 +228,8 @@ fn discover_returns_empty_for_empty_dir() { fn discover_finds_wasm_files() { let tmp = std::env::temp_dir().join(format!("odal-test-plugins-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&tmp).unwrap(); - std::fs::write(tmp.join("sector-textile.wasm"), b"fake").unwrap(); - std::fs::write(tmp.join("sector-battery.wasm"), b"fake").unwrap(); + std::fs::write(tmp.join("product-group-textile.wasm"), b"fake").unwrap(); + std::fs::write(tmp.join("product-group-battery.wasm"), b"fake").unwrap(); std::fs::write(tmp.join("readme.txt"), b"not a plugin").unwrap(); let result = crate::loader::discover_plugins(&tmp).unwrap(); @@ -242,10 +243,10 @@ fn discover_finds_wasm_files() { } #[test] -fn discover_strips_sector_prefix() { +fn discover_strips_product_group_prefix() { let tmp = std::env::temp_dir().join(format!("odal-test-prefix-{}", uuid::Uuid::now_v7())); std::fs::create_dir_all(&tmp).unwrap(); - std::fs::write(tmp.join("sector-steel.wasm"), b"fake").unwrap(); + std::fs::write(tmp.join("product-group-steel.wasm"), b"fake").unwrap(); let result = crate::loader::discover_plugins(&tmp).unwrap(); assert_eq!(result[0].0, "steel"); @@ -276,9 +277,9 @@ fn enrich_input_non_object_passes_through() { // ── generate_passport_payload() ────────────────────────────────────────── #[test] -fn generate_passport_payload_no_plugin_returns_unknown_sector() { +fn generate_passport_payload_no_plugin_returns_unknown_product_group() { let host = WasmPluginHost::new(); - let data = SectorData::Textile(Box::new(TextileData { + let data = ProductGroupData::Textile(Box::new(TextileData { gtin: dpp_domain::Gtin::parse("09506000134352").unwrap(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), @@ -310,12 +311,12 @@ fn generate_passport_payload_no_plugin_returns_unknown_sector() { repair_count: None, pef_score: None, })); - let result = host.generate_passport_payload(&Sector::Textile, &data); + let result = host.generate_passport_payload(&ProductGroup::Textile, &data); assert!(result.is_err()); let err = result.unwrap_err(); assert_eq!( err.kind, - dpp_domain::ports::compliance::ComplianceErrorKind::UnknownSector + dpp_domain::ports::compliance::ComplianceErrorKind::UnknownProductGroup ); } @@ -530,7 +531,7 @@ fn a_new_store_gets_a_new_instant() { // ── hot-reload: atomic swap under load + last-good on rejection ──────────── -/// Build a minimal sector plugin whose `describe()` advertises the given ABI +/// Build a minimal product group plugin whose `describe()` advertises the given ABI /// version and whose `calculate_metrics` returns a fixed `co2e_score` (so two /// builds are behaviourally distinguishable). Input is ignored — the exports /// return pointers into canned `data` segments, mirroring the loader-test @@ -585,7 +586,7 @@ fn hot_swap_under_load_never_drops_a_request() { let engine = build_engine().unwrap(); let dir = tempfile::TempDir::new().unwrap(); - let v1_path = write_wasm(&dir, "sector-battery.wasm", &plugin_wasm(1, 0, 1.5)); + let v1_path = write_wasm(&dir, "product-group-battery.wasm", &plugin_wasm(1, 0, 1.5)); let host = Arc::new(WasmPluginHost::new()); host.register("battery".into(), load_dev(&engine, &v1_path)); @@ -636,7 +637,11 @@ fn hot_swap_under_load_never_drops_a_request() { { std::thread::yield_now(); } - let v2_path = write_wasm(&dir, "sector-battery-v2.wasm", &plugin_wasm(1, 0, 2.5)); + let v2_path = write_wasm( + &dir, + "product-group-battery-v2.wasm", + &plugin_wasm(1, 0, 2.5), + ); host.reload_plugin(load_dev(&engine, &v2_path)); for h in handles { @@ -678,13 +683,17 @@ fn hot_swap_under_load_never_drops_a_request() { fn rejected_reload_leaves_previous_plugin_serving() { let engine = build_engine().unwrap(); let dir = tempfile::TempDir::new().unwrap(); - let v1_path = write_wasm(&dir, "sector-battery.wasm", &plugin_wasm(1, 0, 1.5)); + let v1_path = write_wasm(&dir, "product-group-battery.wasm", &plugin_wasm(1, 0, 1.5)); let host = WasmPluginHost::new(); host.register("battery".into(), load_dev(&engine, &v1_path)); // A replacement declaring a future major ABI: the load gate refuses it. - let bad_path = write_wasm(&dir, "sector-battery-bad.wasm", &plugin_wasm(2, 0, 9.9)); + let bad_path = write_wasm( + &dir, + "product-group-battery-bad.wasm", + &plugin_wasm(2, 0, 9.9), + ); unsafe { std::env::set_var("ALLOW_UNSIGNED_PLUGINS", "true") }; let rejected = LoadedPlugin::from_file(&engine, &bad_path, "battery", None); assert!( @@ -724,9 +733,9 @@ fn reboot( key: &ed25519_dalek::VerifyingKey, ) -> WasmPluginHost { let host = WasmPluginHost::new(); - for (sector, path) in crate::loader::discover_plugins(dir).unwrap() { - let plugin = LoadedPlugin::from_file(engine, &path, §or, Some(key)).unwrap(); - host.register(sector, plugin); + for (product_group, path) in crate::loader::discover_plugins(dir).unwrap() { + let plugin = LoadedPlugin::from_file(engine, &path, &product_group, Some(key)).unwrap(); + host.register(product_group, plugin); } host } @@ -747,7 +756,7 @@ fn install_verifies_persists_and_serves() { let report = host .install("battery", wasm, sig, false) .expect("a correctly signed plugin must install"); - assert_eq!(report.sector, "battery"); + assert_eq!(report.product_group, "battery"); assert_eq!(report.abi_version, "1.0"); // Serving. @@ -761,8 +770,8 @@ fn install_verifies_persists_and_serves() { assert!((score - 1.5).abs() < 1e-9); // Persisted so a restart re-loads it, and no staging dir left behind. - assert!(dir.path().join("sector-battery.wasm").exists()); - assert!(dir.path().join("sector-battery.wasm.sig").exists()); + assert!(dir.path().join("product-group-battery.wasm").exists()); + assert!(dir.path().join("product-group-battery.wasm.sig").exists()); let leftover: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .filter_map(|e| e.ok()) @@ -865,10 +874,10 @@ fn install_not_supported_on_passthrough_host() { ); } -/// A crafted sector key must not escape the plugins directory (path traversal), +/// A crafted product group key must not escape the plugins directory (path traversal), /// even from an admin caller — it is rejected before any file is written. #[test] -fn install_rejects_path_traversal_sector() { +fn install_rejects_path_traversal_product_group() { let engine = build_engine().unwrap(); let dir = tempfile::TempDir::new().unwrap(); let signer = ed25519_dalek::SigningKey::from_bytes(&[19u8; 32]); @@ -880,19 +889,26 @@ fn install_rejects_path_traversal_sector() { let wasm = plugin_wasm(1, 0, 1.5); let sig = sign_wasm(&signer, &wasm); - for bad in ["../../evil", "a/b", "a\\b", "Battery", "sector.evil", ""] { + for bad in [ + "../../evil", + "a/b", + "a\\b", + "Battery", + "product_group.evil", + "", + ] { let err = host .install(bad, wasm.clone(), sig.clone(), false) .unwrap_err(); assert!( matches!(err, PluginInstallError::Rejected(_)), - "sector '{bad}' must be rejected, got: {err}" + "product_group '{bad}' must be rejected, got: {err}" ); } // No file was created anywhere in (or escaping) the plugins dir. assert!( std::fs::read_dir(dir.path()).unwrap().next().is_none(), - "a rejected sector must not create any file" + "a rejected product_group must not create any file" ); } @@ -921,7 +937,7 @@ fn install_precompiled_cwasm_serves_and_persists() { let report = host .install("battery", cwasm, sig, true) .expect("a signed, engine-compatible .cwasm must install"); - assert_eq!(report.sector, "battery"); + assert_eq!(report.product_group, "battery"); let score = host .get_plugin("battery") @@ -933,9 +949,9 @@ fn install_precompiled_cwasm_serves_and_persists() { assert!((score - 3.5).abs() < 1e-9); // Persisted as `.cwasm` (not `.wasm`). - assert!(dir.path().join("sector-battery.cwasm").exists()); - assert!(dir.path().join("sector-battery.cwasm.sig").exists()); - assert!(!dir.path().join("sector-battery.wasm").exists()); + assert!(dir.path().join("product-group-battery.cwasm").exists()); + assert!(dir.path().join("product-group-battery.cwasm.sig").exists()); + assert!(!dir.path().join("product-group-battery.wasm").exists()); } /// Restart convergence holds for AOT too: a fresh host deserializes the @@ -991,5 +1007,5 @@ fn install_rejects_incompatible_precompiled_artifact() { // Nothing was installed or persisted. assert!(host.get_plugin("battery").is_none()); - assert!(!dir.path().join("sector-battery.cwasm").exists()); + assert!(!dir.path().join("product-group-battery.cwasm").exists()); } diff --git a/crates/dpp-plugin-host/tests/fixtures/abi-echo/src/lib.rs b/crates/dpp-plugin-host/tests/fixtures/abi-echo/src/lib.rs index fbcecdb6..f7e4977c 100644 --- a/crates/dpp-plugin-host/tests/fixtures/abi-echo/src/lib.rs +++ b/crates/dpp-plugin-host/tests/fixtures/abi-echo/src/lib.rs @@ -9,7 +9,7 @@ use dpp_plugin_sdk::export_plugin; use dpp_plugin_sdk::traits::{ - AbiVersion, DppSectorPlugin, METRIC_CO2E_SCORE, PluginCapabilities, PluginCapability, + AbiVersion, DppProductGroupPlugin, METRIC_CO2E_SCORE, PluginCapabilities, PluginCapability, PluginComplianceStatus, PluginError, PluginInput, PluginMeta, PluginResult, SchemaVersionRange, }; use serde_json::Value; @@ -17,10 +17,10 @@ use serde_json::Value; #[derive(Default)] struct EchoPlugin; -impl DppSectorPlugin for EchoPlugin { +impl DppProductGroupPlugin for EchoPlugin { fn meta(&self) -> PluginMeta { PluginMeta { - sector: "abi-echo".into(), + product_group: "abi-echo".into(), name: "ABI Echo Fixture".into(), version: env!("CARGO_PKG_VERSION").into(), license: "BSL-1.1".into(), diff --git a/crates/dpp-plugin-host/tests/integration.rs b/crates/dpp-plugin-host/tests/integration.rs index 466961f7..1ec40af4 100644 --- a/crates/dpp-plugin-host/tests/integration.rs +++ b/crates/dpp-plugin-host/tests/integration.rs @@ -7,14 +7,14 @@ //! //! These tests compile minimal WAT (WebAssembly Text Format) modules at test //! time to exercise the full load → sandbox → invoke pipeline without needing -//! pre-built `.wasm` sector plugin binaries. +//! pre-built `.wasm` product group plugin binaries. #![cfg(feature = "integration-tests")] use std::io::Write as IoWrite; use dpp_domain::{ - domain::sector::{Sector, SectorData}, + domain::product_group::{ProductGroup, ProductGroupData}, ports::compliance::ComplianceRegistry, }; use dpp_plugin_host::{ @@ -119,7 +119,7 @@ fn compile_wat_to_temp_file(wat_src: &str) -> tempfile::NamedTempFile { /// Load a plugin the same way [`LoadedPlugin::from_file`] with `trusted_key: /// None` used to, before unsigned loading required an explicit opt-in. These -/// tests load throwaway WAT fixtures, not real sector plugins, so unsigned +/// tests load throwaway WAT fixtures, not real product group plugins, so unsigned /// loading is the correct mode here — opt in for the duration of the call. /// /// SAFETY: mutates a process-global env var; sound because nextest runs each @@ -128,20 +128,20 @@ fn compile_wat_to_temp_file(wat_src: &str) -> tempfile::NamedTempFile { fn load_unsigned_test_plugin( engine: &wasmtime::Engine, path: &std::path::Path, - sector_key: &str, + product_group_key: &str, ) -> anyhow::Result { unsafe { std::env::set_var("ALLOW_UNSIGNED_PLUGINS", "true") }; - LoadedPlugin::from_file(engine, path, sector_key, None) + LoadedPlugin::from_file(engine, path, product_group_key, None) } -fn battery_sector_data() -> SectorData { +fn battery_product_group_data() -> ProductGroupData { // Minimal battery input — the passthrough plugin ignores it but the host // still serialises it and writes it to Wasm memory, exercising that path. use dpp_domain::domain::{ gtin::Gtin, - sector::{BatteryChemistry, BatteryData, BatteryType}, + product_group::{BatteryChemistry, BatteryData, BatteryType}, }; - SectorData::Battery(Box::new(BatteryData { + ProductGroupData::Battery(Box::new(BatteryData { gtin: Gtin::parse("09506000134352").unwrap(), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 400.0, @@ -219,12 +219,12 @@ fn battery_sector_data() -> SectorData { // --------------------------------------------------------------------------- #[test] -fn passthrough_when_no_plugin_registered_for_sector() { +fn passthrough_when_no_plugin_registered_for_product_group() { let host = WasmPluginHost::new(); let result = ComplianceRegistry::compute( &host, - Sector::Battery.catalog_key(), - &battery_sector_data(), + ProductGroup::Battery.catalog_key(), + &battery_product_group_data(), None, ); let r = result.expect("passthrough must not error"); @@ -241,10 +241,10 @@ fn passthrough_when_no_plugin_registered_for_sector() { // was true of the bare passthrough the host returned inline — before it // dispatched through `PassthroughRegistry` at all. Now that it does, battery // routes to `PassthroughBatteryStrategy`, whose whole job is to carry - // manufacturer-supplied values verbatim into the sector-agnostic fields. + // manufacturer-supplied values verbatim into the product group-agnostic fields. // // The invariant that must not move is the status above: no determination is - // made for any sector. A lifted metric claims nothing about itself — the + // made for any product group. A lifted metric claims nothing about itself — the // field is documented as "calculated **or** manufacturer-supplied" — so // asserting its absence pinned the stub, not the contract. assert_eq!( @@ -303,8 +303,8 @@ fn register_plugin_and_compute_via_host() { let result = ComplianceRegistry::compute( &host, - Sector::Battery.catalog_key(), - &battery_sector_data(), + ProductGroup::Battery.catalog_key(), + &battery_product_group_data(), None, ) .expect("compute failed"); @@ -379,8 +379,8 @@ fn discover_plugins_finds_wasm_files_in_dir() { // Create two fake .wasm files and one non-.wasm file. let wasm_bytes = wat::parse_str(PASSTHROUGH_WAT).expect("parse WAT"); - std::fs::write(dir.path().join("sector-battery.wasm"), &wasm_bytes).unwrap(); - std::fs::write(dir.path().join("sector-textile.wasm"), &wasm_bytes).unwrap(); + std::fs::write(dir.path().join("product-group-battery.wasm"), &wasm_bytes).unwrap(); + std::fs::write(dir.path().join("product-group-textile.wasm"), &wasm_bytes).unwrap(); std::fs::write(dir.path().join("readme.txt"), b"ignored").unwrap(); let found = discover_plugins(dir.path()).expect("discover_plugins failed"); @@ -390,11 +390,11 @@ fn discover_plugins_finds_wasm_files_in_dir() { let keys: Vec<&str> = found.iter().map(|(k, _)| k.as_str()).collect(); assert!( keys.contains(&"battery"), - "should strip 'sector-' prefix → 'battery'" + "should strip 'product-group-' prefix → 'battery'" ); assert!( keys.contains(&"textile"), - "should strip 'sector-' prefix → 'textile'" + "should strip 'product-group-' prefix → 'textile'" ); } diff --git a/crates/dpp-render/examples/preview.rs b/crates/dpp-render/examples/preview.rs index bbab794b..98275c53 100644 --- a/crates/dpp-render/examples/preview.rs +++ b/crates/dpp-render/examples/preview.rs @@ -6,7 +6,7 @@ //! //! The fixture below is shaped like a genuine **Public**-tier view, i.e. what //! `dpp-vault::public_view` would actually hand this crate — it deliberately -//! omits `batchId` (and `lintResult`), which `SectorAccessPolicy::passport_default` +//! omits `batchId` (and `lintResult`), which `ProductGroupAccessPolicy::passport_default` //! (dpp-core/crates/dpp-crypto/src/access/policy.rs) tiers as Professional, not //! Public, because both are mutable after publish and can't sit inside the //! signed public payload. This crate itself does no filtering — it renders @@ -23,8 +23,8 @@ fn main() { "productName": "Organic Cotton T-Shirt", "manufacturer": { "name": "Sample Textiles Co." }, "status": "active", - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "gtin": "09506000134352", "countryOfOrigin": "Germany", "careInstructions": "Machine wash cold, tumble dry low", diff --git a/crates/dpp-render/src/carrier.rs b/crates/dpp-render/src/carrier.rs index d5f69c1f..90f180c7 100644 --- a/crates/dpp-render/src/carrier.rs +++ b/crates/dpp-render/src/carrier.rs @@ -5,9 +5,9 @@ use serde_json::Value; /// Build the GS1 Digital Link URI a carrier (QR/Data Matrix) for this /// passport should encode. /// -/// `gtin` lives in the sector-specific payload (`SectorData` is internally -/// tagged on `sector`, e.g. `{"sector":"battery","gtin":"...",...}`), not on -/// the passport itself. `None` when the passport's sector data carries no +/// `gtin` lives in the product group-specific payload (`ProductGroupData` is internally +/// tagged on `product_group`, e.g. `{"product group":"battery","gtin":"...",...}`), not on +/// the passport itself. `None` when the passport's product group data carries no /// GTIN (e.g. an unsold-goods report) or `dpp_id` is not a UUID — there is /// nothing valid to encode. /// @@ -16,7 +16,7 @@ use serde_json::Value; /// carrier URL the vault stores at publish. pub fn carrier_uri(passport: &Value, resolver_base_url: &str, dpp_id: &str) -> Option { let gtin = passport - .get("sectorData") + .get("productGroupData") .and_then(|sd| sd.get("gtin")) .and_then(Value::as_str)?; let batch_id = passport.get("batchId").and_then(Value::as_str); @@ -50,7 +50,7 @@ mod tests { let passport = serde_json::json!({ "id": DPP_ID, "batchId": "BATCH-42", - "sectorData": { "sector": "battery", "gtin": "09506000134352" } + "productGroupData": { "productGroup": "battery", "gtin": "09506000134352" } }); let uri = carrier_uri(&passport, "https://id.odal-node.io", DPP_ID) .expect("gtin present, must build a URI"); @@ -65,7 +65,7 @@ mod tests { fn carrier_uri_omits_batch_segment_when_absent() { let passport = serde_json::json!({ "id": DPP_ID, - "sectorData": { "sector": "battery", "gtin": "09506000134352" } + "productGroupData": { "productGroup": "battery", "gtin": "09506000134352" } }); let uri = carrier_uri(&passport, "https://id.odal-node.io", DPP_ID).unwrap(); assert_eq!( @@ -79,13 +79,13 @@ mod tests { // e.g. an unsold-goods report — no per-unit GTIN to encode. let passport = serde_json::json!({ "id": DPP_ID, - "sectorData": { "sector": "unsoldGoods" } + "productGroupData": { "productGroup": "unsoldGoods" } }); assert!(carrier_uri(&passport, "https://id.odal-node.io", DPP_ID).is_none()); } #[test] - fn carrier_uri_is_none_without_sector_data() { + fn carrier_uri_is_none_without_product_group_data() { let passport = serde_json::json!({ "id": DPP_ID }); assert!(carrier_uri(&passport, "https://id.odal-node.io", DPP_ID).is_none()); } @@ -94,7 +94,7 @@ mod tests { fn carrier_uri_is_none_for_non_uuid_id() { let passport = serde_json::json!({ "id": "not-a-uuid", - "sectorData": { "sector": "battery", "gtin": "09506000134352" } + "productGroupData": { "productGroup": "battery", "gtin": "09506000134352" } }); assert!(carrier_uri(&passport, "https://id.odal-node.io", "not-a-uuid").is_none()); } diff --git a/crates/dpp-render/src/fields.rs b/crates/dpp-render/src/fields.rs index c8a4d991..31368798 100644 --- a/crates/dpp-render/src/fields.rs +++ b/crates/dpp-render/src/fields.rs @@ -1,5 +1,5 @@ -//! Field-extraction helpers for `sectorData` — the same get→as_T→format→ -//! fallback shape repeats across every sector section in [`super::sections`]. +//! Field-extraction helpers for `productGroupData` — the same get→as_T→format→ +//! fallback shape repeats across every product group section in [`super::sections`]. use serde_json::Value; diff --git a/crates/dpp-render/src/page.rs b/crates/dpp-render/src/page.rs index a4ecbea6..4e894f5a 100644 --- a/crates/dpp-render/src/page.rs +++ b/crates/dpp-render/src/page.rs @@ -63,16 +63,16 @@ pub fn render_page( .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown")); - // `gtin` lives in the sector-specific payload, not on the passport itself + // `gtin` lives in the product group-specific payload, not on the passport itself // (see `crate::domain::carrier_uri`'s doc comment for the JSON shape). let gtin = esc(p - .get("sectorData") + .get("productGroupData") .and_then(|sd| sd.get("gtin")) .and_then(|v| v.as_str()) .unwrap_or("-")); let batch_id = esc(p.get("batchId").and_then(|v| v.as_str()).unwrap_or("-")); - let sector_html = sections::build_sector_section(p); + let product_group_html = sections::build_product_group_section(p); let qr_svg = carrier_uri(p, resolver_base_url, dpp_id) .map(|uri| build_qr_svg(&uri)) .unwrap_or_default(); @@ -131,7 +131,7 @@ pub fn render_page( Batch ID{batch_id} - {sector_html} + {product_group_html}
{qr_svg} @@ -200,7 +200,7 @@ mod tests { const DPP_ID: &str = "0190a9f0-1234-7abc-8def-0123456789ab"; /// Deliberately **not** a realistic Public-tier view: it includes `batchId`, - /// which `SectorAccessPolicy::passport_default` tiers as Professional (see + /// which `ProductGroupAccessPolicy::passport_default` tiers as Professional (see /// the README), so a genuine public read would never contain it. Used here /// to test `render_page`'s raw mechanics — it renders whatever it is given, /// by design — not to model what a real public render looks like. @@ -210,7 +210,7 @@ mod tests { "manufacturer": { "name": "Sample Textiles Co." }, "status": "active", "batchId": "BATCH-42", - "sectorData": { "sector": "textile", "gtin": "09506000134352" } + "productGroupData": { "productGroup": "textile", "gtin": "09506000134352" } }) } @@ -244,7 +244,7 @@ mod tests { "productName": "Organic Cotton T-Shirt", "manufacturer": { "name": "Sample Textiles Co." }, "status": "active", - "sectorData": { "sector": "textile", "gtin": "09506000134352" } + "productGroupData": { "productGroup": "textile", "gtin": "09506000134352" } }); let html = render_page(DPP_ID, &p, "https://id.odal-node.io", SnapshotNotice::Live); assert!( diff --git a/crates/dpp-render/src/remainder.rs b/crates/dpp-render/src/remainder.rs index 3925041b..1bc15e09 100644 --- a/crates/dpp-render/src/remainder.rs +++ b/crates/dpp-render/src/remainder.rs @@ -2,7 +2,7 @@ //! //! # Why this exists //! -//! Each sector section in [`super::sections`] is a hand-written table of named +//! Each product group section in [`super::sections`] is a hand-written table of named //! fields, chosen for a readable summary. Battery renders seven. Battery v2.6.0 //! declares **53** fields `x-disclosure: public`, each citing an Annex XIII basis //! in its own schema `description` — so 46 fields that `public_view` deliberately @@ -30,13 +30,13 @@ //! # The drift property this buys //! //! The remainder is computed from the *data*, not from a list. A field added to -//! a sector in `dpp-core` appears on the public page with no change here — which +//! a product group in `dpp-core` appears on the public page with no change here — which //! is the failure mode that produced the original gap, since a hand-written //! table has no way to notice a field it was never told about. //! //! # What it does not do //! -//! It performs no filtering. Its input is `sectorData` from the already-redacted +//! It performs no filtering. Its input is `productGroupData` from the already-redacted //! public view, so a key present here is a key `public_view` decided was public, //! at the schema version the passport's signature was frozen under. Re-deciding //! that here would be a second disclosure policy, which is the defect @@ -47,14 +47,14 @@ use serde_json::Value; use crate::esc::esc; /// Keys that are structural rather than data, and belong on no table. -const SKIP: &[&str] = &["sector"]; +const SKIP: &[&str] = &["productGroup"]; -/// Render every `sectorData` key not already shown by the curated section. +/// Render every `productGroupData` key not already shown by the curated section. /// -/// Returns an empty string when nothing remains, so a sector whose section +/// Returns an empty string when nothing remains, so a product group whose section /// happens to cover everything gets no empty table. pub(crate) fn build_remainder_section(p: &Value, already_rendered: &[&str]) -> String { - let Some(obj) = p.get("sectorData").and_then(Value::as_object) else { + let Some(obj) = p.get("productGroupData").and_then(Value::as_object) else { return String::new(); }; @@ -154,8 +154,8 @@ mod tests { #[test] fn renders_only_what_the_curated_section_did_not() { - let p = json!({ "sectorData": { - "sector": "battery", + let p = json!({ "productGroupData": { + "productGroup": "battery", "batteryChemistry": "LFP", "hazardSymbol": "GHS07", }}); @@ -166,17 +166,18 @@ mod tests { !html.contains("Battery Chemistry"), "the curated section already showed it: {html}" ); - assert!(!html.contains(">sector<"), "the tag is not data"); + assert!(!html.contains(">product_group<"), "the tag is not data"); } #[test] fn nothing_left_renders_nothing() { - let p = json!({ "sectorData": { "sector": "battery", "batteryChemistry": "LFP" }}); + let p = + json!({ "productGroupData": { "productGroup": "battery", "batteryChemistry": "LFP" }}); assert_eq!(build_remainder_section(&p, &["batteryChemistry"]), ""); } #[test] - fn absent_sector_data_renders_nothing() { + fn absent_product_group_data_renders_nothing() { assert_eq!(build_remainder_section(&json!({}), &[]), ""); } @@ -184,7 +185,7 @@ mod tests { /// absent, but a stored document can carry an explicit null. #[test] fn null_values_are_omitted() { - let p = json!({ "sectorData": { "sector": "x", "a": serde_json::Value::Null }}); + let p = json!({ "productGroupData": { "productGroup": "x", "a": serde_json::Value::Null }}); assert_eq!(build_remainder_section(&p, &[]), ""); } @@ -194,8 +195,8 @@ mod tests { /// stored document. #[test] fn values_and_labels_are_escaped() { - let p = json!({ "sectorData": { - "sector": "x", + let p = json!({ "productGroupData": { + "productGroup": "x", "note": "", }}); let html = build_remainder_section(&p, &[]); diff --git a/crates/dpp-render/src/sections/aluminium.rs b/crates/dpp-render/src/sections/aluminium.rs index 6a5f45db..561c7d59 100644 --- a/crates/dpp-render/src/sections/aluminium.rs +++ b/crates/dpp-render/src/sections/aluminium.rs @@ -1,9 +1,9 @@ -//! Aluminium sector HTML section. +//! Aluminium product group HTML section. use crate::fields::{f64_field, str_field}; pub(super) fn build_aluminium_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -33,7 +33,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "aluminium", + "productGroup": "aluminium", "gtin": "09506000134352", "alloyGrade": "6061-T6", "productionRoute": "primary", @@ -51,14 +51,14 @@ mod tests { #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_aluminium_section(&p); assert!(html.contains("Aluminium Product Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_aluminium_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/battery.rs b/crates/dpp-render/src/sections/battery.rs index 26b3f001..9f676df5 100644 --- a/crates/dpp-render/src/sections/battery.rs +++ b/crates/dpp-render/src/sections/battery.rs @@ -1,9 +1,9 @@ -//! Battery sector HTML section. +//! Battery product group HTML section. use crate::fields::{f64_field, str_field, u64_field}; pub(super) fn build_battery_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; diff --git a/crates/dpp-render/src/sections/construction.rs b/crates/dpp-render/src/sections/construction.rs index 3013c116..0e89ae99 100644 --- a/crates/dpp-render/src/sections/construction.rs +++ b/crates/dpp-render/src/sections/construction.rs @@ -1,9 +1,9 @@ -//! Construction products sector HTML section. +//! Construction products product group HTML section. use crate::fields::{bool_field, f64_field, str_field}; pub(super) fn build_construction_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -32,7 +32,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "construction", + "productGroup": "construction", "gtin": "09506000134352", "productFamily": "Insulation Board", "countryOfOrigin": "FR", @@ -49,21 +49,21 @@ mod tests { #[test] fn ce_marking_false_renders_no() { - let p = serde_json::json!({"sectorData": {"ceMarking": false}}); + let p = serde_json::json!({"productGroupData": {"ceMarking": false}}); let html = build_construction_section(&p); assert!(html.contains(">No<")); } #[test] fn missing_fields_fall_back_to_dashes_and_default_unit() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_construction_section(&p); assert!(html.contains("Construction Product Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_construction_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/detergent.rs b/crates/dpp-render/src/sections/detergent.rs index aebdbb37..14f75fe3 100644 --- a/crates/dpp-render/src/sections/detergent.rs +++ b/crates/dpp-render/src/sections/detergent.rs @@ -1,9 +1,9 @@ -//! Detergent sector HTML section. +//! Detergent product group HTML section. use crate::fields::{array_len_field, bool_field, str_field}; pub(super) fn build_detergent_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -37,7 +37,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "detergent", + "productGroup": "detergent", "gtin": "09506000134352", "productType": "Laundry Detergent", "format": "Liquid", @@ -58,21 +58,21 @@ mod tests { #[test] fn not_biodegradable_renders_the_negative_message() { - let p = serde_json::json!({"sectorData": {"biodegradable": false}}); + let p = serde_json::json!({"productGroupData": {"biodegradable": false}}); let html = build_detergent_section(&p); assert!(html.contains("Not fully biodegradable")); } #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_detergent_section(&p); assert!(html.contains("Detergent Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_detergent_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/electronics.rs b/crates/dpp-render/src/sections/electronics.rs index 607740f8..2bc72a8d 100644 --- a/crates/dpp-render/src/sections/electronics.rs +++ b/crates/dpp-render/src/sections/electronics.rs @@ -1,4 +1,4 @@ -//! Electronics sector HTML section. +//! Electronics product group HTML section. use crate::fields::{f64_field, str_field, u64_field}; @@ -23,7 +23,7 @@ fn device_type_label(raw: &str) -> String { } pub(super) fn build_electronics_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -63,7 +63,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "electronics", + "productGroup": "electronics", "gtin": "09506000134352", "productCategory": "smartphone", "energyEfficiencyClass": "A", @@ -81,14 +81,14 @@ mod tests { #[test] fn missing_co2e_reports_not_disclosed() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_electronics_section(&p); assert!(html.contains("Not disclosed")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_electronics_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/furniture.rs b/crates/dpp-render/src/sections/furniture.rs index 8ab75d31..7ed3450a 100644 --- a/crates/dpp-render/src/sections/furniture.rs +++ b/crates/dpp-render/src/sections/furniture.rs @@ -1,9 +1,9 @@ -//! Furniture sector HTML section. +//! Furniture product group HTML section. use crate::fields::{f64_field, str_field}; pub(super) fn build_furniture_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -33,7 +33,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "furniture", + "productGroup": "furniture", "gtin": "09506000134352", "productType": "Office Chair", "primaryMaterial": "Steel & Fabric", @@ -54,14 +54,14 @@ mod tests { #[test] fn missing_co2e_reports_not_disclosed() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_furniture_section(&p); assert!(html.contains("Not disclosed")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_furniture_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/mod.rs b/crates/dpp-render/src/sections/mod.rs index f03bb90d..2720c979 100644 --- a/crates/dpp-render/src/sections/mod.rs +++ b/crates/dpp-render/src/sections/mod.rs @@ -1,4 +1,4 @@ -//! Per-sector HTML section dispatch — one file per EU DPP sector. +//! Per-product group HTML section dispatch — one file per EU DPP product group. mod aluminium; mod battery; @@ -11,7 +11,7 @@ mod textile; mod toy; mod tyre; -/// Build the sector-specific HTML for a passport: the curated summary section, +/// Build the product group-specific HTML for a passport: the curated summary section, /// followed by a table of every public field the summary did not show. /// /// The second half is not decoration. Each curated section is a hand-written @@ -19,14 +19,14 @@ mod tyre; /// `x-disclosure: public`. Those 46 others were in the signed public payload and /// in the JSON-LD representation, and absent from the page a phone camera /// reaches by scanning the carrier. See [`crate::remainder`]. -pub(crate) fn build_sector_section(p: &serde_json::Value) -> String { - let sector = p - .get("sectorData") - .and_then(|s| s.get("sector")) +pub(crate) fn build_product_group_section(p: &serde_json::Value) -> String { + let product_group = p + .get("productGroupData") + .and_then(|s| s.get("productGroup")) .and_then(|v| v.as_str()) .unwrap_or(""); - let curated = match sector { + let curated = match product_group { "battery" => battery::build_battery_section(p), "textile" | "unsoldGoods" => textile::build_textile_section(p), "electronics" => electronics::build_electronics_section(p), @@ -37,17 +37,17 @@ pub(crate) fn build_sector_section(p: &serde_json::Value) -> String { "aluminium" => aluminium::build_aluminium_section(p), "furniture" => furniture::build_furniture_section(p), "detergent" => detergent::build_detergent_section(p), - // An unmodelled sector gets no curated section, and the remainder below + // An unmodelled product group gets no curated section, and the remainder below // still shows everything `public_view` let through — so an unknown - // sector degrades to "all of it, plainly" rather than to nothing. + // product group degrades to "all of it, plainly" rather than to nothing. _ => String::new(), }; - let remainder = crate::remainder::build_remainder_section(p, rendered_keys(sector)); + let remainder = crate::remainder::build_remainder_section(p, rendered_keys(product_group)); format!("{curated}\n{remainder}") } -/// The `sectorData` keys each curated section already displays. +/// The `productGroupData` keys each curated section already displays. /// /// Kept beside the dispatch rather than in each section file so the whole /// picture is on one screen, and pinned by `curated_keys_are_actually_rendered` @@ -56,8 +56,8 @@ pub(crate) fn build_sector_section(p: &serde_json::Value) -> String { /// the remainder table, an extra one hides it from there while the curated /// section still shows it — but a duplicate reads as a bug, so the test keeps it /// honest. -pub(crate) fn rendered_keys(sector: &str) -> &'static [&'static str] { - match sector { +pub(crate) fn rendered_keys(product_group: &str) -> &'static [&'static str] { + match product_group { "battery" => &[ "batteryChemistry", "nominalVoltageV", @@ -131,7 +131,7 @@ pub(crate) fn rendered_keys(sector: &str) -> &'static [&'static str] { } } -/// Round-trip a section fixture through `dpp_domain::SectorData`. +/// Round-trip a section fixture through `dpp_domain::ProductGroupData`. /// /// Every section renderer reads its fields by **string key** out of a /// `serde_json::Value` with a `"-"` fallback, so a field renamed in `dpp-core` @@ -147,10 +147,11 @@ pub(crate) fn rendered_keys(sector: &str) -> &'static [&'static str] { /// one comes back absent, and either way the section's assertion fails instead of /// the value quietly disappearing from the public page. #[cfg(test)] -pub(super) fn typed_fixture(sector_data: serde_json::Value) -> serde_json::Value { - let typed: dpp_domain::domain::sector::SectorData = serde_json::from_value(sector_data) - .expect("section fixture must satisfy dpp-domain's SectorData contract"); - serde_json::json!({ "sectorData": serde_json::to_value(typed).expect("serialize") }) +pub(super) fn typed_fixture(product_group_data: serde_json::Value) -> serde_json::Value { + let typed: dpp_domain::domain::product_group::ProductGroupData = + serde_json::from_value(product_group_data) + .expect("section fixture must satisfy dpp-domain's ProductGroupData contract"); + serde_json::json!({ "productGroupData": serde_json::to_value(typed).expect("serialize") }) } #[cfg(test)] @@ -164,14 +165,14 @@ mod completeness { //! cannot catch an omission; these tests close that. //! //! They resolve the public field set through - //! `SectorAccessPolicy::for_schema_version` — the exact entry point + //! `ProductGroupAccessPolicy::for_schema_version` — the exact entry point //! `public_view` uses to decide what the public payload contains — rather //! than restating a field list or re-reading the schema JSON. Same reason - //! `typed_fixture` imports `SectorData`: a test carrying its own copy of the + //! `typed_fixture` imports `ProductGroupData`: a test carrying its own copy of the //! authority agrees with itself forever. - use dpp_domain::access::SectorAccessPolicy; - use dpp_domain::{Disclosure, Sector, SectorCatalog}; + use dpp_domain::access::ProductGroupAccessPolicy; + use dpp_domain::{Disclosure, ProductGroup, ProductGroupCatalog}; /// Every field the disclosure policy classifies `Public` must reach the page /// — through the curated section or through the remainder table. @@ -182,7 +183,7 @@ mod completeness { /// policy's 53. #[test] fn every_public_field_reaches_the_page() { - let catalog = SectorCatalog::new(); + let catalog = ProductGroupCatalog::new(); let mut checked = 0usize; for descriptor in catalog.all() { @@ -190,7 +191,7 @@ mod completeness { let Some(version) = catalog.current_schema_version(key) else { continue; }; - let Some(policy) = SectorAccessPolicy::for_schema_version(key, version) else { + let Some(policy) = ProductGroupAccessPolicy::for_schema_version(key, version) else { continue; }; @@ -205,12 +206,12 @@ mod completeness { } // A passport carrying every public field with a placeholder value. - let mut sector_data = serde_json::Map::new(); - sector_data.insert("sector".into(), serde_json::json!(key)); + let mut product_group_data = serde_json::Map::new(); + product_group_data.insert("productGroup".into(), serde_json::json!(key)); for name in &public { - sector_data.insert((*name).clone(), serde_json::json!("PLACEHOLDER-VALUE")); + product_group_data.insert((*name).clone(), serde_json::json!("PLACEHOLDER-VALUE")); } - let p = serde_json::json!({ "sectorData": sector_data }); + let p = serde_json::json!({ "productGroupData": product_group_data }); let curated = super::rendered_keys(key); let remainder = crate::remainder::build_remainder_section(&p, curated); @@ -238,7 +239,7 @@ mod completeness { assert!( checked > 0, - "no sector was actually checked — the catalog/policy lookup skipped \ + "no product_group was actually checked — the catalog/policy lookup skipped \ everything, which would make this test vacuous" ); } @@ -249,8 +250,8 @@ mod completeness { /// this design could still drop a field. #[test] fn curated_keys_are_actually_rendered() { - for sector in [ - Sector::Battery.catalog_key(), + for product_group in [ + ProductGroup::Battery.catalog_key(), "textile", "electronics", "steel", @@ -261,23 +262,23 @@ mod completeness { "furniture", "detergent", ] { - let claimed = super::rendered_keys(sector); + let claimed = super::rendered_keys(product_group); if claimed.is_empty() { continue; } - let mut sector_data = serde_json::Map::new(); - sector_data.insert("sector".into(), serde_json::json!(sector)); + let mut product_group_data = serde_json::Map::new(); + product_group_data.insert("productGroup".into(), serde_json::json!(product_group)); for k in claimed { - sector_data.insert((*k).to_owned(), serde_json::json!("CURATED-MARKER")); + product_group_data.insert((*k).to_owned(), serde_json::json!("CURATED-MARKER")); } - let p = serde_json::json!({ "sectorData": sector_data }); + let p = serde_json::json!({ "productGroupData": product_group_data }); // With only the curated keys present, the remainder must be empty — // which is exactly the assertion that every claimed key was consumed // by the curated section. let remainder = crate::remainder::build_remainder_section(&p, claimed); assert_eq!( remainder, "", - "{sector}: rendered_keys claims keys the curated section does not consume" + "{product_group}: rendered_keys claims keys the curated section does not consume" ); } } diff --git a/crates/dpp-render/src/sections/steel.rs b/crates/dpp-render/src/sections/steel.rs index 8a645f93..49126105 100644 --- a/crates/dpp-render/src/sections/steel.rs +++ b/crates/dpp-render/src/sections/steel.rs @@ -1,9 +1,9 @@ -//! Steel sector HTML section. +//! Steel product group HTML section. use crate::fields::{f64_field, str_field}; pub(super) fn build_steel_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -33,7 +33,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "steel", + "productGroup": "steel", "gtin": "09506000134352", "productionRoute": "electric-arc", "productCategory": "Flat Steel", @@ -51,14 +51,14 @@ mod tests { #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_steel_section(&p); assert!(html.contains("Steel Product Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_steel_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/textile.rs b/crates/dpp-render/src/sections/textile.rs index 389d9f25..4433e103 100644 --- a/crates/dpp-render/src/sections/textile.rs +++ b/crates/dpp-render/src/sections/textile.rs @@ -1,10 +1,10 @@ -//! Textile sector HTML section, including the fibre-composition bar chart. +//! Textile product group HTML section, including the fibre-composition bar chart. use crate::esc::esc; use crate::fields::{f64_field, str_field}; pub(super) fn build_textile_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -106,7 +106,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "textile", + "productGroup": "textile", "gtin": "09506000134352", "countryOfOrigin": "Germany", "careInstructions": "Machine wash cold", @@ -128,28 +128,28 @@ mod tests { #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_textile_section(&p); assert!(html.contains("Textile Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_textile_section(&p), ""); } - /// The textile sector catalog (`dpp-core/crates/dpp-domain/sectors/textile.json`) + /// The textile product group catalog (`dpp-core/crates/dpp-domain/product groups/textile.json`) /// marks `svhcSubstances`, `disassemblyInstructions` and `sparePartsAvailable` as /// professional-tier, not public. This section is expected to receive an /// already-redacted Public-tier view and performs no filtering of its own — but /// it also never names these fields, so even an unredacted passport renders /// none of them. Guards that property against a future edit that starts - /// serializing `sectorData` wholesale instead of field-by-field. + /// serializing `productGroupData` wholesale instead of field-by-field. #[test] fn professional_tier_fields_are_never_rendered() { - let p = serde_json::json!({"sectorData": { + let p = serde_json::json!({"productGroupData": { "countryOfOrigin": "Germany", "svhcSubstances": "MARKER_SVHC_SUBSTANCE", "disassemblyInstructions": "MARKER_DISASSEMBLY_INSTRUCTIONS", diff --git a/crates/dpp-render/src/sections/toy.rs b/crates/dpp-render/src/sections/toy.rs index 618df260..fc63d153 100644 --- a/crates/dpp-render/src/sections/toy.rs +++ b/crates/dpp-render/src/sections/toy.rs @@ -1,9 +1,9 @@ -//! Toy safety sector HTML section. +//! Toy safety product group HTML section. use crate::fields::{bool_field, str_field}; pub(super) fn build_toy_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -29,7 +29,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "toy", + "productGroup": "toy", "gtin": "09506000134352", "ageGroup": "3+", "primaryMaterial": "ABS Plastic", @@ -45,21 +45,21 @@ mod tests { #[test] fn ce_marking_false_renders_no() { - let p = serde_json::json!({"sectorData": {"ceMarking": false}}); + let p = serde_json::json!({"productGroupData": {"ceMarking": false}}); let html = build_toy_section(&p); assert!(html.contains(">No<")); } #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_toy_section(&p); assert!(html.contains("Toy Safety Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_toy_section(&p), ""); } diff --git a/crates/dpp-render/src/sections/tyre.rs b/crates/dpp-render/src/sections/tyre.rs index f06d85bc..3010bcd6 100644 --- a/crates/dpp-render/src/sections/tyre.rs +++ b/crates/dpp-render/src/sections/tyre.rs @@ -1,9 +1,9 @@ -//! Tyre sector HTML section. +//! Tyre product group HTML section. use crate::fields::{f64_field, str_field}; pub(super) fn build_tyre_section(p: &serde_json::Value) -> String { - let sd = match p.get("sectorData") { + let sd = match p.get("productGroupData") { Some(v) => v, None => return String::new(), }; @@ -31,7 +31,7 @@ mod tests { #[test] fn full_data_populates_all_fields() { let p = crate::sections::typed_fixture(serde_json::json!({ - "sector": "tyre", + "productGroup": "tyre", "gtin": "09506000134352", "tyreClass": "C1", "fuelEfficiencyClass": "B", @@ -49,14 +49,14 @@ mod tests { #[test] fn missing_fields_fall_back_to_dashes() { - let p = serde_json::json!({"sectorData": {}}); + let p = serde_json::json!({"productGroupData": {}}); let html = build_tyre_section(&p); assert!(html.contains("Tyre Labelling Information")); assert!(html.contains(">-<")); } #[test] - fn absent_sector_data_returns_empty_string() { + fn absent_product_group_data_returns_empty_string() { let p = serde_json::json!({}); assert_eq!(build_tyre_section(&p), ""); } diff --git a/crates/dpp-resolver/src/handlers/resolve_aas.rs b/crates/dpp-resolver/src/handlers/resolve_aas.rs index 865a3c1d..c7b5f5f3 100644 --- a/crates/dpp-resolver/src/handlers/resolve_aas.rs +++ b/crates/dpp-resolver/src/handlers/resolve_aas.rs @@ -71,7 +71,7 @@ pub async fn resolve_aas_handler( }; // The AAS asset identity is the GTIN. Unsold-goods reports and untyped - // sectors carry none — they do not identify a trade item — so no AAS + // product groups carry none — they do not identify a trade item — so no AAS // representation of them exists. 406 is the honest answer to "I want this // as AAS": the resource has no representation matching the request. // @@ -82,7 +82,11 @@ pub async fn resolve_aas_handler( // is unavailable is *this* representation at *this* URL, and the fix is not // to invent a `globalAssetId`: a fabricated trade-item identifier inside a // document an integrator treats as authoritative is worse than a 406. - let Some(gtin) = passport.sector_data.as_ref().and_then(|sd| sd.gtin()) else { + let Some(gtin) = passport + .product_group_data + .as_ref() + .and_then(|sd| sd.gtin()) + else { return problem( StatusCode::NOT_ACCEPTABLE, "This passport has no GTIN, so it has no AAS representation.", diff --git a/crates/dpp-resolver/src/handlers/resolve_html/mod.rs b/crates/dpp-resolver/src/handlers/resolve_html/mod.rs index f5b89485..4b6e78af 100644 --- a/crates/dpp-resolver/src/handlers/resolve_html/mod.rs +++ b/crates/dpp-resolver/src/handlers/resolve_html/mod.rs @@ -1,5 +1,5 @@ //! Handler for `GET /dpp/{dppId}` when `Accept: text/html` — renders a self-contained -//! HTML passport page with sector-specific tables and an inline SVG QR code. +//! HTML passport page with product group-specific tables and an inline SVG QR code. use axum::{ extract::{Path, State}, diff --git a/crates/dpp-resolver/src/handlers/resolve_json.rs b/crates/dpp-resolver/src/handlers/resolve_json.rs index 4bcce48f..097e3ba6 100644 --- a/crates/dpp-resolver/src/handlers/resolve_json.rs +++ b/crates/dpp-resolver/src/handlers/resolve_json.rs @@ -9,7 +9,7 @@ use dpp_common::http_problem; use serde_json::Value; use dpp_domain::Audience; -use dpp_domain::access::{SectorAccessPolicy, filter_by_audience}; +use dpp_domain::access::{ProductGroupAccessPolicy, filter_by_audience}; use crate::{infra::did, state::AppState}; @@ -114,7 +114,7 @@ fn parse_access_tier(_headers: &HeaderMap) -> Audience { /// Apply two-level access tier filtering: /// 1. Top-level passport fields (jws, batchId, retentionLocked). -/// 2. Sector-specific fields within `sectorData` (e.g. battery supply chain data). +/// 2. ProductGroup-specific fields within `productGroupData` (e.g. battery supply chain data). fn apply_access_tier_filter(passport: Value, tier: Audience) -> Value { // Read before filtering: the version that governs this passport's disclosure // is the one it was validated against, and it must be taken from the document @@ -126,70 +126,76 @@ fn apply_access_tier_filter(passport: Value, tier: Audience) -> Value { .unwrap_or_default() .to_owned(); - let passport_policy = SectorAccessPolicy::passport_default(); + let passport_policy = ProductGroupAccessPolicy::passport_default(); let decision = filter_by_audience(&passport, &passport_policy, tier); let mut doc = decision.filtered_data; - // Also filter sector data sub-object if present. + // Also filter product group data sub-object if present. if let Some(obj) = doc.as_object_mut() - && let Some(sd) = obj.remove("sectorData") + && let Some(sd) = obj.remove("productGroupData") { - let sector_policy = detect_sector_policy(&sd, &schema_version); - if let Some(policy) = sector_policy { + let product_group_policy = detect_product_group_policy(&sd, &schema_version); + if let Some(policy) = product_group_policy { let inner = filter_by_audience(&sd, &policy, tier); - obj.insert("sectorData".into(), inner.filtered_data); - } else if is_tagged_unknown_sector(&sd) { - // Fail closed (RT2-1 / RT2-5): the sub-object carries a `sector` + obj.insert("productGroupData".into(), inner.filtered_data); + } else if is_tagged_unknown_product_group(&sd) { + // Fail closed (RT2-1 / RT2-5): the sub-object carries a `product_group` // tag the catalog doesn't recognise, so we have no policy telling // us which fields are public. Rather than leak professional / - // confidential fields, drop everything except the sector + // confidential fields, drop everything except the product group // identifier at non-elevated tiers. if tier == Audience::Public { - obj.insert("sectorData".into(), redacted_unknown_sector(&sd)); + obj.insert( + "productGroupData".into(), + redacted_unknown_product_group(&sd), + ); } else { - obj.insert("sectorData".into(), sd); + obj.insert("productGroupData".into(), sd); } } else { - // Genuinely untagged/legacy record: no sector tag and no shape + // Genuinely untagged/legacy record: no product group tag and no shape // match. Preserve existing passthrough behaviour. - obj.insert("sectorData".into(), sd); + obj.insert("productGroupData".into(), sd); } } doc } -/// True when `sectorData` carries a non-empty `sector` tag (i.e. it is a tagged +/// True when `productGroupData` carries a non-empty `product_group` tag (i.e. it is a tagged /// record, as opposed to a legacy untagged one). Used to decide whether an -/// unrecognised sector should fail closed. -fn is_tagged_unknown_sector(sector_data: &Value) -> bool { - sector_data +/// unrecognised product group should fail closed. +fn is_tagged_unknown_product_group(product_group_data: &Value) -> bool { + product_group_data .as_object() - .and_then(|o| o.get("sector")) + .and_then(|o| o.get("productGroup")) .and_then(Value::as_str) .is_some_and(|s| !s.is_empty()) } -/// Minimal, fail-closed `sectorData` for an unrecognised sector at the Public -/// tier: keep only the `sector` identifier, drop every other (potentially +/// Minimal, fail-closed `productGroupData` for an unrecognised product group at the Public +/// tier: keep only the `product_group` identifier, drop every other (potentially /// professional/confidential) field. -fn redacted_unknown_sector(sector_data: &Value) -> Value { +fn redacted_unknown_product_group(product_group_data: &Value) -> Value { let mut out = serde_json::Map::new(); - if let Some(tag) = sector_data.get("sector") { - out.insert("sector".into(), tag.clone()); + if let Some(tag) = product_group_data.get("productGroup") { + out.insert("productGroup".into(), tag.clone()); } Value::Object(out) } -/// Select the sector-specific access policy from the catalog. +/// Select the product group-specific access policy from the catalog. /// -/// The stored `sectorData` carries a `"sector"` discriminant; the policy and its -/// field tiers come from the catalog, so this covers every sector — not just +/// The stored `productGroupData` carries a `"product group"` discriminant; the policy and its +/// field tiers come from the catalog, so this covers every product group — not just /// battery/textile. Falls back to field-shape detection for legacy records that -/// predate the tagged `sectorData` format. -fn detect_sector_policy(sector_data: &Value, schema_version: &str) -> Option { - let obj = sector_data.as_object()?; - let key = match obj.get("sector").and_then(Value::as_str) { +/// predate the tagged `productGroupData` format. +fn detect_product_group_policy( + product_group_data: &Value, + schema_version: &str, +) -> Option { + let obj = product_group_data.as_object()?; + let key = match obj.get("productGroup").and_then(Value::as_str) { Some("unsoldGoods") => "unsold-goods", Some(tag) => tag, None if obj.contains_key("batteryChemistry") || obj.contains_key("battery_chemistry") => { @@ -202,10 +208,10 @@ fn detect_sector_policy(sector_data: &Value, schema_version: &str) -> Option Result { @@ -269,42 +275,42 @@ pub(crate) fn fetch_problem(status: StatusCode) -> axum::response::Response { #[cfg(test)] mod security_regression { //! **RT2-5**: access-tier redaction must fail *closed* for an unrecognised - //! sector tag — it must not pass professional/confidential `sectorData` + //! product group tag — it must not pass professional/confidential `productGroupData` //! fields through verbatim at the Public tier. use super::*; use serde_json::json; #[test] - fn unknown_sector_tag_fails_closed_at_public_tier() { + fn unknown_product_group_tag_fails_closed_at_public_tier() { let passport = json!({ "id": "x", "productName": "Widget", - "sectorData": { - "sector": "totallyMadeUpSector", + "productGroupData": { + "productGroup": "totallyMadeUpProductGroup", "supplierCostEur": 12.50, "internalNotes": "trade secret" } }); let out = apply_access_tier_filter(passport, Audience::Public); - let sd = out.get("sectorData").expect("sectorData kept"); - // Only the sector identifier survives; the unknown sensitive fields drop. + let sd = out.get("productGroupData").expect("productGroupData kept"); + // Only the product group identifier survives; the unknown sensitive fields drop. assert_eq!( - sd.get("sector").and_then(Value::as_str), - Some("totallyMadeUpSector") + sd.get("productGroup").and_then(Value::as_str), + Some("totallyMadeUpProductGroup") ); assert!(sd.get("supplierCostEur").is_none(), "leaked: {sd}"); assert!(sd.get("internalNotes").is_none(), "leaked: {sd}"); } #[test] - fn untagged_legacy_sector_data_passes_through() { - // No `sector` tag and no recognised shape → legacy passthrough preserved. + fn untagged_legacy_product_group_data_passes_through() { + // No `product_group` tag and no recognised shape → legacy passthrough preserved. let passport = json!({ "id": "x", - "sectorData": { "someLegacyField": "value" } + "productGroupData": { "someLegacyField": "value" } }); let out = apply_access_tier_filter(passport, Audience::Public); - let sd = out.get("sectorData").expect("sectorData kept"); + let sd = out.get("productGroupData").expect("productGroupData kept"); assert_eq!( sd.get("someLegacyField").and_then(Value::as_str), Some("value") diff --git a/crates/dpp-resolver/src/handlers/resolve_qr.rs b/crates/dpp-resolver/src/handlers/resolve_qr.rs index 7568f015..44f1e2e2 100644 --- a/crates/dpp-resolver/src/handlers/resolve_qr.rs +++ b/crates/dpp-resolver/src/handlers/resolve_qr.rs @@ -50,12 +50,12 @@ async fn fetch_passport(state: &AppState, dpp_id: &str) -> Result serde_json::Value { json!({ "id": "00000000-0000-4000-8000-0000000000aa", "productName": "Test Battery", - "sector": "battery", + "productGroup": "battery", "manufacturer": { "name": "Acme Corp", "address": "1 Main St, Berlin, DE" }, "materials": [], "co2ePerUnit": null, "repairabilityScore": null, - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", "batteryType": "ev", diff --git a/crates/dpp-resolver/tests/resolver_e2e.rs b/crates/dpp-resolver/tests/resolver_e2e.rs index 180f706a..b22c7b59 100644 --- a/crates/dpp-resolver/tests/resolver_e2e.rs +++ b/crates/dpp-resolver/tests/resolver_e2e.rs @@ -3,7 +3,7 @@ //! Covers functionality not exercised by the inline unit tests in `lib.rs`: //! - QR PNG generation and cacheability //! - Content negotiation edge cases (no Accept, wildcard Accept) -//! - HTML rendering with battery and textile sector data +//! - HTML rendering with battery and textile product group data //! - Health and readiness probes //! //! JWS verification itself (valid/tampered/missing signatures, DID @@ -67,11 +67,11 @@ fn sample_passport() -> serde_json::Value { }) } -/// `sample_passport()` plus sector data carrying a GTIN — what a real +/// `sample_passport()` plus product group data carrying a GTIN — what a real /// carrier-bearing product passport looks like, for the QR tests. fn sample_passport_with_gtin() -> serde_json::Value { let mut p = sample_passport(); - p["sectorData"] = json!({ "sector": "electronics", "gtin": "09506000134352" }); + p["productGroupData"] = json!({ "productGroup": "electronics", "gtin": "09506000134352" }); p } @@ -81,8 +81,8 @@ fn sample_battery_passport() -> serde_json::Value { "productName": "EcoCell 48V", "status": "active", "manufacturer": {"name": "GreenCell GmbH", "address": "Munich, DE"}, - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "batteryChemistry": "LFP", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, @@ -100,8 +100,8 @@ fn sample_textile_passport() -> serde_json::Value { "productName": "Eco Jacket", "status": "active", "manufacturer": {"name": "FabriqGreen", "address": "Milan, IT"}, - "sectorData": { - "sector": "textile", + "productGroupData": { + "productGroup": "textile", "countryOfOrigin": "IT", "careInstructions": "Machine wash cold", "chemicalComplianceStandard": "OEKO-TEX 100", @@ -191,8 +191,8 @@ async fn qr_endpoint_returns_png() { assert!(body.len() > 100, "PNG must not be empty"); } -/// A passport whose sector data carries no GTIN (e.g. an unsold-goods report, -/// or here simply no `sectorData` at all) has no valid GS1 Digital Link +/// A passport whose product group data carries no GTIN (e.g. an unsold-goods report, +/// or here simply no `productGroupData` at all) has no valid GS1 Digital Link /// carrier to print — the endpoint must fail closed, not encode a broken or /// misleading code. #[tokio::test] @@ -294,11 +294,11 @@ async fn wildcard_accept_returns_json_ld() { } // --------------------------------------------------------------------------- -// HTML rendering with sector data +// HTML rendering with product group data // --------------------------------------------------------------------------- #[tokio::test] -async fn html_includes_battery_sector_data() { +async fn html_includes_battery_product_group_data() { let passport = sample_battery_passport(); let vault = { let p = passport.clone(); @@ -584,7 +584,7 @@ async fn gtin_resolution_verifies_a_valid_signature() { let passport = json!({ "id": passport_id, "productName": "Signed GTIN Widget", - "sectorData": { "sector": "electronics", "gtin": gtin }, + "productGroupData": { "productGroup": "electronics", "gtin": gtin }, "publicJwsSignature": valid_jws }); let vault = Router::new().route( diff --git a/crates/dpp-types/src/api_key.rs b/crates/dpp-types/src/api_key.rs index 765444d4..fad2d2b1 100644 --- a/crates/dpp-types/src/api_key.rs +++ b/crates/dpp-types/src/api_key.rs @@ -137,7 +137,7 @@ impl std::fmt::Debug for NewApiKey { } /// Request body for `POST /api/v1/api-keys`. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateApiKeyRequest { /// Human-readable name for the key (e.g. `"CI pipeline"`, `"Mobile app"`). diff --git a/crates/dpp-types/src/operator.rs b/crates/dpp-types/src/operator.rs index 0b85991f..fd7fa558 100644 --- a/crates/dpp-types/src/operator.rs +++ b/crates/dpp-types/src/operator.rs @@ -116,12 +116,12 @@ fn default_data_residency() -> String { /// and Reg. (EU) 2024/3110 (CPR) imposes the same 10 years on the economic /// operator. /// -/// This is a **floor**, not the answer: the per-sector figure lives in the -/// catalog as `SectorDescriptor::retention_years`, and a sector whose act +/// This is a **floor**, not the answer: the per-product group figure lives in the +/// catalog as `ProductGroupDescriptor::retention_years`, and a product group whose act /// demands more must carry it there. Two cases this constant deliberately does /// not express — CPR additionally requires the *construction DPP system* to /// stay accessible for **25 years**, which binds a passport service provider -/// rather than a node; and the ESPR sectors' figure is tied to expected product +/// rather than a node; and the ESPR product groups' figure is tied to expected product /// lifetime, so their catalog value is an assumption rather than a citation. pub const MIN_RETENTION_DAYS: i64 = 3650; @@ -187,7 +187,7 @@ impl OperatorConfig { /// Partial-update payload for `PATCH /api/v1/operator`. /// /// Only `Some` fields are applied; `None` fields leave the existing value unchanged. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UpdateOperatorConfig { pub legal_name: Option, diff --git a/crates/dpp-types/src/registry_identity.rs b/crates/dpp-types/src/registry_identity.rs index 9c3ba847..32e077b9 100644 --- a/crates/dpp-types/src/registry_identity.rs +++ b/crates/dpp-types/src/registry_identity.rs @@ -35,7 +35,7 @@ pub struct Facility { } /// Request body for `POST /api/v1/facilities`. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateFacilityRequest { pub name: String, @@ -69,7 +69,7 @@ pub struct OperatorIdentifier { } /// Request body for `POST /api/v1/operator-identifiers`. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateOperatorIdentifierRequest { pub scheme: String, diff --git a/crates/dpp-vault/src/domain/compliance/calc_battery.rs b/crates/dpp-vault/src/domain/compliance/calc_battery.rs index 2afb8c41..286ac0fc 100644 --- a/crates/dpp-vault/src/domain/compliance/calc_battery.rs +++ b/crates/dpp-vault/src/domain/compliance/calc_battery.rs @@ -33,7 +33,7 @@ use dpp_calc::clock::AssessmentClock; use dpp_calc::recycled_content::{RecycledContentInputs, calculate}; use dpp_calc::ruleset_registry::resolve_recycled_content; use dpp_domain::compliance::PassthroughBatteryStrategy; -use dpp_domain::domain::sector::{BatteryData, SectorData}; +use dpp_domain::domain::product_group::{BatteryData, ProductGroupData}; use dpp_domain::ports::compliance::{ ComplianceError, ComplianceErrorKind, ComplianceFinding, ComplianceResult, ComplianceStrategy, }; @@ -46,21 +46,21 @@ use dpp_rules::batteries::recycled_content::{ pub struct CalcBatteryStrategy; impl ComplianceStrategy for CalcBatteryStrategy { - fn sector_key(&self) -> &str { + fn product_group_key(&self) -> &str { "battery" } fn compute( &self, - data: &SectorData, + data: &ProductGroupData, law_in_force_on: Option, ) -> Result { - let SectorData::Battery(battery) = data else { + let ProductGroupData::Battery(battery) = data else { return Err(ComplianceError { kind: ComplianceErrorKind::InvalidInput, message: format!( "battery strategy received {} data", - data.sector().catalog_key() + data.product_group().catalog_key() ), }); }; diff --git a/crates/dpp-vault/src/domain/compliance/tests.rs b/crates/dpp-vault/src/domain/compliance/tests.rs index d479d7ab..e3afd5e1 100644 --- a/crates/dpp-vault/src/domain/compliance/tests.rs +++ b/crates/dpp-vault/src/domain/compliance/tests.rs @@ -8,8 +8,8 @@ use chrono::NaiveDate; use dpp_domain::Gtin; -use dpp_domain::domain::sector::{ - BatteryChemistry, BatteryData, BatteryType, SectorData, TextileData, +use dpp_domain::domain::product_group::{ + BatteryChemistry, BatteryData, BatteryType, ProductGroupData, TextileData, }; use dpp_domain::ports::compliance::{ComplianceErrorKind, ComplianceStatus, ComplianceStrategy}; @@ -20,8 +20,8 @@ fn day(y: i32, m: u32, d: u32) -> NaiveDate { } /// An EV battery whose declared shares clear Art. 8(2) and fail Art. 8(3). -fn ev_battery() -> SectorData { - SectorData::Battery(Box::new(BatteryData { +fn ev_battery() -> ProductGroupData { + ProductGroupData::Battery(Box::new(BatteryData { gtin: Gtin::parse("09506000134352").expect("valid gtin"), battery_chemistry: BatteryChemistry::Nmc, battery_type: BatteryType::Ev, @@ -154,7 +154,7 @@ fn a_missing_market_date_is_reported_rather_than_guessed() { /// not measured against a minimum it cannot have. #[test] fn shares_are_scoped_to_the_metals_the_chemistry_contains() { - let SectorData::Battery(mut b) = ev_battery() else { + let ProductGroupData::Battery(mut b) = ev_battery() else { unreachable!() }; b.battery_chemistry = BatteryChemistry::Lfp; @@ -164,7 +164,7 @@ fn shares_are_scoped_to_the_metals_the_chemistry_contains() { b.recycled_content_lithium_pct = Some(12.0); let result = CalcBatteryStrategy - .compute(&SectorData::Battery(b), Some(day(2036, 8, 18))) + .compute(&ProductGroupData::Battery(b), Some(day(2036, 8, 18))) .expect("battery data"); let codes = shortfall_codes(&result); @@ -178,13 +178,13 @@ fn shares_are_scoped_to_the_metals_the_chemistry_contains() { /// Portable batteries are outside Art. 8 entirely — no finding, no receipt. #[test] fn a_portable_battery_is_outside_art8() { - let SectorData::Battery(mut b) = ev_battery() else { + let ProductGroupData::Battery(mut b) = ev_battery() else { unreachable!() }; b.battery_type = BatteryType::Portable; let result = CalcBatteryStrategy - .compute(&SectorData::Battery(b), Some(day(2036, 8, 18))) + .compute(&ProductGroupData::Battery(b), Some(day(2036, 8, 18))) .expect("battery data"); assert!(shortfall_codes(&result).is_empty(), "{result:?}"); @@ -207,8 +207,8 @@ fn the_declared_metrics_are_still_lifted() { /// A routing mistake is reportable, not fatal. #[test] -fn another_sectors_data_is_refused() { - let textile = SectorData::Textile(Box::new( +fn another_product_groups_data_is_refused() { + let textile = ProductGroupData::Textile(Box::new( serde_json::from_value::(serde_json::json!({ "gtin": "09506000134352", "fibreComposition": [{"fibre": "cotton", "pct": 100.0}], diff --git a/crates/dpp-vault/src/domain/service/create.rs b/crates/dpp-vault/src/domain/service/create.rs index d82b2e04..67084962 100644 --- a/crates/dpp-vault/src/domain/service/create.rs +++ b/crates/dpp-vault/src/domain/service/create.rs @@ -12,7 +12,7 @@ use dpp_domain::{ error::DppError, graph::{ComponentEdges, DEFAULT_DEPTH_CAP, EdgeRejection, check_edge}, passport::{Passport, PassportId, PassportRef}, - sector::{CarbonFootprint, RepairabilityScore, SectorData}, + product_group::{CarbonFootprint, ProductGroupData, RepairabilityScore}, status::PassportStatus, }, ports::compliance::ComplianceRegistry, @@ -27,7 +27,7 @@ use super::catalog; impl PassportService { /// Create a new passport in `Draft` status. /// - /// Assigns a fresh `PassportId`, normalises `schema_version` from the sector + /// Assigns a fresh `PassportId`, normalises `schema_version` from the product group /// catalog, runs compliance enrichment, persists, appends an audit entry, /// and emits `dpp.passport.created` (non-blocking — failure is logged only). #[tracing::instrument(skip(self, passport), fields(passport_id = tracing::field::Empty))] @@ -42,7 +42,7 @@ impl PassportService { passport.created_at = Utc::now(); passport.updated_at = Utc::now(); passport.schema_version = catalog() - .current_schema_version(passport.sector.catalog_key()) + .current_schema_version(passport.product_group.catalog_key()) .unwrap_or("1.0.0") .to_owned(); @@ -256,16 +256,20 @@ fn local_component_id(uri: &str) -> Option { } fn apply_compliance(passport: &mut Passport, registry: &dyn ComplianceRegistry) { - let Some(sector_data) = passport.sector_data.as_ref() else { + let Some(product_group_data) = passport.product_group_data.as_ref() else { return; }; - let sector = sector_data.sector(); + let product_group = product_group_data.product_group(); // The date the governing law attached to this product, read from its own // record. Never `Utc::now()`: a determination made against today's date is // wrong for every product not placed on the market today, and would change // its own answer as phase dates pass. let law_in_force_on = passport.placed_on_market_date; - if let Ok(mut result) = registry.compute(sector.catalog_key(), sector_data, law_in_force_on) { + if let Ok(mut result) = registry.compute( + product_group.catalog_key(), + product_group_data, + law_in_force_on, + ) { // Backfill the two display metrics only when the caller didn't supply them. if passport.co2e_per_unit.is_none() { passport.co2e_per_unit = result.co2e_score.map(CarbonFootprint::from_kg); @@ -288,10 +292,10 @@ fn apply_compliance(passport: &mut Passport, registry: &dyn ComplianceRegistry) /// Backfill `lint_result` from the `dpp-rules` plausibility lint pack. /// Unlike `apply_compliance`, always overwrites — the pack is cheap to /// re-run and freshness (not preserving a caller-supplied value) is the -/// point. A no-op when the passport carries no sector data. +/// point. A no-op when the passport carries no product group data. fn apply_lint(passport: &mut Passport) { - if let Some(sector_data) = passport.sector_data.as_ref() { - passport.lint_result = Some(dpp_domain::LintResult::compute(sector_data)); + if let Some(product_group_data) = passport.product_group_data.as_ref() { + passport.lint_result = Some(dpp_domain::LintResult::compute(product_group_data)); } } @@ -313,7 +317,7 @@ const PATCHABLE_FIELDS: [&str; 5] = [ "productName", "co2ePerUnit", "repairabilityScore", - "sectorData", + "productGroupData", "componentRefs", ]; @@ -335,7 +339,7 @@ fn delta_for(passport: &Passport, applied: &[&'static str]) -> serde_json::Map serde_json::json!(passport.product_name), "co2ePerUnit" => serde_json::json!(passport.co2e_per_unit), "repairabilityScore" => serde_json::json!(passport.repairability_score), - "sectorData" => serde_json::json!(passport.sector_data), + "productGroupData" => serde_json::json!(passport.product_group_data), "componentRefs" => serde_json::json!(passport.component_refs), // Unreachable: `applied` only ever contains `PATCHABLE_FIELDS` // entries, and adding one there without a case here fails the @@ -357,7 +361,7 @@ fn delta_for(passport: &Passport, applied: &[&'static str]) -> serde_json::Map = serde_json::from_value(v.clone()) @@ -438,7 +443,7 @@ mod tests { domain::{ error::DppError, passport::{ManufacturerInfo, Passport, PassportId}, - sector::{Sector, SectorData}, + product_group::{ProductGroup, ProductGroupData}, status::PassportStatus, }, ports::compliance::{ @@ -451,7 +456,9 @@ mod tests { id: PassportId::new(), batch_id: None, product_name: "Test".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), @@ -462,7 +469,7 @@ mod tests { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Draft, qr_code_url: None, jws_signature: None, @@ -493,11 +500,11 @@ mod tests { fn compute( &self, _: &str, - _: &SectorData, + _: &ProductGroupData, _: Option, ) -> Result { Err(ComplianceError { - kind: ComplianceErrorKind::UnknownSector, + kind: ComplianceErrorKind::UnknownProductGroup, message: "noop".into(), }) } @@ -580,12 +587,12 @@ mod tests { } /// A create-shaped body is what the integrator actually PUTs on its - /// `update_draft` path, and `CreatePassportRequest` serialises `sector` and + /// `update_draft` path, and `CreatePassportRequest` serialises `product_group` and /// `schemaVersion` as explicit `null` (neither carries /// `skip_serializing_if`). Both are in the repository's protected list, and /// `contains_key` is true for a null value — so the old /// echo-the-request-body delta made that request fail with - /// "cannot modify protected field(s): schemaVersion, sector" on **every** + /// "cannot modify protected field(s): schemaVersion, product group" on **every** /// call. Building from the allow-list drops them before the repository sees /// them. #[test] @@ -593,14 +600,14 @@ mod tests { let mut p = stub(); let patch = serde_json::json!({ "productName": "Imported", - "sector": serde_json::Value::Null, + "productGroup": serde_json::Value::Null, "schemaVersion": serde_json::Value::Null, "manufacturer": { "name": "ACME", "address": "1 Street" }, "batchId": serde_json::Value::Null, }); let applied = apply_patch(&mut p, &patch).unwrap(); let delta = super::delta_for(&p, &applied); - for protected in ["sector", "schemaVersion", "manufacturer", "batchId"] { + for protected in ["productGroup", "schemaVersion", "manufacturer", "batchId"] { assert!(!delta.contains_key(protected), "{protected} leaked"); } assert_eq!(delta.len(), 1, "only productName should be written"); @@ -663,11 +670,11 @@ mod tests { } #[test] - fn patch_invalid_sector_data_returns_validation_error() { + fn patch_invalid_product_group_data_returns_validation_error() { let mut p = stub(); let err = apply_patch( &mut p, - &serde_json::json!({"sectorData": {"type": "unknown", "garbage": true}}), + &serde_json::json!({"productGroupData": {"type": "unknown", "garbage": true}}), ) .unwrap_err(); assert!(matches!(err, DppError::Validation(_))); @@ -684,8 +691,8 @@ mod tests { // ── apply_compliance ───────────────────────────────────────────────────── #[test] - fn no_sector_data_is_noop() { - let mut p = stub(); // sector_data is None → early return + fn no_product_group_data_is_noop() { + let mut p = stub(); // product_group_data is None → early return apply_compliance(&mut p, &NoopRegistry); assert!(p.co2e_per_unit.is_none()); assert!(p.repairability_score.is_none()); diff --git a/crates/dpp-vault/src/domain/service/lifecycle.rs b/crates/dpp-vault/src/domain/service/lifecycle.rs index cfac3198..464338ce 100644 --- a/crates/dpp-vault/src/domain/service/lifecycle.rs +++ b/crates/dpp-vault/src/domain/service/lifecycle.rs @@ -86,7 +86,7 @@ impl PassportService { /// Permanently archive a passport after retention expiry. /// /// Blocked by the ESPR retention guard: if `retention_locked` is set and the - /// sector's minimum retention period has not yet elapsed from `published_at`, + /// product group's minimum retention period has not yet elapsed from `published_at`, /// returns `DppError::Validation`. Emits `dpp.passport.archived`. #[tracing::instrument(skip(self), fields(passport_id = %id))] pub async fn archive(&self, id: PassportId, auth: &AuthContext) -> Result { @@ -108,7 +108,7 @@ impl PassportService { { // Same resolver publish uses, so the guard and the sealed deadline // agree by construction. - let retention_years = i64::from(retention_years_for(&passport.sector)); + let retention_years = i64::from(retention_years_for(&passport.product_group)); let retention_end = published_at + chrono::Duration::days(365 * retention_years); if Utc::now() < retention_end { tracing::warn!( diff --git a/crates/dpp-vault/src/domain/service/lint.rs b/crates/dpp-vault/src/domain/service/lint.rs index 31ba38fe..2909e201 100644 --- a/crates/dpp-vault/src/domain/service/lint.rs +++ b/crates/dpp-vault/src/domain/service/lint.rs @@ -8,9 +8,9 @@ use dpp_domain::domain::{ use super::PassportService; impl PassportService { - /// Re-run the plausibility lint pack against a passport's current sector + /// Re-run the plausibility lint pack against a passport's current product group /// data and persist the refreshed result. A no-op (returns the passport - /// unchanged) when it carries no sector data. + /// unchanged) when it carries no product group data. /// /// Unlike every other mutating method in this module, this does **not** /// append an audit entry or emit an event: lint findings are advisory @@ -31,10 +31,10 @@ impl PassportService { pub async fn relint(&self, id: PassportId) -> Result { let passport = self.find_by_id(id).await?; - let Some(sector_data) = passport.sector_data.as_ref() else { + let Some(product_group_data) = passport.product_group_data.as_ref() else { return Ok(passport); }; - let lint_result = dpp_domain::LintResult::compute(sector_data); + let lint_result = dpp_domain::LintResult::compute(product_group_data); self.repo .patch_fields(id, serde_json::json!({ "lintResult": lint_result })) diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index a9af8783..ab8922b9 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -319,32 +319,67 @@ impl PassportService { } } -/// Process-wide sector catalog (manifests parsed once). Shared across the +/// Process-wide product group catalog (manifests parsed once). Shared across the /// split lifecycle files — `super::catalog()` from any of them. -fn catalog() -> &'static dpp_domain::SectorCatalog { - static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); - CATALOG.get_or_init(dpp_domain::SectorCatalog::new) +fn catalog() -> &'static dpp_domain::ProductGroupCatalog { + static CATALOG: std::sync::OnceLock = + std::sync::OnceLock::new(); + CATALOG.get_or_init(dpp_domain::ProductGroupCatalog::new) +} + +/// Process-wide instrument catalog (manifests parsed once). The product group +/// catalog no longer carries any law — status, dates and retention are properties +/// of an act reaching a group, and a group may be reached by several. +pub(crate) fn instruments() -> &'static dpp_domain::InstrumentCatalog { + static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); + CATALOG.get_or_init(dpp_domain::InstrumentCatalog::new) } -/// The retention floor applied when the catalog has no entry for a sector. +/// Whether a **binding passport obligation** is live for `product_group_key` — +/// some act reaching it is in force *and* requires a passport. +/// +/// Both halves are needed and neither implies the other, which is the whole +/// reason this is a function rather than a flag on the product group: +/// +/// - ESPR Arts. 24-25 bind today and impose no passport at all; and +/// - the ecodesign and energy-labelling pair for mobile devices binds today +/// while its information duty is discharged through EPREL instead of a +/// passport, under ESPR Art. 9(4)(b). /// -/// Not an invented number: every sector the catalog does describe declares ten +/// Asking only "is it in force" answers *yes* for both, which is how passport +/// obligations that do not exist came to be enforced at publish. +pub(crate) fn passport_obligation_live(product_group_key: &str) -> bool { + let catalog = instruments(); + !catalog.determinable_for(product_group_key).is_empty() + && catalog.passport_required_for(product_group_key) +} + +/// The retention floor applied when the catalog has no entry for a product group. +/// +/// Not an invented number: every product group the catalog does describe declares ten /// years, and `docs/legal/DPP-RETENTION.md` records ">= 10 years" as the floor /// across all of them, pending delegated acts included. /// -/// A sector with no catalog entry is reachable — a passport carrying no sector -/// data at all resolves to `Sector::Other`, and the open sector axis means a +/// A product group with no catalog entry is reachable — a passport carrying no product group +/// data at all resolves to `ProductGroup::Other`, and the open product group axis means a /// passport can name a product group this build has never seen. Refusing to /// publish those would be a larger behaviour change than sourcing retention /// warrants, so the floor applies and the data is kept at least as long as any /// known obligation requires. pub(crate) const RETENTION_FLOOR_YEARS: u32 = 10; -/// Years of retention for a sector: the catalog's value, else the floor. -pub(crate) fn retention_years_for(sector: &dpp_domain::Sector) -> u32 { - catalog() - .retention_years(sector.catalog_key()) - .unwrap_or(RETENTION_FLOOR_YEARS) +/// Years of retention for a product group: the longest any applicable act +/// imposes, else the floor. +/// +/// The **maximum** across acts, not a single figure, because retention periods +/// are floors: a record kept long enough for the longest satisfies all of them, +/// and a product group reached by two acts owes both. The fold and its +/// provenance marker live in the instrument catalog; only the number is needed +/// here, since a passport is either kept long enough or it is not. +pub(crate) fn retention_years_for(product_group: &dpp_domain::ProductGroup) -> u32 { + instruments() + .retention_for(product_group.catalog_key()) + .map_or(RETENTION_FLOOR_YEARS, |(years, _basis)| years) } /// Process-wide versioned JSON Schema registry (parsed once). @@ -360,7 +395,7 @@ mod snapshot_render_tests { use chrono::Utc; use dpp_domain::domain::{ passport::{ManufacturerInfo, Passport, PassportId}, - sector::Sector, + product_group::ProductGroup, status::PassportStatus, }; @@ -380,7 +415,9 @@ mod snapshot_render_tests { id: PassportId::new(), batch_id: None, product_name: "Snapshot Test".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), @@ -391,7 +428,7 @@ mod snapshot_render_tests { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Published, qr_code_url: Some("https://id.example/01/09506000134352".into()), jws_signature: Some("full.jws.signature".into()), diff --git a/crates/dpp-vault/src/domain/service/publish.rs b/crates/dpp-vault/src/domain/service/publish.rs index 874fdb66..273004d6 100644 --- a/crates/dpp-vault/src/domain/service/publish.rs +++ b/crates/dpp-vault/src/domain/service/publish.rs @@ -9,7 +9,7 @@ use dpp_domain::{ domain::{ error::DppError, passport::{Passport, PassportId}, - sector::SectorData, + product_group::ProductGroupData, status::PassportStatus, }, ports::registry_sync::{RegisteringOperator, RegistrationGranularity, RegistrationRequest}, @@ -26,13 +26,13 @@ use super::{catalog, schema_registry}; mod reason { pub const INVALID_TRANSITION: &str = "invalid_transition"; pub const MISSING_REGISTRY_IDENTITY: &str = "missing_registry_identity"; - pub const SECTOR_DATA_INVALID: &str = "sector_data_invalid"; + pub const SECTOR_DATA_INVALID: &str = "product_group_data_invalid"; pub const SCHEMA_INVALID: &str = "schema_invalid"; pub const COMPLIANCE_VIOLATIONS: &str = "compliance_violations"; pub const SIGNING_FAILED: &str = "signing_failed"; /// The battery's category requires content the passport does not carry. /// - /// Distinct from `sector_data_invalid`, which is data that is present and + /// Distinct from `product_group_data_invalid`, which is data that is present and /// wrong. This is data that is absent, and it is the one rejection a caller /// cannot diagnose from their own request: the requirement lives in /// `dpp-domain`'s category table, not in anything they sent. @@ -75,7 +75,7 @@ fn reject(reason: &'static str, e: DppError) -> DppError { impl PassportService { /// Sign and publish a draft passport with Ed25519 / JWS. /// - /// Validates sector data, calls the identity service to sign, atomically + /// Validates product group data, calls the identity service to sign, atomically /// writes the JWS + QR URL + `Published` status, seals the retention clock, /// fires non-blocking EU registry sync, and emits `dpp.passport.published`. /// @@ -100,7 +100,7 @@ impl PassportService { )); } - // Annex III completeness (ESPR): a published DPP for an in-force sector must + // Annex III completeness (ESPR): a published DPP for an in-force product group must // carry the unique facility identifier (Annex III point (i)) and the // responsible-operator identifier (point (k)). Backfill from the current // registry defaults first — so a draft created before the default facility / @@ -127,7 +127,7 @@ impl PassportService { primary_identifier.as_ref().map(|(_, value)| value.clone()); } } - if catalog().is_in_force(passport.sector.catalog_key()) { + if super::passport_obligation_live(passport.product_group.catalog_key()) { let mut missing = Vec::new(); if passport.facility.is_none() { missing.push("facility (Annex III unique facility identifier)"); @@ -156,32 +156,32 @@ impl PassportService { } } - // Publish-time validation (domain Gap 7 / vault V3): never sign sector + // Publish-time validation (domain Gap 7 / vault V3): never sign product group // data that fails its JSON Schema + cross-field rules. // - // NOTE: this validates sector data *when present*. Hard-requiring its - // presence at publish (a published EU DPP should always carry sector + // NOTE: this validates product group data *when present*. Hard-requiring its + // presence at publish (a published EU DPP should always carry product group // data) is the stricter completeness policy — deferred until the // integration fixtures that publish minimal passports are updated and a // Docker run confirms them (roadmap 1.3). - if let Some(sector_data) = passport.sector_data.as_ref() { - dpp_domain::validate_sector_data(sector_data) + if let Some(product_group_data) = passport.product_group_data.as_ref() { + dpp_domain::validate_product_group_data(product_group_data) .map_err(|e| reject(REASON_SECTOR_DATA_INVALID, DppError::Validation(e)))?; // JSON Schema gate (fail-closed): enum sets, string patterns, and // numeric ranges that the Rust types don't enforce. Runs after typed // validation so field-level messages are the primary signal. - validate_schema_for_publish(sector_data) + validate_schema_for_publish(product_group_data) .map_err(|e| reject(REASON_SCHEMA_INVALID, e))?; - // Compliance gate: a sector whose DPP obligation is in force must not + // Compliance gate: a product group whose DPP obligation is in force must not // be signed/published while it carries *binding* violations. Advisory // warnings (e.g. recycled-content thresholds not yet in force) never // block — they are surfaced on the persisted determination instead. - if catalog().is_in_force(sector_data.sector().catalog_key()) + if super::passport_obligation_live(product_group_data.product_group().catalog_key()) && let Ok(determination) = self.compliance.compute( - sector_data.sector().catalog_key(), - sector_data, + product_group_data.product_group().catalog_key(), + product_group_data, passport.placed_on_market_date, ) && determination.has_violations() @@ -233,7 +233,7 @@ impl PassportService { .map_err(|e| reject(REASON_MANDATORY_CONTENT, e))?; // Engine-side obligations, which core has no view of: the retention - // horizon comes from this deployment's sector catalog, and the carrier + // horizon comes from this deployment's product group catalog, and the carrier // URL from its resolver. Both are derived from the timestamp core just // set, so all three agree on when the publish happened. if first_publish && passport.retention_until.is_none() { @@ -242,7 +242,7 @@ impl PassportService { // act that imposes it. A stricter delegated-act period can be set // by the operator before publishing. let published_at = passport.published_at.unwrap_or_else(Utc::now); - let years = retention_years_for(&passport.sector); + let years = retention_years_for(&passport.product_group); passport.retention_until = Some(published_at + chrono::Duration::days(365 * i64::from(years))); } @@ -282,7 +282,7 @@ impl PassportService { // stays Confidential for authenticated full-passport verification. let public_view = crate::public_view::public_view( &payload, - passport.sector.catalog_key(), + passport.product_group.catalog_key(), &passport.schema_version, ); let public_jws = self @@ -314,7 +314,7 @@ impl PassportService { self.identity.as_ref(), passport.id, &payload, - passport.sector.catalog_key(), + passport.product_group.catalog_key(), &passport.schema_version, ) .await @@ -423,7 +423,7 @@ impl PassportService { // 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 // deadline cannot disagree. - let retention_years = retention_years_for(&updated.sector); + let retention_years = retention_years_for(&updated.product_group); if let Err(e) = self.archive.archive(&updated, retention_years).await { tracing::warn!( passport_id = %updated.id, @@ -456,7 +456,7 @@ impl PassportService { } impl PassportService { - /// Whether this sector data would clear the publish-time gates, without + /// Whether this product group data would clear the publish-time gates, without /// publishing anything. /// /// Runs the same two checks `publish` runs, in the same order, so a @@ -464,45 +464,46 @@ impl PassportService { /// the compliance gate below them: that needs a `placed_on_market_date` /// and a persisted passport, and reporting "compliant" against a date the /// caller has not supplied would be a fabricated answer. - pub fn publish_readiness(&self, sector_data: &SectorData) -> Result<(), DppError> { - dpp_domain::validate_sector_data(sector_data).map_err(DppError::Validation)?; - validate_schema_for_publish(sector_data) + pub fn publish_readiness(&self, product_group_data: &ProductGroupData) -> Result<(), DppError> { + dpp_domain::validate_product_group_data(product_group_data) + .map_err(DppError::Validation)?; + validate_schema_for_publish(product_group_data) } } -/// Validate `sector_data` against its sector's current JSON Schema before it +/// Validate `product_group_data` against its product group's current JSON Schema before it /// can be published. Fails closed: a published, signed DPP must pass a real -/// schema check whenever it carries sector data — unlike `create`, where a +/// schema check whenever it carries product group data — unlike `create`, where a /// draft may stay lenient. `Ok` covers a resolved-and-valid schema; `Err` /// covers both a resolved-but-invalid schema and no schema resolved at all. -fn validate_schema_for_publish(sector_data: &SectorData) -> Result<(), DppError> { - let schema_key = sector_data.sector().catalog_key().to_owned(); +fn validate_schema_for_publish(product_group_data: &ProductGroupData) -> Result<(), DppError> { + let schema_key = product_group_data.product_group().catalog_key().to_owned(); let Some(schema_version) = catalog().resolve_schema_version(&schema_key, None) else { - // Every built-in sector has a catalog entry (CI-enforced parity guard), - // so this is unreachable via `SectorData`'s named variants today; the - // only value that resolves here is `SectorData::Other`, which is itself - // already blocked by `validate_sector_data` above (no "other" validator + // Every built-in product group has a catalog entry (CI-enforced parity guard), + // so this is unreachable via `ProductGroupData`'s named variants today; the + // only value that resolves here is `ProductGroupData::Other`, which is itself + // already blocked by `validate_product_group_data` above (no "other" validator // is registered by default). Kept fail-closed as defence in depth for - // when the open sector model gains a real per-sector validator. - metrics::counter!("publish_schema_unresolved_total", "sector" => schema_key.clone()) + // when the open product group model gains a real per-product group validator. + metrics::counter!("publish_schema_unresolved_total", "productGroup" => schema_key.clone()) .increment(1); tracing::warn!( - sector = %schema_key, - "publish blocked — no registered JSON Schema for this sector" + product_group = %schema_key, + "publish blocked — no registered JSON Schema for this product_group" ); return Err(DppError::Validation( format!( - "cannot publish: no registered JSON Schema for sector '{schema_key}' — \ - publish requires a resolvable schema when sector data is present" + "cannot publish: no registered JSON Schema for product_group '{schema_key}' — \ + publish requires a resolvable schema when product_group data is present" ) .into(), )); }; - let mut sd_json = - serde_json::to_value(sector_data).map_err(|e| DppError::Serialisation(e.to_string()))?; - // SectorData is internally tagged; schemas validate the inner object. + let mut sd_json = serde_json::to_value(product_group_data) + .map_err(|e| DppError::Serialisation(e.to_string()))?; + // ProductGroupData is internally tagged; schemas validate the inner object. if let Some(obj) = sd_json.as_object_mut() { - obj.remove("sector"); + obj.remove("productGroup"); } schema_registry() .validate_strict(&schema_key, &schema_version, &sd_json) @@ -512,7 +513,7 @@ fn validate_schema_for_publish(sector_data: &SectorData) -> Result<(), DppError> /// Build the carrier (QR / Data Matrix) URL a passport should encode, on the /// node's configured resolver base. /// -/// When the sector data carries a GTIN — every trade-item sector — produces a +/// When the product group data carries a GTIN — every trade-item product group — produces a /// GS1 Digital Link (`{base}/01/{gtin}[/10/{batch}]/21/{serial}`) with a /// GS1-conformant 20-char serial derived from the passport id. When it does not /// (an unsold-goods report or untyped record, which identify no trade item), @@ -520,7 +521,11 @@ fn validate_schema_for_publish(sector_data: &SectorData) -> Result<(), DppError> /// never a hardcoded host. fn build_carrier_url(passport: &Passport, resolver_base: &str) -> String { let base = resolver_base.trim_end_matches('/'); - match passport.sector_data.as_ref().and_then(SectorData::gtin) { + match passport + .product_group_data + .as_ref() + .and_then(ProductGroupData::gtin) + { Some(gtin) => build_qr_url( base, gtin, @@ -589,7 +594,7 @@ mod tests { use dpp_domain::domain::{ error::DppError, passport::{ManufacturerInfo, Passport, PassportId}, - sector::{Sector, SectorData}, + product_group::{ProductGroup, ProductGroupData}, status::PassportStatus, }; @@ -598,7 +603,9 @@ mod tests { id: PassportId::new(), batch_id: None, product_name: "Test".into(), - sector: Sector::Battery, + product_group: ProductGroup::Battery, + applicable_instruments: Vec::new(), + granularity: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), @@ -609,7 +616,7 @@ mod tests { repairability_score: None, compliance_result: None, lint_result: None, - sector_data: None, + product_group_data: None, status: PassportStatus::Draft, qr_code_url: None, jws_signature: None, @@ -641,17 +648,17 @@ mod tests { // An unsold-goods report / untyped record carries no trade-item GTIN, so // the carrier points at the passport's own page on the configured base — // never the old hardcoded `p.odal-node.io` host. - let p = stub(); // sector_data is None → no GTIN + let p = stub(); // product_group_data is None → no GTIN let url = build_carrier_url(&p, "https://id.example.com/"); assert_eq!(url, format!("https://id.example.com/dpp/{}", p.id)); assert!(!url.contains("p.odal-node.io")); } #[test] - fn gtin_sector_builds_gs1_dl_with_conformant_serial() { - use dpp_domain::domain::sector::ConstructionData; + fn gtin_product_group_builds_gs1_dl_with_conformant_serial() { + use dpp_domain::domain::product_group::ConstructionData; let mut p = stub(); - p.sector_data = Some(SectorData::Construction(ConstructionData { + p.product_group_data = Some(ProductGroupData::Construction(ConstructionData { gtin: dpp_domain::Gtin::parse("09506000134352").unwrap(), product_family: "cement".into(), country_of_origin: "DE".into(), @@ -678,12 +685,12 @@ mod tests { // ── validate_schema_for_publish (Q-2) ──────────────────────────────────── #[test] - fn unresolvable_sector_schema_fails_closed() { - // `SectorData::Other`'s catalog key ("other") has no embedded schema — - // the only value that can reach this branch, since every named sector + fn unresolvable_product_group_schema_fails_closed() { + // `ProductGroupData::Other`'s catalog key ("other") has no embedded schema — + // the only value that can reach this branch, since every named product group // has a catalog entry (CI-enforced parity guard). Publish must refuse // it outright, not warn-and-pass. - let sd = SectorData::other(serde_json::json!({"anything": "goes"})) + let sd = ProductGroupData::other(serde_json::json!({"anything": "goes"})) .expect("an untagged payload has no typed variant"); let err = validate_schema_for_publish(&sd).unwrap_err(); assert!(matches!(err, DppError::Validation(_))); diff --git a/crates/dpp-vault/src/domain/service/query.rs b/crates/dpp-vault/src/domain/service/query.rs index 1ff58536..2e37366d 100644 --- a/crates/dpp-vault/src/domain/service/query.rs +++ b/crates/dpp-vault/src/domain/service/query.rs @@ -29,11 +29,21 @@ impl PassportService { } /// Fetch a published passport by GS1 GTIN (O(n) scan — see `PgPassportRepo`). + /// + /// **Not for a public route.** It folds "no such GTIN" and "suspended" into + /// the same `None`, so a caller cannot serve `410 Gone` for a recall. Use + /// [`find_by_gtin_any_status`](Self::find_by_gtin_any_status) there. pub async fn find_published_by_gtin(&self, gtin: &str) -> Result, DppError> { self.repo.find_published_by_gtin(gtin).await } - /// Fetch a passport by exact compound identity (sector, GTIN, batch), + /// Fetch a passport by GS1 GTIN regardless of status, so the caller can + /// distinguish a missing product from a recalled one. + pub async fn find_by_gtin_any_status(&self, gtin: &str) -> Result, DppError> { + self.repo.find_by_gtin_any_status(gtin).await + } + + /// Fetch a passport by exact compound identity (product group, GTIN, batch), /// across `Draft` and `Published` — the import delta-matcher's lookup. pub async fn find_by_identity( &self, diff --git a/crates/dpp-vault/src/handlers/archive.rs b/crates/dpp-vault/src/handlers/archive.rs index 44a252dc..d8a90fd8 100644 --- a/crates/dpp-vault/src/handlers/archive.rs +++ b/crates/dpp-vault/src/handlers/archive.rs @@ -16,7 +16,7 @@ use super::error::{ /// `POST /api/v1/dpp/{dppId}/archive` — permanently archive a published or suspended passport. /// -/// Blocked by the ESPR retention guard until the sector's minimum retention +/// 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( State(state): State, diff --git a/crates/dpp-vault/src/handlers/audience_read.rs b/crates/dpp-vault/src/handlers/audience_read.rs index 4d940649..a7892df7 100644 --- a/crates/dpp-vault/src/handlers/audience_read.rs +++ b/crates/dpp-vault/src/handlers/audience_read.rs @@ -40,15 +40,15 @@ //! # What this route still does not do //! //! Product-category scope is not *evaluated*, and a credential that claims one -//! therefore unlocks nothing here. Sector scope is enforced; category has no +//! therefore unlocks nothing here. ProductGroup scope is enforced; category has no //! counterpart to be enforced against, because a passport carries no product -//! category — the field that once did was renamed `sector`, and category is now -//! a per-sector concept with a different shape in each. +//! category — the field that once did was renamed `product_group`, and category is now +//! a per-product group concept with a different shape in each. //! //! Rather than reading an unevaluable restriction as no restriction, which //! granted a narrowed credential its full audience over every product in its -//! sectors, such a credential is downgraded to `Audience::Public`. See -//! [`crate::middleware::credential::VerifiedCredential::audience_for_sector`]. +//! product groups, such a credential is downgraded to `Audience::Public`. See +//! [`crate::middleware::credential::VerifiedCredential::audience_for_product_group`]. use axum::{ extract::{Path, State}, @@ -82,7 +82,7 @@ pub async fn audience_read_handler( // Authenticate the caller *before* touching the database: a rejected // credential gets the same answer whether or not the passport exists, so // this route cannot be used to probe for passport ids. The credential is - // only held here — what it *grants* depends on the passport's sector, which + // only held here — what it *grants* depends on the passport's product group, which // is not known until the row is loaded. let credential = match (&state.credential_directory, &state.trusted_issuers) { (Some(directory), Some(trust)) => { @@ -101,11 +101,11 @@ pub async fn audience_read_handler( match state.service.find_by_id_any_status(passport_id).await { Ok(Some(p)) if p.status == PassportStatus::Published => { - // Scope the credential to *this* passport's sector. A credential + // Scope the credential to *this* passport's product group. A credential // issued for batteries grants nothing extra on a textile passport. let audience = match (&credential, &state.trusted_issuers) { (Some(c), Some(trust)) => { - c.audience_for_sector(p.sector.catalog_key(), trust.as_ref()) + c.audience_for_product_group(p.product_group.catalog_key(), trust.as_ref()) } _ => Audience::Public, }; @@ -209,7 +209,7 @@ async fn record_access( "holderName": subject.name, "disclosureSet": granted.disclosure_key(), "disclosureClasses": classes, - "sector": passport.sector.catalog_key(), + "productGroup": passport.product_group.catalog_key(), })); state.service.audit.append(entry).await @@ -218,8 +218,13 @@ async fn record_access( /// Strip the fields a given audience may not see. Exposed for tests and for the /// snapshot path; the route above is the only production caller. #[must_use] -pub fn view_for(full: &Value, sector_key: &str, schema_version: &str, audience: Audience) -> Value { - audience_view(full, sector_key, schema_version, audience) +pub fn view_for( + full: &Value, + product_group_key: &str, + schema_version: &str, + audience: Audience, +) -> Value { + audience_view(full, product_group_key, schema_version, audience) } #[cfg(test)] @@ -231,7 +236,7 @@ mod tests { /// /// A real version, not a placeholder: disclosure classes are now resolved /// from the passport's own schema version, so an invented one resolves to no - /// policy and the fail-closed path strips `sectorData` entirely — every + /// policy and the fail-closed path strips `productGroupData` entirely — every /// assertion below would then pass or fail for the wrong reason. const BATTERY_SCHEMA: &str = "2.6.0"; @@ -251,8 +256,8 @@ mod tests { "retentionLocked": true, "jwsSignature": "eyJ.signed.value", "publicJwsSignature": "eyJ.public.proof", - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": "09506000134352", "stateOfHealthPct": 87.5, "cathodeMaterial": "LFP" @@ -260,10 +265,10 @@ mod tests { }) } - fn sector_data(v: &Value) -> &serde_json::Map { - v.get("sectorData") + fn product_group_data(v: &Value) -> &serde_json::Map { + v.get("productGroupData") .and_then(Value::as_object) - .expect("sectorData") + .expect("productGroupData") } /// Art. 77(2)(c): individual-item data goes to legitimate-interest holders. @@ -275,8 +280,8 @@ mod tests { BATTERY_SCHEMA, Audience::LegitimateInterest, ); - assert!(sector_data(&v).contains_key("stateOfHealthPct")); - assert!(sector_data(&v).contains_key("cathodeMaterial")); + assert!(product_group_data(&v).contains_key("stateOfHealthPct")); + assert!(product_group_data(&v).contains_key("cathodeMaterial")); } /// Art. 77(2)(b) assigns authorities Annex XIII points 2 and 3 — **not** @@ -286,11 +291,11 @@ mod tests { fn an_authority_does_not_see_individual_item_data() { let v = view_for(&battery(), "battery", BATTERY_SCHEMA, Audience::Authority); assert!( - !sector_data(&v).contains_key("stateOfHealthPct"), + !product_group_data(&v).contains_key("stateOfHealthPct"), "Art. 77(2)(b) withholds point 4 from authorities" ); assert!( - sector_data(&v).contains_key("cathodeMaterial"), + product_group_data(&v).contains_key("cathodeMaterial"), "point 2 is shared" ); } @@ -340,28 +345,28 @@ mod tests { #[test] fn the_public_view_carries_neither() { let v = view_for(&battery(), "battery", BATTERY_SCHEMA, Audience::Public); - assert!(!sector_data(&v).contains_key("stateOfHealthPct")); - assert!(!sector_data(&v).contains_key("cathodeMaterial")); + assert!(!product_group_data(&v).contains_key("stateOfHealthPct")); + assert!(!product_group_data(&v).contains_key("cathodeMaterial")); assert!(v.get("jwsSignature").is_none()); } - /// An unmodelled sector has no field policy for *any* audience, so a + /// An unmodelled product group has no field policy for *any* audience, so a /// credentialed reader must not get more from it than an anonymous one. #[test] - fn an_unknown_sector_fails_closed_for_every_audience() { + fn an_unknown_product_group_fails_closed_for_every_audience() { let unknown = json!({ "id": "0190a9f0-1234-7abc-8def-0123456789ab", - "sectorData": { "sector": "not-a-sector", "secret": "value" } + "productGroupData": { "productGroup": "not-a-product_group", "secret": "value" } }); for audience in [ Audience::Public, Audience::LegitimateInterest, Audience::Authority, ] { - let v = view_for(&unknown, "not-a-sector", BATTERY_SCHEMA, audience); + let v = view_for(&unknown, "not-a-product_group", BATTERY_SCHEMA, audience); assert!( - !sector_data(&v).contains_key("secret"), - "{audience:?} must not receive unmodelled sector data" + !product_group_data(&v).contains_key("secret"), + "{audience:?} must not receive unmodelled product_group data" ); } } diff --git a/crates/dpp-vault/src/handlers/create.rs b/crates/dpp-vault/src/handlers/create.rs index a2bb9f26..0dd27e67 100644 --- a/crates/dpp-vault/src/handlers/create.rs +++ b/crates/dpp-vault/src/handlers/create.rs @@ -5,17 +5,17 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use chrono::Utc; use dpp_common::url_guard::validate_public_https_url; use dpp_digital_link::validate_gtin; use dpp_domain::{ - SectorCatalog, + ProductGroupCatalog, domain::passport::{ManufacturerInfo, MaterialEntry, Passport, PassportId, PassportRef}, - domain::sector::{CarbonFootprint, RepairabilityScore, Sector, SectorData}, + domain::product_group::{CarbonFootprint, ProductGroup, ProductGroupData, RepairabilityScore}, domain::status::PassportStatus, - domain::validation::validate_sector_data, + domain::validation::validate_product_group_data, schemas::VersionedSchemaRegistry, }; use std::sync::OnceLock; @@ -26,17 +26,17 @@ use crate::{middleware::auth::AuthContext, state::AppState}; use super::error::{api_error, internal_error, require_write}; /// Request body for passport creation. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateRequest { pub product_name: String, - /// EU ESPR sector (dispatch key). Optional — derived from `sectorData` when omitted. - pub sector: Option, + /// EU ESPR product group (dispatch key). Optional — derived from `productGroupData` when omitted. + pub product_group: Option, pub manufacturer: ManufacturerInfo, pub materials: Option>, pub co2e_per_unit: Option, pub repairability_score: Option, - pub sector_data: Option, + pub product_group_data: Option, pub batch_id: Option, /// The date this product was placed on the EU market — the regulated /// triggering event that fixes which law governs it. @@ -64,7 +64,7 @@ pub struct CreateRequest { /// `POST /api/v1/dpp` — validate fields and create a new passport in `Draft` status. /// /// Rejects blank required fields, unsafe Unicode characters (null bytes, bidi -/// overrides), out-of-range numeric values, invalid sector data, and malformed +/// overrides), out-of-range numeric values, invalid product group data, and malformed /// GTINs before touching the database. pub async fn create_handler( State(state): State, @@ -81,14 +81,14 @@ pub async fn create_handler( return resp; } - // Sector is the dispatch key: explicit if supplied, else derived from the - // typed sector data, else Other. - let sector = body - .sector - .or_else(|| body.sector_data.as_ref().map(|d| d.sector())) - .unwrap_or_else(|| Sector::Other("other".to_owned())); + // ProductGroup is the dispatch key: explicit if supplied, else derived from the + // typed product group data, else Other. + let product_group = body + .product_group + .or_else(|| body.product_group_data.as_ref().map(|d| d.product_group())) + .unwrap_or_else(|| ProductGroup::Other("other".to_owned())); - // A new passport is written at the sector's current schema version, and only + // A new passport is written at the product group's current schema version, and only // that one. Never silently down-version to a hardcoded "1.0.0". // // `PassportService::create` already overwrites this from the catalog on @@ -107,17 +107,17 @@ pub async fn create_handler( // server could honour anyway; it is a claim about the body that is already // false. let schema_version = catalog() - .resolve_schema_version(sector.catalog_key(), None) + .resolve_schema_version(product_group.catalog_key(), None) .unwrap_or_else(|| "1.0.0".into()); // If co2e_per_unit not supplied at the top level, derive it from the - // typed sector data so callers don't have to duplicate the value. + // typed product group data so callers don't have to duplicate the value. let co2e_per_unit = body .co2e_per_unit .or_else(|| { - body.sector_data.as_ref().and_then(|sd| match sd { - SectorData::Battery(b) => Some(b.co2e_per_unit_kg), - SectorData::Textile(t) => t.carbon_footprint_kg_co2e, + body.product_group_data.as_ref().and_then(|sd| match sd { + ProductGroupData::Battery(b) => Some(b.co2e_per_unit_kg), + ProductGroupData::Textile(t) => t.carbon_footprint_kg_co2e, _ => None, }) }) @@ -143,7 +143,17 @@ pub async fn create_handler( let passport = Passport { id: PassportId(Uuid::now_v7()), product_name: body.product_name, - sector, + // Recorded once, here, from the acts the catalog knows reach this + // product group — and never recomputed afterwards. The law that governs + // a product is the law at placing on the market, and the set is not + // derivable from the product group alone, so a later refresh could only + // narrow it. + applicable_instruments: dpp_domain::InstrumentCatalog::new() + .instrument_refs_for(product_group.catalog_key()), + // Set by the applicable delegated act, and no adopted act fixes one for + // any product group yet. + granularity: None, + product_group, manufacturer: body.manufacturer, materials: body.materials.unwrap_or_default(), co2e_per_unit, @@ -153,7 +163,7 @@ pub async fn create_handler( // Populated by the service's `apply_compliance`/`apply_lint` after creation. compliance_result: None, lint_result: None, - sector_data: body.sector_data, + product_group_data: body.product_group_data, status: PassportStatus::Draft, qr_code_url: None, jws_signature: None, @@ -190,27 +200,27 @@ fn schema_registry() -> &'static VersionedSchemaRegistry { REGISTRY.get_or_init(VersionedSchemaRegistry::new) } -/// Sector catalog — single source of truth for the current schema version, built once. -fn catalog() -> &'static SectorCatalog { - static CATALOG: OnceLock = OnceLock::new(); - CATALOG.get_or_init(SectorCatalog::new) +/// ProductGroup catalog — single source of truth for the current schema version, built once. +fn catalog() -> &'static ProductGroupCatalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(ProductGroupCatalog::new) } -/// Validate typed sector data against its versioned JSON schema. New passports -/// validate against the sector's current schema version (matching what the -/// service persists); sectors with no embedded schema are skipped. Returns the +/// Validate typed product group data against its versioned JSON schema. New passports +/// validate against the product group's current schema version (matching what the +/// service persists); product groups with no embedded schema are skipped. Returns the /// human-readable error string on failure. -fn validate_against_schema(sd: &SectorData) -> Result<(), String> { - let sector = sd.sector(); - let key = sector.catalog_key(); +fn validate_against_schema(sd: &ProductGroupData) -> Result<(), String> { + let product_group = sd.product_group(); + let key = product_group.catalog_key(); let Some(version) = catalog().resolve_schema_version(key, None) else { return Ok(()); }; let mut json = serde_json::to_value(sd).map_err(|e| e.to_string())?; - // `SectorData` is internally tagged (`#[serde(tag = "sector")]`); the schemas + // `ProductGroupData` is internally tagged (`#[serde(tag = "product group")]`); the schemas // validate the inner object with `additionalProperties: false`, so strip the tag. if let Some(obj) = json.as_object_mut() { - obj.remove("sector"); + obj.remove("productGroup"); } schema_registry() .validate_strict(key, &version, &json) @@ -302,14 +312,14 @@ mod security_regression { #[cfg(test)] mod schema_validation { - //! M-1: typed sector data is also validated against its versioned JSON schema + //! M-1: typed product group data is also validated against its versioned JSON schema //! on the write path, catching schema-only constraints the Rust types miss. use super::*; use dpp_domain::Gtin; - use dpp_domain::domain::sector::{BatteryChemistry, BatteryData, BatteryType}; + use dpp_domain::domain::product_group::{BatteryChemistry, BatteryData, BatteryType}; - fn valid_battery() -> SectorData { - SectorData::Battery(Box::new(BatteryData { + fn valid_battery() -> ProductGroupData { + ProductGroupData::Battery(Box::new(BatteryData { gtin: Gtin::parse("09506000134352").unwrap(), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 3.2, @@ -385,16 +395,16 @@ mod schema_validation { } #[test] - fn sector_data_carries_internal_tag() { + fn product_group_data_carries_internal_tag() { // Documents the assumption that `validate_against_schema` strips: the - // internally-tagged enum emits a `sector` field the schema forbids. + // internally-tagged enum emits a `product_group` field the schema forbids. let json = serde_json::to_value(valid_battery()).unwrap(); - assert_eq!(json["sector"], "battery"); + assert_eq!(json["productGroup"], "battery"); } #[test] fn valid_battery_passes_versioned_schema() { - // Resolves to battery v2.0.0; passes only because the `sector` tag is + // Resolves to battery v2.0.0; passes only because the `product_group` tag is // stripped (the schema uses additionalProperties: false). assert!(validate_against_schema(&valid_battery()).is_ok()); } @@ -404,7 +414,7 @@ mod schema_validation { // A GTIN of the wrong length is rejected by the schema's `^[0-9]{14}$` // pattern — a constraint the Rust types don't carry on the wire shape. let mut json = serde_json::to_value(valid_battery()).unwrap(); - json.as_object_mut().unwrap().remove("sector"); + json.as_object_mut().unwrap().remove("productGroup"); json["gtin"] = serde_json::json!("123"); // too short for ^[0-9]{14}$ assert!( schema_registry() @@ -422,7 +432,7 @@ mod schema_validation { /// Extracted so the dry-run endpoint runs *this* rather than a second copy — a /// preview that disagreed with the real thing would be worse than none, because /// the direction it disagrees is the direction bad data gets through. -pub(crate) fn validate_create_request(body: &CreateRequest) -> Option { +pub fn validate_create_request(body: &CreateRequest) -> Option { // Shadows the module-level helper so every check below keeps the exact // form it had inside `create_handler` — the extraction is a move, not a // rewrite, and the compiler enforces that. @@ -480,8 +490,8 @@ pub(crate) fn validate_create_request(body: &CreateRequest) -> Option Option Option Option, @@ -32,7 +32,7 @@ pub async fn find_by_identity_handler( Query(query): Query, ) -> impl IntoResponse { let identity = ProductIdentity { - sector: query.sector, + product_group: query.product_group, gtin: query.gtin, batch_id: query.batch_id, }; diff --git a/crates/dpp-vault/src/handlers/info.rs b/crates/dpp-vault/src/handlers/info.rs index db586748..ca69fb89 100644 --- a/crates/dpp-vault/src/handlers/info.rs +++ b/crates/dpp-vault/src/handlers/info.rs @@ -1,15 +1,39 @@ use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde_json::json; +use serde::Serialize; + +/// Vault build/version metadata, for dashboard feature detection. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// serialise it and check `components/schemas/VaultInfo` against it. A literal +/// has no shape anything can verify. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultInfo { + /// This node's own `dpp-vault` crate version. + pub version: String, + /// The `dpp-domain` (dpp-core) version this build was compiled against. + pub core_version: String, + /// Auth schemes the vault accepts. A fixed list, not derived from live + /// config — `local` is reported even when `ADMIN_USERNAME`/`ADMIN_PASSWORD` + /// are unset. + pub auth_methods: Vec, + pub features: Vec, +} + +impl VaultInfo { + /// The metadata this build reports. + #[must_use] + pub fn current() -> Self { + Self { + version: env!("CARGO_PKG_VERSION").to_owned(), + core_version: dpp_domain::VERSION.to_owned(), + auth_methods: vec!["api_key".to_owned(), "local".to_owned()], + features: vec!["passthrough_compliance".to_owned()], + } + } +} /// `GET /api/v1/info` — vault metadata for dashboard feature detection. pub async fn info_handler() -> impl IntoResponse { - ( - StatusCode::OK, - Json(json!({ - "version": env!("CARGO_PKG_VERSION"), - "coreVersion": dpp_domain::VERSION, - "authMethods": ["api_key", "local"], - "features": ["passthrough_compliance"] - })), - ) + (StatusCode::OK, Json(VaultInfo::current())) } diff --git a/crates/dpp-vault/src/handlers/lint.rs b/crates/dpp-vault/src/handlers/lint.rs index 8542ada0..8d5e1a4d 100644 --- a/crates/dpp-vault/src/handlers/lint.rs +++ b/crates/dpp-vault/src/handlers/lint.rs @@ -12,7 +12,7 @@ use crate::{middleware::auth::AuthContext, state::AppState}; use super::error::{internal_error, not_found_error, parse_passport_id, require_write}; /// `POST /api/v1/dpp/{dppId}/lint` — recompute and persist the plausibility -/// lint pack's findings against the passport's current sector data. +/// lint pack's findings against the passport's current product group data. /// /// Non-binding: findings never block publish and this endpoint never fails /// on their account. Works regardless of passport status (Draft or diff --git a/crates/dpp-vault/src/handlers/list.rs b/crates/dpp-vault/src/handlers/list.rs index 689029e4..231ce926 100644 --- a/crates/dpp-vault/src/handlers/list.rs +++ b/crates/dpp-vault/src/handlers/list.rs @@ -6,15 +6,32 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde::Deserialize; -use serde_json::json; +use serde::{Deserialize, Serialize}; +use dpp_domain::domain::passport::Passport; use dpp_domain::domain::status::PassportStatus; use crate::{middleware::auth::AuthContext, state::AppState}; use super::error::internal_error; +/// One page of passports, with the totals a caller needs to page through them. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/PassportListResponse` against it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PassportListResponse { + /// This page of passports, in the repository's order. + pub dpps: Vec, + /// Total matching the filter, across every page — not the length of `dpps`. + pub total: u64, + /// The page size actually applied, after clamping. + pub limit: u32, + /// The offset this page starts at. + pub skip: u32, +} + /// Query parameters for the passport list endpoint. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -59,12 +76,12 @@ pub async fn list_handler( ( StatusCode::OK, - Json(json!({ - "dpps": passports, - "total": total, - "limit": limit, - "skip": offset, - })), + Json(PassportListResponse { + dpps: passports, + total, + limit, + skip: offset, + }), ) .into_response() } diff --git a/crates/dpp-vault/src/handlers/node_state.rs b/crates/dpp-vault/src/handlers/node_state.rs index 20804a98..9b0dde08 100644 --- a/crates/dpp-vault/src/handlers/node_state.rs +++ b/crates/dpp-vault/src/handlers/node_state.rs @@ -14,22 +14,30 @@ use super::error::internal_error; /// Setup/readiness state for a node, used by the CLI to keep `odal bootstrap` /// idempotent. +/// Public so the OpenAPI contract test can serialise it and check the shape +/// against `components/schemas/NodeState`. A response type only the handler can +/// name is a response type nothing can gate. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct NodeState { +pub struct NodeState { /// True once the node has been claimed — i.e. at least one active API key /// has been minted. Re-running bootstrap on a claimed node is refused. - bootstrapped: bool, + pub bootstrapped: bool, /// True once the operator's responsible-economic-operator identity is /// complete enough to publish passports. - operator_complete: bool, + pub operator_complete: bool, /// Deployment profile and per-port trust modes — the ghost-honesty signal. /// Absent on a standalone vault, which resolves no trust ports. + /// + /// Untyped and flattened, so the keys it contributes (`profile`, + /// `trustMode`) are **not** covered by the OpenAPI contract test the way + /// the typed fields are — a `serde_json::Value` has no field list to check + /// a schema against. Giving this a real type is the way to close that. #[serde(skip_serializing_if = "Option::is_none", flatten)] - trust: Option, + pub trust: Option, /// Active Compliance Current ruleset version. #[serde(skip_serializing_if = "Option::is_none")] - ruleset_version: Option, + pub ruleset_version: Option, } /// `GET /api/v1/node/state` — report whether the node is claimed, whether the diff --git a/crates/dpp-vault/src/handlers/plugins.rs b/crates/dpp-vault/src/handlers/plugins.rs index 89a8eb0c..a4c52c87 100644 --- a/crates/dpp-vault/src/handlers/plugins.rs +++ b/crates/dpp-vault/src/handlers/plugins.rs @@ -1,4 +1,4 @@ -//! `POST /api/v1/plugins` — admin-only runtime install of a signed sector plugin. +//! `POST /api/v1/plugins` — admin-only runtime install of a signed product group plugin. //! //! Delegates to the node's [`PluginAdmin`] port (the Wasm plugin host), which //! verifies the signature against the pinned publisher key, gates the ABI, @@ -17,15 +17,15 @@ use crate::{middleware::auth::AuthContext, state::AppState}; use super::error::{api_error, require_admin}; -/// `POST /api/v1/plugins` — verify, persist, and hot-swap a signed sector plugin. +/// `POST /api/v1/plugins` — verify, persist, and hot-swap a signed product group plugin. /// /// `multipart/form-data` with: /// - `wasm` (required, file) — the `.wasm` (or precompiled `.cwasm`) artifact. A /// `.cwasm` filename selects the AOT path (loaded only if it matches this /// node's engine). /// - `sig` (required) — its detached Ed25519 signature over `SHA-256(artifact)`. -/// - `sector` (optional, text) — the sector key; if omitted it is derived from -/// the `wasm` part's filename (`sector-.wasm`). +/// - `product_group` (optional, text) — the product group key; if omitted it is derived from +/// the `wasm` part's filename (`product-group-.wasm`). pub async fn install_plugin_handler( State(state): State, Extension(auth): Extension, @@ -45,7 +45,7 @@ pub async fn install_plugin_handler( let mut wasm: Option> = None; let mut sig: Option> = None; - let mut sector: Option = None; + let mut product_group: Option = None; let mut wasm_filename: Option = None; loop { @@ -64,7 +64,9 @@ pub async fn install_plugin_handler( Ok(b) => sig = Some(b.to_vec()), Err(e) => return bad(format!("could not read 'sig' field: {e}")), }, - "sector" => sector = field.text().await.ok().filter(|s| !s.is_empty()), + "productGroup" => { + product_group = field.text().await.ok().filter(|s| !s.is_empty()) + } _ => { // Drain and ignore unknown parts. let _ = field.bytes().await; @@ -79,10 +81,12 @@ pub async fn install_plugin_handler( let (Some(wasm), Some(sig)) = (wasm, sig) else { return bad("multipart body must include both a 'wasm' and a 'sig' field.".to_owned()); }; - let Some(sector) = sector.or_else(|| wasm_filename.as_deref().and_then(derive_sector)) else { + let Some(product_group) = + product_group.or_else(|| wasm_filename.as_deref().and_then(derive_product_group)) + else { return bad( - "sector could not be determined — pass a 'sector' field or name the file \ - 'sector-.wasm'." + "product_group could not be determined — pass a 'product_group' field or name the file \ + 'product-group-.wasm'." .to_owned(), ); }; @@ -93,7 +97,8 @@ pub async fn install_plugin_handler( .is_some_and(|f| f.ends_with(".cwasm")); // Install is blocking (wasm compile + disk IO); keep it off the async worker. - match tokio::task::spawn_blocking(move || admin.install(§or, wasm, sig, precompiled)).await + match tokio::task::spawn_blocking(move || admin.install(&product_group, wasm, sig, precompiled)) + .await { Ok(Ok(report)) => (StatusCode::CREATED, Json(report)).into_response(), Ok(Err(e)) => install_error(e), @@ -105,10 +110,10 @@ pub async fn install_plugin_handler( } } -/// Derive a sector key from an uploaded filename: `sector-battery.wasm` → `battery`. -fn derive_sector(filename: &str) -> Option { +/// Derive a product group key from an uploaded filename: `product-group-battery.wasm` → `battery`. +fn derive_product_group(filename: &str) -> Option { let stem = std::path::Path::new(filename).file_stem()?.to_str()?; - let key = stem.trim_start_matches("sector-"); + let key = stem.trim_start_matches("product-group-"); (!key.is_empty()).then(|| key.to_owned()) } @@ -141,12 +146,15 @@ mod tests { use super::*; #[test] - fn derive_sector_strips_prefix_and_extension() { + fn derive_product_group_strips_prefix_and_extension() { assert_eq!( - derive_sector("sector-battery.wasm").as_deref(), + derive_product_group("product-group-battery.wasm").as_deref(), Some("battery") ); - assert_eq!(derive_sector("textile.wasm").as_deref(), Some("textile")); - assert_eq!(derive_sector("sector-.wasm"), None); + assert_eq!( + derive_product_group("textile.wasm").as_deref(), + Some("textile") + ); + assert_eq!(derive_product_group("product-group-.wasm"), None); } } diff --git a/crates/dpp-vault/src/handlers/public_read.rs b/crates/dpp-vault/src/handlers/public_read.rs index 058931eb..3abd9f2d 100644 --- a/crates/dpp-vault/src/handlers/public_read.rs +++ b/crates/dpp-vault/src/handlers/public_read.rs @@ -18,7 +18,7 @@ use crate::state::AppState; use super::error::{api_error, internal_error, not_found_error, parse_passport_id}; /// Query params for the public read. `schema_view` requests a read-time upcast -/// of the sector data to a newer schema version, served *alongside* the +/// of the product group data to a newer schema version, served *alongside* the /// canonical (signed) passport — never re-signed as if original. #[derive(Deserialize)] pub struct PublicReadQuery { @@ -40,7 +40,7 @@ fn lens_registry() -> &'static LensRegistry { /// never served on this unauthenticated route. /// /// With `?schema_view=`, the response also carries a `schemaView`: the -/// sector data upcast to that version via read-time lenses, with honest lens +/// product group data upcast to that version via read-time lenses, with honest lens /// provenance. The canonical `passport` (and its signature) is unchanged. pub async fn public_read_handler( State(state): State, @@ -65,7 +65,7 @@ pub async fn public_read_handler( }; respond_public_view( view, - p.sector.catalog_key(), + p.product_group.catalog_key(), &p.schema_version, query.schema_view.as_deref(), ) @@ -86,19 +86,19 @@ pub async fn public_read_handler( /// public reads so both expose `?schema_view` identically. pub(crate) fn respond_public_view( view: Value, - sector_key: &str, + product_group_key: &str, from: &str, target: Option<&str>, ) -> axum::response::Response { let Some(target) = target else { return (StatusCode::OK, Json(view)).into_response(); }; - match build_schema_view(&view, sector_key, from, target) { + match build_schema_view(&view, product_group_key, from, target) { Ok(body) => (StatusCode::OK, Json(body)).into_response(), - Err(SchemaViewError::NoSectorData) => api_error( + Err(SchemaViewError::NoProductGroupData) => api_error( StatusCode::UNPROCESSABLE_ENTITY, "NO_SECTOR_DATA", - "This passport has no sector data to re-view.", + "This passport has no product_group data to re-view.", ), Err(SchemaViewError::Upcast(UpcastError::Transform(e))) => { internal_error(dpp_domain::DppError::Internal(e.to_string())) @@ -113,24 +113,24 @@ pub(crate) fn respond_public_view( #[derive(Debug)] enum SchemaViewError { - NoSectorData, + NoProductGroupData, Upcast(UpcastError), } /// Build the alongside body: the canonical public `view` plus a `schemaView` -/// derived by upcasting its sector data from `from` to `target`. The canonical +/// derived by upcasting its product group data from `from` to `target`. The canonical /// view is passed through untouched. fn build_schema_view( view: &Value, - sector_key: &str, + product_group_key: &str, from: &str, target: &str, ) -> Result { - let sector_data = view - .get("sectorData") - .ok_or(SchemaViewError::NoSectorData)?; + let product_group_data = view + .get("productGroupData") + .ok_or(SchemaViewError::NoProductGroupData)?; let derived = lens_registry() - .upcast_str(sector_key, sector_data, from, target) + .upcast_str(product_group_key, product_group_data, from, target) .map_err(SchemaViewError::Upcast)?; Ok(serde_json::json!({ "passport": view, "schemaView": derived })) } @@ -142,8 +142,8 @@ mod tests { fn battery_view() -> Value { serde_json::json!({ "productName": "Test Pack", - "sectorData": { - "sector": "battery", + "productGroupData": { + "productGroup": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", "nominalVoltageV": 48.0, @@ -167,7 +167,7 @@ mod tests { Some(4800.0) ); // Canonical passport is untouched — no derived field leaks into it. - assert!(out["passport"]["sectorData"]["ratedEnergyWh"].is_null()); + assert!(out["passport"]["productGroupData"]["ratedEnergyWh"].is_null()); } #[test] diff --git a/crates/dpp-vault/src/handlers/public_read_by_gtin.rs b/crates/dpp-vault/src/handlers/public_read_by_gtin.rs index 470f9b2f..404152fa 100644 --- a/crates/dpp-vault/src/handlers/public_read_by_gtin.rs +++ b/crates/dpp-vault/src/handlers/public_read_by_gtin.rs @@ -1,30 +1,40 @@ use axum::{ extract::{Path, Query, State}, + http::StatusCode, response::IntoResponse, }; +use dpp_domain::domain::status::PassportStatus; use crate::public_view::signed_public_view; use crate::state::AppState; -use super::error::{internal_error, not_found_error}; +use super::error::{api_error, internal_error, not_found_error}; use super::public_read::{PublicReadQuery, respond_public_view}; -/// Public, unauthenticated lookup of a published passport by GTIN. +/// Public, unauthenticated lookup of a passport by GTIN. /// /// Used by the resolver's `/01/{gtin}` GS1 Digital Link route. Searches by the -/// GTIN embedded in the passport's `qrCodeUrl` field. Only Battery passports -/// (which carry a GTIN) are addressable this way. Returns 404 if no published -/// passport matches. +/// GTIN embedded in the passport's `qrCodeUrl` field. Only passports carrying a +/// GTIN are addressable this way. +/// +/// # Why the lookup ignores status +/// +/// Looks up regardless of status and branches here, exactly as the by-id route +/// does. Reading through `find_published_by_gtin` folds "no such GTIN" and +/// "that GTIN resolves to a suspended passport" into the same `None`, and a +/// suspension is a **recall**: the person scanning the code on a product is +/// precisely who needs to see `410 Gone` rather than a `404` that reads as a +/// bad label. The two routes now answer a recall identically. pub async fn public_read_by_gtin_handler( State(state): State, Path(gtin): Path, Query(query): Query, ) -> impl IntoResponse { - match state.service.find_published_by_gtin(>in).await { - Ok(Some(p)) => { + match state.service.find_by_gtin_any_status(>in).await { + Ok(Some(p)) if p.status == PassportStatus::Published => { // Same signed payload the by-id route serves. Previously this // handler re-derived the redaction inline, which also skipped - // `public_view`'s unknown-sector backstop; both routes now read the + // `public_view`'s unknown-product group backstop; both routes now read the // one view that was actually signed. let view = match signed_public_view(&p) { Ok(v) => v, @@ -32,12 +42,17 @@ pub async fn public_read_by_gtin_handler( }; respond_public_view( view, - p.sector.catalog_key(), + p.product_group.catalog_key(), &p.schema_version, query.schema_view.as_deref(), ) } - Ok(None) => not_found_error("No published DPP found for this GTIN."), + Ok(Some(p)) if p.status == PassportStatus::Suspended => api_error( + StatusCode::GONE, + "SUSPENDED", + "This passport has been suspended.", + ), + Ok(_) => not_found_error("No published DPP found for this GTIN."), Err(e) => internal_error(e), } } diff --git a/crates/dpp-vault/src/handlers/publish.rs b/crates/dpp-vault/src/handlers/publish.rs index 3fa2d922..d621791f 100644 --- a/crates/dpp-vault/src/handlers/publish.rs +++ b/crates/dpp-vault/src/handlers/publish.rs @@ -16,7 +16,7 @@ use super::error::{ /// `POST /api/v1/dpp/{dppId}/publish` — Ed25519-sign and publish a draft passport. /// -/// Validates sector data, calls the identity service to sign, then atomically +/// Validates product group data, calls the identity service to sign, then atomically /// writes the JWS, QR URL, and `Published` status. Returns `409` if the passport /// is not in a publishable state. pub async fn publish_handler( @@ -62,7 +62,7 @@ pub async fn publish_handler( conflict_error("DPP cannot be published from its current state.") } // Publish-time gates (Annex III completeness, binding compliance - // violations, sector-data validation) surface as client errors, not 500s. + // violations, product group-data validation) surface as client errors, not 500s. Err(dpp_domain::DppError::Validation(msg)) => validation_error(&msg.to_string()), Err(e) => internal_error(e), } diff --git a/crates/dpp-vault/src/handlers/registry_status.rs b/crates/dpp-vault/src/handlers/registry_status.rs index 6852522b..f663778d 100644 --- a/crates/dpp-vault/src/handlers/registry_status.rs +++ b/crates/dpp-vault/src/handlers/registry_status.rs @@ -33,53 +33,53 @@ const STALL_THRESHOLD: i32 = 5; /// One passport's registration, as the registry queue holds it. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct RegistrationView { +pub struct RegistrationView { /// Queue state: `pending`, `submitted`, `registered`, `rejected`, /// `deactivated`. - status: &'static str, + pub status: &'static str, /// The registry's own record id, once it has issued one. #[serde(skip_serializing_if = "Option::is_none")] - registry_id: Option, + pub registry_id: Option, /// The last thing the registry (or the drain) said about it. #[serde(skip_serializing_if = "Option::is_none")] - message: Option, + pub message: Option, /// Attempts so far, and whether that count has reached the stall threshold. - attempts: i32, - stalled: bool, + pub attempts: i32, + pub stalled: bool, /// A status change owed to the registry, independent of the queue state. #[serde(skip_serializing_if = "Option::is_none")] - status_intent: Option<&'static str>, + pub status_intent: Option<&'static str>, } /// One transfer-of-responsibility notification. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct TransferView { - transfer_id: uuid::Uuid, +pub struct TransferView { + pub transfer_id: uuid::Uuid, /// Queue state: `pending`, `notified`, `rejected`. - status: &'static str, + pub status: &'static str, #[serde(skip_serializing_if = "Option::is_none")] - registry_id: Option, + pub registry_id: Option, #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - attempts: i32, - stalled: bool, + pub message: Option, + pub attempts: i32, + pub stalled: bool, } /// `GET /api/v1/dpp/{dppId}/registry` response. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct PassportRegistryView { - passport_id: String, +pub struct PassportRegistryView { + pub passport_id: String, /// Whether this deployment has registry queues at all. - configured: bool, + pub configured: bool, /// `None` when the passport has never been published — it owes no /// registration, which is different from owing one that has not happened. #[serde(skip_serializing_if = "Option::is_none")] - registration: Option, + pub registration: Option, /// Every handover notification recorded for this passport, newest first. #[serde(skip_serializing_if = "Vec::is_empty")] - transfers: Vec, + pub transfers: Vec, /// Who is responsible for this passport **now**, from its transfer chain. /// /// Reported separately because the passport's own `operatorIdentifier` is @@ -89,31 +89,31 @@ struct PassportRegistryView { /// fact about the product, not a defect. `None` when the passport has never /// been transferred, in which case the passport's own field is current. #[serde(skip_serializing_if = "Option::is_none")] - current_operator: Option, + pub current_operator: Option, } /// The operator responsible for a passport today, per its transfer chain. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct CurrentOperatorView { - did: String, - name: String, - country: String, +pub struct CurrentOperatorView { + pub did: String, + pub name: String, + pub country: String, /// How many completed handovers this passport has been through. - transfer_count: usize, + pub transfer_count: usize, } /// `GET /api/v1/registry` response. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct RegistryRollupView { - configured: bool, +pub struct RegistryRollupView { + pub configured: bool, /// Whether this operator may currently register anything at all. - verification: VerificationView, + pub verification: VerificationView, #[serde(skip_serializing_if = "Option::is_none")] - registrations: Option, + pub registrations: Option, #[serde(skip_serializing_if = "Option::is_none")] - transfers: Option, + pub transfers: Option, } /// The operator's verified-registry standing. @@ -124,36 +124,36 @@ struct RegistryRollupView { /// reported whether or not the queues are configured. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct VerificationView { +pub struct VerificationView { /// `false` both when never verified and when lapsed — the registry refuses /// either way, though they are different situations to act on. - current: bool, + pub current: bool, /// `None` when never verified. #[serde(skip_serializing_if = "Option::is_none")] - verified_at: Option>, + pub verified_at: Option>, /// The three-year cap. The eID means may expire sooner, which this cannot /// see, so it is an upper bound rather than a promise. #[serde(skip_serializing_if = "Option::is_none")] - expires_at: Option>, + pub expires_at: Option>, /// Days remaining, negative once lapsed. Absent when never verified. #[serde(skip_serializing_if = "Option::is_none")] - days_remaining: Option, + pub days_remaining: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct RegistrationCounts { - pending: i64, - submitted: i64, - registered: i64, - rejected: i64, - deactivated: i64, +pub struct RegistrationCounts { + pub pending: i64, + pub submitted: i64, + pub registered: i64, + pub rejected: i64, + pub deactivated: i64, /// Status changes owed to the registry. Nothing drains these — the registry /// publishes no status-push API — so they are held durably and counted here /// rather than accumulating out of sight. - status_intents: i64, + pub status_intents: i64, /// Rows that have retried past the point of self-recovery. - stalled: i64, + pub stalled: i64, /// Published passports with **no** outbox row at all. /// /// These owe a registration nobody is tracking: passports published before @@ -161,16 +161,16 @@ struct RegistrationCounts { /// not repaired — the queued payload is what a drain replays, and there is /// none to rebuild, so fabricating a row would create an entry that can /// never drain. - unregistered_published: i64, + pub unregistered_published: i64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct TransferCounts { - pending: i64, - notified: i64, - rejected: i64, - stalled: i64, +pub struct TransferCounts { + pub pending: i64, + pub notified: i64, + pub rejected: i64, + pub stalled: i64, } fn registration_status(s: dpp_types::RegistrySyncStatus) -> &'static str { diff --git a/crates/dpp-vault/src/handlers/transfer.rs b/crates/dpp-vault/src/handlers/transfer.rs index 9984f125..16f5e2f5 100644 --- a/crates/dpp-vault/src/handlers/transfer.rs +++ b/crates/dpp-vault/src/handlers/transfer.rs @@ -8,7 +8,7 @@ use axum::{ response::IntoResponse, }; use dpp_domain::domain::transfer::{ResponsibleOperator, TransferReason}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::{middleware::auth::AuthContext, state::AppState}; @@ -19,7 +19,7 @@ use super::error::{ /// Body for initiating a transfer: the outgoing and incoming operators and the /// reason. In the managed single-node model the caller supplies both parties. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TransferInitiateRequest { /// The current (outgoing) responsible operator — must match the chain head. diff --git a/crates/dpp-vault/src/handlers/validate.rs b/crates/dpp-vault/src/handlers/validate.rs index 7aaadc43..b5764d06 100644 --- a/crates/dpp-vault/src/handlers/validate.rs +++ b/crates/dpp-vault/src/handlers/validate.rs @@ -5,12 +5,32 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde_json::json; +use serde::Serialize; use crate::{middleware::auth::AuthContext, state::AppState}; use super::{create::CreateRequest, error::require_write}; +/// The dry-run verdict. +/// +/// Two booleans rather than one, because create and publish deliberately differ: +/// a body can be creatable as a draft and not yet publishable, and collapsing +/// that into a single flag would hide the gap until the caller tried to publish. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/ValidateResponse` against it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidateResponse { + /// Always `true` when this body is returned — a create-invalid body gets + /// the identical rejection `POST /api/v1/dpp` would have sent instead. + pub create_valid: bool, + /// Whether the stricter publish gate would also pass. + pub product_group_data_valid: bool, + /// Why publish would be blocked. `null` when nothing blocks it. + pub detail: Option, +} + /// `POST /api/v1/dpp/validate` — dry-run a create body and report the verdict. /// /// Persists nothing. Runs [`super::create::validate_create_request`] — the same @@ -19,7 +39,7 @@ use super::{create::CreateRequest, error::require_write}; /// rejection** create would have returned, rather than a paraphrase of it. /// /// **Two verdicts, because create and publish deliberately differ.** Create is -/// lenient about a sector with no resolvable JSON Schema — a draft may stay +/// lenient about a product group with no resolvable JSON Schema — a draft may stay /// incomplete — while publish fails closed on it, because a signed passport /// must have passed a real schema check. A body can therefore be creatable and /// not yet publishable, and collapsing that into one boolean would hide the gap @@ -45,24 +65,24 @@ pub async fn validate_handler( // Create would accept it. Now the stricter publish gate, so the caller // learns now rather than at publish time. - let publish_blocker = match body.sector_data.as_ref() { - Some(sector_data) => state + let publish_blocker = match body.product_group_data.as_ref() { + Some(product_group_data) => state .service - .publish_readiness(sector_data) + .publish_readiness(product_group_data) .err() .map(|e| e.to_string()), - // Sector data is optional at create. Publish validates it only when + // ProductGroup data is optional at create. Publish validates it only when // present, so its absence is not a publish blocker here either. None => None, }; ( StatusCode::OK, - Json(json!({ - "createValid": true, - "sectorDataValid": publish_blocker.is_none(), - "detail": publish_blocker, - })), + Json(ValidateResponse { + create_valid: true, + product_group_data_valid: publish_blocker.is_none(), + detail: publish_blocker, + }), ) .into_response() } diff --git a/crates/dpp-vault/src/handlers/webhooks.rs b/crates/dpp-vault/src/handlers/webhooks.rs index e1162462..176fbceb 100644 --- a/crates/dpp-vault/src/handlers/webhooks.rs +++ b/crates/dpp-vault/src/handlers/webhooks.rs @@ -24,11 +24,11 @@ use super::error::{api_error, internal_error, not_found_error, require_admin, va /// Create response — the redacted subscription plus the signing secret, shown once. #[derive(Serialize)] #[serde(rename_all = "camelCase")] -struct CreatedWebhookResponse { +pub struct CreatedWebhookResponse { #[serde(flatten)] - subscription: WebhookSubscription, + pub subscription: WebhookSubscription, /// Signing secret. Store it now — it is never shown again. - secret: String, + pub secret: String, } /// `GET /api/v1/webhooks` — list subscriptions (secrets redacted). diff --git a/crates/dpp-vault/src/handlers/whoami.rs b/crates/dpp-vault/src/handlers/whoami.rs index 3cd1fe09..18151592 100644 --- a/crates/dpp-vault/src/handlers/whoami.rs +++ b/crates/dpp-vault/src/handlers/whoami.rs @@ -1,10 +1,28 @@ //! `GET /api/v1/whoami` — what the presented credential actually is. use axum::{Json, extract::Extension, http::StatusCode, response::IntoResponse}; -use serde_json::json; +use dpp_types::api_key::ApiKeyScope; +use serde::Serialize; use crate::middleware::auth::AuthContext; +/// What the presented credential is. +/// +/// A named type rather than a `json!` literal so the OpenAPI contract test can +/// check `components/schemas/WhoamiResponse` against it. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WhoamiResponse { + /// The caller's own identity, as authenticated. + pub user_id: String, + /// What this credential is allowed to do. + pub scope: ApiKeyScope, + /// The key's row id — never the token. Absent for local-admin Basic auth, + /// which has no key row. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option, +} + /// `GET /api/v1/whoami` — echo the caller's own identity and scope. /// /// A client cannot otherwise discover what its credential is allowed to do: a @@ -19,12 +37,11 @@ use crate::middleware::auth::AuthContext; pub async fn whoami_handler(Extension(auth): Extension) -> impl IntoResponse { ( StatusCode::OK, - Json(json!({ - "userId": auth.user_id, - "scope": auth.scope, - // Absent for local-admin Basic auth, which has no key row. - "keyId": auth.key_id, - })), + Json(WhoamiResponse { + user_id: auth.user_id, + scope: auth.scope, + key_id: auth.key_id, + }), ) } diff --git a/crates/dpp-vault/src/infra/status_list.rs b/crates/dpp-vault/src/infra/status_list.rs index 21aa9f9e..7db71ab1 100644 --- a/crates/dpp-vault/src/infra/status_list.rs +++ b/crates/dpp-vault/src/infra/status_list.rs @@ -87,7 +87,7 @@ mod tests { name: "Repair Co".into(), role: CredentialRole::AuthorisedRepairer, country: "DE".into(), - sectors: vec!["battery".into()], + product_groups: vec!["battery".into()], product_categories: vec![], }; let mut builder = CredentialBuilder::new("did:web:authority.example.com".into(), subject); diff --git a/crates/dpp-vault/src/middleware/credential.rs b/crates/dpp-vault/src/middleware/credential.rs index aecdd2f8..9645b999 100644 --- a/crates/dpp-vault/src/middleware/credential.rs +++ b/crates/dpp-vault/src/middleware/credential.rs @@ -99,26 +99,26 @@ impl VerifiedCredential { /// The Art. 77(2) audience this credential grants **before** it is scoped to /// a particular passport. /// - /// Prefer [`Self::audience_for_sector`] at a read site: a credential issued - /// for one sector must not elevate a reader on another, and this method + /// Prefer [`Self::audience_for_product_group`] at a read site: a credential issued + /// for one product group must not elevate a reader on another, and this method /// cannot know which passport is being read. #[must_use] pub fn audience(&self) -> Audience { self.0.credential_subject.role.audience() } - /// The audience this credential grants **over a passport in `sector_key`**. + /// The audience this credential grants **over a passport in `product_group_key`**. /// - /// A credential naming sectors grants nothing extra outside them, so an + /// A credential naming product groups grants nothing extra outside them, so an /// out-of-scope credential resolves to [`Audience::Public`] rather than an /// error: it is a valid credential that simply does not cover this product, /// and public access is the baseline every caller already has. Denying - /// instead would also make the route a probe for which sector a passport is + /// instead would also make the route a probe for which product group a passport is /// in. /// - /// The scope rule itself is core's — an empty `sectors` list means unscoped, - /// a populated one must contain the sector — applied by re-running the pure - /// claims check with the sector now known. Nothing here re-implements it, + /// The scope rule itself is core's — an empty `product_groups` list means unscoped, + /// a populated one must contain the product group — applied by re-running the pure + /// claims check with the product group now known. Nothing here re-implements it, /// and nothing here touches the network: authentication, issuer trust and /// revocation were all settled in [`read_and_verify`] before the database /// was reached. @@ -127,28 +127,28 @@ impl VerifiedCredential { /// /// `credential_subject` may carry free-string `product_categories`. There /// is nothing to match them against: a passport has no product category. - /// The field that once held one was renamed `sector` because it was - /// misnamed, and category is now a per-sector concept with a different + /// The field that once held one was renamed `product_group` because it was + /// misnamed, and category is now a per-product group concept with a different /// shape in each — a device type here, a plain string there, an Art. 8 /// scope bucket elsewhere that is explicitly not the passport's category. /// /// So this axis is not scoped, and the consequence used to be inverted: /// passing `None` for the required category meant an issuer who narrowed a /// credential to one category got a credential valid over **every** product - /// in its sectors. The restriction was read as its opposite. + /// in its product groups. The restriction was read as its opposite. /// /// A restriction that cannot be evaluated is treated as unsatisfied, never /// as satisfied: a credential naming any product category resolves to - /// [`Audience::Public`] here. That is the same downgrade an out-of-sector + /// [`Audience::Public`] here. That is the same downgrade an out-of-product group /// credential gets — it is valid, it simply unlocks nothing on this node — /// and it makes the gap loud for an issuer rather than silently generous. /// /// Enforcing the axis properly needs a decision about what a product - /// category *is* across sectors, which is not this layer's to invent. + /// category *is* across product groups, which is not this layer's to invent. #[must_use] - pub fn audience_for_sector( + pub fn audience_for_product_group( &self, - sector_key: &str, + product_group_key: &str, trust: &dyn TrustedIssuerRegistry, ) -> Audience { if !self.0.credential_subject.product_categories.is_empty() { @@ -157,7 +157,7 @@ impl VerifiedCredential { let scoped = dpp_vc::verify_credential_claims_with_trust( &self.0, - Some(sector_key), + Some(product_group_key), None, chrono::Utc::now(), trust, @@ -247,10 +247,10 @@ pub async fn read_and_verify( // Claims, issuer trust and revocation in core's composed path, so the // fail-closed revocation policy is applied where it is defined rather than - // re-implemented here. `required_sector`/`required_product_category` are + // re-implemented here. `required_product_group`/`required_product_category` are // None because this layer does not know which passport is being read; the - // sector scope is applied afterwards by - // [`VerifiedCredential::audience_for_sector`], once the row is loaded. That + // product group scope is applied afterwards by + // [`VerifiedCredential::audience_for_product_group`], once the row is loaded. That // split is deliberate: everything that can make a credential *invalid* is // settled here, before the database is touched, so a bad credential gets // the same 401 whether or not the passport exists. Scope is not validity — @@ -278,7 +278,7 @@ pub async fn read_and_verify( /// /// **Nothing the caller supplied is echoed back.** `MalformedCredential` and /// `OutOfScope` carry free text built from the credential's own contents (its -/// declared sectors, for instance). Reflecting attacker-chosen strings into a +/// declared product groups, for instance). Reflecting attacker-chosen strings into a /// response body is a habit worth not having, whatever this particular endpoint /// renders them into. /// @@ -360,7 +360,7 @@ mod tests { name: "Example Co".to_owned(), role, country: "DE".to_owned(), - sectors: vec!["battery".to_owned()], + product_groups: vec!["battery".to_owned()], product_categories: Vec::new(), }, ) @@ -616,7 +616,7 @@ mod tests { /// passport. The credential stays valid — it simply grants nothing beyond /// public here, which is why the answer is a downgrade and not a 401. #[tokio::test] - async fn a_credential_does_not_elevate_outside_its_sectors() { + async fn a_credential_does_not_elevate_outside_its_product_groups() { let (key, kid) = key_and_kid(); let jws = sign_credential( &key, @@ -633,21 +633,21 @@ mod tests { panic!("the credential itself is valid"); }; assert_eq!( - c.audience_for_sector("battery", &trusting()), + c.audience_for_product_group("battery", &trusting()), Audience::LegitimateInterest, "in scope: the credential names battery" ); assert_eq!( - c.audience_for_sector("textile", &trusting()), + c.audience_for_product_group("textile", &trusting()), Audience::Public, "out of scope: a battery credential must not unlock a textile passport" ); } - /// An unmodelled sector is not in any credential's scope list either, so it + /// An unmodelled product group is not in any credential's scope list either, so it /// gets the same downgrade rather than falling through to the raw audience. #[tokio::test] - async fn an_unknown_sector_is_out_of_scope() { + async fn an_unknown_product_group_is_out_of_scope() { let (key, kid) = key_and_kid(); let jws = sign_credential( &key, @@ -666,20 +666,20 @@ mod tests { }; assert_eq!(c.audience(), Audience::Authority); assert_eq!( - c.audience_for_sector("not-a-sector", &trust), + c.audience_for_product_group("not-a-product_group", &trust), Audience::Public ); } - /// A credential naming **no** sectors is unscoped by core's rule, so it + /// A credential naming **no** product groups is unscoped by core's rule, so it /// keeps its audience everywhere. Locking this in because the downgrade /// above must not silently become "deny unless listed" — that would break /// every general-purpose authority credential. #[tokio::test] - async fn a_credential_with_no_sectors_is_unscoped() { + async fn a_credential_with_no_product_groups_is_unscoped() { let (key, kid) = key_and_kid(); let mut cred = credential(CredentialRole::Recycler, 30); - cred.credential_subject.sectors = Vec::new(); + cred.credential_subject.product_groups = Vec::new(); let jws = sign_credential(&key, &kid, &cred); let out = read_and_verify( &headers_with(Some(&jws)), @@ -691,13 +691,13 @@ mod tests { panic!("valid credential"); }; assert_eq!( - c.audience_for_sector("textile", &trusting()), + c.audience_for_product_group("textile", &trusting()), Audience::LegitimateInterest ); } /// A credential naming a product category unlocks nothing, even where its - /// sector matches. + /// product group matches. /// /// There is nothing on a passport to match the category against, so the /// restriction cannot be evaluated. Before this, the unevaluated axis was @@ -722,16 +722,16 @@ mod tests { // The credential is genuinely valid and its role is elevated … assert_eq!(c.audience(), Audience::LegitimateInterest); - // … and it still unlocks nothing, in its own sector. + // … and it still unlocks nothing, in its own product group. assert_eq!( - c.audience_for_sector("battery", &trusting()), + c.audience_for_product_group("battery", &trusting()), Audience::Public, "an unevaluable restriction must not be read as no restriction" ); } /// The common case must stay unaffected: no product category named means no - /// restriction claimed, and sector scope alone decides. + /// restriction claimed, and product group scope alone decides. #[tokio::test] async fn an_empty_product_category_list_changes_nothing() { let (key, kid) = key_and_kid(); @@ -748,7 +748,7 @@ mod tests { panic!("valid credential"); }; assert_eq!( - c.audience_for_sector("battery", &trusting()), + c.audience_for_product_group("battery", &trusting()), Audience::LegitimateInterest ); } @@ -768,7 +768,7 @@ mod tests { /// Rejection details are fixed sentences: no `Debug` formatting, and /// nothing derived from the credential the caller supplied. The old /// `{result:?}` emitted `UntrustedIssuer { issuer_did: "…" }` and - /// `OutOfScope { reason: "Credential covers sectors […]" }` — Rust internals + /// `OutOfScope { reason: "Credential covers product groups […]" }` — Rust internals /// and caller-chosen strings, straight into a public response body. #[test] fn rejection_details_are_fixed_sentences_not_debug_output() { @@ -783,7 +783,7 @@ mod tests { issuer_did: "did:web:attacker-chosen.example".to_owned(), }, V::OutOfScope { - reason: "Credential covers sectors [\"