dpp-rules 0.18.0

Pure EU ESPR cross-field regulatory rules, shared by dpp-domain and the Wasm sector plugins
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
//! Which battery passport data points a given battery category actually owes.
//!
//! The schema deliberately declares almost every Annex XIII field optional, and
//! that is not laxity: the obligations are **per category**. A field mandatory
//! for an electric-vehicle battery may be *"not to be filled/displayed"* for an
//! LMT one, and requiring it in JSON Schema would make a lawful industrial
//! battery unrepresentable. The constraint belongs here, where it can be
//! expressed as a function of the category.
//!
//! # Source
//!
//! The Commission's *Guidance Document: Digital Batteries Passport — data
//! points by category* (v1.0, 28 July 2026), read in full against the model.
//! Its table has one row per data point and one column per category, which is
//! the shape of the `REQUIREMENTS` table below — so a reviewer can diff the two
//! directly rather than reconstructing the mapping.
//!
//! **The guidance covers EV, LMT and industrial batteries only.** It says
//! nothing about portable or SLI batteries, so this module answers
//! [`Requirement::Unknown`] for them rather than guessing. Silence in the source
//! is not permission, and it is not prohibition either.
//!
//! # What this table is not
//!
//! It is **not** a row-for-row copy of the guidance's 71 data points, and the
//! two cannot be diffed on length. This table keys on the wire names of
//! *sector-data* fields, so a guidance row whose content lives on the passport
//! envelope has no row here and is enforced elsewhere:
//!
//! - Data points 3 and 4 (manufacturer name; postal address) are
//!   `manufacturer.name` and `manufacturer.address`, which the domain's own
//!   `Passport::validate` already requires to be non-empty.
//! - Data point 2 (identity of who is registering or is responsible for the
//!   passport) is carried by the responsible-operator and operator-identifier
//!   envelope fields.
//!
//! An omission here is therefore only meaningful for a data point that *does*
//! map to a sector-data field.
//!
//! # Where all 71 rows went
//!
//! Every guidance row has been walked against the model. The difference between
//! 71 and this table's length is accounted for as follows, so a future reader
//! can check the claim rather than take it:
//!
//! - **Envelope, not sector data** — rows 2, 3, 4 (registrant identity;
//!   manufacturer name; postal address), enforced as above. Row 5
//!   (manufacturer web and email) is *"optional, to be filled if such data is
//!   available"*, so it carries no obligation to enforce.
//! - **One block, many rows** — rows 51–60 are the ten members of
//!   `DynamicPerformance`, rows 61–66 the two disjoint `StateOfHealth`
//!   parameter sets, and rows 68–71 the four members of `UsageHistory`. Each
//!   block is one row here because the block is what a passport carries or
//!   omits; the per-member conditions live in the types.
//! - **Deferred by the guidance** — rows 17, 18, 19 and 44 are *"not to be
//!   filled/displayed as of February 2027"*. The two with a modelled field
//!   (`carbonFootprintClass`, `dueDiligenceUrl`) are listed below as
//!   `NotApplicable`; rows 17 and 44 (carbon footprint *declaration*, and
//!   instructions for use) have no field at all, which is the correct state for
//!   a data point whose format has not been specified.
//! - **Restated elsewhere in the annex** — row 16 is Annex XIII point 1(c)
//!   material composition, deferred, but its constituents are separately
//!   mandatory as Annex VI Part A points 7, 8 and 10 (rows 12, 13, 15) and are
//!   enforced there.
//!
//! Four omissions were found and closed by this audit's first pass: points 1,
//! 7, 8 and 9 — the unique identifier, model identification, place of
//! manufacture and date of manufacture — all mandatory for every covered
//! category, and none of which any battery schema version had ever declared.

/// What a category owes for one data point.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
    /// The guidance marks it mandatory. A published passport without it is
    /// missing content the law requires.
    Mandatory,
    /// *"If applicable"*, *"where possible"*, or *"only applicable for some
    /// industrial batteries"* — the duty exists but its trigger is a fact about
    /// the individual battery that no schema can decide.
    Conditional,
    /// *"Not to be filled/displayed"*. Present in a passport of this category,
    /// the value is not merely surplus — the guidance says it does not belong.
    NotApplicable,
    /// The guidance does not cover this category, or does not name this field.
    /// Distinct from [`Self::NotApplicable`]: one is a recorded exclusion, the
    /// other is an absence of evidence.
    Unknown,
}

impl Requirement {
    /// Whether a passport of this category may carry the field at all.
    #[must_use]
    pub const fn permits_presence(self) -> bool {
        !matches!(self, Self::NotApplicable)
    }
}

