diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example index 98c8c4b3..7d949b1f 100644 --- a/.cargo/config.toml.example +++ b/.cargo/config.toml.example @@ -27,3 +27,7 @@ dpp-plugin-traits = { path = "../dpp-core/crates/dpp-plugin-traits" } dpp-registry = { path = "../dpp-core/crates/dpp-registry" } dpp-calc = { path = "../dpp-core/crates/dpp-calc" } dpp-rules = { path = "../dpp-core/crates/dpp-rules" } +# Not in this workspace's graph, so cargo reports it unused here. The plugin SDK +# ABI guest (crates/dpp-plugin-host/tests/fixtures/abi-echo) depends on it, and +# without it a guest pinned to an unpublished release cannot resolve. +dpp-plugin-sdk = { path = "../dpp-core/crates/dpp-plugin-sdk" } diff --git a/CHANGELOG.md b/CHANGELOG.md index aadbf951..fe7b41b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,104 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ## [Unreleased] +### Breaking + +- **`dpp-core` 0.23.0: a registration refuses an operator identifier with no + EN 18219 clause 6 route.** *(Breaking for an operator whose primary identifier + is a VAT or EORI number.)* Core now accepts an operator or facility identifier + on a registration only under `lei`, `duns` or `gln`: an EU-wide VAT number has + no ICD, none was found for an EORI number, and a DID is scheme 8 only with a + credential a register has issued. Publish now asks the same question, through + core's `ActorIdentifier::from_legacy`, before it queues a registration: where + the product group's obligation is live it refuses with `422` and the reason + `unregistrable_identity`, naming the scheme and the fix, and elsewhere it + publishes without a registration, as it does when the identity is missing. + Queued, such a registration would have been refused on every attempt, because + its payload is fixed at publish. Only a passport that has never queued a + registration is refused: one published before this release keeps its row, and + its identifier cannot change, so a suspended one can still be re-activated. A + VAT or EORI number is still accepted and stamped as the operator identifier. + Facilities are added under a GLN and are not affected. + + **Migration:** make an LEI or a D-U-N-S number the primary operator identifier + (`POST /vault/api/v1/operator-identifiers/{id}/primary`). Drafts, and the + successor an amendment publishes, then take it; a published passport keeps the + identifier it was signed with. A registration already queued under a VAT or + EORI number is rejected by the drain the first time it is tried (see + **Fixed**). (#561) + +- **`dpp-core` 0.23.0: an EV, LMT or industrial battery publishes only with its + placing date, and from 18 August 2027 with its due diligence declaration.** + Core's content table answers the due diligence report (Annex XIII point 1(d)) + as of the battery's facts: from 18 August 2027 it is owed unless Art. 47 + exempts the battery. So the publish gate refuses such a battery that states no + `placedOnMarketDate`, on the envelope or in `productGroupData`, unless it is + second-life, and from that date one that does not carry + `dueDiligenceOperatorScope` (battery schema 2.10.0). `PublishReadiness` says the + count of mandatory data points grows by one from that date, and a test holds + the sentence to the gate. + + **Migration:** send `placedOnMarketDate` on create, or in the import column of + that name. The battery CSV templates have no column for `dueDiligenceUrl` or + `dueDiligenceOperatorScope` yet, so a battery placed from 18 August 2027 cannot + be imported into a passport that publishes. (#561) + +- **`dpp-core` 0.23.0: an anonymous reader no longer receives a critical raw + material's weight or country of origin** from a battery passport written at + schema 2.9.0 or later, or an electronics passport written at 1.5.0 or later. + Core labels both fields `restricted` from those versions. A passport written + against an earlier version keeps its classes and the bytes its signatures + cover; a new one is written against the new version. (#561) + +### Fixed + +- **A draft is published under the operator's primary identifier as it stands + at publish.** It kept the one stamped when it was created, and an amendment's + successor the one its predecessor was signed with. So an operator that changed + its primary, as moving off a VAT number requires, went on publishing under the + old one, and a registration built from a value the primary no longer had named + no scheme, which the registry was bound to refuse. A draft is not signed yet + and create takes no identifier, so nothing an operator chose is overwritten. A + re-publish keeps the identifier the passport was signed with. (#561) + +- **The core candidate run checks the plugin SDK ABI against the candidate's + SDK.** `.cargo/config.toml.example`, which `just core-local` and the run + apply, did not patch `dpp-plugin-sdk`, which only the ABI guest uses. So the + guest resolved the published SDK, and the run checked that one while 0.15.0's + notes said it checked the candidate's, until a guest pinned to an unpublished + release could not resolve at all. The override now patches it. (#561) + +- **A registration or transfer notification the adapter refuses before sending + is rejected, not retried for ever.** Both registry drains read every error as + transient, so a payload the adapter's own checks refused backed off and was + tried again with no end. It can never pass: a registration's payload is fixed + when the passport is published, and a notification's when the transfer is + accepted. Such a row is now marked rejected, with the reason, as a corrupt + payload already was; if that write fails, the row backs off rather than being + checked again on every pass. Only the adapter's checks report a `Validation` + error; a registry that cannot be reached, or that answers with a failure, is + still retried. (#562, #561) + +- **A transfer notification is sent under a stable key, and only an answer that + says the registry took it closes it.** `dpp-core` 0.23.0's registry port + answers a transfer notification with a `TransferReport`, whether the registry + took it, rather than a record's status. The notification now carries the + transfer's own identifier as its `Idempotency-Key`, which is what makes sending + it again the same notification: a reply that is still processing, or a state + this node has no name for, keeps it open and sends it again, and a conflict + naming the replayed key is read as already held and closes it. That answer says + the registry holds the notification, not that it applied the handover, and no + route to ask has been seen. A refusal is recorded with the registry's reasons. + Before, a reply reporting the record suspended or deactivated closed the row as + notified. The transfer route's reply is unobserved, so only `registered` is read + as an acceptance. (#561) + +- **The resolver's JSON-LD defines a term for every product group field.** It + carries `dpp-core` 0.23.0's context, so a JSON-LD processor no longer drops a + product group's data when it expands the document. The context names withheld + fields too, as terms; their values are not served, and the tests that scan the + body for a withheld field now read it without its `@context`. (#561) + ## [0.15.0] - 2026-11-03 ### Breaking diff --git a/Cargo.lock b/Cargo.lock index f8a4b4f7..9603d631 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2159,9 +2159,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dpp-aas" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c0957eeb9edaee056135bed203e98207d381630ce752135be38e1c4cd5bf0f5" +checksum = "f30acb24178aff806266e3eabd98e77d68c29544b3c6e7c693c87b9d3562032f" dependencies = [ "dpp-domain", "dpp-vocab", @@ -2171,9 +2171,9 @@ dependencies = [ [[package]] name = "dpp-calc" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a76a50d0c01e3d9bf051c22cf21eb9e75aa62157c1957a78c3681898e001e8" +checksum = "fd577b3ae6d42ac3fac2a9d8bf332d3c8613ef0cb43156a6e5588fde37508ef5" dependencies = [ "chrono", "dpp-rules", @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "dpp-crypto" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ebeac7dee3982e5c2b12b36f96c10315c137857acb3ce5df0f8aad51fdaa3da" +checksum = "f38eff0e9afbbde450ae8e6607fe053db39c31f79661b504323b9c3bdd9d1a44" dependencies = [ "aes-gcm", "anyhow", @@ -2297,9 +2297,9 @@ dependencies = [ [[package]] name = "dpp-digital-link" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2362fbca86fc9895bba086a94cc4fb7e7db7972b673d59733e35c8fe1b636920" +checksum = "46ae50e9267b797d8976dafc8335a69fad3a099cabec837ac1260956a2d717e1" dependencies = [ "dpp-domain", "dpp-rules", @@ -2310,9 +2310,9 @@ dependencies = [ [[package]] name = "dpp-domain" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b770df4f4835914af417fca35a8d5c804c5ee144fd0d25e9c26768040e3afe17" +checksum = "287ce22b7a08c7b9ad153218b4c27f212ccb0dee8ad2628baa3145964fb4b203" dependencies = [ "async-trait", "chrono", @@ -2489,9 +2489,9 @@ dependencies = [ [[package]] name = "dpp-plugin-traits" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f767576abb9e08c1bede58d2a0a874e6ac79abb9714ca9f900e53bef33b54c9" +checksum = "8e5bf7037651760cedb40e9a80adb765eec6198a62ec7f89575bd4dff9d2d81b" dependencies = [ "semver", "serde", @@ -2501,9 +2501,9 @@ dependencies = [ [[package]] name = "dpp-registry" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a024fb2a4c3a6f5718deb9f123315ffadfbba6e6cabe709f521554578e50ef11" +checksum = "56becfd3198e2d3cd810b867c632cc522cd534ad28a801377b9734e0ce2a984a" dependencies = [ "chrono", "dpp-domain", @@ -2567,9 +2567,9 @@ dependencies = [ [[package]] name = "dpp-rules" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c3b8ed43e1ccf5406630ef0a21a382405123006a2c3463d10971c1244b42348" +checksum = "1c256813b31e63cc14fa8c720ce21eeb4eeb2cf274a5eaebfeffd92ebc8b8ef3" dependencies = [ "base64 0.23.1", "chrono", @@ -2681,9 +2681,9 @@ dependencies = [ [[package]] name = "dpp-vc" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa7a2b467cbf6f3a657465a105dd5f84f20d8a294424c0ab879ed05741991669" +checksum = "27d0ee8d5d7ac269ca2c8c57c9e7c96d89eaf0ae86188bfbdadfaf0fd2a7e6ed" dependencies = [ "anyhow", "async-trait", @@ -2704,9 +2704,9 @@ dependencies = [ [[package]] name = "dpp-vocab" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6e9629ed973689c67a3153e65db810e34c3845655b6a3a41da10506d728cb50" +checksum = "9e655c42afe4f56857936fcf4452e7e41198126f64c0738239cdf108efd6def3" dependencies = [ "serde", "serde_json", @@ -4061,9 +4061,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.58.6" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08" dependencies = [ "ahash", "bytecount", @@ -4090,23 +4090,24 @@ dependencies = [ [[package]] name = "jsonschema-regex" -version = "0.58.6" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a" dependencies = [ "regex-syntax", ] [[package]] name = "jsonschema-value" -version = "0.58.6" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba" dependencies = [ "ahash", "bytecount", "fraction", "getrandom 0.3.4", + "memchr", "num-cmp", "num-traits", "serde_json", @@ -5570,9 +5571,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.58.6" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b" dependencies = [ "ahash", "fluent-uri", diff --git a/Cargo.toml b/Cargo.toml index 1a98b093..a1c82834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,20 +52,20 @@ debug = "line-tables-only" # Core crates — consumed from crates.io (dpp-core is published independently; # see the pins below for the version actually in use, and README.md's sync # note for the other places that must agree on a bump). -dpp-domain = "0.22.0" +dpp-domain = "0.23.0" # Engine-wide types (tenant, auth, audit, API keys) — engine-internal crate. dpp-types = { path = "crates/dpp-types" } -dpp-crypto = "0.22.0" -dpp-vc = "0.22.0" +dpp-crypto = "0.23.0" +dpp-vc = "0.23.0" # KeyStore lives in dpp-core/dpp-crypto; the identity service crate is dpp-identity # (dir: crates/dpp-identity, pkg: dpp-identity). -dpp-digital-link = "0.22.0" -dpp-aas = "0.22.0" -dpp-plugin-traits = "0.22.0" -dpp-registry = "0.22.0" -dpp-calc = "0.22.0" -dpp-rules = { version = "0.22.0", features = ["bundle"] } +dpp-digital-link = "0.23.0" +dpp-aas = "0.23.0" +dpp-plugin-traits = "0.23.0" +dpp-registry = "0.23.0" +dpp-calc = "0.23.0" +dpp-rules = { version = "0.23.0", features = ["bundle"] } # Web framework axum = { version = "0.8", features = ["macros", "multipart"] } diff --git a/README.md b/README.md index 42c211fb..082ac5cc 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ set -a; . ./.env; set +a odal bootstrap --legal-name "Example Batteries GmbH" --country DE \ --address "Musterstrasse 1, 10115 Berlin" --contact-email ops@example.com odal facility add --name "Berlin plant" --value 4012345000009 --country DE --default -odal operator-id add --scheme vat --value DE123456789 --primary +odal operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary ``` `bootstrap` prints an API key once and saves it to `~/.config/odal/credentials.toml`. diff --git a/api/components/schemas/passport/PublishReadiness.yaml b/api/components/schemas/passport/PublishReadiness.yaml index 0ebfb05c..fb7c8ca6 100644 --- a/api/components/schemas/passport/PublishReadiness.yaml +++ b/api/components/schemas/passport/PublishReadiness.yaml @@ -11,9 +11,11 @@ description: | The mandatory-content half is the reason this exists. It is the gate that most often refuses a battery (41 data points for an electric-vehicle one, 40 for - an LMT battery, 33 for an industrial one) and it could not previously be - previewed anywhere: it is reachable only from a stored passport, and the only - dry-run the API offered takes an unsaved request body. + an LMT battery, 33 for an industrial one, and one more for each placed from + 18 August 2027, when the due diligence report falls due unless Art. 47 exempts + the battery) and it could not previously be previewed anywhere: it is + reachable only from a stored passport, and the only dry-run the API offered + takes an unsaved request body. required: - ready - blockers diff --git a/api/openapi.bundled.json b/api/openapi.bundled.json index a43c2c6c..a80fadd6 100644 --- a/api/openapi.bundled.json +++ b/api/openapi.bundled.json @@ -9799,7 +9799,7 @@ }, "PublishReadiness": { "type": "object", - "description": "Whether the passport would clear the publish gates that can be answered\nwithout attempting the transition: the category **mandatory-content** gate and\nthe product-group data/schema gates.\n\nDeliberately not the whole of publish. The registry-identity requirement is\noperator state rather than passport state, and the binding-compliance gate\nneeds a determination this route does not run, so `ready: true` means \"these\ngates pass\", not \"publish will certainly succeed\".\n\nThe mandatory-content half is the reason this exists. It is the gate that most\noften refuses a battery (41 data points for an electric-vehicle one, 40 for\nan LMT battery, 33 for an industrial one) and it could not previously be\npreviewed anywhere: it is reachable only from a stored passport, and the only\ndry-run the API offered takes an unsaved request body.\n", + "description": "Whether the passport would clear the publish gates that can be answered\nwithout attempting the transition: the category **mandatory-content** gate and\nthe product-group data/schema gates.\n\nDeliberately not the whole of publish. The registry-identity requirement is\noperator state rather than passport state, and the binding-compliance gate\nneeds a determination this route does not run, so `ready: true` means \"these\ngates pass\", not \"publish will certainly succeed\".\n\nThe mandatory-content half is the reason this exists. It is the gate that most\noften refuses a battery (41 data points for an electric-vehicle one, 40 for\nan LMT battery, 33 for an industrial one, and one more for each placed from\n18 August 2027, when the due diligence report falls due unless Art. 47 exempts\nthe battery) and it could not previously be previewed anywhere: it is\nreachable only from a stored passport, and the only dry-run the API offered\ntakes an unsaved request body.\n", "required": [ "ready", "blockers", diff --git a/api/openapi.bundled.yaml b/api/openapi.bundled.yaml index dfc252ce..8ea9629e 100644 --- a/api/openapi.bundled.yaml +++ b/api/openapi.bundled.yaml @@ -8430,9 +8430,11 @@ components: The mandatory-content half is the reason this exists. It is the gate that most often refuses a battery (41 data points for an electric-vehicle one, 40 for - an LMT battery, 33 for an industrial one) and it could not previously be - previewed anywhere: it is reachable only from a stored passport, and the only - dry-run the API offered takes an unsaved request body. + an LMT battery, 33 for an industrial one, and one more for each placed from + 18 August 2027, when the due diligence report falls due unless Art. 47 exempts + the battery) and it could not previously be previewed anywhere: it is + reachable only from a stored passport, and the only dry-run the API offered + takes an unsaved request body. required: - ready - blockers diff --git a/cli/README.md b/cli/README.md index 7cb83c5b..17dfcf5c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -96,7 +96,7 @@ set -a; . ./.env; set +a # odal reads ADMIN_USERNAME / ADMIN_PASSWOR odal bootstrap --legal-name "Example GmbH" --country DE \ --address "Musterstrasse 1, 10115 Berlin" --contact-email ops@example.com odal facility add --name "Main plant" --value 4012345000009 --country DE --default -odal operator-id add --scheme vat --value DE123456789 --primary +odal operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary odal passport import ops/demo/samples/battery-full.json # creates a battery draft odal passport validate # checks the stored drafts diff --git a/crates/dpp-dal/src/pg/repo_operator_config.rs b/crates/dpp-dal/src/pg/repo_operator_config.rs index 66ad5512..626dcc20 100644 --- a/crates/dpp-dal/src/pg/repo_operator_config.rs +++ b/crates/dpp-dal/src/pg/repo_operator_config.rs @@ -88,6 +88,10 @@ impl OperatorConfigRepository for PgOperatorConfigRepo { name: r.get("name"), country: r.get::("country"), address: r.get("address"), + // Not typed here: `actor_identifier()` reads the scheme and value + // above when this is unset, and leaving it unset keeps the snapshot's + // bytes as they were. + identifier: None, })) } diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.10.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.10.0.json new file mode 100644 index 00000000..fb4b6afc --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/battery/v2.10.0.json @@ -0,0 +1,179 @@ +{ + "applicableInstruments": [ + { + "instrument": "battery-reg-2023-1542", + "recorded": "catalog" + } + ], + "batchId": "LOT-2026-08", + "co2ePerUnit": { + "valueKg": 39.1 + }, + "commodityCode": "85076000", + "complianceResult": { + "assessedAt": "2026-11-02T16:34:48.989879074Z", + "co2eScore": 39.1, + "complianceStatus": "PASSTHROUGH_NO_VALIDATION", + "recycledContentPct": null, + "repairabilityIndex": null + }, + "createdAt": "2026-11-02T16:34:48.985607004Z", + "disclosureSignatures": { + "public+restricted+conformity": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJiYXR0ZXJ5LXJlZy0yMDIzLTE1NDIiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOiJMT1QtMjAyNi0wOCIsImNvMmVQZXJVbml0Ijp7InZhbHVlS2ciOjM5LjF9LCJjb21tb2RpdHlDb2RlIjoiODUwNzYwMDAiLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4OTg3OTA3NFoiLCJjbzJlU2NvcmUiOjM5LjEsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4NTYwNzAwNFoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTBjMTktNzI2My1iZDZmLTMzZWMwYmI5OGNlNiIsImxpbnRSZXN1bHQiOnsiYXNzZXNzZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NDguOTg5ODgxMjQ3WiIsImZpbmRpbmdzIjpbeyJjb2RlIjoiYmF0dGVyeS5tYXRlcmlhbF9jb21wb3NpdGlvbl9zdW0iLCJmaWVsZCI6ImNhdGhvZGVNYXRlcmlhbCIsIm1lc3NhZ2UiOiJjYXRob2RlTWF0ZXJpYWwgd2VpZ2h0UGN0IGVudHJpZXMgc3VtIHRvIDMyLjAlLCBleHBlY3RlZCB-MTAwJSDigJQgaW50ZW5kZWQ_Iiwic2V2ZXJpdHkiOiJXQVJOSU5HIn0seyJjb2RlIjoiYmF0dGVyeS5tYXRlcmlhbF9jb21wb3NpdGlvbl9zdW0iLCJmaWVsZCI6ImFub2RlTWF0ZXJpYWwiLCJtZXNzYWdlIjoiYW5vZGVNYXRlcmlhbCB3ZWlnaHRQY3QgZW50cmllcyBzdW0gdG8gMTguMCUsIGV4cGVjdGVkIH4xMDAlIOKAlCBpbnRlbmRlZD8iLCJzZXZlcml0eSI6IldBUk5JTkcifSx7ImNvZGUiOiJiYXR0ZXJ5Lm1hdGVyaWFsX2NvbXBvc2l0aW9uX3N1bSIsImZpZWxkIjoiZWxlY3Ryb2x5dGVNYXRlcmlhbCIsIm1lc3NhZ2UiOiJlbGVjdHJvbHl0ZU1hdGVyaWFsIHdlaWdodFBjdCBlbnRyaWVzIHN1bSB0byAxMS4wJSwgZXhwZWN0ZWQgfjEwMCUg4oCUIGludGVuZGVkPyIsInNldmVyaXR5IjoiV0FSTklORyJ9XSwicGFja1ZlcnNpb24iOiIxLjEuMCJ9LCJtYW51ZmFjdHVyZXIiOnsiYWRkcmVzcyI6IkluZHVzdHJpZXN0cmFzc2UgNCwgMTAxMTUgQmVybGluLCBERSIsImNvdW50cnkiOm51bGwsImRpZFdlYlVybCI6Imh0dHBzOi8vYmF0dGVyaWVzLmV4YW1wbGUuY29tLy53ZWxsLWtub3duL2RpZC5qc29uIiwibmFtZSI6IkV4YW1wbGUgQmF0dGVyaWVzIEdtYkgifSwibWF0ZXJpYWxzIjpbeyJjb3VudHJ5T2ZPcmlnaW4iOiJDTCIsIm5hbWUiOiJMaXRoaXVtIiwicmVjeWNsZWRQY3QiOjEyLjUsIndlaWdodEtnIjowLjh9LHsiY291bnRyeU9mT3JpZ2luIjoiQ0QiLCJuYW1lIjoiQ29iYWx0IiwicmVjeWNsZWRQY3QiOjE2LjAsIndlaWdodEtnIjowLjN9XSwib3BlcmF0b3JJZGVudGlmaWVyIjoiREUxMjM0NTY3ODkiLCJwcm9kdWN0R3JvdXAiOiJiYXR0ZXJ5IiwicHJvZHVjdEdyb3VwRGF0YSI6eyJhbm9kZU1hdGVyaWFsIjpbeyJuYW1lIjoiR3JhcGhpdGUiLCJ3ZWlnaHRQY3QiOjE4LjB9XSwiYmF0dGVyeUNoZW1pc3RyeSI6Ik5NQyIsImJhdHRlcnlNb2RlbElkIjoiSU5ELTQ4LzEwMC1BIiwiYmF0dGVyeVBhc3Nwb3J0TnVtYmVyIjoiQlAtMjAyNi0wMDAxMjMiLCJiYXR0ZXJ5VHlwZSI6ImluZHVzdHJpYWwiLCJiYXR0ZXJ5V2VpZ2h0S2ciOjI0LjUsImNhdGhvZGVNYXRlcmlhbCI6W3sibmFtZSI6IkxpTmlNbkNvTzIiLCJ3ZWlnaHRQY3QiOjMyLjB9XSwiY28yZVBlclVuaXRLZyI6MzkuMSwiY29tcG9uZW50UGFydE51bWJlcnMiOlsiQ0VMTC0yMTcwMC1BIiwiQk1TLTQ4MjAiXSwiY3JpdGljYWxSYXdNYXRlcmlhbHMiOlt7ImNhc051bWJlciI6Ijc0NDAtNDgtNCIsIm5hbWUiOiJDb2JhbHQiLCJ3ZWlnaHRHcmFtcyI6MzAwLjB9LHsiY2FzTnVtYmVyIjoiNzQzOS05My0yIiwibmFtZSI6IkxpdGhpdW0iLCJ3ZWlnaHRHcmFtcyI6ODAwLjB9XSwiZGlzYXNzZW1ibHlJbnN0cnVjdGlvbnNVcmwiOiJodHRwczovL2JhdHRlcmllcy5leGFtcGxlLmNvbS9kaXNhc3NlbWJseS9pbmQtNDgiLCJlbGVjdHJvbHl0ZU1hdGVyaWFsIjpbeyJuYW1lIjoiTGlQRjYgaW4gRUMvRE1DIiwid2VpZ2h0UGN0IjoxMS4wfV0sImV1RGVjbGFyYXRpb25PZkNvbmZvcm1pdHkiOiJodHRwczovL2JhdHRlcmllcy5leGFtcGxlLmNvbS9kb2MvaW5kLTQ4LnBkZiIsImV4cGVjdGVkTGlmZXRpbWVDeWNsZXMiOjM1MDAsImV4cGVjdGVkTGlmZXRpbWVSZWZlcmVuY2VUZXN0IjoiSUVDIDYxNDI3LTI6MjAxNSBjeWNsZSBlbmR1cmFuY2UiLCJoYXphcmRvdXNTdWJzdGFuY2VzIjpbeyJjYXNOdW1iZXIiOiIyMTMyNC00MC0zIiwiY29uY2VudHJhdGlvblBjdCI6MS4yLCJuYW1lIjoiTGl0aGl1bSBoZXhhZmx1b3JvcGhvc3BoYXRlIn1dLCJpbnRlcm5hbENlbGxSZXNpc3RhbmNlTW9obSI6MS40LCJpbnRlcm5hbFBhY2tSZXNpc3RhbmNlTW9obSI6MTIuMCwibWFudWZhY3R1cmluZ0RhdGUiOiIyMDI2LTA2LTE1VDAwOjAwOjAwWiIsIm1hbnVmYWN0dXJpbmdQbGFjZSI6IkJlcmxpbiwgREUiLCJtYXJraW5nSW5mb3JtYXRpb24iOiJDRSwgc2VwYXJhdGUtY29sbGVjdGlvbiBzeW1ib2wsIGNhcGFjaXR5IGxhYmVsIiwibWF4aW11bVZvbHRhZ2VWIjo1NC42LCJtaW5pbWFsVm9sdGFnZVYiOjQwLjAsIm5vbWluYWxDYXBhY2l0eUFoIjoxMDAuMCwibm9taW5hbFZvbHRhZ2VWIjo0OC4wLCJub3RJblVzZVRlbXBlcmF0dXJlUmFuZ2UiOnsibWF4QyI6NjAuMCwibWluQyI6LTIwLjB9LCJub3RJblVzZVRlbXBlcmF0dXJlUmVmZXJlbmNlVGVzdCI6IklFQyA2MjY2MC0xOjIwMTggc3RvcmFnZSIsIm9yaWdpbmFsUG93ZXJDYXBhYmlsaXR5VyI6NDgwMC4wLCJwbGFjZWRPbk1hcmtldERhdGUiOiIyMDI2LTA3LTE1IiwicG93ZXJMaW1pdE1heFciOjUyMDAuMCwicG93ZXJMaW1pdE1pblciOjIwMC4wLCJwcm9kdWN0R3JvdXAiOiJiYXR0ZXJ5IiwicHJvZHVjdElkZW50aWZpZXIiOnsiZ3RpbiI6IjA5NTA2MDAwMTM0MzUyIiwic2NoZW1lIjoiZ3MxIn0sInJhdGVkQ2FwYWNpdHlLd2giOm51bGwsInJlY3ljbGVkQ29udGVudENvYmFsdFBjdCI6MTYuMCwicmVjeWNsZWRDb250ZW50TGVhZFBjdCI6MC4wLCJyZWN5Y2xlZENvbnRlbnRMaXRoaXVtUGN0IjoxMi41LCJyZWN5Y2xlZENvbnRlbnROaWNrZWxQY3QiOjYuMCwicmVuZXdhYmxlQ29udGVudFBjdCI6OC4wLCJzYWZldHlNZWFzdXJlcyI6Iklzb2xhdGUgYmVmb3JlIHNlcnZpY2luZzsgZG8gbm90IHB1bmN0dXJlIiwic3BhcmVQYXJ0c0NvbnRhY3RzIjoicGFydHNAYmF0dGVyaWVzLmV4YW1wbGUuY29tIiwidGVzdFJlcG9ydFJlc3VsdHMiOiJJRUMgNjI2MTk6MjAyMiDigJQgcGFzcyIsInVzYWJsZUV4dGluZ3Vpc2hpbmdBZ2VudCI6IldhdGVyLCBDTzIsIEFCQyBkcnkgcG93ZGVyIiwid2FzdGVCYXR0ZXJ5SW5mb3JtYXRpb24iOiJSZXR1cm4gdG8gYW4gYXV0aG9yaXNlZCBjb2xsZWN0aW9uIHBvaW50In0sInByb2R1Y3ROYW1lIjoiR3JpZENlbGwgSU5ELTQ4LzEwMCIsInB1Ymxpc2hlZEF0IjoiMjAyNi0xMS0wMlQxNjozNDo0OS4wMjM2MjE3MjJaIiwicXJDb2RlVXJsIjoiaHR0cHM6Ly9yZXNvbHZlci5leGFtcGxlLmNvbS8wMS8wOTUwNjAwMDEzNDM1Mi8yMS83MjYzYmQ2ZjMzZWMwYmI5OGNlNiIsInJlcGFpcmFiaWxpdHlTY29yZSI6bnVsbCwicmV0ZW50aW9uTG9ja2VkIjp0cnVlLCJyZXRlbnRpb25VbnRpbCI6IjIwMzYtMTEtMDJUMTY6MzQ6NDkuMDIzNjIxNzIyWiIsInNjaGVtYVZlcnNpb24iOiIyLjEwLjAiLCJzdGF0dXMiOiJhY3RpdmUiLCJ1cGRhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ5LjAyMzYyMTcyMloiLCJ2ZXJzaW9uIjoxfQ.test-sig", + "public+restricted+individual": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJiYXR0ZXJ5LXJlZy0yMDIzLTE1NDIiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOiJMT1QtMjAyNi0wOCIsImNvMmVQZXJVbml0Ijp7InZhbHVlS2ciOjM5LjF9LCJjb21tb2RpdHlDb2RlIjoiODUwNzYwMDAiLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4OTg3OTA3NFoiLCJjbzJlU2NvcmUiOjM5LjEsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4NTYwNzAwNFoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTBjMTktNzI2My1iZDZmLTMzZWMwYmI5OGNlNiIsImxpbnRSZXN1bHQiOnsiYXNzZXNzZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NDguOTg5ODgxMjQ3WiIsImZpbmRpbmdzIjpbeyJjb2RlIjoiYmF0dGVyeS5tYXRlcmlhbF9jb21wb3NpdGlvbl9zdW0iLCJmaWVsZCI6ImNhdGhvZGVNYXRlcmlhbCIsIm1lc3NhZ2UiOiJjYXRob2RlTWF0ZXJpYWwgd2VpZ2h0UGN0IGVudHJpZXMgc3VtIHRvIDMyLjAlLCBleHBlY3RlZCB-MTAwJSDigJQgaW50ZW5kZWQ_Iiwic2V2ZXJpdHkiOiJXQVJOSU5HIn0seyJjb2RlIjoiYmF0dGVyeS5tYXRlcmlhbF9jb21wb3NpdGlvbl9zdW0iLCJmaWVsZCI6ImFub2RlTWF0ZXJpYWwiLCJtZXNzYWdlIjoiYW5vZGVNYXRlcmlhbCB3ZWlnaHRQY3QgZW50cmllcyBzdW0gdG8gMTguMCUsIGV4cGVjdGVkIH4xMDAlIOKAlCBpbnRlbmRlZD8iLCJzZXZlcml0eSI6IldBUk5JTkcifSx7ImNvZGUiOiJiYXR0ZXJ5Lm1hdGVyaWFsX2NvbXBvc2l0aW9uX3N1bSIsImZpZWxkIjoiZWxlY3Ryb2x5dGVNYXRlcmlhbCIsIm1lc3NhZ2UiOiJlbGVjdHJvbHl0ZU1hdGVyaWFsIHdlaWdodFBjdCBlbnRyaWVzIHN1bSB0byAxMS4wJSwgZXhwZWN0ZWQgfjEwMCUg4oCUIGludGVuZGVkPyIsInNldmVyaXR5IjoiV0FSTklORyJ9XSwicGFja1ZlcnNpb24iOiIxLjEuMCJ9LCJtYW51ZmFjdHVyZXIiOnsiYWRkcmVzcyI6IkluZHVzdHJpZXN0cmFzc2UgNCwgMTAxMTUgQmVybGluLCBERSIsImNvdW50cnkiOm51bGwsImRpZFdlYlVybCI6Imh0dHBzOi8vYmF0dGVyaWVzLmV4YW1wbGUuY29tLy53ZWxsLWtub3duL2RpZC5qc29uIiwibmFtZSI6IkV4YW1wbGUgQmF0dGVyaWVzIEdtYkgifSwibWF0ZXJpYWxzIjpbeyJjb3VudHJ5T2ZPcmlnaW4iOiJDTCIsIm5hbWUiOiJMaXRoaXVtIiwicmVjeWNsZWRQY3QiOjEyLjUsIndlaWdodEtnIjowLjh9LHsiY291bnRyeU9mT3JpZ2luIjoiQ0QiLCJuYW1lIjoiQ29iYWx0IiwicmVjeWNsZWRQY3QiOjE2LjAsIndlaWdodEtnIjowLjN9XSwib3BlcmF0b3JJZGVudGlmaWVyIjoiREUxMjM0NTY3ODkiLCJwcm9kdWN0R3JvdXAiOiJiYXR0ZXJ5IiwicHJvZHVjdEdyb3VwRGF0YSI6eyJhbm9kZU1hdGVyaWFsIjpbeyJuYW1lIjoiR3JhcGhpdGUiLCJ3ZWlnaHRQY3QiOjE4LjB9XSwiYmF0dGVyeUNoZW1pc3RyeSI6Ik5NQyIsImJhdHRlcnlNb2RlbElkIjoiSU5ELTQ4LzEwMC1BIiwiYmF0dGVyeVBhc3Nwb3J0TnVtYmVyIjoiQlAtMjAyNi0wMDAxMjMiLCJiYXR0ZXJ5U3RhdHVzIjoib3JpZ2luYWwiLCJiYXR0ZXJ5VHlwZSI6ImluZHVzdHJpYWwiLCJiYXR0ZXJ5V2VpZ2h0S2ciOjI0LjUsImNhdGhvZGVNYXRlcmlhbCI6W3sibmFtZSI6IkxpTmlNbkNvTzIiLCJ3ZWlnaHRQY3QiOjMyLjB9XSwiY28yZVBlclVuaXRLZyI6MzkuMSwiY29tcG9uZW50UGFydE51bWJlcnMiOlsiQ0VMTC0yMTcwMC1BIiwiQk1TLTQ4MjAiXSwiY3JpdGljYWxSYXdNYXRlcmlhbHMiOlt7ImNhc051bWJlciI6Ijc0NDAtNDgtNCIsIm5hbWUiOiJDb2JhbHQiLCJ3ZWlnaHRHcmFtcyI6MzAwLjB9LHsiY2FzTnVtYmVyIjoiNzQzOS05My0yIiwibmFtZSI6IkxpdGhpdW0iLCJ3ZWlnaHRHcmFtcyI6ODAwLjB9XSwiZGlzYXNzZW1ibHlJbnN0cnVjdGlvbnNVcmwiOiJodHRwczovL2JhdHRlcmllcy5leGFtcGxlLmNvbS9kaXNhc3NlbWJseS9pbmQtNDgiLCJlbGVjdHJvbHl0ZU1hdGVyaWFsIjpbeyJuYW1lIjoiTGlQRjYgaW4gRUMvRE1DIiwid2VpZ2h0UGN0IjoxMS4wfV0sImV1RGVjbGFyYXRpb25PZkNvbmZvcm1pdHkiOiJodHRwczovL2JhdHRlcmllcy5leGFtcGxlLmNvbS9kb2MvaW5kLTQ4LnBkZiIsImV4cGVjdGVkTGlmZXRpbWVDeWNsZXMiOjM1MDAsImV4cGVjdGVkTGlmZXRpbWVSZWZlcmVuY2VUZXN0IjoiSUVDIDYxNDI3LTI6MjAxNSBjeWNsZSBlbmR1cmFuY2UiLCJoYXphcmRvdXNTdWJzdGFuY2VzIjpbeyJjYXNOdW1iZXIiOiIyMTMyNC00MC0zIiwiY29uY2VudHJhdGlvblBjdCI6MS4yLCJuYW1lIjoiTGl0aGl1bSBoZXhhZmx1b3JvcGhvc3BoYXRlIn1dLCJpbnRlcm5hbENlbGxSZXNpc3RhbmNlTW9obSI6MS40LCJpbnRlcm5hbFBhY2tSZXNpc3RhbmNlTW9obSI6MTIuMCwibWFudWZhY3R1cmluZ0RhdGUiOiIyMDI2LTA2LTE1VDAwOjAwOjAwWiIsIm1hbnVmYWN0dXJpbmdQbGFjZSI6IkJlcmxpbiwgREUiLCJtYXJraW5nSW5mb3JtYXRpb24iOiJDRSwgc2VwYXJhdGUtY29sbGVjdGlvbiBzeW1ib2wsIGNhcGFjaXR5IGxhYmVsIiwibWF4aW11bVZvbHRhZ2VWIjo1NC42LCJtaW5pbWFsVm9sdGFnZVYiOjQwLjAsIm5vbWluYWxDYXBhY2l0eUFoIjoxMDAuMCwibm9taW5hbFZvbHRhZ2VWIjo0OC4wLCJub3RJblVzZVRlbXBlcmF0dXJlUmFuZ2UiOnsibWF4QyI6NjAuMCwibWluQyI6LTIwLjB9LCJub3RJblVzZVRlbXBlcmF0dXJlUmVmZXJlbmNlVGVzdCI6IklFQyA2MjY2MC0xOjIwMTggc3RvcmFnZSIsIm9yaWdpbmFsUG93ZXJDYXBhYmlsaXR5VyI6NDgwMC4wLCJwbGFjZWRPbk1hcmtldERhdGUiOiIyMDI2LTA3LTE1IiwicG93ZXJMaW1pdE1heFciOjUyMDAuMCwicG93ZXJMaW1pdE1pblciOjIwMC4wLCJwcm9kdWN0R3JvdXAiOiJiYXR0ZXJ5IiwicHJvZHVjdElkZW50aWZpZXIiOnsiZ3RpbiI6IjA5NTA2MDAwMTM0MzUyIiwic2NoZW1lIjoiZ3MxIn0sInJhdGVkQ2FwYWNpdHlLd2giOm51bGwsInJlY3ljbGVkQ29udGVudENvYmFsdFBjdCI6MTYuMCwicmVjeWNsZWRDb250ZW50TGVhZFBjdCI6MC4wLCJyZWN5Y2xlZENvbnRlbnRMaXRoaXVtUGN0IjoxMi41LCJyZWN5Y2xlZENvbnRlbnROaWNrZWxQY3QiOjYuMCwicmVuZXdhYmxlQ29udGVudFBjdCI6OC4wLCJzYWZldHlNZWFzdXJlcyI6Iklzb2xhdGUgYmVmb3JlIHNlcnZpY2luZzsgZG8gbm90IHB1bmN0dXJlIiwic3BhcmVQYXJ0c0NvbnRhY3RzIjoicGFydHNAYmF0dGVyaWVzLmV4YW1wbGUuY29tIiwic3RhdGVPZkhlYWx0aFBjdCI6bnVsbCwidXNhYmxlRXh0aW5ndWlzaGluZ0FnZW50IjoiV2F0ZXIsIENPMiwgQUJDIGRyeSBwb3dkZXIiLCJ3YXN0ZUJhdHRlcnlJbmZvcm1hdGlvbiI6IlJldHVybiB0byBhbiBhdXRob3Jpc2VkIGNvbGxlY3Rpb24gcG9pbnQifSwicHJvZHVjdE5hbWUiOiJHcmlkQ2VsbCBJTkQtNDgvMTAwIiwicHVibGlzaGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ5LjAyMzYyMTcyMloiLCJxckNvZGVVcmwiOiJodHRwczovL3Jlc29sdmVyLmV4YW1wbGUuY29tLzAxLzA5NTA2MDAwMTM0MzUyLzIxLzcyNjNiZDZmMzNlYzBiYjk4Y2U2IiwicmVwYWlyYWJpbGl0eVNjb3JlIjpudWxsLCJyZXRlbnRpb25VbnRpbCI6IjIwMzYtMTEtMDJUMTY6MzQ6NDkuMDIzNjIxNzIyWiIsInNjaGVtYVZlcnNpb24iOiIyLjEwLjAiLCJzdGF0dXMiOiJhY3RpdmUiLCJ1cGRhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ5LjAyMzYyMTcyMloiLCJ2ZXJzaW9uIjoxfQ.test-sig" + }, + "facility": { + "country": "DE", + "name": "Seed Plant", + "scheme": "gln", + "value": "4012345000009" + }, + "id": "01a19d1d-0c19-7263-bd6f-33ec0bb98ce6", + "jwsSignature": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJiYXR0ZXJ5LXJlZy0yMDIzLTE1NDIiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOiJMT1QtMjAyNi0wOCIsImNvMmVQZXJVbml0Ijp7InZhbHVlS2ciOjM5LjF9LCJjb21tb2RpdHlDb2RlIjoiODUwNzYwMDAiLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4OTg3OTA3NFoiLCJjbzJlU2NvcmUiOjM5LjEsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4NTYwNzAwNFoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTBjMTktNzI2My1iZDZmLTMzZWMwYmI5OGNlNiIsImp3c1NpZ25hdHVyZSI6bnVsbCwibGludFJlc3VsdCI6eyJhc3Nlc3NlZEF0IjoiMjAyNi0xMS0wMlQxNjozNDo0OC45ODk4ODEyNDdaIiwiZmluZGluZ3MiOlt7ImNvZGUiOiJiYXR0ZXJ5Lm1hdGVyaWFsX2NvbXBvc2l0aW9uX3N1bSIsImZpZWxkIjoiY2F0aG9kZU1hdGVyaWFsIiwibWVzc2FnZSI6ImNhdGhvZGVNYXRlcmlhbCB3ZWlnaHRQY3QgZW50cmllcyBzdW0gdG8gMzIuMCUsIGV4cGVjdGVkIH4xMDAlIOKAlCBpbnRlbmRlZD8iLCJzZXZlcml0eSI6IldBUk5JTkcifSx7ImNvZGUiOiJiYXR0ZXJ5Lm1hdGVyaWFsX2NvbXBvc2l0aW9uX3N1bSIsImZpZWxkIjoiYW5vZGVNYXRlcmlhbCIsIm1lc3NhZ2UiOiJhbm9kZU1hdGVyaWFsIHdlaWdodFBjdCBlbnRyaWVzIHN1bSB0byAxOC4wJSwgZXhwZWN0ZWQgfjEwMCUg4oCUIGludGVuZGVkPyIsInNldmVyaXR5IjoiV0FSTklORyJ9LHsiY29kZSI6ImJhdHRlcnkubWF0ZXJpYWxfY29tcG9zaXRpb25fc3VtIiwiZmllbGQiOiJlbGVjdHJvbHl0ZU1hdGVyaWFsIiwibWVzc2FnZSI6ImVsZWN0cm9seXRlTWF0ZXJpYWwgd2VpZ2h0UGN0IGVudHJpZXMgc3VtIHRvIDExLjAlLCBleHBlY3RlZCB-MTAwJSDigJQgaW50ZW5kZWQ_Iiwic2V2ZXJpdHkiOiJXQVJOSU5HIn1dLCJwYWNrVmVyc2lvbiI6IjEuMS4wIn0sIm1hbnVmYWN0dXJlciI6eyJhZGRyZXNzIjoiSW5kdXN0cmllc3RyYXNzZSA0LCAxMDExNSBCZXJsaW4sIERFIiwiY291bnRyeSI6bnVsbCwiZGlkV2ViVXJsIjoiaHR0cHM6Ly9iYXR0ZXJpZXMuZXhhbXBsZS5jb20vLndlbGwta25vd24vZGlkLmpzb24iLCJuYW1lIjoiRXhhbXBsZSBCYXR0ZXJpZXMgR21iSCJ9LCJtYXRlcmlhbHMiOlt7ImNvdW50cnlPZk9yaWdpbiI6IkNMIiwibmFtZSI6IkxpdGhpdW0iLCJyZWN5Y2xlZFBjdCI6MTIuNSwid2VpZ2h0S2ciOjAuOH0seyJjb3VudHJ5T2ZPcmlnaW4iOiJDRCIsIm5hbWUiOiJDb2JhbHQiLCJyZWN5Y2xlZFBjdCI6MTYuMCwid2VpZ2h0S2ciOjAuM31dLCJvcGVyYXRvcklkZW50aWZpZXIiOiJERTEyMzQ1Njc4OSIsInByb2R1Y3RHcm91cCI6ImJhdHRlcnkiLCJwcm9kdWN0R3JvdXBEYXRhIjp7ImFub2RlTWF0ZXJpYWwiOlt7Im5hbWUiOiJHcmFwaGl0ZSIsIndlaWdodFBjdCI6MTguMH1dLCJiYXR0ZXJ5Q2hlbWlzdHJ5IjoiTk1DIiwiYmF0dGVyeU1vZGVsSWQiOiJJTkQtNDgvMTAwLUEiLCJiYXR0ZXJ5UGFzc3BvcnROdW1iZXIiOiJCUC0yMDI2LTAwMDEyMyIsImJhdHRlcnlTdGF0dXMiOiJvcmlnaW5hbCIsImJhdHRlcnlUeXBlIjoiaW5kdXN0cmlhbCIsImJhdHRlcnlXZWlnaHRLZyI6MjQuNSwiY2F0aG9kZU1hdGVyaWFsIjpbeyJuYW1lIjoiTGlOaU1uQ29PMiIsIndlaWdodFBjdCI6MzIuMH1dLCJjbzJlUGVyVW5pdEtnIjozOS4xLCJjb21wb25lbnRQYXJ0TnVtYmVycyI6WyJDRUxMLTIxNzAwLUEiLCJCTVMtNDgyMCJdLCJjcml0aWNhbFJhd01hdGVyaWFscyI6W3siY2FzTnVtYmVyIjoiNzQ0MC00OC00IiwibmFtZSI6IkNvYmFsdCIsIndlaWdodEdyYW1zIjozMDAuMH0seyJjYXNOdW1iZXIiOiI3NDM5LTkzLTIiLCJuYW1lIjoiTGl0aGl1bSIsIndlaWdodEdyYW1zIjo4MDAuMH1dLCJkaXNhc3NlbWJseUluc3RydWN0aW9uc1VybCI6Imh0dHBzOi8vYmF0dGVyaWVzLmV4YW1wbGUuY29tL2Rpc2Fzc2VtYmx5L2luZC00OCIsImVsZWN0cm9seXRlTWF0ZXJpYWwiOlt7Im5hbWUiOiJMaVBGNiBpbiBFQy9ETUMiLCJ3ZWlnaHRQY3QiOjExLjB9XSwiZXVEZWNsYXJhdGlvbk9mQ29uZm9ybWl0eSI6Imh0dHBzOi8vYmF0dGVyaWVzLmV4YW1wbGUuY29tL2RvYy9pbmQtNDgucGRmIiwiZXhwZWN0ZWRMaWZldGltZUN5Y2xlcyI6MzUwMCwiZXhwZWN0ZWRMaWZldGltZVJlZmVyZW5jZVRlc3QiOiJJRUMgNjE0MjctMjoyMDE1IGN5Y2xlIGVuZHVyYW5jZSIsImhhemFyZG91c1N1YnN0YW5jZXMiOlt7ImNhc051bWJlciI6IjIxMzI0LTQwLTMiLCJjb25jZW50cmF0aW9uUGN0IjoxLjIsIm5hbWUiOiJMaXRoaXVtIGhleGFmbHVvcm9waG9zcGhhdGUifV0sImludGVybmFsQ2VsbFJlc2lzdGFuY2VNb2htIjoxLjQsImludGVybmFsUGFja1Jlc2lzdGFuY2VNb2htIjoxMi4wLCJtYW51ZmFjdHVyaW5nRGF0ZSI6IjIwMjYtMDYtMTVUMDA6MDA6MDBaIiwibWFudWZhY3R1cmluZ1BsYWNlIjoiQmVybGluLCBERSIsIm1hcmtpbmdJbmZvcm1hdGlvbiI6IkNFLCBzZXBhcmF0ZS1jb2xsZWN0aW9uIHN5bWJvbCwgY2FwYWNpdHkgbGFiZWwiLCJtYXhpbXVtVm9sdGFnZVYiOjU0LjYsIm1pbmltYWxWb2x0YWdlViI6NDAuMCwibm9taW5hbENhcGFjaXR5QWgiOjEwMC4wLCJub21pbmFsVm9sdGFnZVYiOjQ4LjAsIm5vdEluVXNlVGVtcGVyYXR1cmVSYW5nZSI6eyJtYXhDIjo2MC4wLCJtaW5DIjotMjAuMH0sIm5vdEluVXNlVGVtcGVyYXR1cmVSZWZlcmVuY2VUZXN0IjoiSUVDIDYyNjYwLTE6MjAxOCBzdG9yYWdlIiwib3JpZ2luYWxQb3dlckNhcGFiaWxpdHlXIjo0ODAwLjAsInBsYWNlZE9uTWFya2V0RGF0ZSI6IjIwMjYtMDctMTUiLCJwb3dlckxpbWl0TWF4VyI6NTIwMC4wLCJwb3dlckxpbWl0TWluVyI6MjAwLjAsInByb2R1Y3RHcm91cCI6ImJhdHRlcnkiLCJwcm9kdWN0SWRlbnRpZmllciI6eyJndGluIjoiMDk1MDYwMDAxMzQzNTIiLCJzY2hlbWUiOiJnczEifSwicmF0ZWRDYXBhY2l0eUt3aCI6bnVsbCwicmVjeWNsZWRDb250ZW50Q29iYWx0UGN0IjoxNi4wLCJyZWN5Y2xlZENvbnRlbnRMZWFkUGN0IjowLjAsInJlY3ljbGVkQ29udGVudExpdGhpdW1QY3QiOjEyLjUsInJlY3ljbGVkQ29udGVudE5pY2tlbFBjdCI6Ni4wLCJyZW5ld2FibGVDb250ZW50UGN0Ijo4LjAsInNhZmV0eU1lYXN1cmVzIjoiSXNvbGF0ZSBiZWZvcmUgc2VydmljaW5nOyBkbyBub3QgcHVuY3R1cmUiLCJzcGFyZVBhcnRzQ29udGFjdHMiOiJwYXJ0c0BiYXR0ZXJpZXMuZXhhbXBsZS5jb20iLCJzdGF0ZU9mSGVhbHRoUGN0IjpudWxsLCJ0ZXN0UmVwb3J0UmVzdWx0cyI6IklFQyA2MjYxOToyMDIyIOKAlCBwYXNzIiwidXNhYmxlRXh0aW5ndWlzaGluZ0FnZW50IjoiV2F0ZXIsIENPMiwgQUJDIGRyeSBwb3dkZXIiLCJ3YXN0ZUJhdHRlcnlJbmZvcm1hdGlvbiI6IlJldHVybiB0byBhbiBhdXRob3Jpc2VkIGNvbGxlY3Rpb24gcG9pbnQifSwicHJvZHVjdE5hbWUiOiJHcmlkQ2VsbCBJTkQtNDgvMTAwIiwicHVibGlzaGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ5LjAyMzYyMTcyMloiLCJxckNvZGVVcmwiOiJodHRwczovL3Jlc29sdmVyLmV4YW1wbGUuY29tLzAxLzA5NTA2MDAwMTM0MzUyLzIxLzcyNjNiZDZmMzNlYzBiYjk4Y2U2IiwicmVwYWlyYWJpbGl0eVNjb3JlIjpudWxsLCJyZXRlbnRpb25Mb2NrZWQiOnRydWUsInJldGVudGlvblVudGlsIjoiMjAzNi0xMS0wMlQxNjozNDo0OS4wMjM2MjE3MjJaIiwic2NoZW1hVmVyc2lvbiI6IjIuMTAuMCIsInN0YXR1cyI6ImFjdGl2ZSIsInVwZGF0ZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NDkuMDIzNjIxNzIyWiIsInZlcnNpb24iOjF9.test-sig", + "lintResult": { + "assessedAt": "2026-11-02T16:34:48.989881247Z", + "findings": [ + { + "code": "battery.material_composition_sum", + "field": "cathodeMaterial", + "message": "cathodeMaterial weightPct entries sum to 32.0%, expected ~100% — intended?", + "severity": "WARNING" + }, + { + "code": "battery.material_composition_sum", + "field": "anodeMaterial", + "message": "anodeMaterial weightPct entries sum to 18.0%, expected ~100% — intended?", + "severity": "WARNING" + }, + { + "code": "battery.material_composition_sum", + "field": "electrolyteMaterial", + "message": "electrolyteMaterial weightPct entries sum to 11.0%, expected ~100% — intended?", + "severity": "WARNING" + } + ], + "packVersion": "1.1.0" + }, + "manufacturer": { + "address": "Industriestrasse 4, 10115 Berlin, DE", + "country": null, + "didWebUrl": "https://batteries.example.com/.well-known/did.json", + "name": "Example Batteries GmbH" + }, + "materials": [ + { + "countryOfOrigin": "CL", + "name": "Lithium", + "recycledPct": 12.5, + "weightKg": 0.8 + }, + { + "countryOfOrigin": "CD", + "name": "Cobalt", + "recycledPct": 16.0, + "weightKg": 0.3 + } + ], + "operatorIdentifier": "DE123456789", + "productGroup": "battery", + "productGroupData": { + "anodeMaterial": [ + { + "name": "Graphite", + "weightPct": 18.0 + } + ], + "batteryChemistry": "NMC", + "batteryModelId": "IND-48/100-A", + "batteryPassportNumber": "BP-2026-000123", + "batteryStatus": "original", + "batteryType": "industrial", + "batteryWeightKg": 24.5, + "cathodeMaterial": [ + { + "name": "LiNiMnCoO2", + "weightPct": 32.0 + } + ], + "co2ePerUnitKg": 39.1, + "componentPartNumbers": [ + "CELL-21700-A", + "BMS-4820" + ], + "criticalRawMaterials": [ + { + "casNumber": "7440-48-4", + "name": "Cobalt", + "weightGrams": 300.0 + }, + { + "casNumber": "7439-93-2", + "name": "Lithium", + "weightGrams": 800.0 + } + ], + "disassemblyInstructionsUrl": "https://batteries.example.com/disassembly/ind-48", + "electrolyteMaterial": [ + { + "name": "LiPF6 in EC/DMC", + "weightPct": 11.0 + } + ], + "euDeclarationOfConformity": "https://batteries.example.com/doc/ind-48.pdf", + "expectedLifetimeCycles": 3500, + "expectedLifetimeReferenceTest": "IEC 61427-2:2015 cycle endurance", + "hazardousSubstances": [ + { + "casNumber": "21324-40-3", + "concentrationPct": 1.2, + "name": "Lithium hexafluorophosphate" + } + ], + "internalCellResistanceMohm": 1.4, + "internalPackResistanceMohm": 12.0, + "manufacturingDate": "2026-06-15T00:00:00Z", + "manufacturingPlace": "Berlin, DE", + "markingInformation": "CE, separate-collection symbol, capacity label", + "maximumVoltageV": 54.6, + "minimalVoltageV": 40.0, + "nominalCapacityAh": 100.0, + "nominalVoltageV": 48.0, + "notInUseTemperatureRange": { + "maxC": 60.0, + "minC": -20.0 + }, + "notInUseTemperatureReferenceTest": "IEC 62660-1:2018 storage", + "originalPowerCapabilityW": 4800.0, + "placedOnMarketDate": "2026-07-15", + "powerLimitMaxW": 5200.0, + "powerLimitMinW": 200.0, + "productGroup": "battery", + "productIdentifier": { + "gtin": "09506000134352", + "scheme": "gs1" + }, + "ratedCapacityKwh": null, + "recycledContentCobaltPct": 16.0, + "recycledContentLeadPct": 0.0, + "recycledContentLithiumPct": 12.5, + "recycledContentNickelPct": 6.0, + "renewableContentPct": 8.0, + "safetyMeasures": "Isolate before servicing; do not puncture", + "sparePartsContacts": "parts@batteries.example.com", + "stateOfHealthPct": null, + "testReportResults": "IEC 62619:2022 — pass", + "usableExtinguishingAgent": "Water, CO2, ABC dry powder", + "wasteBatteryInformation": "Return to an authorised collection point" + }, + "productName": "GridCell IND-48/100", + "publicJwsSignature": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJiYXR0ZXJ5LXJlZy0yMDIzLTE1NDIiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOiJMT1QtMjAyNi0wOCIsImNvMmVQZXJVbml0Ijp7InZhbHVlS2ciOjM5LjF9LCJjb21tb2RpdHlDb2RlIjoiODUwNzYwMDAiLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4OTg3OTA3NFoiLCJjbzJlU2NvcmUiOjM5LjEsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjQ4Ljk4NTYwNzAwNFoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTBjMTktNzI2My1iZDZmLTMzZWMwYmI5OGNlNiIsIm1hbnVmYWN0dXJlciI6eyJhZGRyZXNzIjoiSW5kdXN0cmllc3RyYXNzZSA0LCAxMDExNSBCZXJsaW4sIERFIiwiY291bnRyeSI6bnVsbCwiZGlkV2ViVXJsIjoiaHR0cHM6Ly9iYXR0ZXJpZXMuZXhhbXBsZS5jb20vLndlbGwta25vd24vZGlkLmpzb24iLCJuYW1lIjoiRXhhbXBsZSBCYXR0ZXJpZXMgR21iSCJ9LCJtYXRlcmlhbHMiOlt7ImNvdW50cnlPZk9yaWdpbiI6IkNMIiwibmFtZSI6IkxpdGhpdW0iLCJyZWN5Y2xlZFBjdCI6MTIuNSwid2VpZ2h0S2ciOjAuOH0seyJjb3VudHJ5T2ZPcmlnaW4iOiJDRCIsIm5hbWUiOiJDb2JhbHQiLCJyZWN5Y2xlZFBjdCI6MTYuMCwid2VpZ2h0S2ciOjAuM31dLCJvcGVyYXRvcklkZW50aWZpZXIiOiJERTEyMzQ1Njc4OSIsInByb2R1Y3RHcm91cCI6ImJhdHRlcnkiLCJwcm9kdWN0R3JvdXBEYXRhIjp7ImJhdHRlcnlDaGVtaXN0cnkiOiJOTUMiLCJiYXR0ZXJ5TW9kZWxJZCI6IklORC00OC8xMDAtQSIsImJhdHRlcnlQYXNzcG9ydE51bWJlciI6IkJQLTIwMjYtMDAwMTIzIiwiYmF0dGVyeVR5cGUiOiJpbmR1c3RyaWFsIiwiYmF0dGVyeVdlaWdodEtnIjoyNC41LCJjbzJlUGVyVW5pdEtnIjozOS4xLCJjcml0aWNhbFJhd01hdGVyaWFscyI6W3siY2FzTnVtYmVyIjoiNzQ0MC00OC00IiwibmFtZSI6IkNvYmFsdCJ9LHsiY2FzTnVtYmVyIjoiNzQzOS05My0yIiwibmFtZSI6IkxpdGhpdW0ifV0sImV1RGVjbGFyYXRpb25PZkNvbmZvcm1pdHkiOiJodHRwczovL2JhdHRlcmllcy5leGFtcGxlLmNvbS9kb2MvaW5kLTQ4LnBkZiIsImV4cGVjdGVkTGlmZXRpbWVDeWNsZXMiOjM1MDAsImV4cGVjdGVkTGlmZXRpbWVSZWZlcmVuY2VUZXN0IjoiSUVDIDYxNDI3LTI6MjAxNSBjeWNsZSBlbmR1cmFuY2UiLCJoYXphcmRvdXNTdWJzdGFuY2VzIjpbeyJjYXNOdW1iZXIiOiIyMTMyNC00MC0zIiwiY29uY2VudHJhdGlvblBjdCI6MS4yLCJuYW1lIjoiTGl0aGl1bSBoZXhhZmx1b3JvcGhvc3BoYXRlIn1dLCJpbnRlcm5hbENlbGxSZXNpc3RhbmNlTW9obSI6MS40LCJpbnRlcm5hbFBhY2tSZXNpc3RhbmNlTW9obSI6MTIuMCwibWFudWZhY3R1cmluZ0RhdGUiOiIyMDI2LTA2LTE1VDAwOjAwOjAwWiIsIm1hbnVmYWN0dXJpbmdQbGFjZSI6IkJlcmxpbiwgREUiLCJtYXJraW5nSW5mb3JtYXRpb24iOiJDRSwgc2VwYXJhdGUtY29sbGVjdGlvbiBzeW1ib2wsIGNhcGFjaXR5IGxhYmVsIiwibWF4aW11bVZvbHRhZ2VWIjo1NC42LCJtaW5pbWFsVm9sdGFnZVYiOjQwLjAsIm5vbWluYWxDYXBhY2l0eUFoIjoxMDAuMCwibm9taW5hbFZvbHRhZ2VWIjo0OC4wLCJub3RJblVzZVRlbXBlcmF0dXJlUmFuZ2UiOnsibWF4QyI6NjAuMCwibWluQyI6LTIwLjB9LCJub3RJblVzZVRlbXBlcmF0dXJlUmVmZXJlbmNlVGVzdCI6IklFQyA2MjY2MC0xOjIwMTggc3RvcmFnZSIsIm9yaWdpbmFsUG93ZXJDYXBhYmlsaXR5VyI6NDgwMC4wLCJwbGFjZWRPbk1hcmtldERhdGUiOiIyMDI2LTA3LTE1IiwicG93ZXJMaW1pdE1heFciOjUyMDAuMCwicG93ZXJMaW1pdE1pblciOjIwMC4wLCJwcm9kdWN0R3JvdXAiOiJiYXR0ZXJ5IiwicHJvZHVjdElkZW50aWZpZXIiOnsiZ3RpbiI6IjA5NTA2MDAwMTM0MzUyIiwic2NoZW1lIjoiZ3MxIn0sInJhdGVkQ2FwYWNpdHlLd2giOm51bGwsInJlY3ljbGVkQ29udGVudENvYmFsdFBjdCI6MTYuMCwicmVjeWNsZWRDb250ZW50TGVhZFBjdCI6MC4wLCJyZWN5Y2xlZENvbnRlbnRMaXRoaXVtUGN0IjoxMi41LCJyZWN5Y2xlZENvbnRlbnROaWNrZWxQY3QiOjYuMCwicmVuZXdhYmxlQ29udGVudFBjdCI6OC4wLCJ1c2FibGVFeHRpbmd1aXNoaW5nQWdlbnQiOiJXYXRlciwgQ08yLCBBQkMgZHJ5IHBvd2RlciIsIndhc3RlQmF0dGVyeUluZm9ybWF0aW9uIjoiUmV0dXJuIHRvIGFuIGF1dGhvcmlzZWQgY29sbGVjdGlvbiBwb2ludCJ9LCJwcm9kdWN0TmFtZSI6IkdyaWRDZWxsIElORC00OC8xMDAiLCJwdWJsaXNoZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NDkuMDIzNjIxNzIyWiIsInFyQ29kZVVybCI6Imh0dHBzOi8vcmVzb2x2ZXIuZXhhbXBsZS5jb20vMDEvMDk1MDYwMDAxMzQzNTIvMjEvNzI2M2JkNmYzM2VjMGJiOThjZTYiLCJyZXBhaXJhYmlsaXR5U2NvcmUiOm51bGwsInJldGVudGlvblVudGlsIjoiMjAzNi0xMS0wMlQxNjozNDo0OS4wMjM2MjE3MjJaIiwic2NoZW1hVmVyc2lvbiI6IjIuMTAuMCIsInN0YXR1cyI6ImFjdGl2ZSIsInVwZGF0ZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NDkuMDIzNjIxNzIyWiIsInZlcnNpb24iOjF9.test-sig", + "publishedAt": "2026-11-02T16:34:49.023621722Z", + "qrCodeUrl": "https://resolver.example.com/01/09506000134352/21/7263bd6f33ec0bb98ce6", + "repairabilityScore": null, + "retentionLocked": true, + "retentionUntil": "2036-11-02T16:34:49.023621722Z", + "schemaVersion": "2.10.0", + "status": "active", + "updatedAt": "2026-11-02T16:34:49.023621722Z", + "version": 1 +} diff --git a/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.5.0.json b/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.5.0.json new file mode 100644 index 00000000..026744f3 --- /dev/null +++ b/crates/dpp-dal/tests/fixtures/passport_docs/electronics/v1.5.0.json @@ -0,0 +1,79 @@ +{ + "applicableInstruments": [ + { + "instrument": "ecodesign-energy-labelling-mobile", + "recorded": "catalog" + } + ], + "batchId": null, + "co2ePerUnit": null, + "complianceResult": { + "assessedAt": "2026-11-02T16:34:54.475056927Z", + "co2eScore": null, + "complianceStatus": "PASSTHROUGH_NO_VALIDATION", + "recycledContentPct": null, + "repairabilityIndex": null + }, + "createdAt": "2026-11-02T16:34:54.471910631Z", + "disclosureSignatures": { + "public+restricted+conformity": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJlY29kZXNpZ24tZW5lcmd5LWxhYmVsbGluZy1tb2JpbGUiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOm51bGwsImNvMmVQZXJVbml0IjpudWxsLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3NTA1NjkyN1oiLCJjbzJlU2NvcmUiOm51bGwsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3MTkxMDYzMVoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTIxODctNzMwNy04ZDg5LTkzYjI2MWJhMDc2MSIsImxpbnRSZXN1bHQiOnsiYXNzZXNzZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NTQuNDc1MDU5Mjk2WiIsInBhY2tWZXJzaW9uIjoiMS4xLjAifSwibWFudWZhY3R1cmVyIjp7ImFkZHJlc3MiOiJLZWl6ZXJzZ3JhY2h0IDEwMCwgMTAxNSBBbXN0ZXJkYW0sIE5MIiwiY291bnRyeSI6bnVsbCwiZGlkV2ViVXJsIjpudWxsLCJuYW1lIjoiQXVyb3JhIERldmljZXMgQlYifSwibWF0ZXJpYWxzIjpbeyJjb3VudHJ5T2ZPcmlnaW4iOm51bGwsIm5hbWUiOiJBbHVtaW5pdW0iLCJyZWN5Y2xlZFBjdCI6ODAuMCwid2VpZ2h0S2ciOjAuMDZ9XSwib3BlcmF0b3JJZGVudGlmaWVyIjoiREUxMjM0NTY3ODkiLCJwcm9kdWN0R3JvdXAiOiJlbGVjdHJvbmljcyIsInByb2R1Y3RHcm91cERhdGEiOnsiY28yZVBlclVuaXRLZyI6NTIuNCwiZGlzYXNzZW1ibHlJbnN0cnVjdGlvbnNVcmwiOiJodHRwczovL2F1cm9yYS5leGFtcGxlLmNvbS9kaXNhc3NlbWJseS9zMSIsImVuZXJneUVmZmljaWVuY3lDbGFzcyI6IkIiLCJleHBlY3RlZExpZmV0aW1lWWVhcnMiOjcsImZpcm13YXJlVXBkYXRlVW50aWwiOiIyMDMzLTA5LTAxVDAwOjAwOjAwWiIsInByb2R1Y3RDYXRlZ29yeSI6InNtYXJ0cGhvbmUiLCJwcm9kdWN0R3JvdXAiOiJlbGVjdHJvbmljcyIsInByb2R1Y3RJZGVudGlmaWVyIjp7Imd0aW4iOiIwOTUwNjAwMDEzNDM3NiIsInNjaGVtZSI6ImdzMSJ9LCJyZWN5Y2xlZENvbnRlbnRQY3QiOjIyLjAsInJlcGFpck1hbnVhbFVybCI6Imh0dHBzOi8vYXVyb3JhLmV4YW1wbGUuY29tL3JlcGFpci9zMSIsInJvaHNDb21wbGlhbnQiOnRydWUsInNwYXJlUGFydHNBdmFpbGFibGUiOnRydWUsInN0YW5kYnlQb3dlclciOjAuMTV9LCJwcm9kdWN0TmFtZSI6IkF1cm9yYSBTMSBTbWFydHBob25lIiwicHVibGlzaGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjUwMTkwOTgzNVoiLCJxckNvZGVVcmwiOiJodHRwczovL3Jlc29sdmVyLmV4YW1wbGUuY29tLzAxLzA5NTA2MDAwMTM0Mzc2LzIxLzczMDc4ZDg5OTNiMjYxYmEwNzYxIiwicmVwYWlyYWJpbGl0eVNjb3JlIjpudWxsLCJyZXRlbnRpb25Mb2NrZWQiOnRydWUsInJldGVudGlvblVudGlsIjoiMjAzNi0xMS0wMlQxNjozNDo1NC41MDE5MDk4MzVaIiwic2NoZW1hVmVyc2lvbiI6IjEuNS4wIiwic3RhdHVzIjoiYWN0aXZlIiwidXBkYXRlZEF0IjoiMjAyNi0xMS0wMlQxNjozNDo1NC41MDE5MDk4MzVaIiwidmVyc2lvbiI6MX0.test-sig", + "public+restricted+individual": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJlY29kZXNpZ24tZW5lcmd5LWxhYmVsbGluZy1tb2JpbGUiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOm51bGwsImNvMmVQZXJVbml0IjpudWxsLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3NTA1NjkyN1oiLCJjbzJlU2NvcmUiOm51bGwsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3MTkxMDYzMVoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTIxODctNzMwNy04ZDg5LTkzYjI2MWJhMDc2MSIsImxpbnRSZXN1bHQiOnsiYXNzZXNzZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NTQuNDc1MDU5Mjk2WiIsInBhY2tWZXJzaW9uIjoiMS4xLjAifSwibWFudWZhY3R1cmVyIjp7ImFkZHJlc3MiOiJLZWl6ZXJzZ3JhY2h0IDEwMCwgMTAxNSBBbXN0ZXJkYW0sIE5MIiwiY291bnRyeSI6bnVsbCwiZGlkV2ViVXJsIjpudWxsLCJuYW1lIjoiQXVyb3JhIERldmljZXMgQlYifSwibWF0ZXJpYWxzIjpbeyJjb3VudHJ5T2ZPcmlnaW4iOm51bGwsIm5hbWUiOiJBbHVtaW5pdW0iLCJyZWN5Y2xlZFBjdCI6ODAuMCwid2VpZ2h0S2ciOjAuMDZ9XSwib3BlcmF0b3JJZGVudGlmaWVyIjoiREUxMjM0NTY3ODkiLCJwcm9kdWN0R3JvdXAiOiJlbGVjdHJvbmljcyIsInByb2R1Y3RHcm91cERhdGEiOnsiY28yZVBlclVuaXRLZyI6NTIuNCwiZGlzYXNzZW1ibHlJbnN0cnVjdGlvbnNVcmwiOiJodHRwczovL2F1cm9yYS5leGFtcGxlLmNvbS9kaXNhc3NlbWJseS9zMSIsImVuZXJneUVmZmljaWVuY3lDbGFzcyI6IkIiLCJleHBlY3RlZExpZmV0aW1lWWVhcnMiOjcsImZpcm13YXJlVXBkYXRlVW50aWwiOiIyMDMzLTA5LTAxVDAwOjAwOjAwWiIsInByb2R1Y3RDYXRlZ29yeSI6InNtYXJ0cGhvbmUiLCJwcm9kdWN0R3JvdXAiOiJlbGVjdHJvbmljcyIsInByb2R1Y3RJZGVudGlmaWVyIjp7Imd0aW4iOiIwOTUwNjAwMDEzNDM3NiIsInNjaGVtZSI6ImdzMSJ9LCJyZWN5Y2xlZENvbnRlbnRQY3QiOjIyLjAsInJlcGFpck1hbnVhbFVybCI6Imh0dHBzOi8vYXVyb3JhLmV4YW1wbGUuY29tL3JlcGFpci9zMSIsInJvaHNDb21wbGlhbnQiOnRydWUsInNwYXJlUGFydHNBdmFpbGFibGUiOnRydWUsInN0YW5kYnlQb3dlclciOjAuMTV9LCJwcm9kdWN0TmFtZSI6IkF1cm9yYSBTMSBTbWFydHBob25lIiwicHVibGlzaGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjUwMTkwOTgzNVoiLCJxckNvZGVVcmwiOiJodHRwczovL3Jlc29sdmVyLmV4YW1wbGUuY29tLzAxLzA5NTA2MDAwMTM0Mzc2LzIxLzczMDc4ZDg5OTNiMjYxYmEwNzYxIiwicmVwYWlyYWJpbGl0eVNjb3JlIjpudWxsLCJyZXRlbnRpb25VbnRpbCI6IjIwMzYtMTEtMDJUMTY6MzQ6NTQuNTAxOTA5ODM1WiIsInNjaGVtYVZlcnNpb24iOiIxLjUuMCIsInN0YXR1cyI6ImFjdGl2ZSIsInVwZGF0ZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NTQuNTAxOTA5ODM1WiIsInZlcnNpb24iOjF9.test-sig" + }, + "facility": { + "country": "DE", + "name": "Seed Plant", + "scheme": "gln", + "value": "4012345000009" + }, + "id": "01a19d1d-2187-7307-8d89-93b261ba0761", + "jwsSignature": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJlY29kZXNpZ24tZW5lcmd5LWxhYmVsbGluZy1tb2JpbGUiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImJhdGNoSWQiOm51bGwsImNvMmVQZXJVbml0IjpudWxsLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3NTA1NjkyN1oiLCJjbzJlU2NvcmUiOm51bGwsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3MTkxMDYzMVoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTIxODctNzMwNy04ZDg5LTkzYjI2MWJhMDc2MSIsImp3c1NpZ25hdHVyZSI6bnVsbCwibGludFJlc3VsdCI6eyJhc3Nlc3NlZEF0IjoiMjAyNi0xMS0wMlQxNjozNDo1NC40NzUwNTkyOTZaIiwicGFja1ZlcnNpb24iOiIxLjEuMCJ9LCJtYW51ZmFjdHVyZXIiOnsiYWRkcmVzcyI6IktlaXplcnNncmFjaHQgMTAwLCAxMDE1IEFtc3RlcmRhbSwgTkwiLCJjb3VudHJ5IjpudWxsLCJkaWRXZWJVcmwiOm51bGwsIm5hbWUiOiJBdXJvcmEgRGV2aWNlcyBCViJ9LCJtYXRlcmlhbHMiOlt7ImNvdW50cnlPZk9yaWdpbiI6bnVsbCwibmFtZSI6IkFsdW1pbml1bSIsInJlY3ljbGVkUGN0Ijo4MC4wLCJ3ZWlnaHRLZyI6MC4wNn1dLCJvcGVyYXRvcklkZW50aWZpZXIiOiJERTEyMzQ1Njc4OSIsInByb2R1Y3RHcm91cCI6ImVsZWN0cm9uaWNzIiwicHJvZHVjdEdyb3VwRGF0YSI6eyJjbzJlUGVyVW5pdEtnIjo1Mi40LCJkaXNhc3NlbWJseUluc3RydWN0aW9uc1VybCI6Imh0dHBzOi8vYXVyb3JhLmV4YW1wbGUuY29tL2Rpc2Fzc2VtYmx5L3MxIiwiZW5lcmd5RWZmaWNpZW5jeUNsYXNzIjoiQiIsImV4cGVjdGVkTGlmZXRpbWVZZWFycyI6NywiZmlybXdhcmVVcGRhdGVVbnRpbCI6IjIwMzMtMDktMDFUMDA6MDA6MDBaIiwicHJvZHVjdENhdGVnb3J5Ijoic21hcnRwaG9uZSIsInByb2R1Y3RHcm91cCI6ImVsZWN0cm9uaWNzIiwicHJvZHVjdElkZW50aWZpZXIiOnsiZ3RpbiI6IjA5NTA2MDAwMTM0Mzc2Iiwic2NoZW1lIjoiZ3MxIn0sInJlY3ljbGVkQ29udGVudFBjdCI6MjIuMCwicmVwYWlyTWFudWFsVXJsIjoiaHR0cHM6Ly9hdXJvcmEuZXhhbXBsZS5jb20vcmVwYWlyL3MxIiwicm9oc0NvbXBsaWFudCI6dHJ1ZSwic3BhcmVQYXJ0c0F2YWlsYWJsZSI6dHJ1ZSwic3RhbmRieVBvd2VyVyI6MC4xNX0sInByb2R1Y3ROYW1lIjoiQXVyb3JhIFMxIFNtYXJ0cGhvbmUiLCJwdWJsaXNoZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NTQuNTAxOTA5ODM1WiIsInFyQ29kZVVybCI6Imh0dHBzOi8vcmVzb2x2ZXIuZXhhbXBsZS5jb20vMDEvMDk1MDYwMDAxMzQzNzYvMjEvNzMwNzhkODk5M2IyNjFiYTA3NjEiLCJyZXBhaXJhYmlsaXR5U2NvcmUiOm51bGwsInJldGVudGlvbkxvY2tlZCI6dHJ1ZSwicmV0ZW50aW9uVW50aWwiOiIyMDM2LTExLTAyVDE2OjM0OjU0LjUwMTkwOTgzNVoiLCJzY2hlbWFWZXJzaW9uIjoiMS41LjAiLCJzdGF0dXMiOiJhY3RpdmUiLCJ1cGRhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjUwMTkwOTgzNVoiLCJ2ZXJzaW9uIjoxfQ.test-sig", + "lintResult": { + "assessedAt": "2026-11-02T16:34:54.475059296Z", + "packVersion": "1.1.0" + }, + "manufacturer": { + "address": "Keizersgracht 100, 1015 Amsterdam, NL", + "country": null, + "didWebUrl": null, + "name": "Aurora Devices BV" + }, + "materials": [ + { + "countryOfOrigin": null, + "name": "Aluminium", + "recycledPct": 80.0, + "weightKg": 0.06 + } + ], + "operatorIdentifier": "DE123456789", + "productGroup": "electronics", + "productGroupData": { + "co2ePerUnitKg": 52.4, + "disassemblyInstructionsUrl": "https://aurora.example.com/disassembly/s1", + "energyEfficiencyClass": "B", + "expectedLifetimeYears": 7, + "firmwareUpdateUntil": "2033-09-01T00:00:00Z", + "productCategory": "smartphone", + "productGroup": "electronics", + "productIdentifier": { + "gtin": "09506000134376", + "scheme": "gs1" + }, + "recycledContentPct": 22.0, + "repairManualUrl": "https://aurora.example.com/repair/s1", + "rohsCompliant": true, + "sparePartsAvailable": true, + "standbyPowerW": 0.15 + }, + "productName": "Aurora S1 Smartphone", + "publicJwsSignature": "test-header.eyJhcHBsaWNhYmxlSW5zdHJ1bWVudHMiOlt7Imluc3RydW1lbnQiOiJlY29kZXNpZ24tZW5lcmd5LWxhYmVsbGluZy1tb2JpbGUiLCJyZWNvcmRlZCI6ImNhdGFsb2cifV0sImNvMmVQZXJVbml0IjpudWxsLCJjb21wbGlhbmNlUmVzdWx0Ijp7ImFzc2Vzc2VkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3NTA1NjkyN1oiLCJjbzJlU2NvcmUiOm51bGwsImNvbXBsaWFuY2VTdGF0dXMiOiJQQVNTVEhST1VHSF9OT19WQUxJREFUSU9OIiwicmVjeWNsZWRDb250ZW50UGN0IjpudWxsLCJyZXBhaXJhYmlsaXR5SW5kZXgiOm51bGx9LCJjcmVhdGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjQ3MTkxMDYzMVoiLCJmYWNpbGl0eSI6eyJjb3VudHJ5IjoiREUiLCJuYW1lIjoiU2VlZCBQbGFudCIsInNjaGVtZSI6ImdsbiIsInZhbHVlIjoiNDAxMjM0NTAwMDAwOSJ9LCJpZCI6IjAxYTE5ZDFkLTIxODctNzMwNy04ZDg5LTkzYjI2MWJhMDc2MSIsIm1hbnVmYWN0dXJlciI6eyJhZGRyZXNzIjoiS2VpemVyc2dyYWNodCAxMDAsIDEwMTUgQW1zdGVyZGFtLCBOTCIsImNvdW50cnkiOm51bGwsImRpZFdlYlVybCI6bnVsbCwibmFtZSI6IkF1cm9yYSBEZXZpY2VzIEJWIn0sIm1hdGVyaWFscyI6W3siY291bnRyeU9mT3JpZ2luIjpudWxsLCJuYW1lIjoiQWx1bWluaXVtIiwicmVjeWNsZWRQY3QiOjgwLjAsIndlaWdodEtnIjowLjA2fV0sIm9wZXJhdG9ySWRlbnRpZmllciI6IkRFMTIzNDU2Nzg5IiwicHJvZHVjdEdyb3VwIjoiZWxlY3Ryb25pY3MiLCJwcm9kdWN0R3JvdXBEYXRhIjp7ImNvMmVQZXJVbml0S2ciOjUyLjQsImVuZXJneUVmZmljaWVuY3lDbGFzcyI6IkIiLCJleHBlY3RlZExpZmV0aW1lWWVhcnMiOjcsImZpcm13YXJlVXBkYXRlVW50aWwiOiIyMDMzLTA5LTAxVDAwOjAwOjAwWiIsInByb2R1Y3RDYXRlZ29yeSI6InNtYXJ0cGhvbmUiLCJwcm9kdWN0R3JvdXAiOiJlbGVjdHJvbmljcyIsInByb2R1Y3RJZGVudGlmaWVyIjp7Imd0aW4iOiIwOTUwNjAwMDEzNDM3NiIsInNjaGVtZSI6ImdzMSJ9LCJyZWN5Y2xlZENvbnRlbnRQY3QiOjIyLjAsInJvaHNDb21wbGlhbnQiOnRydWUsInNwYXJlUGFydHNBdmFpbGFibGUiOnRydWUsInN0YW5kYnlQb3dlclciOjAuMTV9LCJwcm9kdWN0TmFtZSI6IkF1cm9yYSBTMSBTbWFydHBob25lIiwicHVibGlzaGVkQXQiOiIyMDI2LTExLTAyVDE2OjM0OjU0LjUwMTkwOTgzNVoiLCJxckNvZGVVcmwiOiJodHRwczovL3Jlc29sdmVyLmV4YW1wbGUuY29tLzAxLzA5NTA2MDAwMTM0Mzc2LzIxLzczMDc4ZDg5OTNiMjYxYmEwNzYxIiwicmVwYWlyYWJpbGl0eVNjb3JlIjpudWxsLCJyZXRlbnRpb25VbnRpbCI6IjIwMzYtMTEtMDJUMTY6MzQ6NTQuNTAxOTA5ODM1WiIsInNjaGVtYVZlcnNpb24iOiIxLjUuMCIsInN0YXR1cyI6ImFjdGl2ZSIsInVwZGF0ZWRBdCI6IjIwMjYtMTEtMDJUMTY6MzQ6NTQuNTAxOTA5ODM1WiIsInZlcnNpb24iOjF9.test-sig", + "publishedAt": "2026-11-02T16:34:54.501909835Z", + "qrCodeUrl": "https://resolver.example.com/01/09506000134376/21/73078d8993b261ba0761", + "repairabilityScore": null, + "retentionLocked": true, + "retentionUntil": "2036-11-02T16:34:54.501909835Z", + "schemaVersion": "1.5.0", + "status": "active", + "updatedAt": "2026-11-02T16:34:54.501909835Z", + "version": 1 +} diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index fc0a1cb4..a0480b67 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -180,6 +180,7 @@ fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus usage_history: None, voltage_temperature_range: None, waste_battery_information: None, + due_diligence_operator_scope: None, }))); p } @@ -193,6 +194,7 @@ fn facility_with_value(value: &str) -> FacilitySnapshot { name: "Test Facility".into(), country: "DE".into(), address: None, + identifier: None, } } diff --git a/crates/dpp-integrator/src/domain/battery_template.rs b/crates/dpp-integrator/src/domain/battery_template.rs index cd671948..020194d3 100644 --- a/crates/dpp-integrator/src/domain/battery_template.rs +++ b/crates/dpp-integrator/src/domain/battery_template.rs @@ -607,6 +607,26 @@ fn csv_cell(value: &str) -> String { } } +/// The facts of an original battery placed on the market the day Art. 77(1) +/// begins, 18 February 2027. +/// +/// The template gates ask the content table with these rather than with +/// `BatteryFacts::default()`, under which the due diligence row answers +/// `Undetermined` instead of the `NotApplicable` it is for such a battery. +/// +/// 🚨 They are the obligations of a battery placed before 18 August 2027. From +/// that date the due diligence report (`dueDiligenceUrl`) is owed unless Art. 47 +/// exempts the battery, and no template has a column for it or for the +/// operator's declaration (`dueDiligenceOperatorScope`), so a battery placed +/// from then cannot be imported into a passport that publishes. +#[cfg(test)] +pub(crate) fn facts_before_due_diligence() -> dpp_rules::batteries::battery_facts::BatteryFacts { + dpp_rules::batteries::battery_facts::BatteryFacts { + placed_on_market: Some(dpp_rules::common::date::CalendarDate::new(2027, 2, 18)), + ..dpp_rules::batteries::battery_facts::BatteryFacts::default() + } +} + #[cfg(test)] mod tests { use super::*; @@ -630,10 +650,12 @@ mod tests { fn every_mandatory_field_has_a_column() { for category in BatteryCategory::ALL { let covered = fields_covered(*category); - let missing: Vec<&str> = - dpp_rules::batteries::passport_content::mandatory_fields(category.as_str()) - .filter(|f| !covered.contains(f)) - .collect(); + let missing: Vec<&str> = dpp_rules::batteries::passport_content::mandatory_fields( + category.as_str(), + facts_before_due_diligence(), + ) + .filter(|f| !covered.contains(f)) + .collect(); assert!( missing.is_empty(), "the {} template has no column for {} mandatory data point(s): {missing:?}", @@ -660,7 +682,10 @@ mod tests { for category in BatteryCategory::ALL { let refused: Vec<&str> = fields_covered(*category) .into_iter() - .filter(|f| !annex_xiii_requirement(f, category.as_str()).permits_presence()) + .filter(|f| { + !annex_xiii_requirement(f, category.as_str(), facts_before_due_diligence()) + .permits_presence() + }) .collect(); assert!( refused.is_empty(), diff --git a/crates/dpp-integrator/src/domain/validate/battery.rs b/crates/dpp-integrator/src/domain/validate/battery.rs index 1e70ede6..467e157c 100644 --- a/crates/dpp-integrator/src/domain/validate/battery.rs +++ b/crates/dpp-integrator/src/domain/validate/battery.rs @@ -276,6 +276,10 @@ pub fn validate_battery_row( // for any category, and a column an operator cannot answer is a column // they will fill with something. placed_on_market_date: None, + // No column yet: core reads the declaration only for a battery placed on + // the market from 18 August 2027, and refuses that battery's first + // publish until it is declared. + due_diligence_operator_scope: None, carbon_footprint_class_ruleset_id: None, carbon_footprint_class_ruleset_version: None, recycled_content_reporting_year: None, diff --git a/crates/dpp-integrator/src/handlers/templates.rs b/crates/dpp-integrator/src/handlers/templates.rs index 076b862c..f04bc589 100644 --- a/crates/dpp-integrator/src/handlers/templates.rs +++ b/crates/dpp-integrator/src/handlers/templates.rs @@ -325,10 +325,12 @@ mod template_publishes { }; let json = serde_json::to_value(&*data).expect("battery data serialises"); - let missing: Vec<&str> = - dpp_rules::batteries::passport_content::mandatory_fields(category.as_str()) - .filter(|f| json.get(*f).is_none_or(serde_json::Value::is_null)) - .collect(); + let missing: Vec<&str> = dpp_rules::batteries::passport_content::mandatory_fields( + category.as_str(), + crate::domain::battery_template::facts_before_due_diligence(), + ) + .filter(|f| json.get(*f).is_none_or(serde_json::Value::is_null)) + .collect(); assert!( missing.is_empty(), "the {} template imports a passport still missing {} mandatory data point(s): \ diff --git a/crates/dpp-node/src/infra/registry/client.rs b/crates/dpp-node/src/infra/registry/client.rs index 9723c81a..165ce22b 100644 --- a/crates/dpp-node/src/infra/registry/client.rs +++ b/crates/dpp-node/src/infra/registry/client.rs @@ -20,6 +20,12 @@ //! the submission exists, and the adapter reports it as pending so the drain //! polls for the verdict instead of posting it a third time. //! +//! A transfer notification carries the transfer's own identifier in the same +//! header. It is what makes sending one again after an answer the node could not +//! settle on (still processing, or a state it has no name for) the same +//! notification rather than a second, and a 409 naming the key is read the same +//! way: the registry already holds it. +//! //! # Unreachable registry //! //! If the registry is unreachable (connection refused, DNS failure) the adapter diff --git a/crates/dpp-node/src/infra/registry/mapping.rs b/crates/dpp-node/src/infra/registry/mapping.rs index ca915832..484c4e5e 100644 --- a/crates/dpp-node/src/infra/registry/mapping.rs +++ b/crates/dpp-node/src/infra/registry/mapping.rs @@ -12,6 +12,7 @@ use dpp_domain::{ ports::registry_sync::{ RegistrationGranularity, RegistrationRequest, RegistryRecord, RegistryStatus, RegistrySyncPort, ServiceProviderRef, SubmissionAcknowledgement, SubmissionReport, + TransferReport, }, transfer::TransferRecord, }; @@ -113,17 +114,51 @@ fn record_status(code: &RegistryStatusCode) -> RegistryStatus { } } -impl EuRegistrySync { - /// Map a bridge `EuRegistryResponse` to a domain `RegistryRecord`. - pub(super) fn response_to_record(resp: &EuRegistryResponse) -> RegistryRecord { - RegistryRecord { - registration_id: resp.registry_id.clone(), - status: record_status(&resp.status), - registered_at: resp.updated_at, - updated_at: resp.updated_at, - } +/// What the registry's reply to a transfer notification says, in the port's +/// words. +/// +/// The reply is a record's status because that is the one shape the registry is +/// known to answer in, but it is not asked as one: whether the notification was +/// taken is the question, so each status reads as an answer to it. +/// `registered` is the record staying in service with the handover applied; +/// `pending` is received and not yet applied. `rejected` is a refusal. A record +/// reported suspended or deactivated, or in a state nobody has named, is not +/// something this node can read as taken or refused, and is reported as it came. +fn transfer_report_for(resp: &EuRegistryResponse) -> TransferReport { + match &resp.status { + RegistryStatusCode::Registered => TransferReport::Accepted, + RegistryStatusCode::Pending => TransferReport::Processing, + RegistryStatusCode::Rejected => TransferReport::Refused { + reason: refusal_reason(resp), + }, + other => TransferReport::Unrecognised { + state: other.wire_str().to_owned(), + }, + } +} + +/// Why the registry refused a notification, in its own words, or that it gave no +/// reason: a refusal with the reason blank would read as nothing having +/// happened. +fn refusal_reason(resp: &EuRegistryResponse) -> String { + let reasons = resp + .rejection_reasons + .iter() + .flatten() + .map(|reason| reason.trim()) + .filter(|reason| !reason.is_empty()) + .collect::>() + .join("; "); + if !reasons.is_empty() { + return reasons; } + match resp.message.as_deref().map(str::trim) { + Some(message) if !message.is_empty() => message.to_owned(), + _ => "the registry refused the notification and gave no reason".to_owned(), + } +} +impl EuRegistrySync { /// Map a bridge `StatusResponse` to a domain `RegistryRecord`. pub(super) fn status_to_record(resp: &StatusResponse) -> RegistryRecord { RegistryRecord { @@ -563,7 +598,7 @@ impl RegistrySyncPort for EuRegistrySync { &self, record: &TransferRecord, registry_id: &str, - ) -> Result { + ) -> Result { let base_url = &self.config.endpoint.base_url; let passport_id = record.passport_id; @@ -624,9 +659,16 @@ impl RegistrySyncPort for EuRegistrySync { // The route names the registered record, so it is built from the // registration identifier, never from this node's passport id. let url = url_for(base_url, TRANSFER_PATH_TEMPLATE, registry_id)?; + // The handover's own identity, which is stable across every attempt at + // telling the registry about it. Sending it as the idempotency key is what + // makes a notification sent again, after an answer this node could not + // settle on, the same notification and not a second: a replay is answered + // with the conflict below, which is read as "the registry already holds it". + let idempotency_key = record.transfer_id.to_string(); self.with_retry(|| { let url = url.clone(); let notification = notification.clone(); + let idempotency_key = idempotency_key.clone(); async move { let token = self .get_token() @@ -637,6 +679,7 @@ impl RegistrySyncPort for EuRegistrySync { .client .post(&url) .bearer_auth(&token) + .header(IDEMPOTENCY_KEY_HEADER, idempotency_key) .json(¬ification) .send() .await @@ -661,6 +704,22 @@ impl RegistrySyncPort for EuRegistrySync { } if !resp.status().is_success() { let body = resp.text().await.unwrap_or_default(); + // A replayed key is not a failure: an earlier attempt reached + // the registry and its answer was lost, or was one this node + // could not settle on. The notification is held, and sending + // it a third time would be wrong, as it is for a submission. + if status_code == STATUS_IDEMPOTENCY_KEY_REUSED + && serde_json::from_str::(&body) + .is_ok_and(|b| b.is_idempotency_key_reused()) + { + tracing::info!( + passport_id = %passport_id, + detail = %refusal_text(&body), + "EU registry already holds this transfer notification \ + (idempotency key replayed) — not sending it again" + ); + return Ok(TransferReport::AlreadyHeld); + } return Err(RetryableError::Fatal(format!( "transfer notification failed {status_code}: {}", refusal_text(&body) @@ -668,10 +727,14 @@ impl RegistrySyncPort for EuRegistrySync { } let eu_resp: EuRegistryResponse = resp.json().await.map_err(|e| { - RetryableError::Fatal(format!("invalid transfer response: {e}")) + RetryableError::Fatal(format!( + "invalid transfer response: {e}; the notification may have been taken, \ + and the retry carries the same Idempotency-Key, so it is recognised, \ + not sent twice" + )) })?; - Ok(Self::response_to_record(&eu_resp)) + Ok(transfer_report_for(&eu_resp)) } }) .await diff --git a/crates/dpp-node/src/infra/registry/tests.rs b/crates/dpp-node/src/infra/registry/tests.rs index 94d1fb9a..4ad12f98 100644 --- a/crates/dpp-node/src/infra/registry/tests.rs +++ b/crates/dpp-node/src/infra/registry/tests.rs @@ -4,14 +4,14 @@ use std::time::{Duration, Instant}; use chrono::Utc; use dpp_domain::passport::PassportId; -use dpp_registry::{EuRegistryResponse, RegistryStatusCode}; +use dpp_registry::RegistryStatusCode; use uuid::Uuid; use dpp_domain::DppError; use dpp_domain::operator::{OperatorRole, ResponsibleOperator}; use dpp_domain::ports::registry_sync::{ RegistrationGranularity, RegistrationRequest, RegistryStatus, RegistrySyncPort, - SubmissionAcknowledgement, SubmissionReport, + SubmissionAcknowledgement, SubmissionReport, TransferReport, }; use dpp_domain::transfer::{TransferReason, TransferRecord}; @@ -43,16 +43,14 @@ fn production_config_requires_mtls() { } #[test] -fn response_to_record_maps_status_correctly() { - let resp = EuRegistryResponse { +fn a_status_reply_of_registered_is_a_registered_record() { + let resp = StatusResponse { registry_id: "EU-REG-2026-00001".into(), - passport_id: Uuid::nil(), status: RegistryStatusCode::Registered, - message: None, - rejection_reasons: None, updated_at: Utc::now(), + message: None, }; - let record = EuRegistrySync::response_to_record(&resp); + let record = EuRegistrySync::status_to_record(&resp); assert_eq!(record.status, RegistryStatus::Registered); assert_eq!(record.registration_id, "EU-REG-2026-00001"); } @@ -62,16 +60,14 @@ fn response_to_record_maps_status_correctly() { /// the port has no name for, and carries it as the registry said it rather than /// reading it as one of the three it does name. #[test] -fn a_record_reply_of_rejected_is_carried_as_the_registry_said_it() { - let resp = EuRegistryResponse { +fn a_status_reply_of_rejected_is_carried_as_the_registry_said_it() { + let resp = StatusResponse { registry_id: "EU-REG-2026-00002".into(), - passport_id: Uuid::nil(), status: RegistryStatusCode::Rejected, - message: Some("invalid data".into()), - rejection_reasons: Some(vec!["bad GTIN".into()]), updated_at: Utc::now(), + message: Some("invalid data".into()), }; - let record = EuRegistrySync::response_to_record(&resp); + let record = EuRegistrySync::status_to_record(&resp); assert_eq!( record.status, RegistryStatus::Unrecognised("rejected".into()) @@ -105,8 +101,10 @@ fn request_with_facility(facility: Option) -> Regi RegistrationRequest { request_id: Uuid::now_v7(), passport_id: PassportId::new(), - operator_identifier: "did:web:test.example".into(), - operator_identifier_scheme: "did".into(), + // An LEI, one of the three schemes with an EN 18219 clause 6 route, + // which is what a registration now has to carry. + operator_identifier: "529900T8BM49AURSDO55".into(), + operator_identifier_scheme: "lei".into(), operator_name: "Test Operator GmbH".into(), facility_identifier: "LEGACY-FAC".into(), facility, @@ -137,6 +135,7 @@ fn valid_request() -> RegistrationRequest { name: "Default Plant".into(), country: "DE".into(), address: Some("1 Allee, Berlin".into()), + identifier: None, })) } @@ -157,6 +156,7 @@ fn facility_identifier_prefers_full_snapshot() { name: "Default Plant".into(), country: "DE".into(), address: Some("1 Allee, Berlin".into()), + identifier: None, })); let fid = facility_identifier_for(&request); assert_eq!(fid.scheme, "gln"); @@ -286,6 +286,9 @@ mod mock_server { pub(super) task_results_read: Mutex>, pub(super) task_pages_asked: Mutex>, pub(super) transfer_queue: Mutex>, + /// The `Idempotency-Key` header of each transfer notification, `None` + /// when the request carried none. + pub(super) transfer_keys: Mutex>>, /// Bodies the registry actually received on `/transfer`. Asserting on /// the wire body is the only way to catch a field the adapter silently /// drops — a status-code assertion passes either way. @@ -362,8 +365,15 @@ mod mock_server { async fn transfer_handler( State(state): State>, Path(_id): Path, + headers: HeaderMap, Json(body): Json, ) -> Response { + state.transfer_keys.lock().await.push_back( + headers + .get(IDEMPOTENCY_KEY_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned), + ); state.transfer_bodies.lock().await.push_back(body); pop_or_500(&state.transfer_queue).await } @@ -963,12 +973,210 @@ async fn notify_transfer_success() { let base_url = mock_server::spawn(state.clone()).await; let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); - let record = sync + let report = sync .notify_transfer(&completed_transfer(), "EU-REG-4") .await .expect("notify_transfer should succeed"); - assert_eq!(record.status, RegistryStatus::Registered); + // The record stays in service with the handover applied: that is the + // registry taking the notification, not a status to be passed on. + assert_eq!(report, TransferReport::Accepted); +} + +/// 🚨 The notification is sent under the handover's own identity. Three of the +/// answers end in the drain sending it again, and the key is what makes that the +/// same notification and not a second: it must be the transfer's, and the same +/// on every attempt. +#[tokio::test] +async fn a_transfer_notification_carries_the_transfers_id_as_its_idempotency_key() { + let state = Arc::new(MockState::default()); + { + let mut queue = state.transfer_queue.lock().await; + queue.push_back((axum::http::StatusCode::OK, registered_response("EU-REG-K"))); + queue.push_back((axum::http::StatusCode::OK, registered_response("EU-REG-K"))); + } + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + let transfer = completed_transfer(); + + sync.notify_transfer(&transfer, "EU-REG-K").await.unwrap(); + sync.notify_transfer(&transfer, "EU-REG-K").await.unwrap(); + + let expected = transfer.transfer_id.to_string(); + assert_eq!( + state + .transfer_keys + .lock() + .await + .iter() + .cloned() + .collect::>(), + vec![Some(expected.clone()), Some(expected)], + "every attempt presents the same key, and it is the transfer's" + ); +} + +/// A different handover is a different notification, so it is a different key. +#[tokio::test] +async fn two_transfers_do_not_share_a_key() { + let state = Arc::new(MockState::default()); + { + let mut queue = state.transfer_queue.lock().await; + queue.push_back((axum::http::StatusCode::OK, registered_response("EU-REG-K"))); + queue.push_back((axum::http::StatusCode::OK, registered_response("EU-REG-K"))); + } + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + sync.notify_transfer(&completed_transfer(), "EU-REG-K") + .await + .unwrap(); + sync.notify_transfer(&completed_transfer(), "EU-REG-K") + .await + .unwrap(); + + let keys = state.transfer_keys.lock().await; + assert_eq!(keys.len(), 2); + assert_ne!(keys[0], keys[1]); +} + +/// The registry's answer to a notification is read as an answer to it: whether +/// it took the notification, and not where a registration stands. Each status is +/// its own report, a state nobody has named is carried as it came, and none is +/// read as an acceptance it did not say. +#[tokio::test] +async fn the_registrys_answer_to_a_notification_is_read_as_one() { + fn reply(status: &str, extra: serde_json::Value) -> serde_json::Value { + let mut body = serde_json::json!({ + "registryId": "EU-REG-R", + "passportId": Uuid::now_v7(), + "status": status, + "updatedAt": Utc::now().to_rfc3339(), + }); + body.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + body + } + + let cases = [ + ( + reply("registered", serde_json::json!({})), + TransferReport::Accepted, + ), + ( + reply("pending", serde_json::json!({})), + TransferReport::Processing, + ), + ( + reply( + "rejected", + serde_json::json!({"rejectionReasons": ["no such record", " ", "operator not verified"]}), + ), + TransferReport::Refused { + reason: "no such record; operator not verified".into(), + }, + ), + ( + reply( + "rejected", + serde_json::json!({"message": "refused by the registry"}), + ), + TransferReport::Refused { + reason: "refused by the registry".into(), + }, + ), + ( + reply("rejected", serde_json::json!({})), + TransferReport::Refused { + reason: "the registry refused the notification and gave no reason".into(), + }, + ), + // A record the registry reports out of service is not a notification + // taken, or refused: it is a state to show as it came. + ( + reply("suspended_by_authority", serde_json::json!({})), + TransferReport::Unrecognised { + state: "suspended_by_authority".into(), + }, + ), + ( + reply("deactivated", serde_json::json!({})), + TransferReport::Unrecognised { + state: "deactivated".into(), + }, + ), + ( + reply("UNDER_REVIEW", serde_json::json!({})), + TransferReport::Unrecognised { + state: "UNDER_REVIEW".into(), + }, + ), + ]; + + let state = Arc::new(MockState::default()); + { + let mut queue = state.transfer_queue.lock().await; + for (body, _) in &cases { + queue.push_back((axum::http::StatusCode::OK, body.clone())); + } + } + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + for (body, expected) in cases { + let report = sync + .notify_transfer(&completed_transfer(), "EU-REG-R") + .await + .expect("every reply is an answer"); + assert_eq!(report, expected, "for {body}"); + } +} + +/// A replayed key is the registry saying it already holds the notification: an +/// earlier attempt got through and its answer was lost, or was one this node +/// could not settle on. Neither a failure nor grounds to send it a third time. +#[tokio::test] +async fn a_replayed_transfer_key_means_the_registry_already_holds_it() { + let state = Arc::new(MockState::default()); + state + .transfer_queue + .lock() + .await + .push_back((axum::http::StatusCode::CONFLICT, key_reused_body())); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let report = sync + .notify_transfer(&completed_transfer(), "EU-REG-K") + .await + .expect("a replayed key is not a failure"); + + assert_eq!(report, TransferReport::AlreadyHeld); + assert_eq!( + state.transfer_bodies.lock().await.len(), + 1, + "a replayed key is not retried" + ); +} + +/// Only the one `subCode` is read as "already done". Any other conflict says +/// nothing about whether the notification was taken. +#[tokio::test] +async fn a_transfer_conflict_that_is_not_a_replayed_key_is_still_an_error() { + let state = Arc::new(MockState::default()); + state.transfer_queue.lock().await.push_back(( + axum::http::StatusCode::CONFLICT, + serde_json::json!({"subCode": "SOME_OTHER_CONFLICT", "traceId": "trace-x"}), + )); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + let err = sync + .notify_transfer(&completed_transfer(), "EU-REG-K") + .await + .expect_err("another conflict is a refusal to be seen, not an acceptance"); + assert!(err.to_string().contains("409"), "{err}"); } // ── Payload validation is fail-closed ─────────────────────────────────────── @@ -1178,6 +1386,7 @@ fn responsible(did: &str, name: &str, country: &str) -> ResponsibleOperator { postal_address: None, electronic_address: None, country: country.to_owned(), + identifier: None, } } @@ -1306,24 +1515,19 @@ async fn a_pending_transfer_reports_its_initiation_time() { /// a false statement `validate` cannot catch, because `did` is the one scheme /// accepted without structural verification. #[tokio::test] -async fn a_vat_operator_is_not_submitted_as_a_did() { +async fn an_lei_operator_is_not_submitted_as_a_did() { let state = Arc::new(MockState::default()); state .register_queue .lock() .await - .push_back((axum::http::StatusCode::OK, accepted_response("CORR-V"))); + .push_back((axum::http::StatusCode::OK, accepted_response("CORR-L"))); let base_url = mock_server::spawn(state.clone()).await; let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); - let request = RegistrationRequest { - operator_identifier: "DE811234567".into(), - operator_identifier_scheme: "vat".into(), - ..valid_request() - }; - sync.register(request) + sync.register(valid_request()) .await - .expect("a VAT-scheme operator must register"); + .expect("an LEI-scheme operator must register"); let sent = state .register_bodies @@ -1332,15 +1536,45 @@ async fn a_vat_operator_is_not_submitted_as_a_did() { .pop_front() .expect("the registry must have received a payload"); let operator = &sent["submission"][0]["operatorId"]; - assert_eq!(operator["scheme"], "vat"); - assert_eq!(operator["value"], "DE811234567"); + assert_eq!(operator["scheme"], "lei"); + assert_eq!(operator["value"], "529900T8BM49AURSDO55"); assert!( operator.get("did").is_none(), - "a VAT number is not a DID and must not be sent as one: {operator}" + "an LEI is not a DID and must not be sent as one: {operator}" ); } -/// A genuine DID still travels in the `did` field. +/// 🚨 A VAT number and a DID are well formed and have no EN 18219 clause 6 route: +/// an EU-wide VAT number has no ICD, and a DID is scheme 8 only with a credential +/// a register has issued. dpp-core 0.23.0 refuses such a registration, and the +/// adapter fails closed on that, so nothing reaches the registry. +#[tokio::test] +async fn an_operator_scheme_with_no_clause_6_route_is_refused_before_it_is_sent() { + let state = Arc::new(MockState::default()); + let base_url = mock_server::spawn(state.clone()).await; + let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + + for (scheme, value) in [("vat", "DE811234567"), ("did", "did:web:test.example")] { + let request = RegistrationRequest { + operator_identifier: value.into(), + operator_identifier_scheme: scheme.into(), + ..valid_request() + }; + let err = sync + .register(request) + .await + .expect_err("a scheme with no clause 6 route must be refused"); + assert!( + matches!(&err, DppError::Validation(_)) && err.to_string().contains("clause 6"), + "a '{scheme}' operator must be refused for its scheme, got: {err:?}" + ); + } + assert_eq!(state.register_hits.load(Ordering::SeqCst), 0); +} + +/// A genuine DID still travels in the `did` field, where an operator has chosen +/// to submit a payload that fails validation (`allow_invalid_payloads`), which is +/// now the only way a DID reaches the registry. #[tokio::test] async fn a_did_operator_keeps_its_did_field() { let state = Arc::new(MockState::default()); @@ -1350,11 +1584,20 @@ async fn a_did_operator_keeps_its_did_field() { .await .push_back((axum::http::StatusCode::OK, accepted_response("CORR-D"))); let base_url = mock_server::spawn(state.clone()).await; - let sync = EuRegistrySync::new(mock_config(&base_url)).unwrap(); + let sync = EuRegistrySync::new(EuRegistrySyncConfig { + allow_invalid_payloads: true, + ..mock_config(&base_url) + }) + .unwrap(); - sync.register(valid_request()) + let request = RegistrationRequest { + operator_identifier: "did:web:test.example".into(), + operator_identifier_scheme: "did".into(), + ..valid_request() + }; + sync.register(request) .await - .expect("a did-scheme operator must register"); + .expect("a did-scheme operator is submitted when the operator allows it"); let sent = state.register_bodies.lock().await.pop_front().unwrap(); let operator = &sent["submission"][0]["operatorId"]; @@ -1430,18 +1673,6 @@ fn a_counterparty_without_an_eu_identifier_falls_back_to_its_did() { /// service, which resubmitting does not fix. #[test] fn a_deactivated_record_is_not_reported_as_rejected() { - let resp = EuRegistryResponse { - registry_id: "EU-REG-D".into(), - passport_id: Uuid::nil(), - status: RegistryStatusCode::Deactivated, - message: None, - rejection_reasons: None, - updated_at: Utc::now(), - }; - let mapped = EuRegistrySync::response_to_record(&resp).status; - assert_eq!(mapped, RegistryStatus::Deactivated); - assert_ne!(mapped, RegistryStatus::Unrecognised("rejected".into())); - let status = StatusResponse { registry_id: "EU-REG-D".into(), status: RegistryStatusCode::Deactivated, diff --git a/crates/dpp-node/src/infra/registry_drain.rs b/crates/dpp-node/src/infra/registry_drain.rs index 6fc42301..284042e6 100644 --- a/crates/dpp-node/src/infra/registry_drain.rs +++ b/crates/dpp-node/src/infra/registry_drain.rs @@ -38,7 +38,8 @@ pub struct DrainStats { pub rejected: u32, /// Rows that failed transiently and were backed off for retry. pub retried: u32, - /// Rows dropped from draining because their payload was missing/corrupt. + /// Rows dropped from draining because their payload was missing, corrupt, or + /// refused by the adapter's own checks before it was sent. pub skipped: u32, } @@ -181,6 +182,27 @@ async fn apply_acknowledgement( .await; stats.retried += 1; } + // The adapter's own checks refused the payload before sending it. The + // payload was fixed when the passport was published, so sending it again + // cannot change that answer: it is rejected with the reason, as a corrupt + // payload is. Every failure to reach or hear from the registry arrives as + // another variant and is retried below. + Err(dpp_domain::DppError::Validation(e)) => { + let reason = format!("refused before sending: {e}"); + if let Err(write) = outbox.mark_rejected(pid, reason.clone()).await { + // Not closed, so it backs off like any failure rather than being + // checked again on every pass. + tracing::warn!( + passport_id = %pid, + error = %write, + "a refused registration could not be recorded as rejected" + ); + let _ = outbox.mark_attempt_failed(pid, reason).await; + stats.retried += 1; + } else { + stats.skipped += 1; + } + } Err(e) => { // Transient/unreachable — back off and retry. The row stays. let _ = outbox.mark_attempt_failed(pid, e.to_string()).await; diff --git a/crates/dpp-node/src/infra/transfer_drain.rs b/crates/dpp-node/src/infra/transfer_drain.rs index 096cb507..f9856974 100644 --- a/crates/dpp-node/src/infra/transfer_drain.rs +++ b/crates/dpp-node/src/infra/transfer_drain.rs @@ -11,7 +11,7 @@ use std::sync::Arc; -use dpp_domain::ports::registry_sync::{RegistryStatus, RegistrySyncPort}; +use dpp_domain::ports::registry_sync::{RegistrySyncPort, TransferReport}; use dpp_domain::transfer::TransferRecord; use dpp_types::{RegistrySyncOutbox, RegistrySyncStatus, RegistryTransferOutbox}; @@ -24,25 +24,14 @@ pub struct TransferDrainStats { pub rejected: u32, /// Rows that failed transiently and were backed off for retry. pub retried: u32, - /// Rows dropped from draining because their payload was corrupt. + /// Rows dropped from draining because their payload was corrupt, or refused + /// by the adapter's own checks before it was sent. pub skipped: u32, /// Rows held back because the passport's own registration has not been /// accepted by the registry yet, so there is no record to amend. pub deferred: u32, } -/// Whether the registry answered a transfer notification with a refusal. -/// -/// A record has no `rejected` state: that was a state of a submission, and the -/// port reports it as a [`SubmissionReport`](dpp_domain::ports::registry_sync::SubmissionReport). -/// The registry's `rejected` reply to a *notification* is still a refusal of it, -/// and arrives as the state the port has no name for, carrying the registry's -/// word. Read as anything else it would be retried for as long as the registry -/// keeps saying no. -fn is_rejection(status: &RegistryStatus) -> bool { - matches!(status, RegistryStatus::Unrecognised(state) if state.eq_ignore_ascii_case("rejected")) -} - /// Drain up to `batch` due rows once. /// /// Never panics and never propagates: a per-row failure is recorded on the row @@ -119,41 +108,94 @@ pub async fn drain_once( metrics::histogram!("registry_transfer_drain_seconds") .record(started.elapsed().as_secs_f64()); match outcome { - Ok(rec) if is_rejection(&rec.status) => { + // Taken, or already held: an earlier attempt under the same key got + // through. Either way the registry has the notification and sending + // it again would be wrong. Already held does not say the handover was + // applied, and no route that would say so has been seen, so the row + // closes on the registry's word that it holds the notification. + Ok(TransferReport::Accepted | TransferReport::AlreadyHeld) => { + // A failed write leaves the row pending, and the next pass sends + // it again under the same key, which the registry answers as + // already held. That recovers; this says why it had to. + if let Err(e) = outbox.mark_notified(tid, registry_id.clone()).await { + tracing::warn!( + transfer_id = %tid, + error = %e, + "the registry holds the transfer notification but the outbox write failed" + ); + } + stats.notified += 1; + } + Ok(TransferReport::Refused { reason }) => { tracing::warn!( passport_id = %row.passport_id, transfer_id = %tid, + reason = %reason, "registry rejected transfer notification" ); metrics::counter!("registry_transfer_rejected_total").increment(1); let _ = outbox - .mark_rejected(tid, "registry rejected transfer notification".into()) + .mark_rejected( + tid, + format!("registry rejected transfer notification: {reason}"), + ) .await; stats.rejected += 1; } - // A state the port has no name for, other than the refusal above, is - // not a verdict. Closing the row as notified would record a - // notification the registry may not have accepted, which the - // registration drain declines to do for the same reason. It is a - // failed attempt: the row backs off, stays pending, and counts toward - // the stalled rollup. The notification route carries no idempotency - // key, so the retry sends it again. - Ok(rec) if matches!(rec.status, RegistryStatus::Unrecognised(_)) => { + // Received and not yet applied, or a state this node has no name for: + // neither is a verdict, and closing the row on either would record a + // handover the registry may not have taken, which its record never + // learns of. The notification stays open as a failed attempt: it backs + // off, counts toward the stalled rollup, and is sent again under the + // same idempotency key, which is what makes that the same notification + // and not a second. When the registry answers a replay is unobserved: an + // answer naming the replayed key closes the row as already held, above, + // and one that never settles is for a person. + Ok(TransferReport::Processing) => { + let _ = outbox + .mark_attempt_failed( + tid, + "the registry has the notification and has not applied it yet — sending \ + it again under the same key" + .into(), + ) + .await; + stats.retried += 1; + } + Ok(other) => { let _ = outbox .mark_attempt_failed( tid, format!( "the registry answered a transfer notification with a state this \ - node has no name for: {:?}", - rec.status + node has no name for ({other:?}) — sending it again under the \ + same key" ), ) .await; stats.retried += 1; } - Ok(rec) => { - let _ = outbox.mark_notified(tid, rec.registration_id).await; - stats.notified += 1; + // The adapter's own checks refused the notification before sending + // it. Its payload was fixed when the transfer was accepted, so sending + // it again cannot change that answer: it is rejected with the reason, + // as `registry_drain` does with a registration. Every failure to + // reach or hear from the registry arrives as another variant and is + // retried below. + Err(dpp_domain::DppError::Validation(e)) => { + let reason = format!("refused before sending: {e}"); + if let Err(write) = outbox.mark_rejected(tid, reason.clone()).await { + // Not closed, so it backs off like any failure rather than + // being checked again on every pass. + tracing::warn!( + transfer_id = %tid, + error = %write, + "a refused transfer notification could not be recorded as rejected" + ); + let _ = outbox.mark_attempt_failed(tid, reason).await; + stats.retried += 1; + } else { + stats.skipped += 1; + } } Err(e) => { // Transient/unreachable — back off and retry. The row stays. @@ -196,6 +238,7 @@ mod tests { postal_address: None, electronic_address: None, country: "DE".to_owned(), + identifier: None, } } @@ -373,12 +416,19 @@ mod tests { } enum Outcome { + /// The registry took the notification. Notified, + /// A replayed key: an earlier attempt got through. + AlreadyHeld, + /// Received and not yet applied. + Processing, + /// Refused. Rejected, - /// A reply carrying a state the port has no name for that is not the - /// registry's `rejected`. + /// A state the port has no name for. Unrecognised, Transient, + /// The adapter's own checks refused the notification before sending it. + RefusedBeforeSending, } struct FakePort { @@ -406,29 +456,28 @@ mod tests { &self, record: &TransferRecord, registry_id: &str, - ) -> Result { + ) -> Result { self.seen .lock() .unwrap() .push((record.clone(), registry_id.to_owned())); - let status = match self.outcome { - // A notified transfer leaves the registration `Registered` — the - // registry amends the record, it does not restatus it. - Outcome::Notified => RegistryStatus::Registered, - // A record has no `rejected` state; the registry's refusal of a - // notification arrives as the state the port has no name for. - Outcome::Rejected => RegistryStatus::Unrecognised("rejected".into()), - Outcome::Unrecognised => RegistryStatus::Unrecognised("UNDER_REVIEW".into()), - Outcome::Transient => { - return Err(DppError::Internal("registry unreachable".into())); - } - }; - Ok(RegistryRecord { - registration_id: "EU-REG-T".into(), - status, - registered_at: Utc::now(), - updated_at: Utc::now(), - }) + match self.outcome { + Outcome::Notified => Ok(TransferReport::Accepted), + Outcome::AlreadyHeld => Ok(TransferReport::AlreadyHeld), + Outcome::Processing => Ok(TransferReport::Processing), + Outcome::Rejected => Ok(TransferReport::Refused { + reason: "no such record".into(), + }), + Outcome::Unrecognised => Ok(TransferReport::Unrecognised { + state: "UNDER_REVIEW".into(), + }), + Outcome::Transient => Err(DppError::Internal("registry unreachable".into())), + Outcome::RefusedBeforeSending => Err(DppError::Validation( + "the incoming operator has no clause 6 route" + .to_owned() + .into(), + )), + } } } @@ -583,9 +632,31 @@ mod tests { assert!(outbox.notified.lock().unwrap().is_empty()); } - /// 🚨 A state this node has no name for, other than `rejected`, is not an - /// acceptance. Recording it as one would close the row for good on a - /// notification the registry may not have taken. + /// A notification the adapter's own checks refuse before sending is rejected, + /// not retried. Its payload was fixed when the transfer was accepted, so + /// sending it again cannot change the answer, and retrying would back off and + /// try again with no end. The registration drain treats the same error the + /// same way. + #[tokio::test] + async fn a_notification_the_adapter_refuses_is_rejected_not_retried() { + let (outbox, port) = fakes(Outcome::RefusedBeforeSending, vec![row_from(&record())]); + + let stats = run(&outbox, &port).await; + + assert_eq!((stats.skipped, stats.retried), (1, 0)); + let rejected = outbox.rejected.lock().unwrap(); + assert_eq!(rejected.len(), 1, "the row is closed as rejected"); + assert!( + rejected[0].starts_with("refused before sending:"), + "with the adapter's reason: {}", + rejected[0] + ); + assert!(outbox.failed.lock().unwrap().is_empty()); + } + + /// 🚨 A state this node has no name for is not an acceptance. Recording it as + /// one would close the row for good on a notification the registry may not + /// have taken. #[tokio::test] async fn an_unrecognised_reply_is_not_recorded_as_a_notification() { let (outbox, port) = fakes(Outcome::Unrecognised, vec![row_from(&record())]); @@ -603,6 +674,51 @@ mod tests { assert!(outbox.rejected.lock().unwrap().is_empty()); } + /// `Processing` is the registry saying it has the notification, so it is not + /// closed (it is not applied) and not refused. It stays open and is sent again + /// as the *same* notification: the record the port is handed carries the same + /// transfer identity each time, which is what the adapter keys the request on. + #[tokio::test] + async fn a_notification_still_being_processed_is_sent_again_under_the_same_identity() { + let rec = record(); + let (outbox, port) = fakes(Outcome::Processing, vec![row_from(&rec)]); + + let first = run(&outbox, &port).await; + let second = run(&outbox, &port).await; + + assert_eq!((first.retried, second.retried), (1, 1)); + assert!(outbox.notified.lock().unwrap().is_empty()); + assert!(outbox.rejected.lock().unwrap().is_empty()); + assert_eq!( + outbox.failed.lock().unwrap().len(), + 2, + "each pass is counted" + ); + + let seen = port.seen.lock().unwrap(); + assert_eq!(seen.len(), 2, "sent again, not dropped"); + assert!( + seen.iter() + .all(|(sent, _)| sent.transfer_id == rec.transfer_id), + "always the same transfer, so a key derived from it is stable" + ); + } + + /// A replay answered as already held is how a notification whose first answer + /// was lost or unsettled is finally closed, without a second one reaching the + /// registry. + #[tokio::test] + async fn a_notification_the_registry_already_holds_is_closed_as_notified() { + let (outbox, port) = fakes(Outcome::AlreadyHeld, vec![row_from(&record())]); + + let stats = run(&outbox, &port).await; + + assert_eq!(stats.notified, 1); + assert_eq!((stats.rejected, stats.retried), (0, 0)); + assert_eq!(outbox.notified.lock().unwrap().len(), 1); + assert!(outbox.failed.lock().unwrap().is_empty()); + } + /// An unreachable registry must not lose the notification: the row backs off /// and stays pending. #[tokio::test] diff --git a/crates/dpp-node/tests/openapi_contract.rs b/crates/dpp-node/tests/openapi_contract.rs index aff25e40..88a961a0 100644 --- a/crates/dpp-node/tests/openapi_contract.rs +++ b/crates/dpp-node/tests/openapi_contract.rs @@ -861,7 +861,7 @@ fn deactivation_reason_kinds() -> Vec { /// 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.22.0"; +const CORE_VERSION_VERIFIED: &str = "0.23.0"; /// Enums whose variants this test cannot enumerate, and so cannot gate. /// @@ -2224,14 +2224,39 @@ fn publish_readiness_quotes_the_real_mandatory_field_counts() { .as_str() .expect("PublishReadiness carries a description"); + use dpp_rules::batteries::battery_facts::BatteryFacts; + use dpp_rules::batteries::due_diligence::{ + DUE_DILIGENCE_REQUIRED_FROM, DueDiligenceSecondLife, OperatorScope, + }; + use dpp_rules::common::date::CalendarDate; + + // The quoted counts are for a battery placed before the due diligence report + // falls due; the sentence says one more is owed from that date, and the + // second count pins that claim. + let before = BatteryFacts { + placed_on_market: Some(CalendarDate::new(2027, 2, 18)), + ..BatteryFacts::default() + }; + let owing = BatteryFacts { + placed_on_market: Some(DUE_DILIGENCE_REQUIRED_FROM), + second_life: DueDiligenceSecondLife::None, + operator_scope: Some(OperatorScope::Subject), + }; for category in ["ev", "lmt", "industrial"] { - let count = dpp_rules::batteries::passport_content::mandatory_fields(category).count(); + let count = + dpp_rules::batteries::passport_content::mandatory_fields(category, before).count(); assert!( description.contains(&count.to_string()), "PublishReadiness quotes mandatory-field counts and does not name {count}, \ which is what the gate requires of a '{category}' battery. The description \ reads:\n{description}" ); + assert_eq!( + dpp_rules::batteries::passport_content::mandatory_fields(category, owing).count(), + count + 1, + "PublishReadiness says a '{category}' battery owes one more data point from \ + 18 August 2027, and the gate no longer agrees" + ); } } @@ -2957,6 +2982,7 @@ mod fixtures { name: "Werk Nord".into(), country: "DE".into(), address: Some("Werkstrasse 4, 21079 Hamburg, DE".into()), + identifier: None, } } @@ -2989,6 +3015,7 @@ mod fixtures { postal_address: Some("Hauptstrasse 1, 10115 Berlin, DE".into()), electronic_address: Some("compliance@nordwerk.example".into()), country: "DE".into(), + identifier: None, } } diff --git a/crates/dpp-node/tests/operator_paths.rs b/crates/dpp-node/tests/operator_paths.rs index 3a6a2f5c..41e38130 100644 --- a/crates/dpp-node/tests/operator_paths.rs +++ b/crates/dpp-node/tests/operator_paths.rs @@ -514,6 +514,8 @@ async fn upgrade_from(fixture: &Path) { ); } + let primary = apply_documented_migrations(&node, release).await; + let rows: Vec<(String, String)> = sqlx::query_as("SELECT id::text, status FROM odal.passport ORDER BY id") .fetch_all(&admin) @@ -530,7 +532,7 @@ async fn upgrade_from(fixture: &Path) { assert_eq!(code, StatusCode::OK, "{release}: GET {read}: {body}"); } match status.as_str() { - "active" => published_lifecycle(&node, &admin, release, &id).await, + "active" => published_lifecycle(&node, &admin, release, &id, &primary).await, "suspended" => { let (code, body) = node .admin(Method::POST, &format!("{path}/publish"), None) @@ -564,9 +566,59 @@ async fn upgrade_from(fixture: &Path) { } } +/// The changes the CHANGELOG's **Breaking** entries ask an operator to make, +/// made through the API an operator uses, before anything else is asked of the +/// upgraded node. Without them a documented break would read as an upgrade +/// defect. Returns the primary operator identifier they leave in place. +/// +/// - A registration names the operator only under a scheme with an EN 18219 +/// clause 6 route (dpp-core 0.23.0), so an operator whose primary identifier +/// is a VAT or EORI number makes an LEI primary. Its passports keep the +/// identifier they were signed with; a draft, an amendment's successor +/// among them, is published under the LEI. +async fn apply_documented_migrations(node: &Node, release: &str) -> String { + const PATH: &str = "/vault/api/v1/operator-identifiers"; + let (code, identifiers) = node.admin(Method::GET, PATH, None).await; + assert_eq!( + code, + StatusCode::OK, + "{release}: list operator identifiers: {identifiers}" + ); + let primary = identifiers + .as_array() + .into_iter() + .flatten() + .find(|identifier| identifier["isPrimary"] == true) + .unwrap_or_else(|| panic!("{release}: the fixture has no primary identifier")); + if !matches!(primary["scheme"].as_str(), Some("vat" | "eori")) { + return primary["value"].as_str().expect("a value").to_owned(); + } + let lei = "529900T8BM49AURSDO55"; + let (code, body) = node + .admin( + Method::POST, + PATH, + Some(json!({ "scheme": "lei", "value": lei, "isPrimary": true })), + ) + .await; + assert_eq!( + code, + StatusCode::CREATED, + "{release}: make an LEI the primary identifier: {body}" + ); + lei.to_owned() +} + /// Suspend, re-activate and amend a passport the release published: the -/// changes an operator makes to one, which must all still work. -async fn published_lifecycle(node: &Node, admin: &sqlx::PgPool, release: &str, id: &str) { +/// changes an operator makes to one, which must all still work. The successor +/// is published under `primary`, the operator's identifier at the time. +async fn published_lifecycle( + node: &Node, + admin: &sqlx::PgPool, + release: &str, + id: &str, + primary: &str, +) { let path = format!("/vault/api/v1/dpp/{id}"); let (code, body) = node .admin( @@ -603,6 +655,10 @@ async fn published_lifecycle(node: &Node, admin: &sqlx::PgPool, release: &str, i successor["productName"], "Corrected after the upgrade", "{release}: the successor carries the correction it was amended with" ); + assert_eq!( + successor["operatorIdentifier"], primary, + "{release}: the successor is published under the operator's identifier of the day" + ); let status: String = sqlx::query_scalar("SELECT status FROM odal.passport WHERE id = $1::uuid") .bind(id) .fetch_one(admin) diff --git a/crates/dpp-node/tests/registry_outbox.rs b/crates/dpp-node/tests/registry_outbox.rs index e1e90fcb..60dc3cc8 100644 --- a/crates/dpp-node/tests/registry_outbox.rs +++ b/crates/dpp-node/tests/registry_outbox.rs @@ -39,7 +39,7 @@ use dpp_domain::{ ports::passport_repo::PassportRepository, ports::registry_sync::{ RegisteringOperator, RegistrationGranularity, RegistrationRequest, RegistryRecord, - RegistrySyncPort, SubmissionAcknowledgement, SubmissionReport, + RegistrySyncPort, SubmissionAcknowledgement, SubmissionReport, TransferReport, }, product_group::{ProductGroup, ProductGroupData}, status::PassportStatus, @@ -123,6 +123,7 @@ fn draft_passport() -> Passport { name: "Outbox Plant".into(), country: "DE".into(), address: None, + identifier: None, }), seal: None, carrier_serial: None, @@ -174,6 +175,8 @@ enum Outcome { Accepted, /// A replayed submission: the registry holds it and gave no handle for it. Held, + /// The adapter's own checks refused the payload before anything was sent. + Invalid, /// The registry could not be reached. Transient, /// Poll: the registry ruled and generated a registration identifier. @@ -215,6 +218,11 @@ impl RegistrySyncPort for MockPort { correlation_id: CORRELATION_ID.into(), }), Outcome::Held => Ok(SubmissionAcknowledgement::AlreadyHeld), + Outcome::Invalid => Err(DppError::Validation( + "EU registry payload failed validation: the operator identifier scheme 'vat' \ + has no EN 18219 clause 6 route in this build" + .into(), + )), Outcome::Transient => Err(DppError::Internal("EU registry transient failure".into())), other => panic!("not an answer to a submission: {}", other as u8), } @@ -249,7 +257,7 @@ impl RegistrySyncPort for MockPort { &self, _record: &dpp_domain::transfer::TransferRecord, _registry_id: &str, - ) -> Result { + ) -> Result { unimplemented!("not exercised") } } @@ -405,6 +413,132 @@ async fn drain_backs_off_on_transient_and_marks_terminal_rejection() { ); } +/// 🚨 A payload the adapter refuses before sending is refused for good. It was +/// fixed when the passport was published, so sending it again cannot change the +/// answer, and retrying it would leave the row backing off for ever with nobody +/// told why. It is rejected with the reason, and never offered to the port again. +#[tokio::test(flavor = "multi_thread")] +async fn a_payload_the_adapter_refuses_is_rejected_not_retried() { + let _c = start_pg().await; + let dal = _c.dal.clone(); + let outbox: Arc = Arc::new(PgRegistrySyncRepo::new(dal.clone())); + + let id = create_and_publish(&dal, &outbox).await; + let (port, calls) = mock(Outcome::Invalid); + let stats = drain_once(&outbox, &port, None, 50).await; + assert_eq!(stats.skipped, 1, "{stats:?}"); + assert_eq!(stats.retried, 0, "a refusal is not a transient failure"); + + let row = outbox.pending_for(id).await.unwrap().unwrap(); + assert_eq!(row.status, RegistrySyncStatus::Rejected); + assert!( + row.message + .as_deref() + .is_some_and(|m| m.contains("clause 6")), + "the reason reaches the row for a human: {:?}", + row.message + ); + + make_due(&dal, id).await; + drain_once(&outbox, &port, None, 50).await; + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "never offered to the port again" + ); +} + +/// A refusal whose rejection cannot be written is recorded as a failed attempt +/// instead, so the row backs off like any failure. Left as it was, it stayed +/// due and was checked again, and failed again, on every pass. +#[tokio::test(flavor = "multi_thread")] +async fn a_refusal_whose_rejection_cannot_be_written_backs_off() { + /// The real outbox, except that recording a rejection fails. + struct RejectionFails(PgRegistrySyncRepo); + + #[async_trait] + impl RegistrySyncOutbox for RejectionFails { + async fn commit_publish( + &self, + passport: &Passport, + payload: serde_json::Value, + ) -> Result<(), DppError> { + self.0.commit_publish(passport, payload).await + } + async fn enqueue_status( + &self, + passport_id: PassportId, + intent: RegistryStatusIntent, + ) -> Result<(), DppError> { + self.0.enqueue_status(passport_id, intent).await + } + async fn due(&self, limit: i64) -> Result, DppError> { + self.0.due(limit).await + } + async fn mark_submitted( + &self, + id: PassportId, + registry_id: String, + ) -> Result<(), DppError> { + self.0.mark_submitted(id, registry_id).await + } + async fn mark_deactivated(&self, id: PassportId, message: String) -> Result<(), DppError> { + self.0.mark_deactivated(id, message).await + } + async fn mark_registered( + &self, + id: PassportId, + registry_id: String, + ) -> Result<(), DppError> { + self.0.mark_registered(id, registry_id).await + } + async fn mark_rejected(&self, _id: PassportId, _message: String) -> Result<(), DppError> { + Err(DppError::Internal("the database stopped answering".into())) + } + async fn mark_attempt_failed( + &self, + id: PassportId, + message: String, + ) -> Result<(), DppError> { + self.0.mark_attempt_failed(id, message).await + } + async fn pending_for( + &self, + id: PassportId, + ) -> Result, DppError> { + self.0.pending_for(id).await + } + async fn status_counts( + &self, + stall_threshold: i32, + ) -> Result { + self.0.status_counts(stall_threshold).await + } + async fn unregistered_published_count(&self) -> Result { + self.0.unregistered_published_count().await + } + } + + let _c = start_pg().await; + let dal = _c.dal.clone(); + let plain: Arc = Arc::new(PgRegistrySyncRepo::new(dal.clone())); + let id = create_and_publish(&dal, &plain).await; + let outbox: Arc = + Arc::new(RejectionFails(PgRegistrySyncRepo::new(dal.clone()))); + + let (port, _) = mock(Outcome::Invalid); + let stats = drain_once(&outbox, &port, None, 50).await; + assert_eq!((stats.skipped, stats.retried), (0, 1), "{stats:?}"); + + let row = plain.pending_for(id).await.unwrap().unwrap(); + assert_eq!(row.status, RegistrySyncStatus::Pending); + assert_eq!(row.attempts, 1, "a failed attempt, so it backs off"); + assert!( + row.next_attempt_at > Utc::now(), + "and is not due again on the next pass" + ); +} + // ─── 0024 heals rows the old write path already clobbered ───────────────────── /// Bring a fresh database up to the pre-0024 schema by applying every migration diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index ea4c5ec9..e961f3eb 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -234,6 +234,7 @@ fn draft_passport() -> Passport { name: "Odal Simulation Plant".into(), country: "MK".into(), address: Some("Skopje, MK".into()), + identifier: None, }), seal: None, carrier_serial: None, diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index 1518c1c2..6f2d501e 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -637,7 +637,7 @@ async fn route_inventory_matches_assembled_router() { let operator_id_resp = client .post(format!("{base}/vault/api/v1/operator-identifiers")) .bearer_auth(&token) - .json(&serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true })) + .json(&serde_json::json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true })) .send() .await .expect("operator-identifier seed request failed"); @@ -922,7 +922,7 @@ async fn second_life_successor_verifies_against_pinned_parent() { let op_id_resp = client .post(format!("{base}/vault/api/v1/operator-identifiers")) .bearer_auth(&token) - .json(&serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true })) + .json(&serde_json::json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true })) .send() .await .expect("operator-identifier seed request failed"); @@ -1243,7 +1243,7 @@ async fn the_quick_start_battery_sample_creates_and_publishes() { let operator_id = client .post(format!("{base}/vault/api/v1/operator-identifiers")) .bearer_auth(&token) - .json(&serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true })) + .json(&serde_json::json!({ "scheme": "lei", "value": "984500ODALEXAMPLE069", "isPrimary": true })) .send() .await .expect("operator-identifier request failed"); @@ -2336,7 +2336,7 @@ async fn publish_layered_battery(base: &str, token: &str, client: &reqwest::Clie let resp = client .post(format!("{base}/vault/api/v1/operator-identifiers")) .bearer_auth(token) - .json(&serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true })) + .json(&serde_json::json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true })) .send() .await .expect("operator-identifier seed request failed"); diff --git a/crates/dpp-node/tests/transfer_outbox.rs b/crates/dpp-node/tests/transfer_outbox.rs index 6c61b361..45638e15 100644 --- a/crates/dpp-node/tests/transfer_outbox.rs +++ b/crates/dpp-node/tests/transfer_outbox.rs @@ -98,6 +98,7 @@ fn operator(did: &str, name: &str) -> ResponsibleOperator { postal_address: None, electronic_address: None, country: "DE".to_owned(), + identifier: None, } } diff --git a/crates/dpp-plugin-host/tests/fixtures/abi-echo/Cargo.toml b/crates/dpp-plugin-host/tests/fixtures/abi-echo/Cargo.toml index 9cddb771..e2a0c3f3 100644 --- a/crates/dpp-plugin-host/tests/fixtures/abi-echo/Cargo.toml +++ b/crates/dpp-plugin-host/tests/fixtures/abi-echo/Cargo.toml @@ -20,7 +20,7 @@ path = "src/lib.rs" # the guest is built against the same contract the host links. Bump the two # together. A path into a sibling dpp-core checkout built whatever that working # tree held, and resolved only from a checkout sitting next to it. -dpp-plugin-sdk = "0.22.0" +dpp-plugin-sdk = "0.23.0" serde_json = "1" [profile.release] diff --git a/crates/dpp-plugin-host/tests/integration.rs b/crates/dpp-plugin-host/tests/integration.rs index cc7a9d14..7683f94f 100644 --- a/crates/dpp-plugin-host/tests/integration.rs +++ b/crates/dpp-plugin-host/tests/integration.rs @@ -213,6 +213,8 @@ fn battery_product_group_data() -> ProductGroupData { usage_history: None, voltage_temperature_range: None, waste_battery_information: None, + // The Art. 47 declaration, added in dpp-core 0.23.0. + due_diligence_operator_scope: None, })) } diff --git a/crates/dpp-resolver/src/jws_verification_tests.rs b/crates/dpp-resolver/src/jws_verification_tests.rs index cc9127c5..7c5e4969 100644 --- a/crates/dpp-resolver/src/jws_verification_tests.rs +++ b/crates/dpp-resolver/src/jws_verification_tests.rs @@ -438,7 +438,7 @@ async fn access_tier_header_cannot_unlock_restricted_fields() { let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); - let s = String::from_utf8_lossy(&body); + let s = without_context(&body); assert!( !s.contains("svhcSubstances") && !s.contains("disassemblyInstructions"), "tier header '{tier}' must not unlock restricted fields, got: {s}" @@ -446,6 +446,19 @@ async fn access_tier_header_cannot_unlock_restricted_fields() { } } +/// A JSON-LD body as text, without its top-level `@context`. +/// +/// The inline context defines a term for every field the product group can +/// carry, served or withheld, so a field's name in it says nothing about what was +/// served. A scan for a withheld field reads the document without it. +fn without_context(body: &[u8]) -> String { + let mut doc: serde_json::Value = serde_json::from_slice(body).expect("a JSON-LD body"); + if let Some(object) = doc.as_object_mut() { + object.remove("@context"); + } + doc.to_string() +} + // ─── AAS door ───────────────────────────────────────────────────────────────── /// A published battery passport carrying restricted and individual-tier fields. @@ -775,11 +788,15 @@ async fn the_aas_door_projects_the_signed_payload_not_the_live_row() { // ─── cross-door agreement ───────────────────────────────────────────────────── -/// Every object key in `value`, at any depth. +/// Every object key in `value`, at any depth, outside a JSON-LD `@context`. +/// +/// A context's term definitions are keyed by field name and name every field the +/// product group can carry, served or withheld, so they are not the document's +/// fields. fn keys_at_any_depth(value: &serde_json::Value, out: &mut std::collections::BTreeSet) { match value { serde_json::Value::Object(map) => { - for (key, child) in map { + for (key, child) in map.iter().filter(|(key, _)| key.as_str() != "@context") { out.insert(key.clone()); keys_at_any_depth(child, out); } diff --git a/crates/dpp-resolver/tests/resolver_e2e.rs b/crates/dpp-resolver/tests/resolver_e2e.rs index 7fc13d64..2202a896 100644 --- a/crates/dpp-resolver/tests/resolver_e2e.rs +++ b/crates/dpp-resolver/tests/resolver_e2e.rs @@ -986,7 +986,13 @@ async fn a_restricted_product_group_field_is_absent_from_the_public_view() { let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - let body = String::from_utf8(body.to_vec()).expect("utf-8"); + // The inline `@context` defines a term for every field the product group can + // carry, served or withheld, so the scan reads the document without it. + let mut doc: serde_json::Value = serde_json::from_slice(&body).expect("a JSON-LD body"); + if let Some(object) = doc.as_object_mut() { + object.remove("@context"); + } + let body = doc.to_string(); assert!( !body.contains("cathodeMaterial"), diff --git a/crates/dpp-vault/src/domain/service/publish.rs b/crates/dpp-vault/src/domain/service/publish.rs index 549ed324..b3425928 100644 --- a/crates/dpp-vault/src/domain/service/publish.rs +++ b/crates/dpp-vault/src/domain/service/publish.rs @@ -6,7 +6,7 @@ use chrono::Utc; use dpp_common::{event, event_codes}; use dpp_digital_link::build_qr_url; use dpp_domain::{ - ComplianceError, ComplianceErrorKind, ComplianceResult, + ActorIdentifier, ActorPosition, ComplianceError, ComplianceErrorKind, ComplianceResult, error::DppError, passport::{Passport, PassportId}, ports::registry_sync::{ @@ -33,6 +33,10 @@ use super::{catalog, schema_registry}; mod reason { pub const INVALID_TRANSITION: &str = "invalid_transition"; pub const MISSING_REGISTRY_IDENTITY: &str = "missing_registry_identity"; + /// The operator or facility identifier is there, under a scheme with no + /// EN 18219 clause 6 route, so the registration this publish owes would be + /// refused. Distinct from `missing_registry_identity`, where it is absent. + pub const UNREGISTRABLE_IDENTITY: &str = "unregistrable_identity"; pub const SECTOR_DATA_INVALID: &str = "product_group_data_invalid"; pub const SCHEMA_INVALID: &str = "schema_invalid"; pub const COMPLIANCE_VIOLATIONS: &str = "compliance_violations"; @@ -69,6 +73,7 @@ use reason::{ MISSING_REGISTRY_IDENTITY as REASON_MISSING_REGISTRY_IDENTITY, SCHEMA_INVALID as REASON_SCHEMA_INVALID, SECTOR_DATA_INVALID as REASON_SECTOR_DATA_INVALID, SIGNING_FAILED as REASON_SIGNING_FAILED, + UNREGISTRABLE_IDENTITY as REASON_UNREGISTRABLE_IDENTITY, }; /// Count a publish rejection, then return its error unchanged. @@ -200,7 +205,18 @@ impl PassportService { .await .unwrap_or(None); } - if passport.operator_identifier.is_none() { + // A draft takes the primary identifier as it stands now, not as it + // stood when the draft was created or, for an amendment, when its + // predecessor was: an operator that changed its primary, as moving + // off a VAT number requires, publishes under the new one. A draft is + // not signed yet, and create takes no identifier, so nothing an + // operator chose is overwritten. A re-publish keeps the identifier + // the passport was signed with. + if passport.status == PassportStatus::Draft { + if let Some((_, value)) = &primary_identifier { + passport.operator_identifier = Some(value.clone()); + } + } else if passport.operator_identifier.is_none() { passport.operator_identifier = primary_identifier.as_ref().map(|(_, value)| value.clone()); } @@ -463,9 +479,12 @@ impl PassportService { // product group data mandatory at publish for every group — a // decision deferred above, not one to take as a side effect of how // registrations are built. + // + // An identifier the registration cannot carry is treated the same way, + // under its own reason: see `unregistrable_identity`. let registration = match &self.registry_outbox { None => None, - Some(_) => { + Some(outbox) => { // The scheme is only assertable for the value the passport // actually carries. If that value did not come from the current // primary identifier — the primary was changed after the draft @@ -477,18 +496,38 @@ impl PassportService { .filter(|(_, value)| Some(value) == passport.operator_identifier.as_ref()) .map(|(scheme, _)| scheme.as_str()) .unwrap_or_default(); - match RegistrationRequest::from_published_passport( - &passport, - RegisteringOperator { - legal_name: &self.operator.legal_name, - country: &self.operator.country, - identifier_scheme, - }, - // Item level: the granularity the battery product group is - // defined at, and the only one the registry accepts today. - // Set per product group once further delegated acts land. - RegistrationGranularity::Item, - ) { + let unregistrable = unregistrable_identity(identifier_scheme, &passport); + // An identity the registry cannot take is refused only for a + // passport that has never queued a registration. One that has + // keeps its row, whatever became of it: the identifier it was + // signed with cannot change, so refusing a re-publish would fix + // nothing and only keep a suspended passport from being + // re-activated. Its registration is queued as before, which the + // outbox leaves alone unless the row was rejected, and a rejected + // one the drain rejects again with its reason. This is how an + // operator who published under a VAT number before it was + // refused re-activates a passport after upgrading. + let refused = match &unregistrable { + Some(_) => outbox.pending_for(id).await?.is_none(), + None => false, + }; + let built = match unregistrable { + Some(why) if refused => Err((REASON_UNREGISTRABLE_IDENTITY, why.into())), + _ => RegistrationRequest::from_published_passport( + &passport, + RegisteringOperator { + legal_name: &self.operator.legal_name, + country: &self.operator.country, + identifier_scheme, + }, + // Item level: the granularity the battery product group is + // defined at, and the only one the registry accepts today. + // Set per product group once further delegated acts land. + RegistrationGranularity::Item, + ) + .map_err(|missing| (REASON_MISSING_REGISTRY_IDENTITY, missing)), + }; + match built { Ok(mut reg_req) => { declare_backup( &mut reg_req, @@ -499,17 +538,14 @@ impl PassportService { .map_err(|e| DppError::Serialisation(e.to_string()))?; Some(payload) } - Err(missing) + Err((reason, missing)) if super::passport_obligation_live( passport.product_group.catalog_key(), ) => { - return Err(reject( - REASON_MISSING_REGISTRY_IDENTITY, - DppError::Validation(missing), - )); + return Err(reject(reason, DppError::Validation(missing))); } - Err(missing) => { + Err((_, missing)) => { tracing::warn!( passport_id = %id, missing = %missing, @@ -654,6 +690,42 @@ fn validate_schema_for_publish(product_group_data: &ProductGroupData) -> Result< .map_err(DppError::from) } +/// Why the registration this passport owes would be refused for its operator or +/// facility identifier, if it would. +/// +/// dpp-core accepts an identifier on a registration only under a scheme with an +/// EN 18219 clause 6 route, and the registry adapter refuses one without before +/// anything is sent. A queued registration is fixed, so that refusal would come +/// on every attempt and reach nobody who could act on it. This asks the same +/// question here, through [`ActorIdentifier::from_legacy`], core's one reading of +/// a scheme and value, so the two cannot answer differently. +/// +/// `None` when an identifier is absent, which building the registration reports +/// under its own reason, and when the operator's scheme is empty because the +/// stamped value is no longer the primary one, which this cannot read. +fn unregistrable_identity(operator_scheme: &str, passport: &Passport) -> Option { + if let Some(value) = passport.operator_identifier.as_deref() + && !operator_scheme.is_empty() + && let Err(e) = + ActorIdentifier::from_legacy(operator_scheme, value, ActorPosition::Operator) + { + return Some(format!( + "cannot publish: the operator identifier cannot be registered with the EU \ + registry: {e}. A registration carries an LEI or a D-U-N-S number; make one the \ + primary operator identifier (`odal operator-id`)." + )); + } + if let Some(facility) = &passport.facility + && let Err(e) = facility.actor_identifier() + { + return Some(format!( + "cannot publish: the facility identifier cannot be registered with the EU \ + registry: {e}. A registration carries a GLN, an LEI or a D-U-N-S number." + )); + } + None +} + /// The independently-hosted back-up URL declared to the EU registry. /// /// The path is [`snapshot_json_key`], which is the same definition the snapshot diff --git a/crates/dpp-vault/src/domain/verify/engine.rs b/crates/dpp-vault/src/domain/verify/engine.rs index 698ccafa..9659eae3 100644 --- a/crates/dpp-vault/src/domain/verify/engine.rs +++ b/crates/dpp-vault/src/domain/verify/engine.rs @@ -744,6 +744,7 @@ mod tests { postal_address: None, electronic_address: None, country: "DE".into(), + identifier: None, }; let mut record = TransferRecord { transfer_id: Uuid::now_v7(), @@ -872,6 +873,7 @@ mod tests { postal_address: None, electronic_address: None, country: "DE".into(), + identifier: None, }; let record = TransferRecord { transfer_id: Uuid::now_v7(), diff --git a/crates/dpp-vault/src/domain/verify/transfer_chain.rs b/crates/dpp-vault/src/domain/verify/transfer_chain.rs index 529875e8..cd23016e 100644 --- a/crates/dpp-vault/src/domain/verify/transfer_chain.rs +++ b/crates/dpp-vault/src/domain/verify/transfer_chain.rs @@ -222,6 +222,7 @@ mod tests { postal_address: None, electronic_address: None, country: "DE".into(), + identifier: None, } } diff --git a/crates/dpp-vault/src/handlers/create.rs b/crates/dpp-vault/src/handlers/create.rs index 8ce9446d..cf4b5e3c 100644 --- a/crates/dpp-vault/src/handlers/create.rs +++ b/crates/dpp-vault/src/handlers/create.rs @@ -472,6 +472,7 @@ mod schema_validation { usage_history: None, voltage_temperature_range: None, waste_battery_information: None, + due_diligence_operator_scope: None, })) } diff --git a/crates/dpp-vault/tests/battery.rs b/crates/dpp-vault/tests/battery.rs index 18bee255..176ec67a 100644 --- a/crates/dpp-vault/tests/battery.rs +++ b/crates/dpp-vault/tests/battery.rs @@ -28,6 +28,9 @@ async fn test_battery_create_publish_resolve() { "materials": [ {"name": "Lithium Iron Phosphate", "weightKg": 1.2} ], + // An industrial battery publishes only with its placing date: the due + // diligence row is answered as of it, and the gate refuses without it. + "placedOnMarketDate": "2026-07-15", // The complete content the Battery Regulation makes mandatory for an // industrial battery — 38 fields, not the six that merely satisfy the // schema. This is the one test whose subject *is* a battery passport, so @@ -186,8 +189,14 @@ async fn an_incomplete_industrial_battery_cannot_be_published() { "the refusal must say what is wrong and for which category: {body}" ); // Reported together, not one per attempt: an operator fixing these one at a - // time would need a publish round-trip per field. - for field in ["batteryModelId", "manufacturingPlace", "cathodeMaterial"] { + // time would need a publish round-trip per field. The missing placing date is + // among them, since the due diligence row cannot be answered without it. + for field in [ + "batteryModelId", + "manufacturingPlace", + "cathodeMaterial", + "placedOnMarketDate", + ] { assert!( body.contains(field), "every missing field is named at once; `{field}` is absent from: {body}" diff --git a/crates/dpp-vault/tests/continuity_snapshot.rs b/crates/dpp-vault/tests/continuity_snapshot.rs index 12740e3b..d5276fde 100644 --- a/crates/dpp-vault/tests/continuity_snapshot.rs +++ b/crates/dpp-vault/tests/continuity_snapshot.rs @@ -281,6 +281,7 @@ fn draft_passport() -> Passport { name: "Test Facility".into(), country: "DE".into(), address: None, + identifier: None, }), seal: None, carrier_serial: None, diff --git a/crates/dpp-vault/tests/demo_passports_publish.rs b/crates/dpp-vault/tests/demo_passports_publish.rs index 325737c2..90a69e91 100644 --- a/crates/dpp-vault/tests/demo_passports_publish.rs +++ b/crates/dpp-vault/tests/demo_passports_publish.rs @@ -19,8 +19,11 @@ use std::fs; use std::path::{Path, PathBuf}; +use chrono::Datelike; use dpp_domain::ProductGroupData; +use dpp_rules::batteries::battery_facts::BatteryFacts; use dpp_rules::batteries::passport_content; +use dpp_rules::common::date::CalendarDate; fn demo_passport_files() -> Vec { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ops/demo/passports"); @@ -61,6 +64,28 @@ fn product_group_data(path: &Path) -> ProductGroupData { }) } +/// The facts the content table reads for one demo file: its placing date, from +/// the envelope, as the publish gate takes it. The second-life limb and the +/// operator's declaration stay at their defaults, which only a battery placed +/// from 18 August 2027 is asked about. +fn battery_facts(path: &Path) -> BatteryFacts { + let raw = fs::read_to_string(path).expect("read demo passport"); + let body: serde_json::Value = serde_json::from_str(&raw).expect("demo passport is JSON"); + let placed_on_market = body + .get("placedOnMarketDate") + .and_then(serde_json::Value::as_str) + .map(|date| { + let date = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap_or_else(|e| { + panic!("{}'s placedOnMarketDate is not a date: {e}", path.display()) + }); + CalendarDate::new(date.year(), date.month() as u8, date.day() as u8) + }); + BatteryFacts { + placed_on_market, + ..BatteryFacts::default() + } +} + /// Every demo file must land on its typed variant, not the open `Other` one. /// /// `Other` is deliberately forgiving — a product group this build has no @@ -109,9 +134,10 @@ fn every_demo_passport_satisfies_the_mandatory_content_gate() { // Present-but-null is absent: `skip_serializing_if` means a `None` never // reaches the wire, so an explicit null carries no value either way. - let missing: Vec<&str> = passport_content::mandatory_fields(battery_type) - .filter(|f| value.get(*f).is_none_or(serde_json::Value::is_null)) - .collect(); + let missing: Vec<&str> = + passport_content::mandatory_fields(battery_type, battery_facts(&path)) + .filter(|f| value.get(*f).is_none_or(serde_json::Value::is_null)) + .collect(); if !missing.is_empty() { broken.push(format!( @@ -159,7 +185,8 @@ fn no_demo_passport_carries_content_its_category_may_not() { .map(|(k, _)| k.as_str()) .collect(); let barred: Vec<&str> = - passport_content::fields_not_applicable(&present, battery_type).collect(); + passport_content::fields_not_applicable(&present, battery_type, battery_facts(&path)) + .collect(); if !barred.is_empty() { broken.push(format!( diff --git a/crates/dpp-vault/tests/evidence_dossier.rs b/crates/dpp-vault/tests/evidence_dossier.rs index 4ff31102..3fac9c3b 100644 --- a/crates/dpp-vault/tests/evidence_dossier.rs +++ b/crates/dpp-vault/tests/evidence_dossier.rs @@ -265,6 +265,7 @@ fn draft_passport() -> Passport { name: "Test Facility".into(), country: "DE".into(), address: None, + identifier: None, }), seal: None, carrier_serial: None, @@ -319,6 +320,7 @@ async fn publish_transfer_eol_then_generate_verifies_and_persists() { postal_address: None, electronic_address: None, country: "DE".into(), + identifier: None, }; service .initiate_transfer( diff --git a/crates/dpp-vault/tests/facility_operator_stamping.rs b/crates/dpp-vault/tests/facility_operator_stamping.rs index 121372ba..840fb592 100644 --- a/crates/dpp-vault/tests/facility_operator_stamping.rs +++ b/crates/dpp-vault/tests/facility_operator_stamping.rs @@ -134,3 +134,377 @@ async fn duplicate_facility_returns_422_not_500() { let dup = client.post_json("/api/v1/facilities", body).await; assert_eq!(dup.status(), 422, "a duplicate facility must return 422"); } + +/// 🚨 A VAT number is stamped as the operator identifier, and is refused as the +/// identifier of a registration. dpp-core accepts only a scheme with an EN 18219 +/// clause 6 route there (an LEI, a D-U-N-S number or a GLN), and a queued +/// registration is fixed, so one built from a VAT number would be refused on +/// every attempt. Publish says so instead, where the operator can act on it, +/// and queues nothing. +/// +/// A product group with no live registration duty publishes under the same +/// identifier, with no registration, as it does when the identity is missing. +#[tokio::test(flavor = "multi_thread")] +async fn a_vat_primary_identifier_is_refused_at_publish_where_a_registration_is_owed() { + use dpp_dal::pg::PgRegistrySyncRepo; + use dpp_domain::passport::PassportId; + use dpp_types::RegistrySyncOutbox; + + let pg = start_postgres().await; + seed_operator_config(&pg.dal).await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt("00000000-0000-0000-0000-000000000001"); + let client = TestClient::new(&vault_url, &token); + let outbox = PgRegistrySyncRepo::new(pg.dal.clone()); + + let resp = client + .post_json( + "/api/v1/facilities", + serde_json::json!({ + "name": "Default Plant", + "identifierScheme": "gln", + "identifierValue": "4012345000009", + "country": "DE", + "isDefault": true + }), + ) + .await; + assert_eq!(resp.status(), 201); + let resp = client + .post_json( + "/api/v1/operator-identifiers", + serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true }), + ) + .await; + assert_eq!(resp.status(), 201); + + // A battery owes a registration. + let resp = client + .post_json( + "/api/v1/dpp", + serde_json::json!({ + "productName": "Portable Cell", + "manufacturer": { "name": "Example Batteries GmbH", "address": "Berlin, DE" }, + "productGroupData": { + "productGroup": "battery", + "productIdentifier": { "scheme": "gs1", "gtin": "09506000134352" }, + "batteryChemistry": "LFP", + "batteryType": "portable", + "nominalVoltageV": 3.7, + "nominalCapacityAh": 2.5, + "co2ePerUnitKg": 1.8 + } + }), + ) + .await; + assert_eq!(resp.status(), 201); + let battery = resp.json::().await.unwrap()["id"] + .as_str() + .expect("created passport has an id") + .to_owned(); + + let resp = client + .post_json( + &format!("/api/v1/dpp/{battery}/publish"), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 422, + "a registration it cannot carry is refused" + ); + let body = resp.text().await.unwrap_or_default(); + assert!( + body.contains("operator identifier cannot be registered") + && body.contains("'vat'") + && body.contains("LEI"), + "the refusal names the scheme and the fix: {body}" + ); + let after: serde_json::Value = client + .get(&format!("/api/v1/dpp/{battery}")) + .await + .json() + .await + .unwrap(); + assert_eq!(after["status"], "draft", "a refused publish leaves a draft"); + let battery_id = PassportId(uuid::Uuid::parse_str(&battery).unwrap()); + assert!( + outbox.pending_for(battery_id).await.unwrap().is_none(), + "nothing is queued for a registration the adapter would refuse" + ); + + // A textile passport owes none yet, so it publishes under the same identifier. + let resp = client + .post_json( + "/api/v1/dpp", + serde_json::json!({ + "productName": "Organic Cotton Tee", + "manufacturer": { "name": "Example Textiles SpA", "address": "Milano, IT" }, + "materials": [{"name": "Cotton", "weightKg": 0.22}], + "productGroupData": { + "productGroup": "textile", + "productIdentifier": { "scheme": "gs1", "gtin": "09506000134369" }, + "carbonFootprintKgCo2e": 4.2, + "careInstructions": "Machine wash cold, line dry", + "chemicalComplianceStandard": "OEKO-TEX 100", + "countryOfOrigin": "IT", + "countryOfRawMaterialOrigin": "IN", + "disassemblyInstructions": "Remove label and neck tape before recycling", + "endOfLifeInstructions": "Return to a textile collection point", + "expectedWashCycles": 50, + "fibreComposition": [{"fibre": "cotton", "pct": 100}], + "microplasticSheddingMgPerWash": 12, + "productWeightGrams": 220, + "recyclabilityClass": "B", + "recycledContentPct": 30, + "sparePartsAvailable": false, + "waterUseLitres": 1800 + } + }), + ) + .await; + assert_eq!(resp.status(), 201, "a textile passport is created"); + let textile = resp.json::().await.unwrap()["id"] + .as_str() + .expect("created passport has an id") + .to_owned(); + let resp = client + .post_json( + &format!("/api/v1/dpp/{textile}/publish"), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 200, + "no registration is owed, so none is refused: {}", + resp.text().await.unwrap_or_default() + ); + let textile_id = PassportId(uuid::Uuid::parse_str(&textile).unwrap()); + assert!( + outbox.pending_for(textile_id).await.unwrap().is_none(), + "and none is queued" + ); +} + +/// An identity the registry cannot take is refused only for a passport that has +/// never queued a registration (the test above). One that has keeps its row: +/// the identifier it was signed with cannot change, so refusing would fix +/// nothing and only keep a suspended passport from being re-activated. An +/// operator who published under a VAT number before it was refused has exactly +/// such rows, `pending` until the drain reaches them and `rejected` after. +/// Both re-publish. A pending row is left as it is, and a rejected one is +/// queued again, for the drain to reject again with its reason. +/// +/// The row is written directly, as a node from before the refusal wrote it, and +/// publish meets it by the path a re-activation takes. `operator_paths.rs` runs +/// the real case end to end: a suspended passport in a database v0.14.0 wrote. +#[tokio::test(flavor = "multi_thread")] +async fn an_unregistrable_identity_is_refused_only_before_a_registration_was_queued() { + use dpp_dal::pg::{PgRegistrySyncRepo, sqlx}; + use dpp_domain::passport::PassportId; + use dpp_types::{RegistrySyncOutbox, RegistrySyncStatus}; + + let pg = start_postgres().await; + seed_operator_config(&pg.dal).await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt("00000000-0000-0000-0000-000000000001"); + let client = TestClient::new(&vault_url, &token); + let outbox = PgRegistrySyncRepo::new(pg.dal.clone()); + + let resp = client + .post_json( + "/api/v1/facilities", + serde_json::json!({ + "name": "Default Plant", + "identifierScheme": "gln", + "identifierValue": "4012345000009", + "country": "DE", + "isDefault": true + }), + ) + .await; + assert_eq!(resp.status(), 201); + let resp = client + .post_json( + "/api/v1/operator-identifiers", + serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true }), + ) + .await; + assert_eq!(resp.status(), 201); + + for (row_status, gtin) in [ + (RegistrySyncStatus::Pending, "09506000134376"), + (RegistrySyncStatus::Rejected, "09506000134383"), + ] { + let status = row_status.as_db(); + let resp = client + .post_json( + "/api/v1/dpp", + serde_json::json!({ + "productName": "Portable Cell", + "manufacturer": { "name": "Example Batteries GmbH", "address": "Berlin, DE" }, + "productGroupData": { + "productGroup": "battery", + "productIdentifier": { "scheme": "gs1", "gtin": gtin }, + "batteryChemistry": "LFP", + "batteryType": "portable", + "nominalVoltageV": 3.7, + "nominalCapacityAh": 2.5, + "co2ePerUnitKg": 1.8 + } + }), + ) + .await; + assert_eq!(resp.status(), 201); + let battery = resp.json::().await.unwrap()["id"] + .as_str() + .expect("created passport has an id") + .to_owned(); + let battery_id = PassportId(uuid::Uuid::parse_str(&battery).unwrap()); + sqlx::query( + "INSERT INTO odal.registry_sync (passport_id, payload, status) \ + VALUES ($1, '{\"queued\": \"before the upgrade\"}', $2)", + ) + .bind(battery_id.0) + .bind(status) + .execute(pg.dal.pool()) + .await + .expect("write the row an earlier node queued"); + + let resp = client + .post_json( + &format!("/api/v1/dpp/{battery}/publish"), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 200, + "publish over a `{status}` registration: {}", + resp.text().await.unwrap_or_default() + ); + let row = outbox.pending_for(battery_id).await.unwrap().unwrap(); + let before = Some(serde_json::json!({ "queued": "before the upgrade" })); + match row_status { + RegistrySyncStatus::Pending => { + assert_eq!(row.status, RegistrySyncStatus::Pending); + assert_eq!(row.payload, before, "a pending row is left as it was"); + } + _ => { + assert_eq!( + row.status, + RegistrySyncStatus::Pending, + "a rejected row is queued again" + ); + assert_ne!(row.payload, before, "with this publish's registration"); + } + } + } +} + +/// A draft is published under the operator's primary identifier as it stands at +/// publish, not as it stood when the draft was created or, for an amendment, +/// when its predecessor was. A draft is not signed yet, and nothing an operator +/// sent is overwritten: create takes no identifier. Without this, an operator +/// that moved its primary from a VAT number to an LEI, as the migration for the +/// clause 6 refusal says to, still published under the VAT number, with a +/// registration that names no scheme and that the registry was bound to refuse. +#[tokio::test(flavor = "multi_thread")] +async fn a_draft_is_published_under_the_operators_current_primary_identifier() { + use dpp_dal::pg::PgRegistrySyncRepo; + use dpp_domain::passport::PassportId; + use dpp_types::RegistrySyncOutbox; + + let pg = start_postgres().await; + seed_operator_config(&pg.dal).await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt("00000000-0000-0000-0000-000000000001"); + let client = TestClient::new(&vault_url, &token); + let outbox = PgRegistrySyncRepo::new(pg.dal.clone()); + + let resp = client + .post_json( + "/api/v1/facilities", + serde_json::json!({ + "name": "Default Plant", + "identifierScheme": "gln", + "identifierValue": "4012345000009", + "country": "DE", + "isDefault": true + }), + ) + .await; + assert_eq!(resp.status(), 201); + let resp = client + .post_json( + "/api/v1/operator-identifiers", + serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true }), + ) + .await; + assert_eq!(resp.status(), 201); + + let resp = client + .post_json( + "/api/v1/dpp", + serde_json::json!({ + "productName": "Portable Cell", + "manufacturer": { "name": "Example Batteries GmbH", "address": "Berlin, DE" }, + "productGroupData": { + "productGroup": "battery", + "productIdentifier": { "scheme": "gs1", "gtin": "09506000134390" }, + "batteryChemistry": "LFP", + "batteryType": "portable", + "nominalVoltageV": 3.7, + "nominalCapacityAh": 2.5, + "co2ePerUnitKg": 1.8 + } + }), + ) + .await; + assert_eq!(resp.status(), 201); + let draft = resp.json::().await.unwrap(); + assert_eq!( + draft["operatorIdentifier"], "DE123456789", + "create stamps the primary identifier of the moment" + ); + let battery = draft["id"].as_str().expect("an id").to_owned(); + + // The migration: an LEI becomes the primary identifier. + let resp = client + .post_json( + "/api/v1/operator-identifiers", + serde_json::json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true }), + ) + .await; + assert_eq!(resp.status(), 201); + + let resp = client + .post_json( + &format!("/api/v1/dpp/{battery}/publish"), + serde_json::json!({}), + ) + .await; + assert_eq!( + resp.status(), + 200, + "{}", + resp.text().await.unwrap_or_default() + ); + let published = resp.json::().await.unwrap(); + assert_eq!( + published["operatorIdentifier"], "529900T8BM49AURSDO55", + "the draft is published under the primary identifier of the publish" + ); + let row = outbox + .pending_for(PassportId(uuid::Uuid::parse_str(&battery).unwrap())) + .await + .unwrap() + .expect("a registration is queued"); + let payload = row.payload.expect("with its payload").to_string(); + assert!( + payload.contains("529900T8BM49AURSDO55") && !payload.contains("DE123456789"), + "the registration names the LEI and not the VAT number: {payload}" + ); +} diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index 52c7ffda..97888cd6 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -578,8 +578,11 @@ pub async fn seed_complete_operator(dal: &PgDal) { STANDALONE_OPERATOR_ID, OperatorIdentifier { id: uuid::Uuid::now_v7(), - scheme: "vat".into(), - value: "DE123456789".into(), + // An LEI: a registration carries only a scheme with an EN 18219 + // clause 6 route, so a VAT number here would refuse every publish + // that owes one. + scheme: "lei".into(), + value: "529900T8BM49AURSDO55".into(), label: None, is_primary: true, created_at: chrono::Utc::now(), diff --git a/crates/dpp-vault/tests/registry_identity_governance.rs b/crates/dpp-vault/tests/registry_identity_governance.rs index 5dc9fb9d..7c3d4603 100644 --- a/crates/dpp-vault/tests/registry_identity_governance.rs +++ b/crates/dpp-vault/tests/registry_identity_governance.rs @@ -39,10 +39,12 @@ async fn add_default_facility(client: &TestClient) { ) .await; assert_eq!(resp.status(), 201); + // An LEI: a battery owes a registration, which carries only a scheme with an + // EN 18219 clause 6 route. let resp = client .post_json( "/api/v1/operator-identifiers", - json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true }), + json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true }), ) .await; assert_eq!(resp.status(), 201); @@ -92,7 +94,7 @@ async fn publish_is_blocked_without_annexiii_identity_then_backfills() { .await .unwrap(); assert_eq!(dpp["facility"]["value"], "4012345000009"); - assert_eq!(dpp["operatorIdentifier"], "DE123456789"); + assert_eq!(dpp["operatorIdentifier"], "529900T8BM49AURSDO55"); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/dpp-vault/tests/registry_status_route.rs b/crates/dpp-vault/tests/registry_status_route.rs index b1146e83..b60337e4 100644 --- a/crates/dpp-vault/tests/registry_status_route.rs +++ b/crates/dpp-vault/tests/registry_status_route.rs @@ -43,7 +43,7 @@ async fn a_published_passport_reports_its_registration() { let resp = client .post_json( "/api/v1/operator-identifiers", - serde_json::json!({ "scheme": "vat", "value": "DE123456789", "isPrimary": true }), + serde_json::json!({ "scheme": "lei", "value": "529900T8BM49AURSDO55", "isPrimary": true }), ) .await; assert_eq!( diff --git a/crates/dpp-vault/tests/seal_route.rs b/crates/dpp-vault/tests/seal_route.rs index d76306c5..f1988823 100644 --- a/crates/dpp-vault/tests/seal_route.rs +++ b/crates/dpp-vault/tests/seal_route.rs @@ -421,6 +421,7 @@ async fn a_completed_handover_says_responsibility_may_have_moved() { postal_address: None, electronic_address: None, country: "DE".to_owned(), + identifier: None, }; let from = operator("did:web:acme.example", "Acme GmbH"); let mut chain = TransferChain::new(id, from.clone()); diff --git a/docs/guides/DEMO-RUNBOOK.md b/docs/guides/DEMO-RUNBOOK.md index 2680067c..2ac33123 100644 --- a/docs/guides/DEMO-RUNBOOK.md +++ b/docs/guides/DEMO-RUNBOOK.md @@ -91,7 +91,7 @@ odal bootstrap --legal-name "Demo GmbH" --country DE \ --address "Musterstrasse 1, 10115 Berlin" --contact-email demo@example.com odal facility add --name "Demo plant" --scheme gln --value 4012345000009 \ --country DE --default -odal operator-id add --scheme vat --value DE123456789 --primary +odal operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary ``` `bootstrap` prints the first API key once; keep it. The demo passports are batteries, diff --git a/docs/guides/DEVELOPER-GUIDE.md b/docs/guides/DEVELOPER-GUIDE.md index 48160444..9b8b3844 100644 --- a/docs/guides/DEVELOPER-GUIDE.md +++ b/docs/guides/DEVELOPER-GUIDE.md @@ -30,7 +30,7 @@ set -a; . ./.env; set +a just cli -- bootstrap --legal-name "Example GmbH" --country DE \ --address "Musterstrasse 1, 10115 Berlin" --contact-email ops@example.com just cli -- facility add --name "Main plant" --value 4012345000009 --country DE --default -just cli -- operator-id add --scheme vat --value DE123456789 --primary +just cli -- operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary just cli -- passport import ops/demo/samples/battery-full.json just cli -- passport validate just cli -- passport publish diff --git a/docs/guides/OPERATOR-SETUP.md b/docs/guides/OPERATOR-SETUP.md index 8451b2e7..06822918 100644 --- a/docs/guides/OPERATOR-SETUP.md +++ b/docs/guides/OPERATOR-SETUP.md @@ -70,7 +70,7 @@ odal --profile prod operator set --legal-name "Example GmbH" --country DE \ --address "Musterstrasse 1, 10115 Berlin" --contact-email ops@example.com odal --profile prod facility add --name "Berlin plant" --scheme gln \ --value 4012345000009 --country DE --default -odal --profile prod operator-id add --scheme vat --value DE123456789 --primary +odal --profile prod operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary ``` - `bootstrap` mints the first API key, prints it once and saves it to @@ -264,6 +264,7 @@ Serve the resolver (port 8003) on the `RESOLVER_BASE_URL` host. | Node stays `starting`; logs repeat *"password authentication failed for user odal_app"* | The role-provisioning hook was not mounted on first start | Make sure `ops/bootstrap/` is beside `docker/`, then recreate the database volume (the hook runs only on first initialisation) | | *"No profile configured yet"* | Nothing configured on this machine | `odal init` or `odal profile create --node-url ` | | Publish fails `422` *"missing required registry identity"* | No default facility or primary operator identifier | `odal facility add … --default`, `odal operator-id add … --primary` | +| Publish fails `422` *"the operator identifier cannot be registered"* | The primary operator identifier is a VAT or EORI number, which the EU registry does not take | Make an LEI or a D-U-N-S number primary: `odal operator-id add --scheme lei … --primary` | | Import rejects every row on `gtin` | 13-digit GTINs, or wrong check digits | Use GTIN-14; the error names the expected digit | | Retire fails *"retention policy forbids retiring before …"* | The retention window is still running | Suspend instead | | `odal verify `: *"Dossier not found"* | A passport id was passed | Pass a dossier id from `odal passport evidence ` | diff --git a/docs/ops/METRICS-PLAN.md b/docs/ops/METRICS-PLAN.md index 7f1063d8..c9ef3d31 100644 --- a/docs/ops/METRICS-PLAN.md +++ b/docs/ops/METRICS-PLAN.md @@ -64,7 +64,7 @@ path template (for example `/vault/api/v1/dpp/{dppId}`), never the requested URL | Metric | Type | Labels | |---|---|---| | `passport_publish_total` | counter | `outcome` = `success`, `error` | -| `passport_publish_rejected_total` | counter | `reason` = `invalid_transition`, `missing_registry_identity`, `product_group_data_invalid`, `schema_invalid`, `mandatory_content`, `compliance_violations`, `compliance_unavailable`, `carrier_invalid`, `carrier_taken`, `signing_failed` | +| `passport_publish_rejected_total` | counter | `reason` = `invalid_transition`, `missing_registry_identity`, `unregistrable_identity`, `product_group_data_invalid`, `schema_invalid`, `mandatory_content`, `compliance_violations`, `compliance_unavailable`, `carrier_invalid`, `carrier_taken`, `signing_failed` | | `publish_schema_unresolved_total` | counter | `productGroup` | | `signing_failures_total` | counter | | | `auth_failures_total` | counter | `reason` = `missing`, `invalid`, `suspended` | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 8b05a5b0..0031f53b 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -24,8 +24,8 @@ dpp-seal = { path = "../crates/dpp-seal" } # Core, at the version the workspace pins in its root `Cargo.toml`. A repin moves # these with it: the credential target implements dpp-vault's traits over # dpp-vc's types, and two versions of one crate do not share a type. -dpp-domain = "0.22.0" -dpp-vc = "0.22.0" +dpp-domain = "0.23.0" +dpp-vc = "0.23.0" async-trait = "0.1" base64 = "0.23" chrono = "0.4" diff --git a/scripts/capture-upgrade-fixture.sh b/scripts/capture-upgrade-fixture.sh index 304889d8..e9a2198a 100644 --- a/scripts/capture-upgrade-fixture.sh +++ b/scripts/capture-upgrade-fixture.sh @@ -119,7 +119,10 @@ odal bootstrap --legal-name "Upgrade Fixture GmbH" --country DE \ --admin-user "$admin_user" > /dev/null odal facility add --name "Demo Plant" --scheme gln --value 4012345000009 \ --country DE --address "Werkstrasse 2, 10115 Berlin" --default > /dev/null -odal operator-id add --scheme vat --value DE123456789 --primary > /dev/null +# A registrable primary: from 0.16.0 a battery does not publish under a VAT +# number. The v0.14.0 and v0.15.0 fixtures were seeded with one, as those +# releases allowed, and the upgrade test makes the documented move to an LEI. +odal operator-id add --scheme lei --value 984500ODALEXAMPLE069 --primary > /dev/null key="$(sed -n 's/^default = "\(.*\)"$/\1/p' "$work/home/.config/odal/credentials.toml")" auth="authorization: Bearer $key" json='content-type: application/json'