dpp-domain 0.5.0

EU Digital Product Passport domain types, port traits, and open-core boundary
Documentation
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
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
//! Open, data-driven catalog of EU ESPR sectors.
//!
//! The catalog is the single source of truth for *what sectors exist* and
//! *where each stands in the EU regulatory pipeline*. Unlike a closed `enum`,
//! sectors are described by **data** — one embedded manifest per sector at
//! `dpp-core/crates/dpp-domain/sectors/{key}.json` — and new sectors can be
//! added at runtime via [`SectorCatalog::register`] without recompiling core.
//!
//! Each [`SectorDescriptor`] ties together a sector's canonical key, regulatory
//! status, legal basis, schema versions, retention, product categories, and
//! plugin binding — resolving the "four spellings of a sector" problem by
//! giving every component one record to agree on.
//!
//! [`RegulatoryStatus`] gates behaviour: only `InForce` sectors may carry a
//! binding compliance determination. `Provisional` sectors (on the ESPR working
//! plan but without an adopted delegated act) are present but **flagged** —
//! their schemas are best-effort drafts and plugins must not assert
//! COMPLIANT/NON_COMPLIANT.

use serde::{Deserialize, Serialize};

// ─── Embedded manifests ───────────────────────────────────────────────────────

struct EmbeddedManifest {
    key: &'static str,
    json: &'static str,
}

/// One manifest per sector. Adding a sector at compile time is a single entry +
/// a JSON file; adding one at runtime is [`SectorCatalog::register`].
const EMBEDDED: &[EmbeddedManifest] = &[
    EmbeddedManifest {
        key: "battery",
        json: include_str!("../../sectors/battery.json"),
    },
    EmbeddedManifest {
        key: "electronics",
        json: include_str!("../../sectors/electronics.json"),
    },
    EmbeddedManifest {
        key: "unsold-goods",
        json: include_str!("../../sectors/unsold-goods.json"),
    },
    EmbeddedManifest {
        key: "textile",
        json: include_str!("../../sectors/textile.json"),
    },
    EmbeddedManifest {
        key: "steel",
        json: include_str!("../../sectors/steel.json"),
    },
    EmbeddedManifest {
        key: "construction",
        json: include_str!("../../sectors/construction.json"),
    },
    EmbeddedManifest {
        key: "tyre",
        json: include_str!("../../sectors/tyre.json"),
    },
    EmbeddedManifest {
        key: "toy",
        json: include_str!("../../sectors/toy.json"),
    },
    EmbeddedManifest {
        key: "aluminium",
        json: include_str!("../../sectors/aluminium.json"),
    },
    EmbeddedManifest {
        key: "furniture",
        json: include_str!("../../sectors/furniture.json"),
    },
    EmbeddedManifest {
        key: "detergent",
        json: include_str!("../../sectors/detergent.json"),
    },
];

// ─── Regulatory status ────────────────────────────────────────────────────────

/// Where a sector's DPP obligation stands in the EU regulatory pipeline.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RegulatoryStatus {
    /// A DPP / ecodesign obligation is legally in force, or has a firm adopted
    /// applicability date. Plugins may emit binding compliance determinations.
    InForce,
    /// On the ESPR working plan, or a delegated act is anticipated, but no DPP
    /// obligation is in force yet. Schemas are best-effort drafts; plugins must
    /// not assert COMPLIANT/NON_COMPLIANT — only structural validation applies.
    Provisional,
}

impl RegulatoryStatus {
    /// Whether a sector with this status may carry a *binding* compliance
    /// determination (vs. structural validation only).
    #[must_use]
    pub fn allows_determination(&self) -> bool {
        matches!(self, Self::InForce)
    }
}

// ─── Sector descriptor ────────────────────────────────────────────────────────

