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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 71 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,14 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for
industrial battery with no meaningful cycle figure unrepresentable. The
constraint belongs in a category-conditional rule, not in the schema.

`BatteryData` also gains three fields (below) and is not `#[non_exhaustive]`,
so struct-literal construction must be updated. The `v2.5.0 → v2.6.0` lens is
an identity: a relaxation plus optional additions cannot strand a record that
already validated, which is why this hop passes through where the `v2.4.0`
one refuses.
`BatteryData` is not `#[non_exhaustive]`, so **every** struct literal
constructing it must be updated. Across this release the type goes from 37
fields to 68 — the Annex XIII point 1, 2, 3 and 4 tiers, plus the four Annex
VI Part A identity fields — and none of the 37 is removed. Struct-update
syntax (`..base`) is unaffected; exhaustive literals are not. The
`v2.5.0 → v2.6.0` lens is an identity: a relaxation plus optional additions
cannot strand a record that already validated, which is why this hop passes
through where the `v2.4.0` one refuses.

- **`BatteryData.battery_type` is required and closed.** Annex VI Part A
point 2, made public by Annex XIII point 1(a), lists the battery category as
Expand Down Expand Up @@ -428,6 +431,51 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for
EU regulation changes; this changes when GS1 or IDTA publishes, which is why
it sits apart from the passport model rather than inside it.

### Changed

- **The battery AAS submodel projects 17 fields it previously dropped.** What an
AAS consumer receives for a battery changes accordingly. Added: the four Annex
VI Part A identity fields, the two carbon-footprint ruleset provenance fields,
`usableExtinguishingAgent`, `hazardSymbol`, `hazardousSubstances`, the two
reference-test conditions, `commercialWarrantyPeriodMonths`,
`recycledContentReportingYear`, `placedOnMarketDate`, and the three
temperature ranges (each a collection of `minC`/`maxC`, since the two numbers
mean nothing apart).

`carbonFootprintClass` is the one that was actively wrong rather than merely
absent: the mapper emitted the bare label while the type's own doc comment
says it is "meaningless without the two provenance fields — the same label
denotes different thresholds under different revisions of the scale". An
exported class with no ruleset is not a weaker claim than a qualified one, it
is an unfalsifiable one. The class and its ruleset now travel together.

Ten schema properties still do not project, and the mapper now says why for
each: seven are documents, contacts or time series that a technical-data
snapshot deliberately excludes; `expectedLifetime` and `stateOfHealth` are a
recorded gap rather than a decision.

- **`carbonFootprintClass` and `dueDiligenceUrl` are barred from a battery
passport for now.** The Commission's guidance marks both *"not to be
filled/displayed as of February 2027 — format still to be specified in the
upcoming implementing act"*, and the per-category table had no row for either.
Both are now `NotApplicable` for every covered category, the same class
`ratedCapacityAh` already carried for the same words. Advisory rather than
blocking: the publish gate reads only the mandatory set, so a passport
carrying one is flagged, not refused.

Not to be confused with disclosure. `dueDiligenceUrl` is Annex XIII point 1(d)
and therefore *public* once it is filled; whether a field may be seen and
whether it may be filled are different axes, and this release changes both in
opposite directions for the same field.

- **All 71 guidance data points are now accounted for.** The per-category table
previously stated that the difference between its length and the guidance's 71
rows was unclassified. Each row has since been walked against the model and the
module records where every one went: envelope fields, one-row-per-block
groupings for the point 4 tiers, rows the guidance defers, and rows restated
elsewhere in the annex. Four omissions found and closed — see the Breaking
entry on the identity data points.

### Fixed

- **Battery schema `v2.6.0` now declares four Annex VI Part A fields the type
Expand Down Expand Up @@ -476,6 +524,24 @@ This file was started retroactively on 2026-07-03 at v0.4.0; entries for
version from the catalog instead of naming one, so it cannot quietly end up
asserting against a superseded schema.

The same guard now exists for textile, the other product group with a real
model rather than a stub. A mechanical struct-versus-schema sweep across all
ten sectors found battery the only one that had drifted; the other nine were
at exact parity.

