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: "textile-unsold",
42        json: include_str!("../../sectors/textile-unsold.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"`, `"textile-unsold"`. 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", "textile-unsold"]);
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("textile-unsold"));
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::TextileUnsoldGoods,
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
454            .get("textile-unsold")
455            .expect("textile-unsold present");
456        let basis = textile.legal_basis.join(" ");
457        assert!(
458            basis.contains("Article 25"),
459            "unsold-goods legal basis must cite ESPR Article 25, got: {basis}"
460        );
461        assert!(
462            !basis.contains("Article 22"),
463            "the incorrect Article 22 citation must be gone, got: {basis}"
464        );
465    }
466
467    #[test]
468    fn descriptor_round_trips_camel_case() {
469        let catalog = SectorCatalog::new();
470        let battery = catalog.get("battery").unwrap();
471        let json = serde_json::to_value(battery).unwrap();
472        assert_eq!(json["dppAppliesFrom"], "2027-02-18");
473        assert_eq!(json["status"], "in_force");
474        let back: SectorDescriptor = serde_json::from_value(json).unwrap();
475        assert_eq!(back.key, "battery");
476    }
477
478    // Drift guard: every key in a sector's access_tiers manifest must correspond to
479    // a real JSON field in that sector's current schema. A key that doesn't match any
480    // schema property silently fails to gate any field — the redaction is a no-op.
481    #[cfg(not(target_arch = "wasm32"))]
482    #[test]
483    fn access_tiers_keys_match_schema_properties() {
484        use crate::schemas::VersionedSchemaRegistry;
485        let catalog = SectorCatalog::new();
486        let registry = VersionedSchemaRegistry::new();
487
488        for descriptor in catalog.all() {
489            if descriptor.access_tiers.is_empty() {
490                continue;
491            }
492            let version: semver::Version = descriptor
493                .current_schema_version
494                .parse()
495                .unwrap_or_else(|_| {
496                    panic!(
497                        "sector '{}' currentSchemaVersion '{}' is not valid semver",
498                        descriptor.key, descriptor.current_schema_version
499                    )
500                });
501            let schema_json = registry.get(&descriptor.key, &version).unwrap_or_else(|| {
502                panic!(
503                    "schema not found for sector '{}' v{}",
504                    descriptor.key, descriptor.current_schema_version
505                )
506            });
507            let schema: serde_json::Value =
508                serde_json::from_str(schema_json).expect("embedded schema must be valid JSON");
509            let properties = schema
510                .get("properties")
511                .and_then(|p| p.as_object())
512                .unwrap_or_else(|| {
513                    panic!(
514                        "schema for sector '{}' has no top-level 'properties' object",
515                        descriptor.key
516                    )
517                });
518
519            for key in descriptor.access_tiers.keys() {
520                assert!(
521                    properties.contains_key(key),
522                    "access_tiers key '{}' in sector '{}' does not match any property in schema v{} \
523                     (properties: {:?}). Either rename the key to match the serialised field name, \
524                     or remove it — a mismatched key silently fails to gate the field.",
525                    key,
526                    descriptor.key,
527                    descriptor.current_schema_version,
528                    properties.keys().collect::<Vec<_>>()
529                );
530            }
531        }
532    }
533
534    // The key enforcement: catalog ↔ schema registry must agree, so the
535    // "four spellings of a sector" problem cannot silently reappear.
536    #[cfg(not(target_arch = "wasm32"))]
537    #[test]
538    fn catalog_agrees_with_schema_registry() {
539        use crate::schemas::VersionedSchemaRegistry;
540        let catalog = SectorCatalog::new();
541        let registry = VersionedSchemaRegistry::new();
542
543        // Every schema version a sector declares must exist in the registry,
544        // and its current version must be one of them.
545        for d in catalog.all() {
546            let reg_versions: Vec<String> = registry
547                .versions_for(&d.key)
548                .iter()
549                .map(|v| v.to_string())
550                .collect();
551            for v in &d.schema_versions {
552                assert!(
553                    reg_versions.contains(v),
554                    "catalog sector '{}' declares schema {v} not embedded in the registry (registry has {reg_versions:?})",
555                    d.key
556                );
557            }
558            assert!(
559                d.schema_versions.contains(&d.current_schema_version),
560                "catalog sector '{}' currentSchemaVersion {} is not in its schemaVersions {:?}",
561                d.key,
562                d.current_schema_version,
563                d.schema_versions
564            );
565        }
566
567        // No orphan schemas: every registry sector must have a catalog entry.
568        for sector in registry.sectors() {
569            assert!(
570                catalog.get(sector).is_some(),
571                "schema registry has sector '{sector}' with no catalog entry"
572            );
573        }
574
575        // Every in-force sector must declare a plugin binding.
576        for d in catalog.in_force() {
577            assert!(
578                d.plugin.is_some(),
579                "in-force sector '{}' must declare a plugin binding",
580                d.key
581            );
582        }
583    }
584}