/// A single sector's catalog entry — the canonical record every component
/// (schema registry, plugin host, passport model) resolves against.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SectorDescriptor {
    /// Canonical sector key, e.g. `"battery"`, `"unsold-goods"`. Matches the
    /// schema-registry sector key and the plugin's `meta().sector`.
    pub key: String,
    /// Human-readable title.
    pub title: String,
    /// Regulatory status — gates whether determinations are binding.
    pub status: RegulatoryStatus,
    /// EU legal instrument(s) this sector derives from.
    pub legal_basis: Vec<String>,
    /// ISO-8601 date the DPP obligation applies from, when known.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dpp_applies_from: Option<String>,
    /// Minimum data retention in years required by the applicable act.
    pub retention_years: u32,
    /// Schema versions available for this sector (semver strings).
    pub schema_versions: Vec<String>,
    /// The schema version applicable to *new* passports in this sector right
    /// now. Decouples "current" from "latest embedded" so a future schema can
    /// ship embedded without becoming current until its act is in force. Must
    /// be one of `schema_versions`.
    pub current_schema_version: String,
    /// Product categories *within* this sector — sub-types a plugin may branch
    /// on, never dispatch keys. See `DATA-MODEL.md` §3.5.
    #[serde(default)]
    pub product_categories: Vec<String>,
    /// Per-field minimum ESPR access tier (public/professional/confidential) for
    /// this sector's data: field name → tier; unlisted fields default to public.
    /// Universal confidential fields (signatures, audit trails) are folded in by
    /// the access-policy engine, so they are not repeated per sector here.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub access_tiers: std::collections::HashMap<String, crate::domain::identity::AccessTier>,
    /// Plugin that handles this sector (crate / filename stem, e.g.
    /// `"sector-battery"`). `None` if no plugin is bound yet.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plugin: Option<String>,
    /// Free-text regulatory note (effective dates, scope, caveats).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
}

// ─── Errors ───────────────────────────────────────────────────────────────────

/// Errors from runtime catalog registration.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CatalogError {
    /// A descriptor for this key already exists.
    AlreadyExists(String),
}

impl std::fmt::Display for CatalogError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::AlreadyExists(key) => write!(f, "sector '{key}' already in catalog"),
        }
    }
}

impl std::error::Error for CatalogError {}

// ─── Catalog ──────────────────────────────────────────────────────────────────

/// Open, data-driven sector catalog. Pre-loaded with embedded manifests and
/// extensible at runtime.
pub struct SectorCatalog {
    entries: Vec<SectorDescriptor>,
}

impl SectorCatalog {
    /// Create a catalog pre-loaded with all embedded sector manifests.
    #[must_use]
    pub fn new() -> Self {
        let entries = EMBEDDED
            .iter()
            .map(|m| {
                let descriptor: SectorDescriptor =
                    serde_json::from_str(m.json).unwrap_or_else(|e| {
                        panic!("embedded sector manifest '{}' is invalid: {e}", m.key)
                    });
                assert_eq!(
                    descriptor.key, m.key,
                    "manifest key '{}' does not match its file key '{}'",
                    descriptor.key, m.key
                );
                descriptor
            })
            .collect();
        Self { entries }
    }