- **A misspelt `x-disclosure` class published the field it was meant to
withhold.** `SectorAccessPolicy::from_schema` matches four known tokens and
drops anything else, so `"restrcted"` produced no map entry and the field fell
through to the `Public` default. One transposed character was enough, and the
coverage tests could not catch it because they asserted only that *some*
string was present.

Both coverage tests now validate the token against the four the constructor
accepts, so a typo fails the build rather than the audience check. Raising the
default instead does not work: the policy applies to the whole document, and
the passport envelope's public fields are declared in no sector schema, so a
non-public default would erase them.

## [0.16.0] - 2026-08-07

### Added
Expand Down
17 changes: 11 additions & 6 deletions crates/dpp-domain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,25 @@ use dpp_domain::catalog::SectorCatalog;
use dpp_domain::Audience;
use serde_json::json;

// Sector metadata is data, not code: regime, status, retention and per-field
// disclosure all come from the catalog manifests.
// Sector metadata is data, not code: regime, status and retention all come
// from the catalog manifests.
let catalog = SectorCatalog::new();
let battery = catalog.get("battery").expect("battery is in the catalog");
assert_eq!(battery.key, "battery");

// The disclosure classes declared in that manifest are what the filter applies.
let policy = SectorAccessPolicy::from_catalog(&catalog, "battery")
.expect("battery declares a disclosure policy");
// Disclosure classes come from the schema *version* a passport declares,
// not from one unversioned map. That is what lets a published passport keep
// the classification its signatures were taken under: reclassifying a field
// is a new schema version, and an older passport goes on being filtered by
// the version it was validated against.
let policy = SectorAccessPolicy::for_schema_version("battery", &battery.current_schema_version)
.expect("the current battery schema classifies every property");

let full = json!({ "productName": "EcoCell", "stateOfHealthPct": 87.5 });
let public = filter_by_audience(&full, &policy, Audience::Public);