/// The category a rule is being asked about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
    Ev,
    Lmt,
    Industrial,
}

fn category_of(battery_type: &str) -> Option<Category> {
    let t = battery_type.trim();
    let eq = |s: &str| t.eq_ignore_ascii_case(s);
    if eq("ev") {
        Some(Category::Ev)
    } else if eq("lmt") {
        Some(Category::Lmt)
    } else if eq("industrial") {
        Some(Category::Industrial)
    } else {
        // portable, sli / starting-lighting-ignition, unknown — outside the
        // guidance's scope entirely.
        None
    }
}

use Requirement::{Conditional as C, Mandatory as M, NotApplicable as X};

/// One row per data point: the wire field name, then EV, LMT, industrial.
///
/// Ordered to follow the Commission's own numbering, so the two can be read
/// side by side. Fields the guidance marks mandatory for all three are included
/// rather than defaulted, because "we checked and it is uniform" and "we did not
/// check" must not look the same in this table.
const REQUIREMENTS: &[(&str, Requirement, Requirement, Requirement)] = &[
    // ── Art. 77(3) — the identifier the passport is reached by ─────────────
    // Guidance data point 1, "unique identifier", mandatory for all three.
    // The envelope has no home for it: `product_id` is documented as an
    // opaque internal link and explicitly not a legal identifier, and the
    // serial inside `qr_code_url` is derived here rather than attributed by
    // the operator, which is what Art. 77(3) asks for.
    ("batteryPassportNumber", M, M, M),
    // ── Annex VI Part A, reached by Annex XIII point 1(a) ──────────────────
    ("batteryType", M, M, M),
    // Guidance data point 7 — "model identification and batch or serial
    // number, or product number or another element allowing their
    // identification", the second half of Part A point 2.
    ("batteryModelId", M, M, M),
    // Guidance data point 8 — Part A point 3.
    ("manufacturingPlace", M, M, M),
    // Guidance data point 9 — Part A point 4, "month and year".
    ("manufacturingDate", M, M, M),
    ("batteryWeightKg", M, M, M),
    ("nominalCapacityAh", M, M, M),
    ("batteryChemistry", M, M, M),
    ("hazardousSubstances", M, M, M),
    ("usableExtinguishingAgent", M, M, M),
    ("criticalRawMaterials", M, M, M),
    // ── Annex XIII point 1 ─────────────────────────────────────────────────
    // Guidance rows 18 and 19 are both *"not to be filled/displayed as of
    // February 2027 — format still to be specified in the upcoming
    // implementing act"*, for all three categories.
    //
    // `NotApplicable` is the honest class for that, and it is the same one
    // `ratedCapacityAh` already carries for the same words below. It is not a
    // statement that the field is wrong — it is the guidance saying the format
    // does not exist yet, so a value filed today cannot be the one the act will
    // ask for. Both entries come out when that act lands.
    //
    // Distinct from disclosure: `dueDiligenceUrl` is Annex XIII point 1(d) and
    // therefore *public* when it is eventually filled. What may be seen and
    // whether it may be filled are different axes, and this table only answers
    // the second.
    ("carbonFootprintClass", X, X, X),
    ("dueDiligenceUrl", X, X, X),
    ("recycledContentCobaltPct", M, M, M),
    ("recycledContentLithiumPct", M, M, M),
    ("recycledContentNickelPct", M, M, M),
    ("recycledContentLeadPct", M, M, M),
    ("renewableContentPct", M, M, M),
    // Point 1(g) is "not to be filled/displayed" for every category. The Ah
    // figure a passport does carry is Annex VI Part A point 6, above.
    ("ratedCapacityAh", X, X, X),
    ("minimalVoltageV", M, M, M),
    ("nominalVoltageV", M, M, M),
    ("maximumVoltageV", M, M, M),
    ("originalPowerCapabilityW", M, M, M),
    ("powerLimitMinW", M, M, M),
    ("powerLimitMaxW", M, M, M),
    // 1(j): "only applicable for some industrial batteries where lifetime can
    // be expressed in cycles".
    ("expectedLifetimeCycles", M, M, C),
    ("expectedLifetimeReferenceTest", M, M, C),
    // 1(k): mandatory for EV only, and explicitly not for the other two.
    ("capacityThresholdForExhaustionPct", M, X, X),
    ("notInUseTemperatureRange", M, M, M),
    ("notInUseTemperatureReferenceTest", M, M, M),
    // 1(m): "only if applicable (if commercial warranty envisaged)".
    ("commercialWarrantyPeriodMonths", C, C, C),
    // 1(n): "only applicable for some industrial batteries".
    ("initialRoundTripEfficiencyPct", M, M, C),
    ("roundTripEfficiencyAtHalfCycleLifePct", M, M, C),
    ("internalCellResistanceMohm", M, M, M),
    ("internalPackResistanceMohm", M, M, M),
    ("cycleLifeTestCRate", M, M, C),
    // 1(q), guidance row 40: "the marking requirements laid down in Article
    // 13(4)" — the separate-collection symbol.
    //
    // **Do not "correct" these two against the OJ text of 1(q).** It reads
    // "the marking requirements laid down in Article 13(3) and (4)", while the
    // guidance splits 1(q) into rows 40 and 41 citing 13(4) and 13(5). The two
    // instruments disagree, and this table follows the guidance because that is
    // the source it mirrors and the one that assigns per-category obligations.
    // Art. 13(3) is the non-rechargeable-portable duration label, which is
    // outside the three categories the guidance covers at all.
    ("markingInformation", M, M, M),
    // 1(q), guidance row 41, Art. 13(5): "cadmium or lead symbol if
    // applicable" — the same words for all three categories.
    ("hazardSymbol", C, C, C),
    ("euDeclarationOfConformity", M, M, M),
    ("wasteBatteryInformation", M, M, M),
    // ── Annex XIII points 2 and 3 ──────────────────────────────────────────
    ("cathodeMaterial", M, M, M),
    ("anodeMaterial", M, M, M),
    ("electrolyteMaterial", M, M, M),
    ("componentPartNumbers", M, M, M),
    ("sparePartsContacts", M, M, M),
    ("disassemblyInstructionsUrl", M, M, M),
    ("safetyMeasures", M, M, M),
    ("testReportResults", M, M, M),
    // ── Annex XIII point 4 — the individual-battery tier ───────────────────
    // 4(a): mandatory for EV and LMT, "if applicable" for industrial.
    ("dynamicPerformance", M, M, C),
    // 4(b): the two disjoint state-of-health lists. Which parameter set applies
    // is `degradation::annex_vii_parameter_set_for`; that the block is owed at
    // all is here.
    ("stateOfHealth", M, M, C),
    ("batteryStatus", M, M, M),
    // 4(d): "if applicable" for every category.
    ("usageHistory", C, C, C),
];

