1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
//! The [`Passport`] aggregate root.
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{
FacilitySnapshot, ManufacturerInfo, MaterialEntry, PassportId, PassportRef, PassportView,
ProductCategory,
};
use crate::domain::{
identity::{Audience, Disclosure, PASSPORT_FIELD_DISCLOSURE},
lint::LintResult,
sector::{CarbonFootprint, RepairabilityScore, Sector, SectorData},
status::PassportStatus,
};
use crate::ports::compliance::ComplianceResult;
use crate::ports::seal::SealedEnvelope;
/// The canonical Digital Product Passport record as defined by EU ESPR.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Passport {
pub id: PassportId,
/// Optional batch or lot identifier.
pub batch_id: Option<String>,
pub product_name: String,
/// EU ESPR sector — the delegated-act bucket that selects the applicable
/// schema and plugin. (Replaces the former misnamed `product_category`
/// field, which actually held a sector.)
pub sector: Sector,
/// Optional typed product category — a sub-type *within* `sector`
/// (e.g. `Smartphone`, `EvBattery`). Never a dispatch key. See DATA-MODEL §3.5.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_category: Option<ProductCategory>,
pub manufacturer: ManufacturerInfo,
pub materials: Vec<MaterialEntry>,
/// CO₂ equivalent per unit — manufacturer-supplied or engine-calculated.
pub co2e_per_unit: Option<CarbonFootprint>,
/// Repairability score (non-regulatory heuristic — not EN 45554 / EU 2023/1669).
pub repairability_score: Option<RepairabilityScore>,
/// The computed compliance determination — status, metrics, binding
/// `violations` + advisory `warnings`, and (when a calculation ran) a
/// receipt. Attached by the engine's `apply_compliance` at create/update.
/// Part of the signed payload and immutable after retention lock. `None`
/// until a determination is computed (e.g. a sector with no plugin loaded).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compliance_result: Option<ComplianceResult>,
/// Non-binding plausibility findings from the `dpp-rules` lint pack —
/// arithmetic and physical-plausibility checks distinct from binding
/// compliance rules. Never gates publish and may be recomputed at any
/// time after publish (a lint re-check), unlike `compliance_result` —
/// see the vault's `POST /dpp/{id}/lint` endpoint. `None` until a lint
/// pass has run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lint_result: Option<LintResult>,
/// Typed, sector-specific DPP data (EU Battery Regulation, Textile DPP, etc.).
///
/// `None` for passports where sector-specific data has not yet been supplied.
/// Set this field when publishing to ensure regulatory compliance validation.
pub sector_data: Option<SectorData>,
pub status: PassportStatus,
/// The publicly accessible QR code URL for this passport.
pub qr_code_url: Option<String>,
/// Compact JWS signature over the **full** canonical passport payload
/// (`Disclosure::Conformity` — for authenticated, full-passport verification).
pub jws_signature: Option<String>,
/// Compact JWS signature over the **public (redacted) view** of this passport
/// (`Disclosure::Public`). Lets anyone verify the public passport independently — the
/// resolver checks this on the unauthenticated `/public/dpp/{id}` route.
/// Set at publish time; `None` for drafts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_jws_signature: Option<String>,
/// Compact JWS signatures over the **non-public** redacted views, keyed by
/// [`disclosure_key`](crate::domain::identity::disclosure_key) — e.g.
/// `public+restricted+individual`.
///
/// Every audience that receives more than the public view needs a proof over
/// *its* view: `public_jws_signature` covers only the public payload and
/// `jws_signature` only the full one, so a reader given a filtered body and
/// either of those holds a signature that cannot verify against the bytes it
/// received. A repairer or recycler making a safety or resale call on the
/// data is precisely the caller who must be able to check it.
///
/// **Keyed by disclosure set, never by audience name.** ESPR's actor
/// vocabulary is not battery Art. 77(2)'s three audiences, and the delegated
/// act mapping actors to data is unadopted. An artefact named for the data it
/// covers survives that mapping arriving; one named `"legitimateInterest"`
/// would need every passport re-signed.
///
/// A `BTreeMap` so serialisation is key-ordered and the signed bytes are
/// reproducible. Frozen at publish alongside its two siblings, and empty for
/// drafts.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub disclosure_signatures: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
/// Semantic version of the *sector* schema used to validate this record.
///
/// Scoped to `sector_data` only — there is no equivalent version for the
/// envelope fields on this struct. [`Passport::from_stored`] uses this to
/// decide whether `sector_data` needs upcasting through a lens before
/// this record can be re-read. Envelope fields have no such escape hatch
/// and never will: a lens transforms one sector's sub-object, but an
/// envelope field is shared by every sector's stored documents, so a
/// non-additive envelope change would need a transform over the whole
/// document — one mistake there corrupts every sector at once, not one.
/// The envelope's rule is therefore additive-only, permanently, with no
/// exception path: `Option<T>` + `#[serde(default)]`, or a rename that
/// keeps accepting the old key, never a bare requirement added to an
/// existing field.
pub schema_version: String,
/// Set to `true` permanently on first publish; never unset thereafter.
///
/// Retention-locked passports must remain publicly accessible for the period
/// defined in the applicable EU ESPR delegated act (typically 10–15 years after
/// the product's end of life).
#[serde(default)]
pub retention_locked: bool,
// ── 0.2 data-model fields ──────────────────────────────────────────────
/// Monotonically increasing version counter. `1` on first publish; increments
/// each time a new passport version supersedes this one (set on the successor).
#[serde(default = "default_version")]
pub version: u32,
/// The passport ID this record supersedes. `None` for first-version passports.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes_id: Option<PassportId>,
/// Cross-operator reference to the predecessor this passport derives from
/// (second-life successor linkage). `None` unless this record was issued as a
/// successor citing a source passport held by another operator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_passport_ref: Option<PassportRef>,
/// Cross-operator references to the constituent passports this product is
/// assembled from — its bill of materials. Empty for a unit with no modelled
/// sub-assemblies. The inverse edge of `parent_passport_ref`: `component_refs`
/// point down to many constituents, `parent_passport_ref` points up to one
/// predecessor.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub component_refs: Vec<PassportRef>,
/// Deadline by which this record must remain accessible. Confirmed against the
/// verbatim OJ text (Regulation (EU) 2024/1781): **Art. 9(2)(i)** requires the
/// delegated act to specify "the period during which the digital product
/// passport is to remain available, which shall correspond to at least the
/// expected lifetime of a specific product"; **Art. 11(e)** restates this as an
/// essential requirement, available "including after an insolvency, a
/// liquidation or a cessation of activity" of the responsible operator. The
/// separate back-up-copy obligation (via a DPP service provider) is **Art.
/// 10(4)**, not the retention period itself.
/// Computed at publish time from `SectorCatalog::retention_years` for the
/// sector — the single source of the retention obligation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_until: Option<DateTime<Utc>>,
/// Opaque link to an internal product-template record. Not a legal identifier.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_id: Option<Uuid>,
/// Customs tariff classification (HS-6, CN-8 or TARIC-10).
///
/// Registration data the EU registry stores and verifies against the ranges
/// its product group permits. `None` where the product group does not call
/// for one — the regulation qualifies it "where relevant" — and a registry
/// that requires it will refuse the registration rather than this node
/// inventing a classification it cannot derive.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commodity_code: Option<crate::domain::commodity_code::CommodityCode>,
/// EORI or national economic-operator identifier for the responsible party.
/// Confirmed against the verbatim OJ text (Regulation (EU) 2024/1781):
/// **Annex III, point (k)** is the data-content basis — "the name, contact
/// details and unique operator identifier of the economic operator established
/// in the Union responsible for carrying out the tasks set out in Article 4 of
/// Regulation (EU) 2019/1020 [...]"; the identifier-issuance mechanics are
/// **Art. 12**. (**Art. 13** governs uploading identifiers to the EU registry —
/// a related but distinct obligation, not the field's basis.) Populated by the
/// engine from `operator_config`.
///
/// **This is the operator that published the passport, frozen at publish —
/// not necessarily the operator responsible for it now.** A transfer of
/// responsibility moves the current operator, and the authoritative record
/// of that is the passport's [`TransferChain`](crate::domain::transfer::TransferChain)
/// via `current_operator()`. This field is not rewritten by a transfer and
/// cannot be: a published passport's content is immutable and this value is
/// covered by the signature over it. Reading it as "who is responsible
/// today" is wrong for any passport that has changed hands.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator_identifier: Option<String>,
/// Snapshot of the Annex III facility where this product was manufactured or
/// processed, copied by value at create time. Self-contained so the signed
/// passport stays a complete record independent of the operator's mutable
/// facility registry (a retired facility never orphans a published passport).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub facility: Option<FacilitySnapshot>,
/// The eIDAS qualified electronic seal applied to this passport, if any.
/// `placeholder: true` on the envelope means no legally valid seal exists yet —
/// consumers must check this flag rather than inferring validity from presence.
/// `None` until a seal (real or placeholder) has been applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seal: Option<SealedEnvelope>,
}
fn default_version() -> u32 {
1
}
/// The catalog sector key and recorded schema version a stored document's
/// `sectorData` was written under, read without assuming the document
/// deserializes into the current shape. `None` if either is absent or
/// malformed — [`Passport::from_stored`] then skips upcasting and lets the
/// final deserialize surface whatever is actually wrong.
fn stored_sector_version(doc: &serde_json::Value) -> Option<(String, String)> {
let sector_data = doc.get("sectorData")?;
let tag = sector_data.get("sector")?.as_str()?;
let sector_key = Sector::from_wire_tag(tag).catalog_key().to_owned();
let recorded = doc.get("schemaVersion")?.as_str()?.to_owned();
Some((sector_key, recorded))
}
impl Passport {
/// Deserialize a passport as it was actually stored. Tries the direct,
/// current-shape deserialize first — most schema evolution is additive
/// and a document written under an older `schemaVersion` reads directly
/// with no transform needed, exactly as before this method existed. Only
/// on failure does it fall back to upcasting `sectorData` through the
/// registered lens chain and retrying, so a version gap that needs no
/// lens (the common case) never pays for one.
///
/// The fallback upcasts as far toward the sector's current version as the
/// registered lenses reach ([`crate::schemas::lens::LensRegistry::upcast_toward`]),
/// not to a chain landing on it exactly. A sector whose schema has moved on
/// additively since its last lens has no hop ending at the current version,
/// and requiring one would refuse every document the lenses it *does* have
/// would have made readable. The additive remainder needs no transform by
/// definition, so the deserialize below closes it.
///
/// **Envelope fields (everything outside `sectorData`) are not lensed —
/// deliberately, not an oversight.** A lens transforms one sector's
/// sub-object; an envelope field is shared by every sector's documents, so
/// a non-additive envelope change would need a transform over the *whole*
/// document, and getting that wrong silently corrupts every sector at
/// once rather than one. The envelope's compatibility rule is simpler and
/// stricter instead: additive only, permanently, no exceptions — see
/// [`Passport::schema_version`]'s doc comment. A stored document that
/// still fails to deserialize after its `sectorData` has been upcast is
/// therefore either genuinely malformed or violates that rule, and this
/// method does not try to guess which.
///
/// Two distinct failure shapes, both typed rather than a generic error:
/// - [`crate::domain::error::DppError::SchemaIncompatible`] — the recorded `schemaVersion` is
/// older than current and no registered lens bridges any of the gap.
/// This is not always fixable by writing one: a required field the
/// document predates (no source data anywhere in the document to derive
/// it from) has no honest transform, and this crate will not synthesize
/// one.
/// - [`crate::domain::error::DppError::Serialisation`] — the direct attempt failed for a reason
/// unrelated to a bridgeable version gap (no sector data, sector
/// unknown to the catalog, already at the current version, or the
/// upcast document still does not match the current shape).
pub fn from_stored(
doc: serde_json::Value,
lenses: &crate::schemas::lens::LensRegistry,
catalog: &crate::catalog::SectorCatalog,
) -> Result<Self, crate::domain::error::DppError> {
use crate::domain::error::DppError;
use serde::Deserialize as _;
let direct_err = match Self::deserialize(&doc) {
Ok(passport) => return Ok(passport),
Err(e) => e,
};
let Some((sector_key, recorded)) = stored_sector_version(&doc) else {
return Err(DppError::Serialisation(direct_err.to_string()));
};
let Some(current) = catalog.current_schema_version(§or_key) else {
return Err(DppError::Serialisation(direct_err.to_string()));
};
if recorded == current {
return Err(DppError::Serialisation(direct_err.to_string()));
}
let sector_data = doc["sectorData"].clone();
let derived = lenses.upcast_str_toward(§or_key, §or_data, &recorded, current)?;
let mut doc = doc;
doc["sectorData"] = derived.data;
serde_json::from_value(doc).map_err(|e| DppError::Serialisation(e.to_string()))
}
/// Validate passport fields for structural correctness and sector-data integrity.
///
/// Checks:
/// - `product_name` is non-empty
/// - `manufacturer.name` is non-empty
/// - `manufacturer.address` is non-empty
/// - `schema_version` follows semver pattern (x.y.z)
/// - `co2e_per_unit` is non-negative if present
/// - `repairability_score` is in range [0.0, 10.0] if present
/// - `sector_data.sector()` matches `self.sector` if present
/// - `sector_data` passes JSON Schema + cross-field rules via
/// [`crate::domain::validation::validate_sector_data`] (non-wasm32 only)
pub fn validate(&self) -> Result<(), crate::domain::error::DppError> {
use crate::domain::field_error::{FieldError, ValidationErrors};
let mut errors: Vec<FieldError> = Vec::new();
if self.product_name.trim().is_empty() {
errors.push(FieldError {
field: "/productName".to_owned(),
message: "product_name must not be empty".to_owned(),
});
}
if self.manufacturer.name.trim().is_empty() {
errors.push(FieldError {
field: "/manufacturer/name".to_owned(),
message: "manufacturer.name must not be empty".to_owned(),
});
}
if self.manufacturer.address.trim().is_empty() {
errors.push(FieldError {
field: "/manufacturer/address".to_owned(),
message: "manufacturer.address must not be empty".to_owned(),
});
}
// Must parse as strict semver (major.minor.patch, optional pre-release
// / build metadata). A hand-rolled digit check let ".5.0" (empty major)
// and "1.0.abc" (non-numeric patch) through — both then fail
// `semver::Version` parsing at schema resolution and silently skip
// schema validation, so reject them here rather than downstream.
if self.schema_version.parse::<semver::Version>().is_err() {
errors.push(FieldError {
field: "/schemaVersion".to_owned(),
message: "schema_version must be valid semver (e.g. 1.0.0)".to_owned(),
});
}
if let Some(ref cf) = self.co2e_per_unit
&& cf.value_kg < 0.0
{
errors.push(FieldError {
field: "/co2ePerUnit".to_owned(),
message: "co2e_per_unit must not be negative".to_owned(),
});
}
if let Some(ref rs) = self.repairability_score
&& !(0.0..=10.0).contains(&rs.overall)
{
errors.push(FieldError {
field: "/repairabilityScore".to_owned(),
message: "repairability_score must be between 0.0 and 10.0".to_owned(),
});
}
// The declared sector must match the sector of the typed data, if present.
if let Some(ref data) = self.sector_data
&& data.sector() != self.sector
{
errors.push(FieldError {
field: "/sector".to_owned(),
message: "sector must match sector_data's sector".to_owned(),
});
}
// Sector-data validation: JSON Schema + cross-field rules (fibre sum, SVHC, etc.).
// Excluded from wasm32 builds because jsonschema depends on reqwest's blocking API.
#[cfg(not(target_arch = "wasm32"))]
if let Some(ref data) = self.sector_data
&& let Err(ve) = crate::domain::validation::validate_sector_data(data)
{
errors.extend(ve.errors);
}
if errors.is_empty() {
Ok(())
} else {
Err(crate::domain::error::DppError::Validation(
ValidationErrors { errors },
))
}
}
/// Transition the passport to a new status, enforcing the state machine.
///
/// Valid transitions:
/// ```text
/// Draft → Published | Archived
/// Published → Suspended | Archived
/// Suspended → Published | Archived
/// ```
///
/// On the first `Draft → Published` transition this method also:
/// - Sets `retention_locked = true` (ESPR retention obligation).
/// - Sets `published_at` to the current timestamp.
/// - Updates `updated_at`.
pub fn transition_to(
&mut self,
next: PassportStatus,
) -> Result<(), crate::domain::error::DppError> {
if !self.status.can_transition_to(&next) {
return Err(crate::domain::error::DppError::InvalidTransition {
current: self.status.to_string(),
required: next.to_string(),
});
}
let now = chrono::Utc::now();
// First publish: lock retention and record the timestamp.
if next == PassportStatus::Published && self.published_at.is_none() {
self.retention_locked = true;
self.published_at = Some(now);
}
self.status = next;
self.updated_at = now;
Ok(())
}
/// Return an audience-filtered JSON view of this passport.
///
/// NOTE: this is a self-contained domain convenience, **not** the authoritative
/// public view. The payload that is signed (`publicJwsSignature`) and served on
/// the public route is produced by the `dpp-crypto` policy engine (vault
/// `public_view`), which fails closed on unknown sectors. Do not wire this into
/// the public-serving path expecting byte-parity with the signed view.
///
/// Top-level fields are removed according to
/// [`PASSPORT_FIELD_DISCLOSURE`]
/// — the single source for this fact, shared with the crypto layer's
/// `SectorAccessPolicy::passport_default()`. At present:
/// - `Restricted`: `batchId`, `lintResult`
/// - `Conformity`: `jwsSignature`, `retentionLocked`
///
/// Anything absent from that table is `Public`.
///
/// `sectorData`, when present, is independently redacted via
/// [`crate::domain::sector::redact_sector_data`] against the sector descriptor
/// from `catalog`. If the sector is not in the catalog, sector data is
/// **withheld** from every audience except `Authority` (fail-closed): without
/// the descriptor's per-field disclosure classes the domain layer cannot tell
/// which fields are safe to expose, so it exposes none.
pub fn redact(
&self,
audience: Audience,
catalog: &crate::catalog::SectorCatalog,
) -> PassportView {
let mut value = match serde_json::to_value(self) {
Ok(v) => v,
Err(_) => return PassportView(serde_json::Value::Null),
};
if let Some(obj) = value.as_object_mut() {
// Driven by the shared table, not a second hand-written list —
// the two used to disagree about `lintResult`.
for (field, class) in PASSPORT_FIELD_DISCLOSURE {
if !audience.may_see(*class) {
obj.remove(*field);
}
}
// Re-redact sectorData using the catalog's per-field disclosure map.
if let Some(ref sd) = self.sector_data {
let sector = sd.sector();
let key = sector.catalog_key();
let redacted = if let Some(descriptor) = catalog.get(key) {
crate::domain::sector::redact_sector_data(sd, audience, descriptor)
} else if audience.may_see(Disclosure::Conformity) {
// Unknown sector: the full payload is only safe for the
// audience that already sees conformity evidence.
serde_json::to_value(sd).unwrap_or(serde_json::Value::Null)
} else {
// Fail closed: without per-field classes we cannot tell
// which fields are restricted, so withhold sector data
// entirely rather than leak it.
serde_json::Value::Null
};
obj.insert("sectorData".into(), redacted);
} else {
obj.remove("sectorData");
}
}
PassportView(value)
}
}