// State of health is per-item data — withheld from the public audience.
// State of health is Annex XIII point 4 — data about one individual battery,
// reserved to holders of a legitimate interest and withheld from the public.
assert_eq!(public.filtered_data["productName"], "EcoCell");
assert!(public.filtered_data.get("stateOfHealthPct").is_none());
```
Expand Down
97 changes: 56 additions & 41 deletions docs/architecture/DATA-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

This document defines the canonical data structures for all Digital Product Passports. It is the authoritative reference for domain types in `dpp-domain`, JSON Schema fields, and Verifiable Credential payloads.

> **Last updated**: 2026-05-29 (aligned to actual `Passport` struct, `BatteryData` v2.0.0, `TextileData` v1.1.0)
> **Last updated**: 2026-08-13 (aligned to `BatteryData` v2.6.0, `TextileData` v1.2.0,
> `ElectronicsData` v1.2.0). Field-level detail lives in the types and schemas;
> this document describes shape and rules, not every column.

---

Expand Down Expand Up @@ -99,7 +101,7 @@ Elements of `Passport.materials` — bill of materials entries.
|---|---|---|
| `battery` | `battery_type` | Battery Reg. 2023/1542 Art. 1(3) — closed, five categories, required |
| `steel` | `product_category` | `"flat"` / `"long"` / … |
| `electronics` | `product_category` | `"smartphone"` / `"laptop"` / |
| `electronics` | `product_category` | `"smartphone"` / `"other-mobile-phone"` / `"cordless-phone"` / `"tablet"` — closed, Reg. (EU) 2023/1670 Art. 1(1) |
| `unsold-goods` | `product_category` | `"apparel"` / `"footwear"` / … |
| `furniture` | `product_type` | — |
| `tyre` | `tyre_class` | `"C1"` / … |
Expand All @@ -119,54 +121,64 @@ Sector-specific data is stored in `SectorData`, a tagged enum. Each variant has

**Serde**: `SectorData` uses `rename_all = "camelCase"` with internally-tagged format.

### 4.1 Battery Sector (`BatteryData`) — v2.0.0
### 4.1 Battery Sector (`BatteryData`) — v2.6.0

Source: EU Battery Regulation (EU) 2023/1542, Annex XIII. Battery DPP mandatory from 18 Feb 2027.
Source: EU Battery Regulation (EU) 2023/1542. Battery DPP mandatory from
18 Feb 2027.

**Required fields** (6):
**This section deliberately does not list every field.** `BatteryData` carries
68, and a hand-maintained copy of that list is the same drift that let four
Annex VI Part A fields sit in the type for seven schema versions with no schema
property to validate against. The authoritative pair is:

| Field | Rust Type | JSON name | Reg. Source |
|---|---|---|---|
| `gtin` | `String` (14 digits) | `"gtin"` | GS1 / ESPR |
| `battery_chemistry` | `String` | `"batteryChemistry"` | Art. 13(1)(a) |
| `nominal_voltage_v` | `f64` | `"nominalVoltageV"` | Art. 13(1)(b) |
| `nominal_capacity_ah` | `f64` | `"nominalCapacityAh"` | Art. 13(1)(c) |
| `expected_lifetime_cycles` | `u32` | `"expectedLifetimeCycles"` | Art. 10(1) |
| `co2e_per_unit_kg` | `f64` | `"co2ePerUnitKg"` | Art. 7(1) |
- `crates/dpp-domain/src/domain/sector/data/battery.rs` — the type, with a
per-field regulatory citation on each doc comment.
- `crates/dpp-domain/schemas/battery/v2.6.0.json` — the wire contract, with an
`x-disclosure` class on every property. `additionalProperties` is `false`, and
a test asserts the two agree field-for-field.

**Required** (6, and the only ones a passport cannot omit at any category):
`gtin`, `batteryChemistry`, `nominalVoltageV`, `nominalCapacityAh`,
`co2ePerUnitKg`, `batteryType`. Everything else is `Option` — not laxity, but
because the obligations are **per category**: a field mandatory for an
electric-vehicle battery may be "not to be filled/displayed" for an LMT one.
That constraint lives in `dpp_rules::batteries::passport_content`, which the
publish gate enforces, rather than in the schema.

**Optional fields** (21 — all `Option`, `skip_serializing_if = "Option::is_none"`):
**Shape.** The fields group by the Annex XIII tier that governs who may see
them, which is also what `x-disclosure` records:

| Field | Rust Type | JSON name | Reg. Source |
| Tier | Annex XIII | Audience | Examples |
|---|---|---|---|
| `recycled_content_cobalt_pct` | `Option<f64>` | `"recycledContentCobaltPct"` | Art. 8 |
| `recycled_content_lithium_pct` | `Option<f64>` | `"recycledContentLithiumPct"` | Art. 8 |
| `recycled_content_nickel_pct` | `Option<f64>` | `"recycledContentNickelPct"` | Art. 8 |
| `recycled_content_lead_pct` | `Option<f64>` | `"recycledContentLeadPct"` | Art. 8 (lead-acid) |
| `state_of_health_pct` | `Option<f64>` | `"stateOfHealthPct"` | Art. 14 |
| `rated_capacity_kwh` | `Option<f64>` | `"ratedCapacityKwh"` | Art. 13(1)(d) |
| `rated_energy_wh` | `Option<f64>` | `"ratedEnergyWh"` | Art. 13(1)(d) |
| `carbon_footprint_class` | `Option<String>` | `"carbonFootprintClass"` | Art. 7(2) — A–E |
| `due_diligence_url` | `Option<String>` | `"dueDiligenceUrl"` | Art. 47-52 |
| `cathode_material` | `Option<Vec<MaterialComposition>>` | `"cathodeMaterial"` | Annex XIII §4 |
| `anode_material` | `Option<Vec<MaterialComposition>>` | `"anodeMaterial"` | Annex XIII §4 |
| `electrolyte_material` | `Option<Vec<MaterialComposition>>` | `"electrolyteMaterial"` | Annex XIII §4 |
| `critical_raw_materials` | `Option<Vec<CriticalRawMaterial>>` | `"criticalRawMaterials"` | EU CRM Act 2024/1252 |
| `disassembly_instructions_url` | `Option<String>` | `"disassemblyInstructionsUrl"` | Annex XIII §6 |
| `soh_methodology` | `Option<String>` | `"sohMethodology"` | Art. 14(2) |
| `operating_temp_min_c` | `Option<f64>` | `"operatingTempMinC"` | Annex XIII |
| `operating_temp_max_c` | `Option<f64>` | `"operatingTempMaxC"` | Annex XIII |
| `battery_weight_kg` | `Option<f64>` | `"batteryWeightKg"` | Annex XIII |
| `battery_type` | `String` (required since v2.5.0) | `"batteryType"` | Art. 1(3), closed enum: portable, industrial, ev, lmt, starting-lighting-ignition — mandatory public content (Annex VI Part A pt 2 via Annex XIII pt 1(a)) |
| `round_trip_efficiency_pct` | `Option<f64>` | `"roundTripEfficiencyPct"` | Art. 10 — at 50% SoC |
| `internal_resistance_mohm` | `Option<f64>` | `"internalResistanceMohm"` | Art. 10 — at 50% SoC |
| `public` | point 1, incl. Annex VI Part A via 1(a) | anyone | chemistry, voltages, recycled content, place and date of manufacture |
| `restricted` | point 2 | authorities **and** legitimate interest | cathode/anode/electrolyte composition, dismantling, safety measures |
| `conformity` | point 3 | authorities only | test report results |
| `individual` | point 4 | legitimate interest only | measured performance, state of health, status, use history |

Point 4 is per **item** rather than per model, so those fields nest into
`DynamicPerformance`, `StateOfHealth`, `UsageHistory` and `ExpectedLifetime`
rather than flattening — a declared model figure and a measured one are
different claims and must not sit side by side under near-identical names.

**Legacy fields.** `state_of_health_pct`, `round_trip_efficiency_pct` and
`internal_resistance_mohm` are superseded but retained: a stored record keeps
its value under the name it was written with. See the type's own doc comment
for the rule and for why deletion is reserved for the cases where keeping the
field is itself the defect.

**Helper types**:
- `MaterialComposition { name: String, weight_pct: f64, cas_number: Option<String> }`
- `CriticalRawMaterial { name: String, cas_number: Option<String>, weight_grams: Option<f64>, country_of_origin: Option<String> }`
- `MaterialComposition { name, weight_pct, cas_number }`
- `CriticalRawMaterial { name, cas_number, weight_grams, country_of_origin }`
- `HazardousSubstance { name, cas_number, concentration_pct }`
- `TemperatureRange { min_c, max_c }`

Schemas: `schemas/battery/v{1.0.0, 2.0.0 … 2.6.0}.json`. Older versions stay
registered so a passport validated against one remains verifiable, and each
carries its own disclosure classes — which is what stops a reclassification
changing the bytes served for an already-published passport.

Schema: `schemas/battery/v2.0.0.json`

### 4.2 Textile Sector (`TextileData`) — v1.1.0
### 4.2 Textile Sector (`TextileData`) — v1.2.0

Source: ESPR Working Group on Textiles. Delegated act adoption anticipated ~Q2 2027, compliance ~2028–2029.

Expand Down Expand Up @@ -246,4 +258,7 @@ Schemas follow semver. The `VersionedSchemaRegistry` in `dpp-domain` discovers a
| Minor (`1.x.0`) | New optional fields; provisional -> strict | Yes |
| Major (`x.0.0`) | Field renamed, type changed, or removed | No |

Current schemas: battery/v2.0.0, textile/v1.0.0, textile/v1.1.0, unsold-goods/v1.0.0, steel/v1.0.0.
Current schemas: 28 embedded versions across 11 sectors — see
`crates/dpp-domain/src/schemas/embedded.rs` for the registered list. Every one
is reachable at runtime; a passport is validated against the version it
declares, not against the newest.
2 changes: 1 addition & 1 deletion docs/architecture/PLUGIN-HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ All ten plugins run on the SDK (`dpp-plugin-sdk` + `export_plugin!`):

| Plugin | Sector | Schema | ABI |
|---|---|---|---|
| `sector-battery` | battery | `schemas/battery/v{1.0.0,2.0.0}.json` | SDK (`DppSectorPlugin`) |
| `sector-battery` | battery | `schemas/battery/v{1.0.0, 2.0.0 … 2.6.0}.json` | SDK (`DppSectorPlugin`) |
| `sector-textile` | textile, unsold-goods | `schemas/textile/*`, `unsold-goods/*` | SDK (`DppSectorPlugin`) |
| `sector-steel` | steel | `schemas/steel/v1.0.0.json` | SDK (`DppSectorPlugin`) |
| `sector-electronics`, `-construction`, `-tyre`, `-toy`, `-aluminium`, `-furniture`, `-detergent` | resp. | `schemas/{sector}/v1.0.0.json` | SDK (`DppSectorPlugin`) |
Expand Down
16 changes: 11 additions & 5 deletions docs/governance/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,17 @@ order:
2. `dpp-plugin-traits` (no workspace dependencies)
3. `dpp-crypto` (no workspace dependencies)
4. `dpp-calc` (no workspace dependencies)
5. `dpp-domain` (depends on dpp-rules)
6. `dpp-registry` (depends on dpp-domain)
7. `dpp-digital-link`, `dpp-aas` (depend on dpp-domain)
8. `dpp-vc` (depends on dpp-domain + dpp-crypto)
9. `dpp-plugin-sdk` (depends on dpp-plugin-traits + dpp-rules)
5. `dpp-vocab` (no workspace dependencies — a leaf, by design)
6. `dpp-domain` (depends on dpp-rules)
7. `dpp-registry` (depends on dpp-domain)
8. `dpp-digital-link` (depends on dpp-domain)
9. `dpp-aas` (depends on dpp-domain + dpp-vocab)
10. `dpp-vc` (depends on dpp-domain + dpp-crypto)
11. `dpp-plugin-sdk` (depends on dpp-plugin-traits + dpp-rules)

`dpp-vocab` is new in 0.17.0 and has never been published. It has no baseline on
crates.io, which is why `just semver` excludes it — delete that exclusion once
this release has shipped it once.

`dpp-tests` is `publish = false` and is not published, but it is still lockstep-
versioned with everything else. `cargo-release` handles publish ordering
Expand Down
3 changes: 2 additions & 1 deletion docs/regulatory/COMPLIANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Passports mandated by:
| Regulation | Reference | Status | dpp-core Coverage |
|---|---|---|---|
| ESPR | Regulation (EU) 2024/1781 | In force | Core passport model, per-audience field disclosure, schema validation |
| Battery Regulation | Regulation (EU) 2023/1542 | In force, DPP deadline Feb 2027 | Battery schemas (v1.0.0, v2.0.0), sector plugin |
| Battery Regulation | Regulation (EU) 2023/1542 | In force, DPP deadline Feb 2027 | Battery schemas (v1.0.0, v2.0.0 … v2.6.0), sector plugin, per-category content rules |
| Textile (anticipated) | ESPR delegated act (draft) | See timeline note below | Textile schemas (v1.0.0, v1.1.0), sector plugin |
| CBAM | Regulation (EU) 2023/956 | In force | Steel schema (v1.0.0), embedded emissions fields |

Expand Down Expand Up @@ -40,6 +40,7 @@ Each JSON schema under `schemas/` is traceable to its regulatory basis:
|---|---|---|---|
| `battery/v1.0.0.json` | 2023/1542 | Art. 77, Annex XIII | Minimum viable battery passport |
| `battery/v2.0.0.json` | 2023/1542 | Art. 77, Annex XIII | Extended fields for carbon footprint |
| `battery/v2.6.0.json` | 2023/1542 | Annex VI Part A, Annex XIII points 1–4 | Current. All four disclosure tiers, per-property `x-disclosure` |
| `textile/v1.0.0.json` | ESPR 2024/1781 | Art. 9-10 (framework) | Baseline textile passport |
| `textile/v1.1.0.json` | ESPR 2024/1781 | Art. 9-10 + anticipated delegated act | Adds fibre composition, durability, microplastics |
| `unsold-goods/v1.0.0.json` | ESPR 2024/1781 | Art. 25 / Annex VII | Unsold-goods destruction-ban compliance |
Expand Down
5 changes: 4 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ audit:
# position, so the tool waives every lint. Measured on this workspace at
# 0.16.0 -> 0.17.0: **0 checks run, 253 skipped, "no semver update required"** —
# green because it stopped looking. With `--release-type patch` the same tree
# runs 223 checks and reports all five real removals.
# runs 223 checks per crate and reports every real break — seven of them on the
# merged 0.17.0 tree, across five lints: a removed const, enum, enum variant,
# module and struct field, plus a newly `#[deprecated]` method and the fields
# `BatteryData` gained (breaking for exhaustive struct literals).
#
# So this answers "what broke since the last published release", and gives the
# same answer before and after a version bump. A failure is not a defect by
Expand Down