/// What a battery of `battery_type` owes for the passport field `field`.
///
/// `field` is the **wire** name — `expectedLifetimeCycles`, not
/// `expected_lifetime_cycles` — because this crate is `no_std` and zero-dep and
/// is consumed both by the domain types and by the Wasm sector plugins, which
/// see JSON and never the Rust struct.
///
/// Returns [`Requirement::Unknown`] for a category the guidance does not cover
/// (portable, SLI) and for a field it does not name. A caller must not read
/// that as permission or as prohibition — it means nobody has checked.
#[must_use]
pub fn annex_xiii_requirement(field: &str, battery_type: &str) -> Requirement {
    let Some(category) = category_of(battery_type) else {
        return Requirement::Unknown;
    };
    let mut i = 0;
    while i < REQUIREMENTS.len() {
        let (name, ev, lmt, ind) = REQUIREMENTS[i];
        if name.as_bytes() == field.as_bytes() {
            return match category {
                Category::Ev => ev,
                Category::Lmt => lmt,
                Category::Industrial => ind,
            };
        }
        i += 1;
    }
    Requirement::Unknown
}

/// Every field this category must carry, in table order.
///
/// The publish gate's input. Iterating the table rather than exposing it keeps
/// the rows private, so a caller cannot come to depend on their order or arity.
pub fn mandatory_fields(battery_type: &str) -> impl Iterator<Item = &'static str> {
    let category = category_of(battery_type);
    REQUIREMENTS.iter().filter_map(move |(name, ev, lmt, ind)| {
        let r = match category? {
            Category::Ev => *ev,
            Category::Lmt => *lmt,
            Category::Industrial => *ind,
        };
        (r == Requirement::Mandatory).then_some(*name)
    })
}

/// Every field of `present` that this category must not carry.
///
/// The complement of the usual question. A passport asserting a capacity
/// threshold for exhaustion on an LMT battery is not missing anything — it is
/// carrying content the guidance says does not belong, which a
/// mandatory-fields check would never notice.
pub fn fields_not_applicable<'a>(
    present: &'a [&'a str],
    battery_type: &str,
) -> impl Iterator<Item = &'a str> {
    present
        .iter()
        .copied()
        .filter(move |f| annex_xiii_requirement(f, battery_type) == Requirement::NotApplicable)
}

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

    #[test]
    fn point_1_k_is_ev_only() {
        // The sharpest per-category split in the guidance: mandatory for EV,
        // "not to be filled/displayed" for the other two.
        let f = "capacityThresholdForExhaustionPct";
        assert_eq!(annex_xiii_requirement(f, "ev"), Requirement::Mandatory);
        assert_eq!(annex_xiii_requirement(f, "lmt"), Requirement::NotApplicable);
        assert_eq!(
            annex_xiii_requirement(f, "industrial"),
            Requirement::NotApplicable
        );
    }

    #[test]
    fn cycle_lifetime_is_conditional_for_industrial_only() {
        for f in ["expectedLifetimeCycles", "expectedLifetimeReferenceTest"] {
            assert_eq!(annex_xiii_requirement(f, "ev"), Requirement::Mandatory);
            assert_eq!(annex_xiii_requirement(f, "lmt"), Requirement::Mandatory);
            assert_eq!(
                annex_xiii_requirement(f, "industrial"),
                Requirement::Conditional,
                "{f}: 'only applicable for some industrial batteries where \
                 lifetime can be expressed in cycles'"
            );
        }
    }

    #[test]
    fn the_suppressed_ah_figure_is_not_the_mandatory_one() {
        // Point 1(g) is suppressed for every category while Annex VI Part A
        // point 6 is mandatory for every category. Two data points, one
        // quantity — the pair this table exists to keep apart.
        assert_eq!(
            annex_xiii_requirement("ratedCapacityAh", "ev"),
            Requirement::NotApplicable
        );
        assert_eq!(
            annex_xiii_requirement("nominalCapacityAh", "ev"),
            Requirement::Mandatory
        );
    }

    /// The two fields the guidance defers are barred for every category, and
    /// the deferral is not the same thing as a disclosure class.
    ///
    /// Guidance rows 18 and 19 read *"not to be filled/displayed as of February
    /// 2027 — format still to be specified in the upcoming implementing act"*.
    /// `dueDiligenceUrl` is simultaneously an Annex XIII point 1(d) field and
    /// therefore **public** once it is filled; the two facts sit on different
    /// axes and neither cancels the other. A reader who conflates them will
    /// either publish a value the act has not defined, or withhold one the annex
    /// puts in the public tier.
    #[test]
    fn the_deferred_data_points_are_barred_for_every_category() {
        for field in ["carbonFootprintClass", "dueDiligenceUrl"] {
            for category in ["ev", "lmt", "industrial"] {
                assert_eq!(
                    annex_xiii_requirement(field, category),
                    Requirement::NotApplicable,
                    "{field} is deferred by the guidance for {category}"
                );
            }
            assert!(
                !annex_xiii_requirement(field, "ev").permits_presence(),
                "{field} must not be carried while the format is unspecified"
            );
        }

        // And they are reported by the helper the linter uses, rather than only
        // being absent from the mandatory set. No `Vec` here — this crate is
        // `no_std`.
        let present = ["gtin", "dueDiligenceUrl", "carbonFootprintClass"];
        let mut flagged = fields_not_applicable(&present, "ev");
        assert_eq!(flagged.next(), Some("dueDiligenceUrl"));
        assert_eq!(flagged.next(), Some("carbonFootprintClass"));
        assert_eq!(flagged.next(), None, "gtin is mandatory, not barred");
    }

    #[test]
    fn portable_and_sli_are_unknown_not_exempt() {
        // The guidance covers three categories. Answering "not applicable" for
        // the other two would turn an absence of evidence into a finding.
        for t in ["portable", "starting-lighting-ignition", "sli", ""] {
            assert_eq!(
                annex_xiii_requirement("batteryType", t),
                Requirement::Unknown,
                "{t} is outside the guidance's scope"
            );
        }
    }

    #[test]
    fn an_unnamed_field_is_unknown() {
        assert_eq!(
            annex_xiii_requirement("somethingNobodyHasChecked", "ev"),
            Requirement::Unknown
        );
    }

    #[test]
    fn category_matching_ignores_case_and_padding() {
        assert_eq!(
            annex_xiii_requirement("batteryType", "  EV  "),
            Requirement::Mandatory
        );
    }

    #[test]
    fn not_applicable_is_the_only_verdict_that_bars_presence() {
        assert!(!Requirement::NotApplicable.permits_presence());
        for r in [
            Requirement::Mandatory,
            Requirement::Conditional,
            Requirement::Unknown,
        ] {
            assert!(r.permits_presence());
        }
    }
}