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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .redocly.lint-ignore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ api/openapi.bundled.yaml:
- '#/paths/~1identity~1health/get'
- '#/paths/~1identity~1ready/get'
- '#/paths/~1integrator~1health/get'
- '#/paths/~1integrator~1api~1v1~1templates~1{sector}/get'
- '#/paths/~1integrator~1api~1v1~1templates~1{productGroup}/get'
- '#/paths/~1integrator~1api~1v1~1schemas/get'
- '#/paths/~1integrator~1api~1v1~1schemas~1{sector}/get'
- '#/paths/~1integrator~1api~1v1~1schemas~1{sector}~1{version}/get'
- '#/paths/~1integrator~1api~1v1~1schemas~1{productGroup}/get'
- '#/paths/~1integrator~1api~1v1~1schemas~1{productGroup}~1{version}/get'
- '#/paths/~1dpp~1{dppId}/get'
- '#/paths/~1dpp~1{dppId}~1qr/get'
- '#/paths/~101~1{gtin}/get'
Expand Down
104 changes: 104 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,110 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):

## [Unreleased]

### Added

- **The create request is one type, not two kept in step by a comment.**
`dpp_types::CreatePassportRequest` is now the body both sides use: the vault
deserialises it, the bulk importer serialises it. They were separate structs in
separate crates, coupled by a doc comment reading *"Shape must match …"* — and
the importer's copy had drifted four fields short. A comment cannot fail a
build; a shared type makes the gap unrepresentable, because a field the vault
accepts is now a field the importer must decide about or it does not compile.

Two of the four are now importable: `placedOnMarketDate` (below) and
`commodityCode`, which is validated at import so the error names the offending
**row** — the vault rejects a bad code too, but cannot say which line of a
thousand-row spreadsheet carried it.

`parentPassportRef` and `componentRefs` stay absent on the import path, now
explicitly and with a test saying why: each carries a URI *and* a hash of the
referenced passport's public signature, and a hash cannot be authored in a
spreadsheet. An invented one produces a link that fails verification, so absent
is the only honest value a CSV can supply.

- **Bulk import can set `placedOnMarketDate`.** The vault's own create route has
always accepted it; the import path had no field for it, so the same product
imported rather than posted got a passport that could not say which law
governed it.

That is not one field among many. Staged EU obligations attach at placing on
the market and do not move afterwards, so a determination computed without the
date is computed against the wrong date for every product not placed on the
market today — and the applicable-instrument set is frozen at that moment too,
which made the gap worse than when it was first noticed.

Every CSV template gains a `placedOnMarketDate [OPTIONAL]` column. A malformed
value is **refused**, not dropped: silently ignoring it would import a passport
whose governing law is unknown while looking exactly like one where the
operator deliberately left the column blank.

- **`PassportResponse` is the API's own type.** Until now the JSON on the wire
*was* `dpp_domain::Passport` — a library's internal aggregate, serialised
straight out of the handler — so the published API was whatever core's struct
happened to be. The `sector` → `productGroup` rename demonstrated the cost:
it rewrote every response body, request body, database column and schema in one
step, with no point at which anyone had to agree the *API* should change.

The two shapes are identical today and a test proves it byte for byte, on a
minimal passport as well as a populated one, because `skip_serializing_if`
differences are invisible when every field is set. What changed is that they
are now allowed to differ, and that making them differ is an edit someone has
to write down. A second test checks the type against
`dpp_domain::PASSPORT_WIRE_KEYS`, so a field added to core is either served or
listed as deliberately withheld — otherwise a mirror would trade one silent
drift for a quieter one.

The OpenAPI contract gate now checks the spec against this type rather than
against the core aggregate, which is what it was always meant to check.

### 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-<key>.wasm` rather than `sector-<key>.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
Expand Down
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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) |
Expand All @@ -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 |
Expand All @@ -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 |
Expand All @@ -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/*`
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading