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
//! The [`Passport`] aggregate root.
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>,
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.
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 the catalog `retention_years` for the sector.
#[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>,
/// 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`.
#[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
}
impl Passport {
/// 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 key = sd.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)
}
}