Skip to main content

dpp_domain/catalog/
mod.rs

1//! Open, data-driven catalog of EU ESPR sectors.
2//!
3//! The catalog is the single source of truth for *what sectors exist* and
4//! *where each stands in the EU regulatory pipeline*. Unlike a closed `enum`,
5//! sectors are described by **data** — one embedded manifest per sector at
6//! `dpp-core/crates/dpp-domain/sectors/{key}.json` — and new sectors can be
7//! added at runtime via [`SectorCatalog::register`] without recompiling core.
8//!
9//! Each [`SectorDescriptor`] ties together a sector's canonical key, regulatory
10//! status, legal basis, schema versions, retention, product categories, and
11//! plugin binding — resolving the "four spellings of a sector" problem by
12//! giving every component one record to agree on.
13//!
14//! [`RegulatoryStatus`] gates behaviour: only `InForce` sectors may carry a
15//! binding compliance determination. `Provisional` sectors (on the ESPR working
16//! plan but without an adopted delegated act) are present but **flagged** —
17//! their schemas are best-effort drafts and plugins must not assert
18//! COMPLIANT/NON_COMPLIANT.
19
20use serde::{Deserialize, Serialize};
21
22// ─── Embedded manifests ───────────────────────────────────────────────────────
23
24struct EmbeddedManifest {
25    key: &'static str,
26    json: &'static str,
27}
28
29/// One manifest per sector. Adding a sector at compile time is a single entry +
30/// a JSON file; adding one at runtime is [`SectorCatalog::register`].
31const EMBEDDED: &[EmbeddedManifest] = &[
32    EmbeddedManifest {
33        key: "battery",
34        json: include_str!("../../sectors/battery.json"),
35    },
36    EmbeddedManifest {
37        key: "electronics",
38        json: include_str!("../../sectors/electronics.json"),
39    },
40    EmbeddedManifest {
41        key: "unsold-goods",
42        json: include_str!("../../sectors/unsold-goods.json"),
43    },
44    EmbeddedManifest {
45        key: "textile",
46        json: include_str!("../../sectors/textile.json"),
47    },
48    EmbeddedManifest {
49        key: "steel",
50        json: include_str!("../../sectors/steel.json"),
51    },
52    EmbeddedManifest {
53        key: "construction",
54        json: include_str!("../../sectors/construction.json"),
55    },
56    EmbeddedManifest {
57        key: "tyre",
58        json: include_str!("../../sectors/tyre.json"),
59    },
60    EmbeddedManifest {
61        key: "toy",
62        json: include_str!("../../sectors/toy.json"),
63    },
64    EmbeddedManifest {
65        key: "aluminium",
66        json: include_str!("../../sectors/aluminium.json"),
67    },
68    EmbeddedManifest {
69        key: "furniture",
70        json: include_str!("../../sectors/furniture.json"),
71    },
72    EmbeddedManifest {
73        key: "detergent",
74        json: include_str!("../../sectors/detergent.json"),
75    },
76];
77
78// ─── Regulatory status ────────────────────────────────────────────────────────
79
80/// Where a sector's DPP obligation stands in the EU regulatory pipeline.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum RegulatoryStatus {
85    /// A DPP / ecodesign obligation is legally in force, or has a firm adopted
86    /// applicability date. Plugins may emit binding compliance determinations.
87    InForce,
88    /// On the ESPR working plan, or a delegated act is anticipated, but no DPP
89    /// obligation is in force yet. Schemas are best-effort drafts; plugins must
90    /// not assert COMPLIANT/NON_COMPLIANT — only structural validation applies.
91    Provisional,
92}
93
94impl RegulatoryStatus {
95    /// Whether a sector with this status may carry a *binding* compliance
96    /// determination (vs. structural validation only).
97    #[must_use]
98    pub fn allows_determination(&self) -> bool {
99        matches!(self, Self::InForce)
100    }
101}
102
103// ─── Sector descriptor ────────────────────────────────────────────────────────
104
105/// A single sector's catalog entry — the canonical record every component
106/// (schema registry, plugin host, passport model) resolves against.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct SectorDescriptor {
110    /// Canonical sector key, e.g. `"battery"`, `"unsold-goods"`. Matches the
111    /// schema-registry sector key and the plugin's `meta().sector`.
112    pub key: String,
113    /// Human-readable title.
114    pub title: String,
115    /// Regulatory status — gates whether determinations are binding.
116    pub status: RegulatoryStatus,
117    /// EU legal instrument(s) this sector derives from.
118    pub legal_basis: Vec<String>,
119    /// ISO-8601 date the DPP obligation applies from, when known.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub dpp_applies_from: Option<String>,
122    /// Minimum data retention in years required by the applicable act.
123    pub retention_years: u32,
124    /// Schema versions available for this sector (semver strings).
125    pub schema_versions: Vec<String>,
126    /// The schema version applicable to *new* passports in this sector right
127    /// now. Decouples "current" from "latest embedded" so a future schema can
128    /// ship embedded without becoming current until its act is in force. Must
129    /// be one of `schema_versions`.
130    pub current_schema_version: String,
131    /// Product categories *within* this sector — sub-types a plugin may branch
132    /// on, never dispatch keys. See `DATA-MODEL.md` §3.5.
133    #[serde(default)]
134    pub product_categories: Vec<String>,
135    /// Per-field minimum ESPR access tier (public/professional/confidential) for
136    /// this sector's data: field name → tier; unlisted fields default to public.
137    /// Universal confidential fields (signatures, audit trails) are folded in by
138    /// the access-policy engine, so they are not repeated per sector here.
139    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
140    pub access_tiers: std::collections::HashMap<String, crate::domain::identity::AccessTier>,
141    /// Plugin that handles this sector (crate / filename stem, e.g.
142    /// `"sector-battery"`). `None` if no plugin is bound yet.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub plugin: Option<String>,
145    /// Free-text regulatory note (effective dates, scope, caveats).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub notes: Option<String>,
148}
149
150// ─── Errors ───────────────────────────────────────────────────────────────────
151
152/// Errors from runtime catalog registration.
153#[derive(Debug, Clone, PartialEq, Eq)]
154#[non_exhaustive]
155pub enum CatalogError {
156    /// A descriptor for this key already exists.
157    AlreadyExists(String),
158}
159
160impl std::fmt::Display for CatalogError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::AlreadyExists(key) => write!(f, "sector '{key}' already in catalog"),
164        }
165    }
166}
167
168impl std::error::Error for CatalogError {}
169
170// ─── Catalog ──────────────────────────────────────────────────────────────────
171
172/// Open, data-driven sector catalog. Pre-loaded with embedded manifests and
173/// extensible at runtime.
174pub struct SectorCatalog {
175    entries: Vec<SectorDescriptor>,
176}
177
178impl SectorCatalog {
179    /// Create a catalog pre-loaded with all embedded sector manifests.
180    #[must_use]
181    pub fn new() -> Self {
182        let entries = EMBEDDED
183            .iter()
184            .map(|m| {
185                let descriptor: SectorDescriptor =
186                    serde_json::from_str(m.json).unwrap_or_else(|e| {
187                        panic!("embedded sector manifest '{}' is invalid: {e}", m.key)
188                    });
189                assert_eq!(
190                    descriptor.key, m.key,
191                    "manifest key '{}' does not match its file key '{}'",
192                    descriptor.key, m.key
193                );
194                descriptor
195            })
196            .collect();
197        Self { entries }
198    }
199
200    /// Look up a sector by canonical key.
201    #[must_use]
202    pub fn get(&self, key: &str) -> Option<&SectorDescriptor> {
203        self.entries.iter().find(|d| d.key == key)
204    }
205
206    /// All sector descriptors.
207    #[must_use]
208    pub fn all(&self) -> &[SectorDescriptor] {
209        &self.entries
210    }
211
212    /// Sectors whose DPP obligation is in force (determinations are binding).
213    #[must_use]
214    pub fn in_force(&self) -> Vec<&SectorDescriptor> {
215        self.entries
216            .iter()
217            .filter(|d| d.status == RegulatoryStatus::InForce)
218            .collect()
219    }
220
221    /// Sectors that are flagged provisional (no binding determinations).
222    #[must_use]
223    pub fn provisional(&self) -> Vec<&SectorDescriptor> {
224        self.entries
225            .iter()
226            .filter(|d| d.status == RegulatoryStatus::Provisional)
227            .collect()
228    }
229
230    /// Whether the sector exists and may carry a binding determination.
231    #[must_use]
232    pub fn is_in_force(&self, key: &str) -> bool {
233        self.get(key)
234            .is_some_and(|d| d.status.allows_determination())
235    }
236
237    /// The schema version applicable to *new* passports in `key`.
238    #[must_use]
239    pub fn current_schema_version(&self, key: &str) -> Option<&str> {
240        self.get(key).map(|d| d.current_schema_version.as_str())
241    }
242
243    /// Resolve which schema version to validate against — the one mechanism that
244    /// replaces hardcoded `"1.0.0"` / `latest()` at call sites.
245    ///
246    /// - `stored = Some(v)` (an *existing* passport): that version is
247    ///   authoritative — a record is always re-validated against the version it
248    ///   was published under, for immutability and audit. Returned as-is.
249    /// - `stored = None` (a *new* passport): the sector's current version from
250    ///   the catalog is used.
251    ///
252    /// Returns `None` only if `stored` is `None` and the sector is unknown.
253    #[must_use]
254    pub fn resolve_schema_version(&self, key: &str, stored: Option<&str>) -> Option<String> {
255        match stored {
256            Some(v) => Some(v.to_owned()),
257            None => self.current_schema_version(key).map(ToOwned::to_owned),
258        }
259    }
260
261    /// All sector keys, sorted.
262    #[must_use]
263    pub fn keys(&self) -> Vec<&str> {
264        let mut keys: Vec<&str> = self.entries.iter().map(|d| d.key.as_str()).collect();
265        keys.sort_unstable();
266        keys
267    }
268
269    /// Register a new sector at runtime. Returns `AlreadyExists` if the key is
270    /// taken.
271    pub fn register(&mut self, descriptor: SectorDescriptor) -> Result<(), CatalogError> {
272        if self.get(&descriptor.key).is_some() {
273            return Err(CatalogError::AlreadyExists(descriptor.key));
274        }
275        self.entries.push(descriptor);
276        Ok(())
277    }
278
279    /// Number of sectors in the catalog.
280    #[must_use]
281    pub fn len(&self) -> usize {
282        self.entries.len()
283    }
284
285    /// Whether the catalog is empty.
286    #[must_use]
287    pub fn is_empty(&self) -> bool {
288        self.entries.is_empty()
289    }
290}
291
292impl Default for SectorCatalog {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298// ─── Tests ────────────────────────────────────────────────────────────────────
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn loads_all_embedded_manifests() {
306        let catalog = SectorCatalog::new();
307        assert_eq!(catalog.len(), 11);
308    }
309
310    #[test]
311    fn exactly_three_sectors_are_in_force() {
312        let catalog = SectorCatalog::new();
313        let mut in_force: Vec<&str> = catalog.in_force().iter().map(|d| d.key.as_str()).collect();
314        in_force.sort_unstable();
315        assert_eq!(in_force, vec!["battery", "electronics", "unsold-goods"]);
316    }
317
318    #[test]
319    fn provisional_sectors_are_flagged_not_dropped() {
320        let catalog = SectorCatalog::new();
321        // All eight not-yet-adopted sectors are still present, just flagged.
322        assert_eq!(catalog.provisional().len(), 8);
323        assert!(!catalog.is_in_force("textile"));
324        assert!(!catalog.is_in_force("steel"));
325    }
326
327    #[test]
328    fn battery_descriptor_is_complete() {
329        let catalog = SectorCatalog::new();
330        let battery = catalog.get("battery").expect("battery in catalog");
331        assert_eq!(battery.status, RegulatoryStatus::InForce);
332        assert_eq!(battery.dpp_applies_from.as_deref(), Some("2027-02-18"));
333        assert_eq!(battery.retention_years, 10);
334        assert!(battery.schema_versions.contains(&"2.0.0".to_string()));
335        // Current version is v2.0.0 (Annex XIII), not the older v1.0.0.
336        assert_eq!(battery.current_schema_version, "2.0.0");
337        assert_eq!(battery.plugin.as_deref(), Some("sector-battery"));
338    }
339
340    #[test]
341    fn resolve_schema_version_new_vs_existing() {
342        let catalog = SectorCatalog::new();
343        // New passport (stored = None) → catalog current version.
344        assert_eq!(
345            catalog.resolve_schema_version("battery", None).as_deref(),
346            Some("2.0.0")
347        );
348        // Existing passport → its stored version is authoritative, even if old.
349        assert_eq!(
350            catalog
351                .resolve_schema_version("battery", Some("1.0.0"))
352                .as_deref(),
353            Some("1.0.0")
354        );
355        // Unknown sector, new passport → None.
356        assert_eq!(catalog.resolve_schema_version("unknown", None), None);
357    }
358
359    #[test]
360    fn in_force_gating_is_status_driven() {
361        let catalog = SectorCatalog::new();
362        assert!(catalog.is_in_force("battery"));
363        assert!(catalog.is_in_force("unsold-goods"));
364        assert!(catalog.is_in_force("electronics"));
365        assert!(!catalog.is_in_force("detergent")); // partial → flagged
366        assert!(!catalog.is_in_force("nonexistent"));
367    }
368
369    #[test]
370    fn allows_determination_matches_status() {
371        assert!(RegulatoryStatus::InForce.allows_determination());
372        assert!(!RegulatoryStatus::Provisional.allows_determination());
373    }
374
375    #[test]
376    fn register_runtime_sector() {
377        let mut catalog = SectorCatalog::new();
378        let descriptor = SectorDescriptor {
379            key: "plastics".into(),
380            title: "Plastics".into(),
381            status: RegulatoryStatus::Provisional,
382            legal_basis: vec!["ESPR Working Plan".into()],
383            dpp_applies_from: None,
384            retention_years: 10,
385            schema_versions: vec!["1.0.0".into()],
386            current_schema_version: "1.0.0".into(),
387            product_categories: vec![],
388            access_tiers: std::collections::HashMap::new(),
389            plugin: None,
390            notes: None,
391        };
392        assert!(catalog.register(descriptor.clone()).is_ok());
393        assert_eq!(catalog.len(), 12);
394        assert!(matches!(
395            catalog.register(descriptor),
396            Err(CatalogError::AlreadyExists(_))
397        ));
398    }
399
400    /// Parity guard: the closed [`Sector`] enum and the open [`SectorCatalog`]
401    /// must describe the same set of *compile-time* sectors. Runtime-registered
402    /// sectors degrade to `SectorData::Other`, but every typed `Sector` variant
403    /// (except `Other`) must have an embedded catalog entry, and the embedded
404    /// catalog must not carry a key with no corresponding variant. This stops
405    /// the "four spellings of a sector" drift from reappearing across the
406    /// enum ↔ catalog boundary.
407    #[test]
408    fn sector_enum_and_catalog_agree() {
409        use crate::domain::sector::Sector;
410
411        let catalog = SectorCatalog::new();
412
413        // Every typed Sector variant (except Other) must be in the catalog.
414        let typed = [
415            Sector::Battery,
416            Sector::Textile,
417            Sector::UnsoldGoods,
418            Sector::Steel,
419            Sector::Electronics,
420            Sector::Construction,
421            Sector::Tyre,
422            Sector::Toy,
423            Sector::Aluminium,
424            Sector::Furniture,
425            Sector::Detergent,
426        ];
427        for sector in &typed {
428            let key = sector.catalog_key();
429            assert!(
430                catalog.get(key).is_some(),
431                "Sector::{sector:?} (key '{key}') has no embedded catalog entry"
432            );
433        }
434
435        // No catalog entry without a typed Sector variant.
436        let typed_keys: std::collections::HashSet<&str> =
437            typed.iter().map(Sector::catalog_key).collect();
438        for key in catalog.keys() {
439            assert!(
440                typed_keys.contains(key),
441                "catalog key '{key}' has no corresponding typed Sector variant"
442            );
443        }
444    }
445
446    /// Compliance citation (domain Gap / watchlist 🔴): the ESPR unsold-goods
447    /// destruction ban is **Article 25 / Annex VII**, not Article 22. A wrong
448    /// citation in a compliance artifact erodes auditor trust.
449    /// Source: Regulation (EU) 2024/1781 (ESPR) Article 25, Annex VII.
450    #[test]
451    fn unsold_goods_cites_espr_article_25() {
452        let catalog = SectorCatalog::new();
453        let textile = catalog.get("unsold-goods").expect("unsold-goods present");
454        let basis = textile.legal_basis.join(" ");
455        assert!(
456            basis.contains("Article 25"),
457            "unsold-goods legal basis must cite ESPR Article 25, got: {basis}"
458        );
459        assert!(
460            !basis.contains("Article 22"),
461            "the incorrect Article 22 citation must be gone, got: {basis}"
462        );
463    }
464
465    #[test]
466    fn descriptor_round_trips_camel_case() {
467        let catalog = SectorCatalog::new();
468        let battery = catalog.get("battery").unwrap();
469        let json = serde_json::to_value(battery).unwrap();
470        assert_eq!(json["dppAppliesFrom"], "2027-02-18");
471        assert_eq!(json["status"], "in_force");
472        let back: SectorDescriptor = serde_json::from_value(json).unwrap();
473        assert_eq!(back.key, "battery");
474    }
475
476    // Drift guard: every key in a sector's access_tiers manifest must correspond to
477    // a real JSON field in that sector's current schema. A key that doesn't match any
478    // schema property silently fails to gate any field — the redaction is a no-op.
479    #[cfg(not(target_arch = "wasm32"))]
480    #[test]
481    fn access_tiers_keys_match_schema_properties() {
482        use crate::schemas::VersionedSchemaRegistry;
483        let catalog = SectorCatalog::new();
484        let registry = VersionedSchemaRegistry::new();
485
486        for descriptor in catalog.all() {
487            if descriptor.access_tiers.is_empty() {
488                continue;
489            }
490            let version: semver::Version = descriptor
491                .current_schema_version
492                .parse()
493                .unwrap_or_else(|_| {
494                    panic!(
495                        "sector '{}' currentSchemaVersion '{}' is not valid semver",
496                        descriptor.key, descriptor.current_schema_version
497                    )
498                });
499            let schema_json = registry.get(&descriptor.key, &version).unwrap_or_else(|| {
500                panic!(
501                    "schema not found for sector '{}' v{}",
502                    descriptor.key, descriptor.current_schema_version
503                )
504            });
505            let schema: serde_json::Value =
506                serde_json::from_str(schema_json).expect("embedded schema must be valid JSON");
507            let properties = schema
508                .get("properties")
509                .and_then(|p| p.as_object())
510                .unwrap_or_else(|| {
511                    panic!(
512                        "schema for sector '{}' has no top-level 'properties' object",
513                        descriptor.key
514                    )
515                });
516
517            for key in descriptor.access_tiers.keys() {
518                assert!(
519                    properties.contains_key(key),
520                    "access_tiers key '{}' in sector '{}' does not match any property in schema v{} \
521                     (properties: {:?}). Either rename the key to match the serialised field name, \
522                     or remove it — a mismatched key silently fails to gate the field.",
523                    key,
524                    descriptor.key,
525                    descriptor.current_schema_version,
526                    properties.keys().collect::<Vec<_>>()
527                );
528            }
529        }
530    }
531
532    // The key enforcement: catalog ↔ schema registry must agree, so the
533    // "four spellings of a sector" problem cannot silently reappear.
534    #[cfg(not(target_arch = "wasm32"))]
535    #[test]
536    fn catalog_agrees_with_schema_registry() {
537        use crate::schemas::VersionedSchemaRegistry;
538        let catalog = SectorCatalog::new();
539        let registry = VersionedSchemaRegistry::new();
540
541        // Every schema version a sector declares must exist in the registry,
542        // and its current version must be one of them.
543        for d in catalog.all() {
544            let reg_versions: Vec<String> = registry
545                .versions_for(&d.key)
546                .iter()
547                .map(|v| v.to_string())
548                .collect();
549            for v in &d.schema_versions {
550                assert!(
551                    reg_versions.contains(v),
552                    "catalog sector '{}' declares schema {v} not embedded in the registry (registry has {reg_versions:?})",
553                    d.key
554                );
555            }
556            assert!(
557                d.schema_versions.contains(&d.current_schema_version),
558                "catalog sector '{}' currentSchemaVersion {} is not in its schemaVersions {:?}",
559                d.key,
560                d.current_schema_version,
561                d.schema_versions
562            );
563        }
564
565        // No orphan schemas: every registry sector must have a catalog entry.
566        for sector in registry.sectors() {
567            assert!(
568                catalog.get(sector).is_some(),
569                "schema registry has sector '{sector}' with no catalog entry"
570            );
571        }
572
573        // Every in-force sector must declare a plugin binding.
574        for d in catalog.in_force() {
575            assert!(
576                d.plugin.is_some(),
577                "in-force sector '{}' must declare a plugin binding",
578                d.key
579            );
580        }
581    }
582}