From a020606842a5b98eebb0eccf634f1d7f490fc2bb Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 05:22:49 +0200 Subject: [PATCH 1/7] chore(deps): repin dpp-core to 0.17.0 --- Cargo.lock | 48 ++++++---- Cargo.toml | 18 ++-- crates/dpp-dal/tests/passport_doc_compat.rs | 93 ++++++++++++++++++- crates/dpp-dal/tests/pg_integration.rs | 44 +++++++-- crates/dpp-dal/tests/pg_seal_outbox.rs | 1 - .../src/domain/validate/battery.rs | 77 +++++++++++++-- .../src/domain/validate/textile.rs | 4 +- crates/dpp-integrator/src/handlers/import.rs | 30 ++++-- .../dpp-integrator/templates/battery-v1.csv | 6 +- crates/dpp-node/tests/registry_outbox.rs | 1 - crates/dpp-node/tests/s3_archive.rs | 1 - crates/dpp-node/tests/seal_outbox.rs | 1 - crates/dpp-node/tests/snapshot_outbox.rs | 1 - crates/dpp-node/tests/transfer_outbox.rs | 1 - crates/dpp-plugin-host/src/tests.rs | 8 +- crates/dpp-plugin-host/tests/integration.rs | 42 ++++++++- crates/dpp-render/src/sections/electronics.rs | 24 ++++- .../dpp-resolver/src/handlers/resolve_json.rs | 28 ++++-- .../src/jws_verification_tests.rs | 24 +++-- crates/dpp-vault/src/domain/service/create.rs | 1 - crates/dpp-vault/src/domain/service/mod.rs | 1 - .../dpp-vault/src/domain/service/publish.rs | 8 +- .../dpp-vault/src/handlers/audience_read.rs | 36 +++++-- crates/dpp-vault/src/handlers/create.rs | 45 +++++++-- crates/dpp-vault/src/public_view.rs | 84 ++++++++++------- crates/dpp-vault/tests/continuity_snapshot.rs | 1 - crates/dpp-vault/tests/evidence_dossier.rs | 1 - crates/dpp-vault/tests/seal_route.rs | 1 - 28 files changed, 489 insertions(+), 141 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d48a154..f6f52d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2053,20 +2053,21 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "dpp-aas" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb808069486b9d6cdc8244053451b232a654215bf0d6acdf36445f4822a8324c" +checksum = "fe277e91f576754e1f10d20c59dd517d236a1a91ccb06369d431f3eed4881e89" dependencies = [ "dpp-domain", + "dpp-vocab", "serde", "serde_json", ] [[package]] name = "dpp-calc" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f61d69178918c3e171c570e10aaae4152ffe7555f9e09646f9801cbcf66e0451" +checksum = "70fcc751ec0a550b8d5ebcdab956137ff321b80abc0740f232f1d7a10eb110cd" dependencies = [ "chrono", "hex", @@ -2128,9 +2129,9 @@ dependencies = [ [[package]] name = "dpp-crypto" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882e26e78ae425b1e87c61669986b84dfc505dc44c65fb187aec29d44ebb4392" +checksum = "7aafdbc4e265c1a7d32c049e41297f817ed5bb31ea299790501356e5263b2654" dependencies = [ "aes-gcm", "anyhow", @@ -2177,9 +2178,9 @@ dependencies = [ [[package]] name = "dpp-digital-link" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3917b2560a9d98325aa63cd3b9751579634b79ed188a891f0bb9341d9bd846bf" +checksum = "d3f6967c6e69a2922ca74943ae7928749c80b8e9be893252fcfee74de05d9e58" dependencies = [ "dpp-domain", "serde", @@ -2189,9 +2190,9 @@ dependencies = [ [[package]] name = "dpp-domain" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d15eb9875382001e8ab2f824c86debfe21182714253a715a2995fca12cf0ee" +checksum = "35b31b67a720e84cad613543b6b1f2233a55b7d6bd55c7540b58fcc44df7f4ce" dependencies = [ "async-trait", "chrono", @@ -2356,9 +2357,9 @@ dependencies = [ [[package]] name = "dpp-plugin-traits" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96b5bf986bf6f7ba71113ad5a963ca9d64529a0dad1d5a76ee0e4f050b55253" +checksum = "4c072987218becf0d09b2a6e427c001f2a82cecd909add40dccafb995e9dd3f9" dependencies = [ "semver", "serde", @@ -2368,9 +2369,9 @@ dependencies = [ [[package]] name = "dpp-registry" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cd58e1c01cc319f613f45fd25a3aeafc2841885e35541a4b55a8a8bcfae4ea9" +checksum = "ea818d6429dc6c1c82c257b7a72ff9205c29a92ba81d7f326fbf2463e46f2525" dependencies = [ "chrono", "dpp-domain", @@ -2432,9 +2433,9 @@ dependencies = [ [[package]] name = "dpp-rules" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "880d63f38df7def5db8af05fc94fb4e8c2a0306f4588dc808ac477272392268d" +checksum = "e9b00dfa1f6f9f4a32517e47909bdca446d1e80b6c363bd43585a2bf32bebc64" dependencies = [ "base64 0.23.1", "chrono", @@ -2528,9 +2529,9 @@ dependencies = [ [[package]] name = "dpp-vc" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a82c1859f1426b6451228eef06cad5a3854d97ccf87e279af2f89d8bd79a61" +checksum = "8387541364111b9ca465306b389808c0e990e526e842df8eab59cc4b7e4883ae" dependencies = [ "anyhow", "async-trait", @@ -2547,6 +2548,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "dpp-vocab" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15ca65cab464fefab780d9dab302a1b11f391b8be4a8d5b0dec6a3399f0dc690" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "dunce" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 2ccc52e..83fbcba 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.16.0" +dpp-domain = "0.17.0" # Engine-wide types (tenant, auth, audit, API keys) — engine-internal crate. dpp-types = { path = "crates/dpp-types" } -dpp-crypto = "0.16.0" -dpp-vc = "0.16.0" +dpp-crypto = "0.17.0" +dpp-vc = "0.17.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.16.0" -dpp-aas = "0.16.0" -dpp-plugin-traits = "0.16.0" -dpp-registry = "0.16.0" -dpp-calc = "0.16.0" -dpp-rules = { version = "0.16.0", features = ["bundle"] } +dpp-digital-link = "0.17.0" +dpp-aas = "0.17.0" +dpp-plugin-traits = "0.17.0" +dpp-registry = "0.17.0" +dpp-calc = "0.17.0" +dpp-rules = { version = "0.17.0", features = ["bundle"] } # Web framework axum = { version = "0.8", features = ["macros", "multipart"] } diff --git a/crates/dpp-dal/tests/passport_doc_compat.rs b/crates/dpp-dal/tests/passport_doc_compat.rs index c1c90ae..f0d81fb 100644 --- a/crates/dpp-dal/tests/passport_doc_compat.rs +++ b/crates/dpp-dal/tests/passport_doc_compat.rs @@ -99,6 +99,70 @@ const RETIRED_ENVELOPE_KEYS: &[(&str, &str)] = &[( identifier alone.", )]; +/// Frozen documents that deliberately no longer read, and why. +/// +/// The counterpart to [`RETIRED_ENVELOPE_KEYS`], and it exists for the same +/// reason: the alternative is editing a frozen document until the test passes, +/// and a fixture that has been edited has stopped being evidence about anything. +/// These stay on disk exactly as they were captured; the break is recorded here +/// instead. +/// +/// Each row is an **accepted, permanent** compatibility break. A node holding a +/// document of that shape cannot read it after upgrading past the version named +/// in the reason, and no lens can rescue it — where the gap is a field the +/// regulation later made mandatory, there is no honest value to invent, and +/// `dpp-domain` refuses rather than fabricating one. +/// +/// This is only defensible while no such document exists in any deployment. It +/// stops being defensible the moment one does. +const UNREADABLE_FIXTURES: &[(&str, &str)] = &[ + ( + "battery/v1.0.0.json", + "No `batteryType`. Required from battery schema v2.5.0 — EU 2023/1542 Annex VI \ + Part A point 2, via Annex XIII point 1(a) — and closed to a fixed set, so it \ + cannot be defaulted or derived from anything else the document carries.", + ), + ( + "battery/v2.0.0.json", + "No lens path to the current schema. The 2.0.0 shape predates several \ + non-additive changes and the chain was never built; a document of this \ + shape has been unreachable since the first gap in that chain.", + ), + ( + "battery/v2.1.0.json", + "No lens path to the current schema — same chain gap as v2.0.0.", + ), + ( + "battery/v2.2.0.json", + "No lens path to the current schema — same chain gap as v2.0.0.", + ), + ( + "battery/v2.3.0.json", + "No lens path to the current schema — same chain gap as v2.0.0.", + ), + ( + "battery/v2.4.0.json", + "A lens path exists and refuses: `batteryType` is required from v2.5.0 and this \ + record predates the mandate. The refusal is `dpp-domain`'s, and it is correct — \ + upgrading would mean inventing a regulatory classification the operator never \ + stated.", + ), +]; + +/// The documented-unreadable list, as `sector/file.json` keys. +fn unreadable_key(path: &Path) -> String { + let file = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + let sector = path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or_default(); + format!("{sector}/{file}") +} + #[test] fn every_frozen_passport_doc_still_reads() { let lenses = LensRegistry::new(); @@ -110,11 +174,26 @@ fn every_frozen_passport_doc_still_reads() { ); let mut failures = Vec::new(); + // A row that has stopped being true must be removed, not left to rot. If a + // lens later bridges one of these, this is what says so. + let mut unexpectedly_readable = Vec::new(); + for (sector, path) in &fixtures { let raw = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let value: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{path:?} is not JSON: {e}")); + let key = unreadable_key(path); + if let Some((_, reason)) = UNREADABLE_FIXTURES.iter().find(|(k, _)| *k == key) { + if Passport::from_stored(value, &lenses, &catalog).is_ok() { + unexpectedly_readable.push(format!( + "{key}: recorded as permanently unreadable, but it reads. Remove the row \ + from UNREADABLE_FIXTURES — the recorded reason is no longer true:\n {reason}" + )); + } + continue; + } + match Passport::from_stored(value, &lenses, &catalog) { Ok(passport) => { // A fixture that parses into the wrong document (e.g. an @@ -132,12 +211,20 @@ fn every_frozen_passport_doc_still_reads() { } } + assert!( + unexpectedly_readable.is_empty(), + "UNREADABLE_FIXTURES is stale:\n{}", + unexpectedly_readable.join("\n") + ); + assert!( failures.is_empty(), "one or more frozen passport docs no longer read under the current dpp-domain \ - version — a persisted document shape broke compatibility. Either this is an \ - intentional, accepted break (update the fixture and document why old documents \ - of this shape are no longer supported) or dpp-domain needs a lens for the gap:\n{}", + version — a persisted document shape broke compatibility. Either dpp-domain needs \ + a lens for the gap, or the break is accepted and permanent, in which case record \ + it in UNREADABLE_FIXTURES with the reason. Do not edit the fixture: a frozen \ + document that has been edited to pass a test is no longer evidence of \ + anything:\n{}", failures.join("\n") ); } diff --git a/crates/dpp-dal/tests/pg_integration.rs b/crates/dpp-dal/tests/pg_integration.rs index d3700c0..b8a0d1d 100644 --- a/crates/dpp-dal/tests/pg_integration.rs +++ b/crates/dpp-dal/tests/pg_integration.rs @@ -38,7 +38,7 @@ use dpp_domain::{ gtin::Gtin, passport::{FacilitySnapshot, ManufacturerInfo, Passport, PassportId}, product_identity::ProductIdentity, - sector::{BatteryChemistry, BatteryData, Sector, SectorData}, + sector::{BatteryChemistry, BatteryData, BatteryType, Sector, SectorData}, status::PassportStatus, }, ports::passport_repo::PassportRepository, @@ -116,7 +116,6 @@ fn make_passport() -> Passport { batch_id: Some("LOT-PG-1".into()), product_name: "PG Parity Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), @@ -159,12 +158,12 @@ fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus p.id = PassportId::new(); p.batch_id = batch.map(str::to_owned); p.status = status; - p.sector_data = Some(SectorData::Battery(BatteryData { + p.sector_data = Some(SectorData::Battery(Box::new(BatteryData { gtin: Gtin::parse(gtin).expect("valid test gtin"), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 3.2, nominal_capacity_ah: 100.0, - expected_lifetime_cycles: 3000, + expected_lifetime_cycles: Some(3000), co2e_per_unit_kg: 85.4, recycled_content_cobalt_pct: None, recycled_content_lithium_pct: None, @@ -184,7 +183,7 @@ fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus rated_energy_wh: None, recycled_content_lead_pct: None, battery_weight_kg: None, - battery_type: None, + battery_type: BatteryType::Industrial, round_trip_efficiency_pct: None, internal_resistance_mohm: None, manufacturing_date: None, @@ -197,7 +196,40 @@ fn battery_passport_with(gtin: &str, batch: Option<&str>, status: PassportStatus recycled_content_reporting_year: None, state_of_health: None, expected_lifetime: None, - })); + // Annex VI Part A / Annex XIII points 1-3, added in dpp-core 0.17.0. + // None of them load-bearing for what this harness stores and reads back. + battery_status: None, + capacity_threshold_for_exhaustion_pct: None, + commercial_warranty_period_months: None, + component_part_numbers: None, + cycle_life_test_c_rate: None, + dynamic_performance: None, + eu_declaration_of_conformity: None, + expected_lifetime_reference_test: None, + hazard_symbol: None, + hazardous_substances: None, + initial_round_trip_efficiency_pct: None, + internal_cell_resistance_mohm: None, + internal_pack_resistance_mohm: None, + marking_information: None, + maximum_voltage_v: None, + minimal_voltage_v: None, + not_in_use_temperature_range: None, + not_in_use_temperature_reference_test: None, + original_power_capability_w: None, + power_limit_max_w: None, + power_limit_min_w: None, + power_temperature_range: None, + renewable_content_pct: None, + round_trip_efficiency_at_half_cycle_life_pct: None, + safety_measures: None, + spare_parts_contacts: None, + test_report_results: None, + usable_extinguishing_agent: None, + usage_history: None, + voltage_temperature_range: None, + waste_battery_information: None, + }))); p } diff --git a/crates/dpp-dal/tests/pg_seal_outbox.rs b/crates/dpp-dal/tests/pg_seal_outbox.rs index b8884c7..4b24b12 100644 --- a/crates/dpp-dal/tests/pg_seal_outbox.rs +++ b/crates/dpp-dal/tests/pg_seal_outbox.rs @@ -87,7 +87,6 @@ fn published_passport(jws: &str) -> Passport { batch_id: None, product_name: "Seal Test Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-integrator/src/domain/validate/battery.rs b/crates/dpp-integrator/src/domain/validate/battery.rs index b36f2e3..f04fe18 100644 --- a/crates/dpp-integrator/src/domain/validate/battery.rs +++ b/crates/dpp-integrator/src/domain/validate/battery.rs @@ -92,8 +92,28 @@ pub fn validate_battery_row( let carbon_footprint_class = optional_str(row, "carbonFootprintClass").and_then(|s| { serde_json::from_value::(serde_json::Value::String(s)).ok() }); - let battery_type = optional_str(row, "batteryType") - .and_then(|s| serde_json::from_value::(serde_json::Value::String(s)).ok()); + // Required and closed since Annex VI Part A: a battery without a type is not + // a battery this regulation recognises, and there is no honest default to + // pick. An unrecognised value is reported with the accepted set rather than + // silently dropped to `None`, which is what the old optional parse did — a + // typo used to produce a passport missing a mandatory field instead of a + // failed row. + let battery_type = require_str(row, "batteryType", row_num, &mut errors).and_then(|s| { + match serde_json::from_value::(serde_json::Value::String(s.clone())) { + Ok(t) => Some(t), + Err(_) => { + errors.push(RowError { + row: row_num, + field: "batteryType".into(), + message: format!( + "{s:?} is not a battery type recognised by Annex VI Part A — expected one \ + of: portable, industrial, ev, lmt, starting-lighting-ignition" + ), + }); + None + } + } + }); let repairability_score = optional_f64(row, "repairabilityScore", row_num, &mut errors); let materials = parse_materials(row); @@ -108,15 +128,14 @@ pub fn validate_battery_row( serde_json::from_value(serde_json::Value::String(chemistry_raw)) .unwrap_or(BatteryChemistry::Other); - let battery_data = SectorData::Battery(BatteryData { + let battery_data = SectorData::Battery(Box::new(BatteryData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), battery_chemistry: battery_chemistry_parsed, nominal_voltage_v: nominal_voltage_v .expect("field verified present by errors.is_empty() guard above"), nominal_capacity_ah: nominal_capacity_ah .expect("field verified present by errors.is_empty() guard above"), - expected_lifetime_cycles: expected_lifetime_cycles - .expect("field verified present by errors.is_empty() guard above"), + expected_lifetime_cycles, co2e_per_unit_kg: co2e_per_unit_kg .expect("field verified present by errors.is_empty() guard above"), recycled_content_cobalt_pct: recycled_cobalt, @@ -127,7 +146,8 @@ pub fn validate_battery_row( rated_capacity_kwh, carbon_footprint_class, due_diligence_url, - battery_type, + battery_type: battery_type + .expect("field verified present by errors.is_empty() guard above"), battery_weight_kg, operating_temp_min_c, operating_temp_max_c, @@ -155,7 +175,48 @@ pub fn validate_battery_row( manufacturing_place: None, battery_model_id: None, battery_passport_number: None, - }); + // Added in dpp-core 0.17.0: the remainder of Annex VI Part A and the + // Annex XIII point 1–3 tiers. All optional, and none of them carried by + // the CSV contract, so every one is `None` until the template gains a + // column for it. + // + // Listed individually rather than defaulted because `BatteryData` derives + // no `Default` — deliberately, since the same release made `battery_type` + // required and closed. That means a future Annex field cannot slip into + // an import as an unnoticed `None`: it breaks this literal, and someone + // has to decide whether the template should carry it. + battery_status: None, + capacity_threshold_for_exhaustion_pct: None, + commercial_warranty_period_months: None, + component_part_numbers: None, + cycle_life_test_c_rate: None, + dynamic_performance: None, + eu_declaration_of_conformity: None, + expected_lifetime_reference_test: None, + hazard_symbol: None, + hazardous_substances: None, + initial_round_trip_efficiency_pct: None, + internal_cell_resistance_mohm: None, + internal_pack_resistance_mohm: None, + marking_information: None, + maximum_voltage_v: None, + minimal_voltage_v: None, + not_in_use_temperature_range: None, + not_in_use_temperature_reference_test: None, + original_power_capability_w: None, + power_limit_max_w: None, + power_limit_min_w: None, + power_temperature_range: None, + renewable_content_pct: None, + round_trip_efficiency_at_half_cycle_life_pct: None, + safety_measures: None, + spare_parts_contacts: None, + test_report_results: None, + usable_extinguishing_agent: None, + usage_history: None, + voltage_temperature_range: None, + waste_battery_information: None, + })); Ok(CreatePassportRequest { product_name: product_name @@ -195,6 +256,7 @@ mod tests { ("manufacturerName".into(), "Acme Energy".into()), ("manufacturerCountry".into(), "DE".into()), ("batteryChemistry".into(), "LFP".into()), + ("batteryType".into(), "industrial".into()), ("nominalVoltageV".into(), "48.0".into()), ("nominalCapacityAh".into(), "100.0".into()), ("expectedLifetimeCycles".into(), "3000".into()), @@ -262,6 +324,7 @@ mod tests { "https://greencell.example/.well-known/did.json".into(), ), ("batteryChemistry".into(), "LFP".into()), + ("batteryType".into(), "industrial".into()), ("nominalVoltageV".into(), "48.0".into()), ("nominalCapacityAh".into(), "100.0".into()), ("expectedLifetimeCycles".into(), "3000".into()), diff --git a/crates/dpp-integrator/src/domain/validate/textile.rs b/crates/dpp-integrator/src/domain/validate/textile.rs index 3f3770f..51a13c5 100644 --- a/crates/dpp-integrator/src/domain/validate/textile.rs +++ b/crates/dpp-integrator/src/domain/validate/textile.rs @@ -61,7 +61,7 @@ pub fn validate_textile_row( return Err(errors); } - let textile_data = SectorData::Textile(TextileData { + let textile_data = SectorData::Textile(Box::new(TextileData { gtin: gtin.expect("field verified present by errors.is_empty() guard above"), fibre_composition: fibres.expect("field verified present by errors.is_empty() guard above"), country_of_origin: country_of_origin @@ -91,7 +91,7 @@ pub fn validate_textile_row( repair_history_url: None, repair_count: None, pef_score: None, - }); + })); Ok(CreatePassportRequest { product_name: product_name diff --git a/crates/dpp-integrator/src/handlers/import.rs b/crates/dpp-integrator/src/handlers/import.rs index a04acf2..36a39b6 100644 --- a/crates/dpp-integrator/src/handlers/import.rs +++ b/crates/dpp-integrator/src/handlers/import.rs @@ -558,10 +558,10 @@ mod tests { /// row here since row-level validation runs a real mod-10 checksum, not just a /// length check. const VALID_GTIN: &str = "09506000134352"; - const BATTERY_CSV_HEADER: &str = "productName,gtin,batchId,manufacturerName,manufacturerCountry,batteryChemistry,nominalVoltageV,nominalCapacityAh,expectedLifetimeCycles,co2ePerUnitKg"; + const BATTERY_CSV_HEADER: &str = "productName,gtin,batchId,manufacturerName,manufacturerCountry,batteryChemistry,nominalVoltageV,nominalCapacityAh,expectedLifetimeCycles,co2ePerUnitKg,batteryType"; fn battery_csv_row(gtin: &str) -> String { - format!("EV Battery 48V,{gtin},BATCH-1,Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + format!("EV Battery 48V,{gtin},BATCH-1,Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial") } fn battery_csv(num_rows: usize) -> String { @@ -922,7 +922,9 @@ mod tests { let new_gtin = nth_valid_gtin(4); fn row(product_name: &str, gtin: &str, batch: &str) -> String { - format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + format!( + "{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial" + ) } // ── "v1 sheet": apply-mode import creates three draft passports ────── @@ -1026,7 +1028,9 @@ mod tests { let new_gtin = nth_valid_gtin(14); fn row(product_name: &str, gtin: &str, batch: &str) -> String { - format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + format!( + "{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial" + ) } let mut v1 = String::from(BATTERY_CSV_HEADER); @@ -1182,7 +1186,9 @@ mod tests { let new_gtin = nth_valid_gtin(24); fn row(product_name: &str, gtin: &str, batch: &str) -> String { - format!("{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4") + format!( + "{product_name},{gtin},{batch},Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial" + ) } let mut v1 = String::from(BATTERY_CSV_HEADER); @@ -1290,7 +1296,12 @@ mod tests { csv.push_str(&battery_csv_row(VALID_GTIN)); csv.push('\n'); // GTIN too short — fails the row-level checksum/length check. - csv.push_str("EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4\n"); + // Carries a valid `batteryType` so the bad GTIN stays the only defect — + // the assertions below count errors, and a second one would pass the test + // for the wrong reason. + csv.push_str( + "EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial\n", + ); let body = multipart_body("X", "battery.csv", &csv, None); let resp = app.oneshot(import_request("battery", body)).await.unwrap(); @@ -1419,7 +1430,12 @@ mod tests { csv.push_str(&battery_csv_row(VALID_GTIN)); csv.push('\n'); // GTIN too short — fails the row-level checksum/length check. - csv.push_str("EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4\n"); + // Carries a valid `batteryType` so the bad GTIN stays the only defect — + // the assertions below count errors, and a second one would pass the test + // for the wrong reason. + csv.push_str( + "EV Battery Bad,1234,BATCH-2,Acme Energy,DE,LFP,48.0,100.0,3000,85.4,industrial\n", + ); let body = multipart_body("X", "battery.csv", &csv, None); let resp = app.oneshot(import_request("battery", body)).await.unwrap(); diff --git a/crates/dpp-integrator/templates/battery-v1.csv b/crates/dpp-integrator/templates/battery-v1.csv index 73bddf7..1de477d 100644 --- a/crates/dpp-integrator/templates/battery-v1.csv +++ b/crates/dpp-integrator/templates/battery-v1.csv @@ -1,3 +1,3 @@ -productName [REQUIRED],gtin [REQUIRED],batchId [REQUIRED],manufacturerName [REQUIRED],manufacturerCountry [REQUIRED],batteryChemistry [REQUIRED],nominalVoltageV [REQUIRED],nominalCapacityAh [REQUIRED],expectedLifetimeCycles [REQUIRED],co2ePerUnitKg [REQUIRED],recycledContentPct [OPTIONAL],repairabilityScore [OPTIONAL] -EV Battery 48V LFP Example,12345678901231,BATCH-2026-001,Acme Energy GmbH,DE,LFP,48.0,100.0,3000,85.40,12.5, -E-Bike Battery 36V NMC,98765432109879,BATCH-2026-002,VeloTech BV,NL,NMC,36.0,14.0,800,42.10,,7.5 +productName [REQUIRED],gtin [REQUIRED],batchId [REQUIRED],manufacturerName [REQUIRED],manufacturerCountry [REQUIRED],batteryChemistry [REQUIRED],batteryType [REQUIRED],nominalVoltageV [REQUIRED],nominalCapacityAh [REQUIRED],expectedLifetimeCycles [REQUIRED],co2ePerUnitKg [REQUIRED],recycledContentPct [OPTIONAL],repairabilityScore [OPTIONAL] +EV Battery 48V LFP Example,12345678901231,BATCH-2026-001,Acme Energy GmbH,DE,LFP,ev,48.0,100.0,3000,85.40,12.5, +E-Bike Battery 36V NMC,98765432109879,BATCH-2026-002,VeloTech BV,NL,NMC,lmt,36.0,14.0,800,42.10,,7.5 diff --git a/crates/dpp-node/tests/registry_outbox.rs b/crates/dpp-node/tests/registry_outbox.rs index 47432e0..8e77df7 100644 --- a/crates/dpp-node/tests/registry_outbox.rs +++ b/crates/dpp-node/tests/registry_outbox.rs @@ -108,7 +108,6 @@ fn draft_passport() -> Passport { batch_id: Some("LOT-OUTBOX-1".into()), product_name: "Outbox Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-node/tests/s3_archive.rs b/crates/dpp-node/tests/s3_archive.rs index 25575bf..c52810f 100644 --- a/crates/dpp-node/tests/s3_archive.rs +++ b/crates/dpp-node/tests/s3_archive.rs @@ -58,7 +58,6 @@ fn make_passport() -> Passport { batch_id: None, product_name: "Test Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "Test Co".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 81010b3..2ffa09f 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -196,7 +196,6 @@ fn draft_passport() -> Passport { batch_id: Some("LOT-SEAL-SIM-1".into()), product_name: "Seal Simulation Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "Odal Simulation GmbH".into(), address: "Skopje, MK".into(), diff --git a/crates/dpp-node/tests/snapshot_outbox.rs b/crates/dpp-node/tests/snapshot_outbox.rs index cb46960..cd69a3f 100644 --- a/crates/dpp-node/tests/snapshot_outbox.rs +++ b/crates/dpp-node/tests/snapshot_outbox.rs @@ -255,7 +255,6 @@ fn passport(status: PassportStatus) -> Passport { batch_id: None, product_name: product_name.into(), sector: Sector::Textile, - product_category: None, manufacturer: ManufacturerInfo { name: "Drain Test GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-node/tests/transfer_outbox.rs b/crates/dpp-node/tests/transfer_outbox.rs index d0932ca..435dc28 100644 --- a/crates/dpp-node/tests/transfer_outbox.rs +++ b/crates/dpp-node/tests/transfer_outbox.rs @@ -84,7 +84,6 @@ fn published_passport() -> Passport { batch_id: None, product_name: "Transferred Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-plugin-host/src/tests.rs b/crates/dpp-plugin-host/src/tests.rs index df5f457..42f2e2f 100644 --- a/crates/dpp-plugin-host/src/tests.rs +++ b/crates/dpp-plugin-host/src/tests.rs @@ -105,7 +105,7 @@ fn empty_host_reports_no_sector_plugin() { #[test] fn empty_host_compliance_returns_passthrough() { let host = WasmPluginHost::new(); - let data = SectorData::Textile(TextileData { + let data = SectorData::Textile(Box::new(TextileData { gtin: dpp_domain::Gtin::parse("09506000134352").unwrap(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), @@ -136,7 +136,7 @@ fn empty_host_compliance_returns_passthrough() { repair_history_url: None, repair_count: None, pef_score: None, - }); + })); let result = ComplianceRegistry::compute(&host, Sector::Textile.catalog_key(), &data).unwrap(); assert_eq!( result.compliance_status, @@ -225,7 +225,7 @@ fn enrich_input_non_object_passes_through() { #[test] fn generate_passport_payload_no_plugin_returns_unknown_sector() { let host = WasmPluginHost::new(); - let data = SectorData::Textile(TextileData { + let data = SectorData::Textile(Box::new(TextileData { gtin: dpp_domain::Gtin::parse("09506000134352").unwrap(), fibre_composition: vec![FibreEntry { fibre: "Cotton".into(), @@ -256,7 +256,7 @@ fn generate_passport_payload_no_plugin_returns_unknown_sector() { repair_history_url: None, repair_count: None, pef_score: None, - }); + })); let result = host.generate_passport_payload(&Sector::Textile, &data); assert!(result.is_err()); let err = result.unwrap_err(); diff --git a/crates/dpp-plugin-host/tests/integration.rs b/crates/dpp-plugin-host/tests/integration.rs index 423985e..2808ecf 100644 --- a/crates/dpp-plugin-host/tests/integration.rs +++ b/crates/dpp-plugin-host/tests/integration.rs @@ -139,14 +139,14 @@ fn battery_sector_data() -> SectorData { // still serialises it and writes it to Wasm memory, exercising that path. use dpp_domain::domain::{ gtin::Gtin, - sector::{BatteryChemistry, BatteryData}, + sector::{BatteryChemistry, BatteryData, BatteryType}, }; - SectorData::Battery(BatteryData { + SectorData::Battery(Box::new(BatteryData { gtin: Gtin::parse("09506000134352").unwrap(), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 400.0, nominal_capacity_ah: 100.0, - expected_lifetime_cycles: 3000, + expected_lifetime_cycles: Some(3000), co2e_per_unit_kg: 150.0, recycled_content_cobalt_pct: None, recycled_content_lithium_pct: None, @@ -166,7 +166,7 @@ fn battery_sector_data() -> SectorData { rated_energy_wh: None, recycled_content_lead_pct: None, battery_weight_kg: None, - battery_type: None, + battery_type: BatteryType::Industrial, round_trip_efficiency_pct: None, internal_resistance_mohm: None, manufacturing_date: None, @@ -179,7 +179,39 @@ fn battery_sector_data() -> SectorData { recycled_content_reporting_year: None, state_of_health: None, expected_lifetime: None, - }) + // Annex VI Part A / Annex XIII points 1-3, added in dpp-core 0.17.0. + battery_status: None, + capacity_threshold_for_exhaustion_pct: None, + commercial_warranty_period_months: None, + component_part_numbers: None, + cycle_life_test_c_rate: None, + dynamic_performance: None, + eu_declaration_of_conformity: None, + expected_lifetime_reference_test: None, + hazard_symbol: None, + hazardous_substances: None, + initial_round_trip_efficiency_pct: None, + internal_cell_resistance_mohm: None, + internal_pack_resistance_mohm: None, + marking_information: None, + maximum_voltage_v: None, + minimal_voltage_v: None, + not_in_use_temperature_range: None, + not_in_use_temperature_reference_test: None, + original_power_capability_w: None, + power_limit_max_w: None, + power_limit_min_w: None, + power_temperature_range: None, + renewable_content_pct: None, + round_trip_efficiency_at_half_cycle_life_pct: None, + safety_measures: None, + spare_parts_contacts: None, + test_report_results: None, + usable_extinguishing_agent: None, + usage_history: None, + voltage_temperature_range: None, + waste_battery_information: None, + })) } // --------------------------------------------------------------------------- diff --git a/crates/dpp-render/src/sections/electronics.rs b/crates/dpp-render/src/sections/electronics.rs index 47eefd7..607740f 100644 --- a/crates/dpp-render/src/sections/electronics.rs +++ b/crates/dpp-render/src/sections/electronics.rs @@ -2,12 +2,32 @@ use crate::fields::{f64_field, str_field, u64_field}; +/// Human label for a `DeviceType` wire value. +/// +/// The field became a closed enum with kebab-case wire values, so what used to +/// be operator-written prose is now a machine token. Rendering it raw would put +/// `other-mobile-phone` in front of a consumer on the public page. +/// +/// The four values are the ones Regulation (EU) 2023/1670 Art. 1(1) enumerates. +/// Anything else is passed through untouched rather than mangled: a value this +/// function does not recognise is one it cannot honestly relabel, and an +/// unexpected token is more useful to a reader than a wrong word. +fn device_type_label(raw: &str) -> String { + match raw { + "smartphone" => "Smartphone".to_owned(), + "other-mobile-phone" => "Mobile phone (other than a smartphone)".to_owned(), + "cordless-phone" => "Cordless phone".to_owned(), + "tablet" => "Slate tablet".to_owned(), + other => other.to_owned(), + } +} + pub(super) fn build_electronics_section(p: &serde_json::Value) -> String { let sd = match p.get("sectorData") { Some(v) => v, None => return String::new(), }; - let category = str_field(sd, "productCategory", "-"); + let category = device_type_label(str_field(sd, "productCategory", "-").as_str()); let efficiency = str_field(sd, "energyEfficiencyClass", "-"); let co2e = f64_field(sd, "co2ePerUnitKg", "Not disclosed", |v| { format!("{v:.2} kg CO\u{2082}e") @@ -45,7 +65,7 @@ mod tests { let p = crate::sections::typed_fixture(serde_json::json!({ "sector": "electronics", "gtin": "09506000134352", - "productCategory": "Smartphone", + "productCategory": "smartphone", "energyEfficiencyClass": "A", "co2ePerUnitKg": 42.1, "repairabilityScore": { "overall": 7.5 }, diff --git a/crates/dpp-resolver/src/handlers/resolve_json.rs b/crates/dpp-resolver/src/handlers/resolve_json.rs index 48b7101..9968367 100644 --- a/crates/dpp-resolver/src/handlers/resolve_json.rs +++ b/crates/dpp-resolver/src/handlers/resolve_json.rs @@ -9,7 +9,6 @@ use dpp_common::http_problem; use serde_json::Value; use dpp_domain::Audience; -use dpp_domain::SectorCatalog; use dpp_domain::access::{SectorAccessPolicy, filter_by_audience}; use crate::{infra::did, state::AppState}; @@ -123,6 +122,16 @@ fn parse_access_tier(_headers: &HeaderMap) -> Audience { /// 1. Top-level passport fields (jws, batchId, retentionLocked). /// 2. Sector-specific fields within `sectorData` (e.g. battery supply chain data). fn apply_access_tier_filter(passport: Value, tier: Audience) -> Value { + // Read before filtering: the version that governs this passport's disclosure + // is the one it was validated against, and it must be taken from the document + // rather than assumed current. Absent, it stays empty and resolves to no + // policy — which fails closed for any tagged record. + let schema_version = passport + .get("schemaVersion") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let passport_policy = SectorAccessPolicy::passport_default(); let decision = filter_by_audience(&passport, &passport_policy, tier); let mut doc = decision.filtered_data; @@ -131,7 +140,7 @@ fn apply_access_tier_filter(passport: Value, tier: Audience) -> Value { if let Some(obj) = doc.as_object_mut() && let Some(sd) = obj.remove("sectorData") { - let sector_policy = detect_sector_policy(&sd); + let sector_policy = detect_sector_policy(&sd, &schema_version); if let Some(policy) = sector_policy { let inner = filter_by_audience(&sd, &policy, tier); obj.insert("sectorData".into(), inner.filtered_data); @@ -178,19 +187,13 @@ fn redacted_unknown_sector(sector_data: &Value) -> Value { Value::Object(out) } -/// Process-wide sector catalog (manifests parsed once). -fn catalog() -> &'static SectorCatalog { - static CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); - CATALOG.get_or_init(SectorCatalog::new) -} - /// Select the sector-specific access policy from the catalog. /// /// The stored `sectorData` carries a `"sector"` discriminant; the policy and its /// field tiers come from the catalog, so this covers every sector — not just /// battery/textile. Falls back to field-shape detection for legacy records that /// predate the tagged `sectorData` format. -fn detect_sector_policy(sector_data: &Value) -> Option { +fn detect_sector_policy(sector_data: &Value, schema_version: &str) -> Option { let obj = sector_data.as_object()?; let key = match obj.get("sector").and_then(Value::as_str) { Some("unsoldGoods") => "unsold-goods", @@ -203,7 +206,12 @@ fn detect_sector_policy(sector_data: &Value) -> Option { } None => return None, }; - SectorAccessPolicy::from_catalog(catalog(), key) + // Versioned deliberately: a published passport must be filtered by the + // disclosure classes in force when its signature was frozen, not by whatever + // the catalog says today. `None` here — unknown sector *or* unknown version — + // lands on the fail-closed branch above, which only needs a `sector` tag to + // redact, so a known sector at an unrecognised version is covered too. + SectorAccessPolicy::for_schema_version(key, schema_version) } pub(crate) async fn fetch_passport(state: &AppState, dpp_id: &str) -> Result { diff --git a/crates/dpp-resolver/src/jws_verification_tests.rs b/crates/dpp-resolver/src/jws_verification_tests.rs index bceb6f9..42be896 100644 --- a/crates/dpp-resolver/src/jws_verification_tests.rs +++ b/crates/dpp-resolver/src/jws_verification_tests.rs @@ -362,6 +362,7 @@ fn battery_passport_json() -> serde_json::Value { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "ev", "nominalVoltageV": 3.7, "nominalCapacityAh": 50.0, "expectedLifetimeCycles": 1000, @@ -388,7 +389,7 @@ fn battery_passport_json() -> serde_json::Value { "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", "publishedAt": "2026-01-01T00:00:00Z", - "schemaVersion": "1.0.0" + "schemaVersion": "2.6.0" }) } @@ -482,15 +483,24 @@ async fn aas_door_emits_no_non_public_battery_field() { let body = String::from_utf8(bytes.to_vec()).expect("utf-8"); // Asserted by name rather than by re-reading the catalog, so that a change - // to the disclosure map cannot silently change what this test demands. + // to the disclosure map cannot silently change what this test demands. That + // is working as intended: two fields left this list when `dpp-domain` moved + // to battery schema v2.6.0, and each had to be checked against the schema's + // stated basis rather than deleted to make the test pass. + // + // - `criticalRawMaterials` is now **public**: Annex XIII point 1(b) lists + // critical raw materials alongside chemistry and hazardous substances as + // part of the publicly accessible material composition. + // - `dueDiligenceUrl` is now **public**: Annex XIII point 1(d). + // + // Both are widenings, so both deserved the scrutiny. The six below stay + // non-public — `stateOfHealthPct` is `individual` and the rest `restricted`. for field in [ "anodeMaterial", "cathodeMaterial", "electrolyteMaterial", - "criticalRawMaterials", "disassemblyInstructionsUrl", "sohMethodology", - "dueDiligenceUrl", "stateOfHealthPct", ] { assert!( @@ -753,9 +763,11 @@ async fn the_aas_door_withholds_everything_the_json_door_withholds() { // withheld here, or the comparison below is being run against a passport // that carried nothing worth withholding. Named explicitly so that // reclassifying a field shows up as a diff in this list. + // `dueDiligenceUrl` and `criticalRawMaterials` left this list at battery + // schema v2.6.0, which reclassified both as public (Annex XIII points 1(d) + // and 1(b)). The list is named explicitly precisely so that shows up as a + // diff here rather than as a quietly weaker check. for field in [ - "dueDiligenceUrl", - "criticalRawMaterials", "disassemblyInstructionsUrl", "cathodeMaterial", "anodeMaterial", diff --git a/crates/dpp-vault/src/domain/service/create.rs b/crates/dpp-vault/src/domain/service/create.rs index 8292c44..07ce744 100644 --- a/crates/dpp-vault/src/domain/service/create.rs +++ b/crates/dpp-vault/src/domain/service/create.rs @@ -344,7 +344,6 @@ mod tests { batch_id: None, product_name: "Test".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), diff --git a/crates/dpp-vault/src/domain/service/mod.rs b/crates/dpp-vault/src/domain/service/mod.rs index b15214c..adeaf77 100644 --- a/crates/dpp-vault/src/domain/service/mod.rs +++ b/crates/dpp-vault/src/domain/service/mod.rs @@ -381,7 +381,6 @@ mod snapshot_render_tests { batch_id: None, product_name: "Snapshot Test".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), diff --git a/crates/dpp-vault/src/domain/service/publish.rs b/crates/dpp-vault/src/domain/service/publish.rs index 6491656..8f220ce 100644 --- a/crates/dpp-vault/src/domain/service/publish.rs +++ b/crates/dpp-vault/src/domain/service/publish.rs @@ -203,7 +203,11 @@ impl PassportService { // here is immaterial. `public_jws_signature` is `None` here, so it is // never signed over itself; the full-payload `jws_signature` above // stays Confidential for authenticated full-passport verification. - let public_view = crate::public_view::public_view(&payload, passport.sector.catalog_key()); + let public_view = crate::public_view::public_view( + &payload, + passport.sector.catalog_key(), + &passport.schema_version, + ); let public_jws = self .identity .sign_passport(passport.id, &public_view) @@ -233,6 +237,7 @@ impl PassportService { passport.id, &payload, passport.sector.catalog_key(), + &passport.schema_version, ) .await .map_err(|e| { @@ -448,7 +453,6 @@ mod tests { batch_id: None, product_name: "Test".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), diff --git a/crates/dpp-vault/src/handlers/audience_read.rs b/crates/dpp-vault/src/handlers/audience_read.rs index 7a0561f..f63d7ae 100644 --- a/crates/dpp-vault/src/handlers/audience_read.rs +++ b/crates/dpp-vault/src/handlers/audience_read.rs @@ -213,8 +213,8 @@ async fn record_access( /// Strip the fields a given audience may not see. Exposed for tests and for the /// snapshot path; the route above is the only production caller. #[must_use] -pub fn view_for(full: &Value, sector_key: &str, audience: Audience) -> Value { - audience_view(full, sector_key, audience) +pub fn view_for(full: &Value, sector_key: &str, schema_version: &str, audience: Audience) -> Value { + audience_view(full, sector_key, schema_version, audience) } #[cfg(test)] @@ -222,6 +222,14 @@ mod tests { use super::*; use serde_json::json; + /// The battery schema version these fixtures are written against. + /// + /// A real version, not a placeholder: disclosure classes are now resolved + /// from the passport's own schema version, so an invented one resolves to no + /// policy and the fail-closed path strips `sectorData` entirely — every + /// assertion below would then pass or fail for the wrong reason. + const BATTERY_SCHEMA: &str = "2.6.0"; + /// A battery passport carrying one field of each disclosure class that /// matters: `stateOfHealthPct` is `individual` (Annex XIII point 4), /// `cathodeMaterial` is `restricted` (point 2), and `retentionLocked` is @@ -256,7 +264,12 @@ mod tests { /// Art. 77(2)(c): individual-item data goes to legitimate-interest holders. #[test] fn legitimate_interest_sees_individual_item_data() { - let v = view_for(&battery(), "battery", Audience::LegitimateInterest); + let v = view_for( + &battery(), + "battery", + BATTERY_SCHEMA, + Audience::LegitimateInterest, + ); assert!(sector_data(&v).contains_key("stateOfHealthPct")); assert!(sector_data(&v).contains_key("cathodeMaterial")); } @@ -266,7 +279,7 @@ mod tests { /// the case an ordered tier model gets wrong. #[test] fn an_authority_does_not_see_individual_item_data() { - let v = view_for(&battery(), "battery", Audience::Authority); + let v = view_for(&battery(), "battery", BATTERY_SCHEMA, Audience::Authority); assert!( !sector_data(&v).contains_key("stateOfHealthPct"), "Art. 77(2)(b) withholds point 4 from authorities" @@ -281,8 +294,13 @@ mod tests { /// Art. 77(2)(b), and explicitly withheld from legitimate interest. #[test] fn conformity_evidence_is_authority_only() { - let authority = view_for(&battery(), "battery", Audience::Authority); - let interest = view_for(&battery(), "battery", Audience::LegitimateInterest); + let authority = view_for(&battery(), "battery", BATTERY_SCHEMA, Audience::Authority); + let interest = view_for( + &battery(), + "battery", + BATTERY_SCHEMA, + Audience::LegitimateInterest, + ); assert_eq!(authority.get("retentionLocked"), Some(&json!(true))); assert!(interest.get("retentionLocked").is_none()); } @@ -303,7 +321,7 @@ mod tests { Audience::LegitimateInterest, Audience::Authority, ] { - let v = view_for(&battery(), "battery", audience); + let v = view_for(&battery(), "battery", BATTERY_SCHEMA, audience); for proof in ["publicJwsSignature", "jwsSignature", "disclosureSignatures"] { assert!( v.get(proof).is_none(), @@ -316,7 +334,7 @@ mod tests { /// The public view is the floor: neither restricted nor individual data. #[test] fn the_public_view_carries_neither() { - let v = view_for(&battery(), "battery", Audience::Public); + let v = view_for(&battery(), "battery", BATTERY_SCHEMA, Audience::Public); assert!(!sector_data(&v).contains_key("stateOfHealthPct")); assert!(!sector_data(&v).contains_key("cathodeMaterial")); assert!(v.get("jwsSignature").is_none()); @@ -335,7 +353,7 @@ mod tests { Audience::LegitimateInterest, Audience::Authority, ] { - let v = view_for(&unknown, "not-a-sector", audience); + let v = view_for(&unknown, "not-a-sector", BATTERY_SCHEMA, audience); assert!( !sector_data(&v).contains_key("secret"), "{audience:?} must not receive unmodelled sector data" diff --git a/crates/dpp-vault/src/handlers/create.rs b/crates/dpp-vault/src/handlers/create.rs index b9720d5..9101511 100644 --- a/crates/dpp-vault/src/handlers/create.rs +++ b/crates/dpp-vault/src/handlers/create.rs @@ -208,7 +208,6 @@ pub async fn create_handler( id: PassportId(Uuid::now_v7()), product_name: body.product_name, sector, - product_category: None, manufacturer: body.manufacturer, materials: body.materials.unwrap_or_default(), co2e_per_unit, @@ -370,15 +369,15 @@ mod schema_validation { //! on the write path, catching schema-only constraints the Rust types miss. use super::*; use dpp_domain::Gtin; - use dpp_domain::domain::sector::{BatteryChemistry, BatteryData}; + use dpp_domain::domain::sector::{BatteryChemistry, BatteryData, BatteryType}; fn valid_battery() -> SectorData { - SectorData::Battery(BatteryData { + SectorData::Battery(Box::new(BatteryData { gtin: Gtin::parse("09506000134352").unwrap(), battery_chemistry: BatteryChemistry::Lfp, nominal_voltage_v: 3.2, nominal_capacity_ah: 100.0, - expected_lifetime_cycles: 3000, + expected_lifetime_cycles: Some(3000), co2e_per_unit_kg: 85.4, recycled_content_cobalt_pct: None, recycled_content_lithium_pct: Some(12.5), @@ -398,7 +397,7 @@ mod schema_validation { rated_energy_wh: None, recycled_content_lead_pct: None, battery_weight_kg: None, - battery_type: None, + battery_type: BatteryType::Industrial, round_trip_efficiency_pct: None, internal_resistance_mohm: None, manufacturing_date: None, @@ -411,7 +410,41 @@ mod schema_validation { recycled_content_reporting_year: None, state_of_health: None, expected_lifetime: None, - }) + // Annex VI Part A / Annex XIII points 1-3, added in dpp-core 0.17.0. + // All optional and none of them load-bearing for what these tests + // assert, so all `None`. + battery_status: None, + capacity_threshold_for_exhaustion_pct: None, + commercial_warranty_period_months: None, + component_part_numbers: None, + cycle_life_test_c_rate: None, + dynamic_performance: None, + eu_declaration_of_conformity: None, + expected_lifetime_reference_test: None, + hazard_symbol: None, + hazardous_substances: None, + initial_round_trip_efficiency_pct: None, + internal_cell_resistance_mohm: None, + internal_pack_resistance_mohm: None, + marking_information: None, + maximum_voltage_v: None, + minimal_voltage_v: None, + not_in_use_temperature_range: None, + not_in_use_temperature_reference_test: None, + original_power_capability_w: None, + power_limit_max_w: None, + power_limit_min_w: None, + power_temperature_range: None, + renewable_content_pct: None, + round_trip_efficiency_at_half_cycle_life_pct: None, + safety_measures: None, + spare_parts_contacts: None, + test_report_results: None, + usable_extinguishing_agent: None, + usage_history: None, + voltage_temperature_range: None, + waste_battery_information: None, + })) } #[test] diff --git a/crates/dpp-vault/src/public_view.rs b/crates/dpp-vault/src/public_view.rs index a57b1e2..3b560a0 100644 --- a/crates/dpp-vault/src/public_view.rs +++ b/crates/dpp-vault/src/public_view.rs @@ -13,39 +13,43 @@ //! `dpp-domain` alongside `Audience`. Not moved yet; recorded for the next //! core breaking revision. -use std::sync::OnceLock; - use base64::Engine; use serde_json::Value; use dpp_domain::access::{SectorAccessPolicy, filter_by_audience}; use dpp_domain::domain::passport::Passport; -use dpp_domain::{Audience, DppError, SectorCatalog}; - -/// Embedded sector catalog, built once (used to resolve per-field access tiers). -fn catalog() -> &'static SectorCatalog { - static CATALOG: OnceLock = OnceLock::new(); - CATALOG.get_or_init(SectorCatalog::new) -} +use dpp_domain::{Audience, DppError}; -/// Build the public-read redaction policy for a sector: the sector-agnostic -/// passport defaults plus the sector's own per-field tiers from the catalog. -pub fn public_policy(sector_key: &str) -> SectorAccessPolicy { +/// Build the public-read redaction policy for a sector **at the schema version +/// the passport was validated against**: the sector-agnostic passport defaults +/// plus that version's own per-field tiers. +/// +/// The version is not optional and not "current". A passport's signatures are +/// frozen over the redaction that produced them, so filtering it by whatever the +/// catalog says today would apply rules that may postdate the signature — the +/// served body and its proof would then disagree for reasons no reader could +/// distinguish from tampering. Passing `passport.schema_version` is what keeps a +/// published passport filtered by the classes in force when it was signed, for +/// the life of the passport. +/// +/// `None` when the sector or version is unknown, so an unrecognised pair fails +/// closed. Callers must treat that as "serve no sector data", never as "serve it +/// unfiltered" — see [`audience_view`]. +pub fn public_policy(sector_key: &str, schema_version: &str) -> Option { + let sector_policy = SectorAccessPolicy::for_schema_version(sector_key, schema_version)?; let mut policy = SectorAccessPolicy::passport_default(); - if let Some(sector_policy) = SectorAccessPolicy::from_catalog(catalog(), sector_key) { - policy - .field_disclosure - .extend(sector_policy.field_disclosure); - } policy + .field_disclosure + .extend(sector_policy.field_disclosure); + Some(policy) } /// Redact a full passport JSON value to its **Public**-tier view — exactly what /// the public endpoint serves *and* what `publicJwsSignature` is signed over. /// `publicJwsSignature` itself is absent at signing time (the field is `None` and /// skips serialisation), so the proof never signs over itself. -pub fn public_view(full: &Value, sector_key: &str) -> Value { - audience_view(full, sector_key, Audience::Public) +pub fn public_view(full: &Value, sector_key: &str, schema_version: &str) -> Value { + audience_view(full, sector_key, schema_version, Audience::Public) } /// Redact a full passport to the view a given [`Audience`] may see. @@ -78,8 +82,20 @@ pub fn public_view(full: &Value, sector_key: &str) -> Value { /// So this function returns the payload alone, and whichever layer serves it /// attaches the one proof that covers it — [`signed_public_view`] for the public /// view, [`signed_audience_view`] for the rest. -pub fn audience_view(full: &Value, sector_key: &str, audience: Audience) -> Value { - let policy = public_policy(sector_key); +pub fn audience_view( + full: &Value, + sector_key: &str, + schema_version: &str, + audience: Audience, +) -> Value { + let resolved = public_policy(sector_key, schema_version); + // Unresolved means no sector field tiers are known, so the pass below would + // treat every `sectorData` field as public by default. That output is + // discarded for `sectorData` by the fail-closed step at the end; the passport + // defaults still apply to the top-level fields, which are version-independent. + let policy = resolved + .clone() + .unwrap_or_else(SectorAccessPolicy::passport_default); let mut view = filter_by_audience(full, &policy, audience).filtered_data; if let Some(obj) = view.as_object_mut() { @@ -93,12 +109,18 @@ pub fn audience_view(full: &Value, sector_key: &str, audience: Audience) -> Valu } } - // Fail closed for an unrecognised sector: with no catalog descriptor there is - // no field-tier policy for its `sectorData`, so the default-Public pass above - // would leak potentially professional/confidential fields. Keep only the - // `sector` tag. Parity with the resolver's RT2-5 backstop, so the signed-and- - // served view is identical whether reached directly or via the resolver. - if catalog().get(sector_key).is_none() + // Fail closed whenever the policy could not be resolved: with no field-tier + // table for its `sectorData`, the default-Public pass above would leak + // potentially professional/confidential fields. Keep only the `sector` tag. + // Parity with the resolver's backstop, so the signed-and-served view is + // identical whether reached directly or via the resolver. + // + // Keyed on the *policy*, not on whether the catalog knows the sector. Those + // were the same condition while the policy was unversioned; they are not + // any more. A known sector at an unknown schema version resolves to no + // policy, and a sector-only check would have waved it through with every + // field public. + if resolved.is_none() && let Some(obj) = view.as_object_mut() && let Some(sd) = obj.get("sectorData") && sd @@ -271,6 +293,7 @@ pub async fn sign_disclosure_views( passport_id: dpp_domain::PassportId, payload: &Value, sector_key: &str, + schema_version: &str, ) -> Result, DppError> { let mut signatures = std::collections::BTreeMap::new(); for audience in [Audience::LegitimateInterest, Audience::Authority] { @@ -278,7 +301,7 @@ pub async fn sign_disclosure_views( if signatures.contains_key(&key) { continue; } - let view = audience_view(payload, sector_key, audience); + let view = audience_view(payload, sector_key, schema_version, audience); let signed = identity.sign_passport(passport_id, &view).await?; signatures.insert(key, signed.jws); } @@ -375,7 +398,6 @@ pub(crate) mod tests { batch_id: None, product_name: "Widget".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "ACME".into(), address: "1 Street".into(), @@ -505,7 +527,7 @@ pub(crate) mod tests { "internalNotes": "trade secret" } }); - let view = public_view(&full, "totallyMadeUpSector"); + let view = public_view(&full, "totallyMadeUpSector", "2.6.0"); let sd = &view["sectorData"]; assert_eq!(sd["sector"], json!("totallyMadeUpSector")); assert!(sd.get("supplierCostEur").is_none(), "leaked: {sd}"); @@ -521,7 +543,7 @@ pub(crate) mod tests { "productName": "EcoBattery", "sectorData": { "sector": "battery", "gtin": "09506000134352" } }); - let view = public_view(&full, "battery"); + let view = public_view(&full, "battery", "2.6.0"); // A known sector is filtered by its policy, not blanket-redacted. assert_eq!(view["sectorData"]["gtin"], json!("09506000134352")); assert_eq!(view["sectorData"]["sector"], json!("battery")); diff --git a/crates/dpp-vault/tests/continuity_snapshot.rs b/crates/dpp-vault/tests/continuity_snapshot.rs index a5c6c7b..c5ff045 100644 --- a/crates/dpp-vault/tests/continuity_snapshot.rs +++ b/crates/dpp-vault/tests/continuity_snapshot.rs @@ -255,7 +255,6 @@ fn draft_passport() -> Passport { batch_id: None, product_name: "Continuity Snapshot Widget".into(), sector: Sector::Textile, - product_category: None, manufacturer: ManufacturerInfo { name: "Snapshot Test GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-vault/tests/evidence_dossier.rs b/crates/dpp-vault/tests/evidence_dossier.rs index fafab58..a40f3e3 100644 --- a/crates/dpp-vault/tests/evidence_dossier.rs +++ b/crates/dpp-vault/tests/evidence_dossier.rs @@ -278,7 +278,6 @@ fn draft_passport() -> Passport { batch_id: None, product_name: "Evidence Dossier Test Widget".into(), sector: Sector::Textile, - product_category: None, manufacturer: ManufacturerInfo { name: "Evidence Test GmbH".into(), address: "Berlin, DE".into(), diff --git a/crates/dpp-vault/tests/seal_route.rs b/crates/dpp-vault/tests/seal_route.rs index a61ba8e..a4fe12f 100644 --- a/crates/dpp-vault/tests/seal_route.rs +++ b/crates/dpp-vault/tests/seal_route.rs @@ -36,7 +36,6 @@ async fn seed(dal: &PgDal, seal: Option, jws: Option<&str>) -> P batch_id: None, product_name: "Seal Route Battery".into(), sector: Sector::Battery, - product_category: None, manufacturer: ManufacturerInfo { name: "TestCorp GmbH".into(), address: "Berlin, DE".into(), From 92d8095a1ee5f6f0813e06e86c2677f8b8cedb1f Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 05:26:19 +0200 Subject: [PATCH 2/7] docs: record the dpp-core 0.17.0 repin --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4481016..880f8b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,57 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ## [Unreleased] +### Breaking + +- **Pinned to `dpp-core` 0.17.0**, and three of its changes are visible here. + + **Battery passports stored under schema versions below v2.5.0 can no longer be + read.** `batteryType` became required and closed at v2.5.0 (EU 2023/1542 + Annex VI Part A point 2, via Annex XIII point 1(a)). A record written before + the mandate carries no such value and `dpp-domain` refuses to upgrade it rather + than inventing a regulatory classification the operator never stated — the + right call, and it means no lens can rescue those documents. The frozen-document + guard records each affected shape in `UNREADABLE_FIXTURES` with its reason; the + fixtures themselves are untouched, because a frozen document edited to make a + test pass is no longer evidence of anything. **This is only defensible while no + such document exists in any deployment.** + + **Disclosure is now resolved from the passport's own schema version.** A + published passport is filtered by the classes in force when its signature was + frozen, not by whatever the catalog says today — otherwise a later + reclassification silently changes what an already-signed passport serves, and + body and proof disagree for reasons no reader can distinguish from tampering. + `public_policy` takes the version and returns `Option`; an unknown sector *or + version* now fails closed. + + The fail-closed backstop moved with it, and this is the part worth reading + twice: it used to key on "is the sector unknown to the catalog". That was the + same condition while the policy was unversioned. It is not any more — a + **known** sector at an **unknown** version resolves to no policy, and a + sector-only check would have served every `sectorData` field publicly. It now + keys on whether the policy resolved. + + **`batteryType` is a required CSV column.** The battery import template gains + it, and a row without a recognised value is rejected naming the accepted set. + Previously an absent or misspelled value parsed to `None` and produced a + passport missing a mandatory field instead of a failed row. + +### Changed + +- **Two battery fields became public** at core's battery schema v2.6.0, on its + cited reading of Annex XIII: `criticalRawMaterials` (point 1(b), listed + alongside chemistry and hazardous substances as publicly accessible material + composition) and `dueDiligenceUrl` (point 1(d)). Both are widenings of what the + public and AAS doors emit. The cross-door masking tests name their fields + explicitly rather than re-reading the catalog, so each had to be re-checked + against the schema's stated basis rather than silently dropped. + +- **The electronics page renders a device-type label, not the wire value.** + `productCategory` became a closed `DeviceType` with kebab-case values, so + rendering it raw would have put `other-mobile-phone` in front of a consumer. + Unrecognised values pass through untouched — a token this mapping does not know + is one it cannot honestly relabel. + ### Added - **eIDAS qualified sealing, end to end** (migration `0028_seal_outbox.sql`). From 7ca901234c9d952d6b3cb687ecf366e1dbe16857 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 15:37:58 +0200 Subject: [PATCH 3/7] test: repair create bodies for core 0.17.0 --- crates/dpp-node/tests/smoke.rs | 8 ++------ crates/dpp-vault/tests/audience_read_route.rs | 2 +- crates/dpp-vault/tests/audit_history.rs | 2 +- crates/dpp-vault/tests/battery.rs | 2 +- crates/dpp-vault/tests/compliance_tests.rs | 3 +-- crates/dpp-vault/tests/cross_repo_lifecycle.rs | 5 +---- crates/dpp-vault/tests/evidence_api.rs | 2 +- crates/dpp-vault/tests/facility_operator_stamping.rs | 1 + crates/dpp-vault/tests/facility_retire.rs | 1 + crates/dpp-vault/tests/find_by_identity.rs | 2 +- crates/dpp-vault/tests/integration_test.rs | 2 +- crates/dpp-vault/tests/lint_findings.rs | 4 ++-- crates/dpp-vault/tests/operator_identifier_retire.rs | 1 + crates/dpp-vault/tests/publish_serve_cycle.rs | 2 +- crates/dpp-vault/tests/suspension.rs | 2 +- 15 files changed, 17 insertions(+), 22 deletions(-) diff --git a/crates/dpp-node/tests/smoke.rs b/crates/dpp-node/tests/smoke.rs index de5c181..ed9b8e8 100644 --- a/crates/dpp-node/tests/smoke.rs +++ b/crates/dpp-node/tests/smoke.rs @@ -412,11 +412,11 @@ async fn route_inventory_matches_assembled_router() { "productName": "Route Inventory Battery", "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, @@ -591,11 +591,11 @@ async fn publish_battery( "productName": product_name, "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": gtin, "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, @@ -868,7 +868,6 @@ async fn full_dpp_lifecycle_through_assembled_node() { "productCategory": "BATTERY", "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" })) .send() .await @@ -935,7 +934,6 @@ async fn eol_declaration_deactivates_and_records_reason() { "productCategory": "BATTERY", "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" })) .send() .await @@ -1019,7 +1017,6 @@ async fn transfer_of_responsibility_dual_signed_then_eol() { "productCategory": "BATTERY", "manufacturer": {"name": "SmokeTestCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" })) .send() .await @@ -1123,7 +1120,6 @@ async fn publish_increments_passport_publish_total() { "productCategory": "BATTERY", "manufacturer": {"name": "MetricsCorp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" })) .send() .await diff --git a/crates/dpp-vault/tests/audience_read_route.rs b/crates/dpp-vault/tests/audience_read_route.rs index 9c9ff43..d153916 100644 --- a/crates/dpp-vault/tests/audience_read_route.rs +++ b/crates/dpp-vault/tests/audience_read_route.rs @@ -150,12 +150,12 @@ async fn publish_battery(client: &TestClient) -> String { "productCategory": "BATTERY", "manufacturer": { "name": "GreenCell GmbH", "address": "Berlin, DE" }, "materials": [{ "name": "Lithium", "weightKg": 1.2 }], - "schemaVersion": "2.4.0", "batchId": "LOT-2026-07", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/audit_history.rs b/crates/dpp-vault/tests/audit_history.rs index 4820884..49fbc60 100644 --- a/crates/dpp-vault/tests/audit_history.rs +++ b/crates/dpp-vault/tests/audit_history.rs @@ -31,11 +31,11 @@ async fn lifecycle_actions_are_recorded_in_audit_history() { "productCategory": "BATTERY", "manufacturer": {"name": "Audit Inc", "address": "Berlin, DE"}, "materials": [{"name": "Nickel", "weightKg": 0.5}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NiMH", + "batteryType": "industrial", "nominalVoltageV": 12.0, "nominalCapacityAh": 40.0, "expectedLifetimeCycles": 1000, diff --git a/crates/dpp-vault/tests/battery.rs b/crates/dpp-vault/tests/battery.rs index 86034c7..4cde1f5 100644 --- a/crates/dpp-vault/tests/battery.rs +++ b/crates/dpp-vault/tests/battery.rs @@ -25,11 +25,11 @@ async fn test_battery_create_publish_resolve() { "materials": [ {"name": "Lithium Iron Phosphate", "weightKg": 1.2} ], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/compliance_tests.rs b/crates/dpp-vault/tests/compliance_tests.rs index ea8f103..f5d76d6 100644 --- a/crates/dpp-vault/tests/compliance_tests.rs +++ b/crates/dpp-vault/tests/compliance_tests.rs @@ -18,11 +18,11 @@ async fn test_passthrough_battery_stores_result() { "productCategory": "BATTERY", "manufacturer": {"name": "Compliance Inc", "address": "Test City"}, "materials": [{"name": "Lithium", "weightKg": 1.2}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NMC", + "batteryType": "industrial", "nominalVoltageV": 24.0, "nominalCapacityAh": 80.0, "expectedLifetimeCycles": 2000, @@ -62,7 +62,6 @@ async fn test_passthrough_textile_stores_result() { "productCategory": "TEXTILE", "manufacturer": {"name": "Textile Compliance Ltd", "address": "UK"}, "materials": [{"name": "Cotton", "weightKg": 0.15}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", "gtin": "09506000134352", diff --git a/crates/dpp-vault/tests/cross_repo_lifecycle.rs b/crates/dpp-vault/tests/cross_repo_lifecycle.rs index 7610794..ab5f5e2 100644 --- a/crates/dpp-vault/tests/cross_repo_lifecycle.rs +++ b/crates/dpp-vault/tests/cross_repo_lifecycle.rs @@ -48,11 +48,11 @@ async fn full_lifecycle_draft_to_archived() { {"name": "Lithium", "weightKg": 0.8, "recycledPct": 30.0, "countryOfOrigin": "CL"}, {"name": "Aluminium", "weightKg": 0.3, "recycledPct": 90.0, "countryOfOrigin": "DE"} ], - "schemaVersion": "2.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NMC", + "batteryType": "industrial", "nominalVoltageV": 3.7, "nominalCapacityAh": 50.0, "expectedLifetimeCycles": 2000, @@ -188,7 +188,6 @@ async fn domain_validation_rejects_empty_product_name() { "productCategory": "BATTERY", "manufacturer": {"name": "Test", "address": "Test"}, "materials": [], - "schemaVersion": "1.0.0" }); let resp = client.post_json("/api/v1/dpp", body).await; @@ -217,7 +216,6 @@ async fn materials_round_trip_with_optional_fields() { {"name": "Silicon", "weightKg": 0.05}, {"name": "Tin", "weightKg": 0.01, "countryOfOrigin": "ID"} ], - "schemaVersion": "1.0.0" }); let resp = client.post_json("/api/v1/dpp", body).await; @@ -257,7 +255,6 @@ async fn draft_to_suspended_rejected() { "productCategory": "OTHER", "manufacturer": {"name": "Test", "address": "Test"}, "materials": [], - "schemaVersion": "1.0.0" }), ) .await; diff --git a/crates/dpp-vault/tests/evidence_api.rs b/crates/dpp-vault/tests/evidence_api.rs index 0f7cf92..dafca2c 100644 --- a/crates/dpp-vault/tests/evidence_api.rs +++ b/crates/dpp-vault/tests/evidence_api.rs @@ -19,11 +19,11 @@ fn create_body() -> serde_json::Value { "productCategory": "BATTERY", "manufacturer": {"name": "Evidence API Inc", "address": "Berlin, DE"}, "materials": [{"name": "Nickel", "weightKg": 0.5}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NiMH", + "batteryType": "industrial", "nominalVoltageV": 12.0, "nominalCapacityAh": 40.0, "expectedLifetimeCycles": 1000, diff --git a/crates/dpp-vault/tests/facility_operator_stamping.rs b/crates/dpp-vault/tests/facility_operator_stamping.rs index 356be81..d88bd78 100644 --- a/crates/dpp-vault/tests/facility_operator_stamping.rs +++ b/crates/dpp-vault/tests/facility_operator_stamping.rs @@ -59,6 +59,7 @@ async fn api_created_facility_and_operator_id_are_stamped_on_create() { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/facility_retire.rs b/crates/dpp-vault/tests/facility_retire.rs index d1203aa..66a4b26 100644 --- a/crates/dpp-vault/tests/facility_retire.rs +++ b/crates/dpp-vault/tests/facility_retire.rs @@ -47,6 +47,7 @@ async fn retiring_a_facility_preserves_passport_provenance_and_audits() { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/find_by_identity.rs b/crates/dpp-vault/tests/find_by_identity.rs index 717f4c8..e9b9595 100644 --- a/crates/dpp-vault/tests/find_by_identity.rs +++ b/crates/dpp-vault/tests/find_by_identity.rs @@ -27,11 +27,11 @@ fn battery_passport(gtin: &str) -> serde_json::Value { "materials": [ {"name": "Lithium Iron Phosphate", "weightKg": 1.2} ], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": gtin, "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/integration_test.rs b/crates/dpp-vault/tests/integration_test.rs index 7a9076f..8743ada 100644 --- a/crates/dpp-vault/tests/integration_test.rs +++ b/crates/dpp-vault/tests/integration_test.rs @@ -30,12 +30,12 @@ fn sample_passport() -> serde_json::Value { "materials": [ {"name": "Lithium", "weightKg": 0.8} ], - "schemaVersion": "1.0.0", // Publish-time validation (1.3) requires valid sector data. "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NiMH", + "batteryType": "industrial", "nominalVoltageV": 12.0, "nominalCapacityAh": 40.0, "expectedLifetimeCycles": 1000, diff --git a/crates/dpp-vault/tests/lint_findings.rs b/crates/dpp-vault/tests/lint_findings.rs index 5b14d1d..d0d4e8a 100644 --- a/crates/dpp-vault/tests/lint_findings.rs +++ b/crates/dpp-vault/tests/lint_findings.rs @@ -25,11 +25,11 @@ async fn lint_findings_surface_and_never_block_publish() { "productCategory": "BATTERY", "manufacturer": {"name": "Lint Inc", "address": "Test City"}, "materials": [{"name": "Lithium", "weightKg": 1.2}], - "schemaVersion": "2.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 3.7, "nominalCapacityAh": 10.0, "expectedLifetimeCycles": 500, @@ -117,11 +117,11 @@ async fn clean_sector_data_produces_no_findings() { "productCategory": "BATTERY", "manufacturer": {"name": "Clean Inc", "address": "Test City"}, "materials": [{"name": "Lithium", "weightKg": 1.2}], - "schemaVersion": "2.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 3.7, "nominalCapacityAh": 10.0, "expectedLifetimeCycles": 500, diff --git a/crates/dpp-vault/tests/operator_identifier_retire.rs b/crates/dpp-vault/tests/operator_identifier_retire.rs index 65ede63..e07894e 100644 --- a/crates/dpp-vault/tests/operator_identifier_retire.rs +++ b/crates/dpp-vault/tests/operator_identifier_retire.rs @@ -46,6 +46,7 @@ async fn retiring_an_operator_identifier_preserves_passport_provenance_and_audit "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/publish_serve_cycle.rs b/crates/dpp-vault/tests/publish_serve_cycle.rs index 5a4f138..51f3f51 100644 --- a/crates/dpp-vault/tests/publish_serve_cycle.rs +++ b/crates/dpp-vault/tests/publish_serve_cycle.rs @@ -74,12 +74,12 @@ async fn published_passport_is_served_as_the_payload_its_proof_signed() { "productCategory": "BATTERY", "manufacturer": { "name": "GreenCell GmbH", "address": "Berlin, DE" }, "materials": [{ "name": "Lithium", "weightKg": 1.2 }], - "schemaVersion": "1.0.0", "batchId": "LOT-2026-07", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, diff --git a/crates/dpp-vault/tests/suspension.rs b/crates/dpp-vault/tests/suspension.rs index 782161f..cd255c7 100644 --- a/crates/dpp-vault/tests/suspension.rs +++ b/crates/dpp-vault/tests/suspension.rs @@ -20,11 +20,11 @@ async fn test_suspension_flow() { "productCategory": "BATTERY", "manufacturer": {"name": "Suspend Inc", "address": "Test"}, "materials": [{"name": "Nickel", "weightKg": 0.5}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "NiMH", + "batteryType": "industrial", "nominalVoltageV": 12.0, "nominalCapacityAh": 40.0, "expectedLifetimeCycles": 1000, From 229555924eaa941ba60d471e42559cdfb885d795 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 15:38:25 +0200 Subject: [PATCH 4/7] docs(api): drop productCategory from the spec --- CHANGELOG.md | 8 ++++++++ api/openapi.yaml | 26 -------------------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 880f8b1..28e0abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,14 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): Unrecognised values pass through untouched — a token this mapping does not know is one it cannot honestly relabel. +- **`productCategory` is gone from the documented passport response.** Core + removed `Passport.product_category` in 0.17.0, so the API spec described a + field the response no longer carries. No wire change: the field was + `skip_serializing_if = "Option::is_none"` and every write path set it to + `None`, so the key was never emitted — only the spec claimed otherwise. The + sector-level `productCategory` inside `sectorData` (steel, electronics) is a + different field and is untouched. + ### Added - **eIDAS qualified sealing, end to end** (migration `0028_seal_outbox.sql`). diff --git a/api/openapi.yaml b/api/openapi.yaml index 06ab00e..cf4dbdf 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -93,30 +93,6 @@ components: description: UUID v7 identifier assigned on creation. Embedded in QR codes and public URLs. example: "019723f4-1a2b-7c3d-8e4f-5a6b7c8d9e0f" - ProductCategory: - description: | - Fine-grained product category (a data attribute — the dispatch key is - `sector`). Known categories serialise as snake_case strings; any other - category serialises as `{ "other": "" }`. - oneOf: - - type: string - enum: - - ev_battery - - industrial_battery - - lmt_battery - - apparel - - footwear - - home_textile - - smartphone - - laptop - - charger - - type: object - required: [other] - properties: - other: - type: string - example: "ev_battery" - PassportStatus: type: string enum: [draft, active, suspended, archived] @@ -333,8 +309,6 @@ components: productName: type: string example: "EcoCell Pro 48V" - productCategory: - $ref: "#/components/schemas/ProductCategory" manufacturer: $ref: "#/components/schemas/ManufacturerInfo" materials: From 21044a65d7a0bfedce5978f6faac632b8ac236d6 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 15:38:33 +0200 Subject: [PATCH 5/7] fix(vault): refuse a caller-chosen schemaVersion --- CHANGELOG.md | 18 ++++ api/openapi.yaml | 16 ++- crates/dpp-vault/src/handlers/create.rs | 38 ++++++- crates/dpp-vault/src/public_view.rs | 41 +++++++ crates/dpp-vault/tests/auth_tests.rs | 3 - crates/dpp-vault/tests/error_paths.rs | 1 - crates/dpp-vault/tests/list_and_search.rs | 2 - crates/dpp-vault/tests/textile.rs | 1 - crates/dpp-vault/tests/validation_failures.rs | 102 +++++++++++++++++- 9 files changed, 203 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e0abc..ca92287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,24 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Changed +- **`POST /vault/api/v1/dpp` refuses a `schemaVersion` that is not the sector's + current one**, with `422`. Omitting it is unchanged, and remains the normal + case. + + Previously the field was accepted and silently discarded: the handler resolved + it, and `PassportService::create` then overwrote it from the catalog on + persist. So no caller-supplied version ever reached the database — but nothing + said so, and a client sending `"1.0.0"` had no way to learn it got `2.6.0`. + + That overwrite is also, as of this release, the only thing standing between a + caller and the disclosure table its passport is served under, which the same + release makes version-dependent (above). An older table classifies fewer + fields and `SectorAccessPolicy` defaults the rest to public — battery v1.0.0 + annotates 11 against v2.6.0's 68, so a passport filtered at v1.0.0 would serve + `stateOfHealth` and thirteen others publicly. That hazard is pinned by + `an_older_schema_version_widens_the_public_view`. Refusing the mismatch at the + edge means the guarantee no longer rests on a single line in the service. + - **Two battery fields became public** at core's battery schema v2.6.0, on its cited reading of Annex XIII: `criticalRawMaterials` (point 1(b), listed alongside chemistry and hazardous substances as publicly accessible material diff --git a/api/openapi.yaml b/api/openapi.yaml index cf4dbdf..2b887d5 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -218,10 +218,18 @@ components: example: "BATCH-2026-04-001" schemaVersion: type: string - description: >- - Sector schema version. When omitted, the sector's current version is - used (e.g. battery 2.0.0); a supplied value is honoured. - example: "1.0.0" + description: | + Sector schema version. Optional, and the only accepted value is the + sector's **current** version — omitting it is equivalent. Any other + value is rejected with `422`. + + It is not the caller's to choose: the stored version selects the + disclosure table the passport's public view is filtered through and + signed under, and an older table classifies fewer fields, defaulting + the rest to public. The body is validated against the current schema + in either case, so a differing declaration is already false about the + body it accompanies. + example: "2.6.0" parentPassportRef: $ref: "#/components/schemas/PassportRef" componentRefs: diff --git a/crates/dpp-vault/src/handlers/create.rs b/crates/dpp-vault/src/handlers/create.rs index 9101511..7cd4619 100644 --- a/crates/dpp-vault/src/handlers/create.rs +++ b/crates/dpp-vault/src/handlers/create.rs @@ -168,11 +168,43 @@ pub async fn create_handler( .or_else(|| body.sector_data.as_ref().map(|d| d.sector())) .unwrap_or_else(|| Sector::Other("other".to_owned())); - // Resolve the sector's current schema version (the service re-normalises this - // on persist); never silently down-version to a hardcoded "1.0.0". + // A new passport is written at the sector's current schema version, and only + // that one. Never silently down-version to a hardcoded "1.0.0". + // + // `PassportService::create` already overwrites this from the catalog on + // persist, so a caller-supplied value never reached the database — it was + // computed here and discarded. Two reasons that is not good enough to leave + // alone. It is dishonest: the request was accepted, so the caller has no way + // to learn its declaration was ignored. And the service's one assignment is + // now the only thing standing between a caller and the disclosure table its + // passport is served under — the stored version selects that table, and an + // older one classifies fewer fields while defaulting the rest to `Public` + // (battery v1.0.0 annotates 11 against v2.6.0's 68). Refusing a mismatch + // here means two independent things must go wrong, not one. + // + // The body is validated against the *current* schema regardless — see + // `validate_against_schema` — so a differing declaration is not a variant the + // server could honour anyway; it is a claim about the body that is already + // false. let schema_version = catalog() - .resolve_schema_version(sector.catalog_key(), body.schema_version.as_deref()) + .resolve_schema_version(sector.catalog_key(), None) .unwrap_or_else(|| "1.0.0".into()); + if let Some(requested) = body.schema_version.as_deref() + && requested != schema_version + { + return api_error( + StatusCode::UNPROCESSABLE_ENTITY, + "VALIDATION_ERROR", + &format!( + "schemaVersion must be `{schema_version}` for sector `{}` (or omitted); \ + `{requested}` was requested. A new passport is always written at the \ + sector's current schema version — the stored version selects the \ + disclosure table its public view is signed under, so it is not the \ + caller's to choose.", + sector.catalog_key() + ), + ); + } // If co2e_per_unit not supplied at the top level, derive it from the // typed sector data so callers don't have to duplicate the value. diff --git a/crates/dpp-vault/src/public_view.rs b/crates/dpp-vault/src/public_view.rs index 3b560a0..10b21fc 100644 --- a/crates/dpp-vault/src/public_view.rs +++ b/crates/dpp-vault/src/public_view.rs @@ -385,6 +385,47 @@ pub(crate) mod tests { assert!(signed_public_view(&passport).is_err()); } + /// An older schema version discloses *more*, not less — which is why the + /// stored `schemaVersion` must never be a value the caller chose. + /// + /// A version's disclosure table only classifies the fields that version + /// annotates, and `SectorAccessPolicy` defaults everything else to `Public`. + /// Battery v1.0.0 annotates 11 fields; v2.6.0 annotates 68. So a passport + /// filtered at v1.0.0 serves publicly every field the newer table holds + /// back — `stateOfHealth` among them, the field of a past disclosure defect. + /// + /// This is correct for *reading an old row*: that document really was signed + /// under the old table, and re-filtering it under today's would break its + /// proof. It is a hazard only where a **new** passport's version could be + /// picked by its author — which `PassportService::create` prevents by + /// overwriting it from the catalog, and `create_handler` now refuses outright + /// rather than leaving that the only thing that has to hold. + #[test] + fn an_older_schema_version_widens_the_public_view() { + let full = json!({ + "id": dpp_domain::domain::passport::PassportId::new().to_string(), + "productName": "Cell", + "sectorData": { + "sector": "battery", + "stateOfHealth": { "remainingCapacityPct": 98.2 }, + }, + }); + + let current = public_view(&full, "battery", "2.6.0"); + assert!( + current["sectorData"].get("stateOfHealth").is_none(), + "stateOfHealth is Individual at v2.6.0 and must not be public" + ); + + let downgraded = public_view(&full, "battery", "1.0.0"); + assert!( + downgraded["sectorData"].get("stateOfHealth").is_some(), + "expected the older table to expose it — if this now fails, core has \ + backfilled v1.0.0's annotations and the create-side check that \ + depends on this hazard should be re-read, not deleted" + ); + } + /// Minimal published passport. `pub(crate)` because the seal service's /// tests need the same fixture and duplicating it would let the two drift. pub(crate) fn stub_passport() -> Passport { diff --git a/crates/dpp-vault/tests/auth_tests.rs b/crates/dpp-vault/tests/auth_tests.rs index 81c0c43..e809c0a 100644 --- a/crates/dpp-vault/tests/auth_tests.rs +++ b/crates/dpp-vault/tests/auth_tests.rs @@ -18,7 +18,6 @@ async fn test_jwt_valid() { "productCategory": "BATTERY", "manufacturer": {"name": "Auth Inc", "address": "Auth City"}, "materials": [{"name": "Steel", "weightKg": 1.0}], - "schemaVersion": "1.0.0" }); let resp = client.post_json("/api/v1/dpp", body).await; @@ -41,7 +40,6 @@ async fn test_jwt_expired() { "productCategory": "BATTERY", "manufacturer": {"name": "Exp Inc", "address": "Exp City"}, "materials": [{"name": "Copper", "weightKg": 0.5}], - "schemaVersion": "1.0.0" }); let resp = client.post_json("/api/v1/dpp", body).await; @@ -59,7 +57,6 @@ async fn test_no_auth() { "productCategory": "BATTERY", "manufacturer": {"name": "NoAuth Inc", "address": "NoAuth City"}, "materials": [{"name": "Aluminum", "weightKg": 2.0}], - "schemaVersion": "1.0.0" }); let resp = client.post_no_auth("/api/v1/dpp", body).await; diff --git a/crates/dpp-vault/tests/error_paths.rs b/crates/dpp-vault/tests/error_paths.rs index 8e92468..814ba26 100644 --- a/crates/dpp-vault/tests/error_paths.rs +++ b/crates/dpp-vault/tests/error_paths.rs @@ -15,7 +15,6 @@ fn sample() -> serde_json::Value { "productName": "Test Widget", "manufacturer": {"name": "ACME Corp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" }) } diff --git a/crates/dpp-vault/tests/list_and_search.rs b/crates/dpp-vault/tests/list_and_search.rs index 8be75a5..8ee6cef 100644 --- a/crates/dpp-vault/tests/list_and_search.rs +++ b/crates/dpp-vault/tests/list_and_search.rs @@ -12,7 +12,6 @@ fn sample() -> serde_json::Value { "productName": "Widget Pro", "manufacturer": {"name": "ACME Corp", "address": "Berlin, DE"}, "materials": [], - "schemaVersion": "1.0.0" }) } @@ -90,7 +89,6 @@ async fn list_with_search_query_hits_endpoint() { "productName": "Xenon Laser Module", "manufacturer": {"name": "RayTech", "address": "Hamburg, DE"}, "materials": [], - "schemaVersion": "1.0.0" }), ) .await; diff --git a/crates/dpp-vault/tests/textile.rs b/crates/dpp-vault/tests/textile.rs index 195830c..78e0053 100644 --- a/crates/dpp-vault/tests/textile.rs +++ b/crates/dpp-vault/tests/textile.rs @@ -25,7 +25,6 @@ async fn test_textile_create_publish_resolve() { "materials": [ {"name": "Organic Cotton", "weightKg": 0.2} ], - "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", "gtin": "09506000134352", diff --git a/crates/dpp-vault/tests/validation_failures.rs b/crates/dpp-vault/tests/validation_failures.rs index 8d27a24..b7fdd54 100644 --- a/crates/dpp-vault/tests/validation_failures.rs +++ b/crates/dpp-vault/tests/validation_failures.rs @@ -25,11 +25,11 @@ async fn test_battery_invalid_gtin() { "productCategory": "BATTERY", "manufacturer": {"name": "Test Inc", "address": "Test City"}, "materials": [{"name": "Lithium", "weightKg": 1.0}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "battery", "gtin": "123", "batteryChemistry": "Li-ion", + "batteryType": "industrial", "nominalVoltageV": 12.0, "nominalCapacityAh": 40.0, "expectedLifetimeCycles": 1000, @@ -59,7 +59,6 @@ async fn test_textile_fibre_sum_invalid() { "productCategory": "TEXTILE", "manufacturer": {"name": "BadTextile Inc", "address": "Test"}, "materials": [{"name": "Cotton", "weightKg": 0.1}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", "gtin": "09506000134352", @@ -97,7 +96,6 @@ async fn test_negative_co2e_rejected() { "productName": "Battery", "manufacturer": {"name": "Test", "address": "Test City"}, "materials": [], - "schemaVersion": "1.0.0", "co2ePerUnit": -1.5 }), ) @@ -120,7 +118,6 @@ async fn test_repairability_score_above_100_rejected() { "productName": "Product", "manufacturer": {"name": "Test", "address": "Test City"}, "materials": [], - "schemaVersion": "1.0.0", "repairabilityScore": 150.0 }), ) @@ -146,7 +143,6 @@ async fn test_textile_empty_care_instructions() { "productCategory": "TEXTILE", "manufacturer": {"name": "Textile Co", "address": "Test"}, "materials": [{"name": "Cotton", "weightKg": 0.2}], - "schemaVersion": "1.0.0", "sectorData": { "sector": "textile", "gtin": "09506000134352", @@ -171,3 +167,99 @@ async fn test_textile_empty_care_instructions() { "Error should mention care instructions: {message}" ); } + +/// A caller may not name the schema version its passport is written at. +/// +/// `PassportService::create` already overwrites it from the catalog, so this is +/// not closing a reachable hole — it is refusing to accept a request whose +/// declaration the server has no intention of honouring, and keeping the +/// guarantee off a single assignment in the service. The stored version selects +/// the disclosure table the public view is filtered through and signed under, +/// and an older table classifies fewer fields while defaulting the rest to +/// `Public`. The body is validated against the current schema either way, so a +/// differing declaration is already false about the body it accompanies. +#[tokio::test(flavor = "multi_thread")] +async fn test_battery_rejects_a_caller_chosen_schema_version() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt("00000000-0000-0000-0000-000000000009"); + let client = TestClient::new(&vault_url, &token); + + // A body that is valid against the *current* battery schema, declaring an + // older version whose disclosure table is wider. + let body = serde_json::json!({ + "productName": "Down-declared Battery", + "manufacturer": {"name": "Test Inc", "address": "Test City"}, + "materials": [{"name": "Lithium", "weightKg": 1.0}], + "schemaVersion": "1.0.0", + "sectorData": { + "sector": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "batteryType": "industrial", + "nominalVoltageV": 12.0, + "nominalCapacityAh": 40.0, + "co2ePerUnitKg": 30.0, + "stateOfHealth": { + "parameterSet": "stationaryOrLmt", + "remainingCapacityPct": 98.2, + "selfDischargeRatePctPerMonth": 1.4 + } + } + }); + + let resp = client.post_json("/api/v1/dpp", body).await; + assert_eq!( + resp.status(), + 422, + "a caller-chosen schemaVersion must be refused, not recorded" + ); + let message = resp.text().await.unwrap_or_default().to_lowercase(); + assert!( + message.contains("schemaversion"), + "the refusal must name the offending field: {message}" + ); +} + +/// The current version may be stated explicitly — it agrees with what the server +/// would write anyway, so there is nothing to refuse. Pins that the check +/// rejects *disagreement*, not the presence of the field. +#[tokio::test(flavor = "multi_thread")] +async fn test_battery_accepts_the_current_schema_version() { + let pg = start_postgres().await; + let vault_url = start_vault(pg.dal.clone()).await; + let token = make_jwt("00000000-0000-0000-0000-00000000000c"); + let client = TestClient::new(&vault_url, &token); + + // Read the version from the catalog rather than pinning a literal: this test + // is about the check's *rule*, and hardcoding would turn every future battery + // schema bump into a failure here that says nothing about the rule. + let current = dpp_domain::catalog::SectorCatalog::new() + .current_schema_version("battery") + .expect("battery is a catalog sector") + .to_owned(); + + let body = serde_json::json!({ + "productName": "Current-version Battery", + "manufacturer": {"name": "Test Inc", "address": "Test City"}, + "materials": [{"name": "Lithium", "weightKg": 1.0}], + "schemaVersion": current, + "sectorData": { + "sector": "battery", + "gtin": "09506000134352", + "batteryChemistry": "LFP", + "batteryType": "industrial", + "nominalVoltageV": 12.0, + "nominalCapacityAh": 40.0, + "co2ePerUnitKg": 30.0 + } + }); + + let resp = client.post_json("/api/v1/dpp", body).await; + assert_eq!( + resp.status(), + 201, + "the sector's current version is not a disagreement: {}", + resp.text().await.unwrap_or_default() + ); +} From 2818291415058c14c8ed3d781414cb7f41ad9dee Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 15:56:24 +0200 Subject: [PATCH 6/7] test: give the governance battery body a batteryType --- crates/dpp-vault/tests/registry_identity_governance.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/dpp-vault/tests/registry_identity_governance.rs b/crates/dpp-vault/tests/registry_identity_governance.rs index 86219a1..a2f3851 100644 --- a/crates/dpp-vault/tests/registry_identity_governance.rs +++ b/crates/dpp-vault/tests/registry_identity_governance.rs @@ -19,6 +19,7 @@ fn battery_body() -> serde_json::Value { "sector": "battery", "gtin": "09506000134352", "batteryChemistry": "LFP", + "batteryType": "industrial", "nominalVoltageV": 48.0, "nominalCapacityAh": 100.0, "expectedLifetimeCycles": 3000, From f0d151b9ec0e918fbb41811f8ad49c39cf51d434 Mon Sep 17 00:00:00 2001 From: LKSNDRTMLKV Date: Fri, 14 Aug 2026 18:55:32 +0200 Subject: [PATCH 7/7] docs: record the two 0.17.0 rules left unadopted --- CHANGELOG.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca92287..d57aed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,11 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Breaking -- **Pinned to `dpp-core` 0.17.0**, and three of its changes are visible here. +- **Pinned to `dpp-core` 0.17.0.** Three of its changes are visible here, and + **two of its new refusals are not reached by this engine at all** — see the + note at the end of this entry. The count is stated because "we adopted what + the release changed" and "we adopted the parts that touched our types" must + not look the same from the changelog. **Battery passports stored under schema versions below v2.5.0 can no longer be read.** `batteryType` became required and closed at v2.5.0 (EU 2023/1542 @@ -45,6 +49,28 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): Previously an absent or misspelled value parsed to `None` and produced a passport missing a mandatory field instead of a failed row. + **Two of 0.17.0's new refusals are unreached by this release, and stay + unreached after it.** Both are compliance gates core added, and both live on + methods this engine does not call. + + `Passport::transition_to(Published)` refuses a first publish when a battery + omits content the Battery Regulation makes mandatory for its category. Publish + here checks `PassportStatus::can_transition_to` — the state machine on the + status enum — and then sets `status`, `published_at` and `retention_locked` + itself, so the gate never runs. For `ev`, `lmt` and `industrial` that is + roughly twenty mandatory fields against the six a passport can publish with + today. `Passport::validate()` is likewise never called, so the same release's + requirement that an unsold-goods passport carry an in-scope `commodity_code` + agreeing with its `productCategory` is also unreachable. + + Neither is a regression — both refusals are new in 0.17.0 and were never + enforced here. But a repin that adopts a release's types while leaving its + rules inert is a partial adoption, and saying so is the difference between a + known gap and a silent one. Tracked in #110; not fixed here, because routing + publish through `transition_to` changes what an operator can publish and fails + every battery test until its fixture carries the full set — which is the point, + and is its own change. + ### Changed - **`POST /vault/api/v1/dpp` refuses a `schemaVersion` that is not the sector's