    /// Look up a sector by canonical key.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&SectorDescriptor> {
        self.entries.iter().find(|d| d.key == key)
    }

    /// All sector descriptors.
    #[must_use]
    pub fn all(&self) -> &[SectorDescriptor] {
        &self.entries
    }

    /// Sectors whose DPP obligation is in force (determinations are binding).
    #[must_use]
    pub fn in_force(&self) -> Vec<&SectorDescriptor> {
        self.entries
            .iter()
            .filter(|d| d.status == RegulatoryStatus::InForce)
            .collect()
    }

    /// Sectors that are flagged provisional (no binding determinations).
    #[must_use]
    pub fn provisional(&self) -> Vec<&SectorDescriptor> {
        self.entries
            .iter()
            .filter(|d| d.status == RegulatoryStatus::Provisional)
            .collect()
    }

    /// Whether the sector exists and may carry a binding determination.
    #[must_use]
    pub fn is_in_force(&self, key: &str) -> bool {
        self.get(key)
            .is_some_and(|d| d.status.allows_determination())
    }

    /// The schema version applicable to *new* passports in `key`.
    #[must_use]
    pub fn current_schema_version(&self, key: &str) -> Option<&str> {
        self.get(key).map(|d| d.current_schema_version.as_str())
    }

    /// Resolve which schema version to validate against — the one mechanism that
    /// replaces hardcoded `"1.0.0"` / `latest()` at call sites.
    ///
    /// - `stored = Some(v)` (an *existing* passport): that version is
    ///   authoritative — a record is always re-validated against the version it
    ///   was published under, for immutability and audit. Returned as-is.
    /// - `stored = None` (a *new* passport): the sector's current version from
    ///   the catalog is used.
    ///
    /// Returns `None` only if `stored` is `None` and the sector is unknown.
    #[must_use]
    pub fn resolve_schema_version(&self, key: &str, stored: Option<&str>) -> Option<String> {
        match stored {
            Some(v) => Some(v.to_owned()),
            None => self.current_schema_version(key).map(ToOwned::to_owned),
        }
    }

    /// All sector keys, sorted.
    #[must_use]
    pub fn keys(&self) -> Vec<&str> {
        let mut keys: Vec<&str> = self.entries.iter().map(|d| d.key.as_str()).collect();
        keys.sort_unstable();
        keys
    }

    /// Register a new sector at runtime. Returns `AlreadyExists` if the key is
    /// taken.
    pub fn register(&mut self, descriptor: SectorDescriptor) -> Result<(), CatalogError> {
        if self.get(&descriptor.key).is_some() {
            return Err(CatalogError::AlreadyExists(descriptor.key));
        }
        self.entries.push(descriptor);
        Ok(())
    }

    /// Number of sectors in the catalog.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the catalog is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

impl Default for SectorCatalog {
    fn default() -> Self {
        Self::new()
    }
}

// ─── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn loads_all_embedded_manifests() {
        let catalog = SectorCatalog::new();
        assert_eq!(catalog.len(), 11);
    }

    #[test]
    fn exactly_three_sectors_are_in_force() {
        let catalog = SectorCatalog::new();
        let mut in_force: Vec<&str> = catalog.in_force().iter().map(|d| d.key.as_str()).collect();
        in_force.sort_unstable();
        assert_eq!(in_force, vec!["battery", "electronics", "unsold-goods"]);
    }

    #[test]
    fn provisional_sectors_are_flagged_not_dropped() {
        let catalog = SectorCatalog::new();
        // All eight not-yet-adopted sectors are still present, just flagged.
        assert_eq!(catalog.provisional().len(), 8);
        assert!(!catalog.is_in_force("textile"));
        assert!(!catalog.is_in_force("steel"));
    }

    #[test]
    fn battery_descriptor_is_complete() {
        let catalog = SectorCatalog::new();
        let battery = catalog.get("battery").expect("battery in catalog");
        assert_eq!(battery.status, RegulatoryStatus::InForce);
        assert_eq!(battery.dpp_applies_from.as_deref(), Some("2027-02-18"));
        assert_eq!(battery.retention_years, 10);
        assert!(battery.schema_versions.contains(&"2.0.0".to_string()));
        // Current version is v2.0.0 (Annex XIII), not the older v1.0.0.
        assert_eq!(battery.current_schema_version, "2.0.0");
        assert_eq!(battery.plugin.as_deref(), Some("sector-battery"));
    }

    #[test]
    fn resolve_schema_version_new_vs_existing() {
        let catalog = SectorCatalog::new();
        // New passport (stored = None) → catalog current version.
        assert_eq!(
            catalog.resolve_schema_version("battery", None).as_deref(),
            Some("2.0.0")
        );
        // Existing passport → its stored version is authoritative, even if old.
        assert_eq!(
            catalog
                .resolve_schema_version("battery", Some("1.0.0"))
                .as_deref(),
            Some("1.0.0")
        );
        // Unknown sector, new passport → None.
        assert_eq!(catalog.resolve_schema_version("unknown", None), None);
    }

    #[test]
    fn in_force_gating_is_status_driven() {
        let catalog = SectorCatalog::new();
        assert!(catalog.is_in_force("battery"));
        assert!(catalog.is_in_force("unsold-goods"));
        assert!(catalog.is_in_force("electronics"));
        assert!(!catalog.is_in_force("detergent")); // partial → flagged
        assert!(!catalog.is_in_force("nonexistent"));
    }

    #[test]
    fn allows_determination_matches_status() {
        assert!(RegulatoryStatus::InForce.allows_determination());
        assert!(!RegulatoryStatus::Provisional.allows_determination());
    }

    #[test]
    fn register_runtime_sector() {
        let mut catalog = SectorCatalog::new();
        let descriptor = SectorDescriptor {
            key: "plastics".into(),
            title: "Plastics".into(),
            status: RegulatoryStatus::Provisional,
            legal_basis: vec!["ESPR Working Plan".into()],
            dpp_applies_from: None,
            retention_years: 10,
            schema_versions: vec!["1.0.0".into()],
            current_schema_version: "1.0.0".into(),
            product_categories: vec![],
            access_tiers: std::collections::HashMap::new(),
            plugin: None,
            notes: None,
        };
        assert!(catalog.register(descriptor.clone()).is_ok());
        assert_eq!(catalog.len(), 12);
        assert!(matches!(
            catalog.register(descriptor),
            Err(CatalogError::AlreadyExists(_))
        ));
    }

    /// Parity guard: the closed [`Sector`] enum and the open [`SectorCatalog`]
    /// must describe the same set of *compile-time* sectors. Runtime-registered
    /// sectors degrade to `SectorData::Other`, but every typed `Sector` variant
    /// (except `Other`) must have an embedded catalog entry, and the embedded
    /// catalog must not carry a key with no corresponding variant. This stops
    /// the "four spellings of a sector" drift from reappearing across the
    /// enum ↔ catalog boundary.
    #[test]
    fn sector_enum_and_catalog_agree() {
        use crate::domain::sector::Sector;

        let catalog = SectorCatalog::new();

        // Every typed Sector variant (except Other) must be in the catalog.
        let typed = [
            Sector::Battery,
            Sector::Textile,
            Sector::UnsoldGoods,
            Sector::Steel,
            Sector::Electronics,
            Sector::Construction,
            Sector::Tyre,
            Sector::Toy,
            Sector::Aluminium,
            Sector::Furniture,
            Sector::Detergent,
        ];
        for sector in &typed {
            let key = sector.catalog_key();
            assert!(
                catalog.get(key).is_some(),
                "Sector::{sector:?} (key '{key}') has no embedded catalog entry"
            );
        }

        // No catalog entry without a typed Sector variant.
        let typed_keys: std::collections::HashSet<&str> =
            typed.iter().map(Sector::catalog_key).collect();
        for key in catalog.keys() {
            assert!(
                typed_keys.contains(key),
                "catalog key '{key}' has no corresponding typed Sector variant"
            );
        }
    }

    /// Compliance citation (domain Gap / watchlist 🔴): the ESPR unsold-goods
    /// destruction ban is **Article 25 / Annex VII**, not Article 22. A wrong
    /// citation in a compliance artifact erodes auditor trust.
    /// Source: Regulation (EU) 2024/1781 (ESPR) Article 25, Annex VII.
    #[test]
    fn unsold_goods_cites_espr_article_25() {
        let catalog = SectorCatalog::new();
        let textile = catalog.get("unsold-goods").expect("unsold-goods present");
        let basis = textile.legal_basis.join(" ");
        assert!(
            basis.contains("Article 25"),
            "unsold-goods legal basis must cite ESPR Article 25, got: {basis}"
        );
        assert!(
            !basis.contains("Article 22"),
            "the incorrect Article 22 citation must be gone, got: {basis}"
        );
    }

    #[test]
    fn descriptor_round_trips_camel_case() {
        let catalog = SectorCatalog::new();
        let battery = catalog.get("battery").unwrap();
        let json = serde_json::to_value(battery).unwrap();
        assert_eq!(json["dppAppliesFrom"], "2027-02-18");
        assert_eq!(json["status"], "in_force");
        let back: SectorDescriptor = serde_json::from_value(json).unwrap();
        assert_eq!(back.key, "battery");
    }

    // Drift guard: every key in a sector's access_tiers manifest must correspond to
    // a real JSON field in that sector's current schema. A key that doesn't match any
    // schema property silently fails to gate any field — the redaction is a no-op.
    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn access_tiers_keys_match_schema_properties() {
        use crate::schemas::VersionedSchemaRegistry;
        let catalog = SectorCatalog::new();
        let registry = VersionedSchemaRegistry::new();

        for descriptor in catalog.all() {
            if descriptor.access_tiers.is_empty() {
                continue;
            }
            let version: semver::Version = descriptor
                .current_schema_version
                .parse()
                .unwrap_or_else(|_| {
                    panic!(
                        "sector '{}' currentSchemaVersion '{}' is not valid semver",
                        descriptor.key, descriptor.current_schema_version
                    )
                });
            let schema_json = registry.get(&descriptor.key, &version).unwrap_or_else(|| {
                panic!(
                    "schema not found for sector '{}' v{}",
                    descriptor.key, descriptor.current_schema_version
                )
            });
            let schema: serde_json::Value =
                serde_json::from_str(schema_json).expect("embedded schema must be valid JSON");
            let properties = schema
                .get("properties")
                .and_then(|p| p.as_object())
                .unwrap_or_else(|| {
                    panic!(
                        "schema for sector '{}' has no top-level 'properties' object",
                        descriptor.key
                    )
                });

            for key in descriptor.access_tiers.keys() {
                assert!(
                    properties.contains_key(key),
                    "access_tiers key '{}' in sector '{}' does not match any property in schema v{} \
                     (properties: {:?}). Either rename the key to match the serialised field name, \
                     or remove it — a mismatched key silently fails to gate the field.",
                    key,
                    descriptor.key,
                    descriptor.current_schema_version,
                    properties.keys().collect::<Vec<_>>()
                );
            }
        }
    }

    // The key enforcement: catalog ↔ schema registry must agree, so the
    // "four spellings of a sector" problem cannot silently reappear.
    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn catalog_agrees_with_schema_registry() {
        use crate::schemas::VersionedSchemaRegistry;
        let catalog = SectorCatalog::new();
        let registry = VersionedSchemaRegistry::new();

        // Every schema version a sector declares must exist in the registry,
        // and its current version must be one of them.
        for d in catalog.all() {
            let reg_versions: Vec<String> = registry
                .versions_for(&d.key)
                .iter()
                .map(|v| v.to_string())
                .collect();
            for v in &d.schema_versions {
                assert!(
                    reg_versions.contains(v),
                    "catalog sector '{}' declares schema {v} not embedded in the registry (registry has {reg_versions:?})",
                    d.key
                );
            }
            assert!(
                d.schema_versions.contains(&d.current_schema_version),
                "catalog sector '{}' currentSchemaVersion {} is not in its schemaVersions {:?}",
                d.key,
                d.current_schema_version,
                d.schema_versions
            );
        }

        // No orphan schemas: every registry sector must have a catalog entry.
        for sector in registry.sectors() {
            assert!(
                catalog.get(sector).is_some(),
                "schema registry has sector '{sector}' with no catalog entry"
            );
        }

        // Every in-force sector must declare a plugin binding.
        for d in catalog.in_force() {
            assert!(
                d.plugin.is_some(),
                "in-force sector '{}' must declare a plugin binding",
                d.key
            );
        }
    }
}