dpp-domain 0.21.0

EU Digital Product Passport domain types, port traits, and per-field disclosure policy
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
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
//! The [`Passport`] aggregate root.

use std::collections::BTreeMap;

use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::{
    ComponentRef, DerivationRef, FacilitySnapshot, LifeStatus, ManufacturerInfo, MaterialEntry,
    PassportId,
};
use crate::catalog::Granularity;
use crate::compliance::ComplianceResult;
use crate::instrument::InstrumentRef;
use crate::seal::SealedEnvelope;
use crate::{
    lint::LintResult,
    product_group::{CarbonFootprint, ProductGroup, ProductGroupData, RepairabilityScore},
    status::PassportStatus,
};

/// The canonical Digital Product Passport record as defined by EU ESPR.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Passport {
    pub id: PassportId,
    /// Optional batch or lot identifier.
    ///
    /// Free text, except on a batch-level passport: its carrier prints this in
    /// GS1 AI 10, so [`Self::validate`] holds it to one to twenty CSET 82
    /// characters there. See [`Self::carrier_qualifier`].
    pub batch_id: Option<String>,
    /// The manufacturer's own serial number for this physical unit, where the
    /// passport is item-level.
    ///
    /// # Why the carrier's serial is not this
    ///
    /// The serial a GS1 data carrier prints in AI 21 is
    /// [`carrier_serial`](Self::carrier_serial), a different field with a
    /// different owner. That one is whatever the operator **attributes** as the
    /// passport's unique identifier — the act Art. 77(3) of Regulation (EU)
    /// 2023/1542 names — and by default it is derived from this record's id.
    /// This one is a fact about the object. An operator may attribute its
    /// manufacturer serial as the carrier serial, and then the two hold the same
    /// value; nothing copies one into the other, because choosing what goes on a
    /// label is the operator's decision rather than a side effect of recording
    /// the unit.
    ///
    /// An item-level passport that cannot state the manufacturer's serial cannot
    /// be matched back to the object by anyone holding it, whatever the label
    /// says — which is why this field exists beside the carrier serial rather
    /// than instead of it.
    ///
    /// Who may see it depends on the product group; see
    /// `PASSPORT_FIELD_DISCLOSURE` for the default and the battery exception.
    ///
    /// # Why it is not required at item level
    ///
    /// [`Granularity::Item`] does not force
    /// it. `granularity` is a delegated-act decision and no adopted act has
    /// fixed a level for any product group we carry, so a hard publish-time
    /// requirement would be enforcing a rule no act has made. It is advisory
    /// until one does.
    ///
    /// `Option`, because the envelope is additive-only: a document written
    /// before this field existed reads back as `None`. `None` means "not an
    /// item-level record, or not stated" — never that the unit has no serial.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub serial_number: Option<String>,
    pub product_name: String,
    /// EU ESPR product group — the delegated-act bucket that selects the applicable
    /// schema and plugin. (Replaces the former misnamed `product_category`
    /// field, which actually held a product group.)
    pub product_group: ProductGroup,
    /// The legal instruments recorded as applicable to this product, fixed when
    /// it was placed on the market.
    ///
    /// **Recorded, never recomputed** — see [`InstrumentRef`]. ESPR Art. 5(7)
    /// lets acts overlap with no precedence rule between them, so this is a set
    /// and the governing law is the union of its members' requirements; and
    /// because a horizontal act can reach a product whose product group no
    /// catalog models, the set cannot be derived from
    /// [`Self::product_group`] at all. That is why it is stored rather than
    /// looked up, and why it is protected from patching: re-deriving it would
    /// silently drop every entry a human had to supply.
    ///
    /// Empty on a record issued before this field existed, which is a statement
    /// that nothing was recorded — not that nothing applies.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub applicable_instruments: Vec<InstrumentRef>,
    /// The level this passport describes: one model, one batch, or one item.
    ///
    /// ESPR Art. 9(2)(d) makes this a **delegated-act decision**, so it is a
    /// property of the applicable law rather than an implementer's choice, and
    /// `None` is the honest answer while no adopted act has fixed a level —
    /// which is every product group today. Do not default it: the EU registry
    /// registers batteries at item level, but that is the registry's operational
    /// position and not a level any act has set.
    ///
    /// It also decides what the data carrier prints after the GTIN — nothing,
    /// a batch, or a serial. See [`Self::carrier_qualifier`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub granularity: Option<Granularity>,
    pub manufacturer: ManufacturerInfo,
    pub materials: Vec<MaterialEntry>,
    /// CO₂ equivalent per unit — manufacturer-supplied or calculated.
    pub co2e_per_unit: Option<CarbonFootprint>,
    /// Repairability score (non-regulatory heuristic — not EN 45554 / EU 2023/1669).
    pub repairability_score: Option<RepairabilityScore>,
    /// The computed compliance determination — status, metrics, binding
    /// `violations` + advisory `warnings`, and (when a calculation ran) a
    /// receipt. Attached by the host at create/update, after it runs the
    /// product group's compliance strategy.
    /// Part of the signed payload and immutable after retention lock. `None`
    /// until a determination is computed (e.g. a product group with no plugin loaded).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compliance_result: Option<ComplianceResult>,
    /// Non-binding plausibility findings from the `dpp-rules` lint pack —
    /// arithmetic and physical-plausibility checks distinct from binding
    /// compliance rules. Never gates publish and may be recomputed at any
    /// time after publish (a lint re-check), unlike `compliance_result`.
    /// `None` until a lint pass has run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lint_result: Option<LintResult>,
    /// Typed, product group-specific DPP data (EU Battery Regulation, Textile DPP, etc.).
    ///
    /// `None` for passports where product group-specific data has not yet been supplied.
    /// Set this field when publishing to ensure regulatory compliance validation.
    pub product_group_data: Option<ProductGroupData>,
    pub status: PassportStatus,
    /// The publicly accessible QR code URL for this passport.
    pub qr_code_url: Option<String>,
    /// The serial this passport's GS1 data carrier prints in AI 21, when the
    /// operator has attributed one. Read it through
    /// [`effective_carrier_serial`](Self::effective_carrier_serial), which
    /// supplies the default when this is `None`.
    ///
    /// Printed only when the passport is item-level or states no level; a
    /// model- or batch-level carrier prints no serial, because a serial with a
    /// GTIN names one individual item. See [`Self::carrier_qualifier`]. A
    /// label printed with this serial still resolves at any level.
    ///
    /// # Why the operator attributes it
    ///
    /// Art. 77(3) of Regulation (EU) 2023/1542: the battery passport *"shall be
    /// accessible through the QR code … which links to a unique identifier that
    /// the economic operator placing the battery on the market shall attribute
    /// to it"*. With a GS1 Digital Link, that identifier is the GTIN together
    /// with this serial, so the serial is the part the operator chooses. An
    /// operator that already serialises its units can attribute that serial; one
    /// that does not accepts the default, [`PassportId::default_carrier_serial`],
    /// which carries no creation time and cannot be guessed from a neighbour's.
    ///
    /// # What it must be
    ///
    /// One to twenty CSET 82 characters, which is AI 21 in GS1's syntax
    /// dictionary; [`Self::validate`] refuses anything else, because the carrier
    /// is built from it and must never print a value GS1 would reject. It is on
    /// the printed label, so it is public by nature.
    ///
    /// # Why it is stored at all
    ///
    /// The label outlives every change to the record. An amendment creates a
    /// new passport with a new id, and a default derived from that id would put
    /// a different serial on the successor than the one printed on the object.
    /// A successor therefore carries its predecessor's effective carrier serial
    /// here, explicitly, so the label resolves to every record in the chain.
    ///
    /// `Option`, because the envelope is additive-only: a document written
    /// before this field existed reads back as `None` and keeps the default it
    /// always had.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub carrier_serial: Option<String>,
    /// Compact JWS signature over the **full** canonical passport payload
    /// (`Disclosure::Conformity` — for authenticated, full-passport verification).
    pub jws_signature: Option<String>,
    /// Compact JWS signature over the **public (redacted) view** of this passport
    /// (`Disclosure::Public`). Lets anyone verify the public passport independently — the
    /// resolver checks this on the unauthenticated `/public/dpp/{id}` route.
    /// Set at publish time; `None` for drafts.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub public_jws_signature: Option<String>,
    /// Compact JWS signatures over the **non-public** redacted views, keyed by
    /// [`disclosure_key`](crate::disclosure::disclosure_key) — e.g.
    /// `public+restricted+individual`.
    ///
    /// Every audience that receives more than the public view needs a proof over
    /// *its* view: `public_jws_signature` covers only the public payload and
    /// `jws_signature` only the full one, so a reader given a filtered body and
    /// either of those holds a signature that cannot verify against the bytes it
    /// received. A repairer or recycler making a safety or resale call on the
    /// data is precisely the caller who must be able to check it.
    ///
    /// **Keyed by disclosure set, never by audience name.** ESPR's actor
    /// vocabulary is not battery Art. 77(2)'s three audiences, and the delegated
    /// act mapping actors to data is unadopted. An artefact named for the data it
    /// covers survives that mapping arriving; one named `"legitimateInterest"`
    /// would need every passport re-signed.
    ///
    /// A `BTreeMap` so serialisation is key-ordered and the signed bytes are
    /// reproducible. Frozen at publish alongside its two siblings, and empty for
    /// drafts.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub disclosure_signatures: BTreeMap<String, String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub published_at: Option<DateTime<Utc>>,
    /// The date this product was placed on the EU market — the regulated
    /// triggering event that fixes **which law governs it**.
    ///
    /// Distinct from every other date on this struct, all of which describe
    /// what *this record* did: `created_at`, `updated_at` and `published_at`
    /// are passport lifecycle, and none of them selects a rule. Staged EU
    /// obligations attach at placing on the market and do not move afterwards —
    /// a product lawfully placed on the market in 2030 does not acquire a 2031
    /// minimum by being reassessed in 2033 — so a determination made against
    /// today's date is wrong for every product not placed on the market today.
    ///
    /// Envelope-level rather than per-product group because the triggering event is
    /// not product group-specific: ESPR attaches its duties at placing on the market
    /// for every product group, as do Regulation (EU) 2023/1542 Art. 7, 8 and
    /// 10 for batteries. It lived only on `BatteryData` before, which made the
    /// governing law underivable for the other eleven product groups.
    ///
    /// `None` means the date was not declared, which is **not** a licence to
    /// substitute the current date. A determination that depends on it has no
    /// answer, and saying so is the answer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub placed_on_market_date: Option<NaiveDate>,
    /// Semantic version of the *product group* schema used to validate this record.
    ///
    /// Scoped to `product_group_data` only — there is no equivalent version for the
    /// envelope fields on this struct. [`Passport::from_stored`] uses this to
    /// decide whether `product_group_data` needs upcasting through a lens before
    /// this record can be re-read. Envelope fields have no such escape hatch
    /// and never will: a lens transforms one product group's sub-object, but an
    /// envelope field is shared by every product group's stored documents, so a
    /// non-additive envelope change would need a transform over the whole
    /// document — one mistake there corrupts every product group at once, not one.
    /// The envelope's rule is therefore additive-only: `Option<T>` +
    /// `#[serde(default)]`, or a rename that keeps accepting the old key, never
    /// a bare requirement added to an existing field.
    ///
    /// **That rule has now been broken twice, deliberately both times, and
    /// recording it is more useful than restating the rule as absolute.**
    /// `sector` → `productGroup` and `parentPassportRef` → `derivedFrom` each
    /// renamed an envelope key without keeping the old one readable. They failed
    /// differently, and the second was the more dangerous: `product_group` is
    /// required, so a pre-rename document refuses to deserialize outright,
    /// whereas this struct sets no `deny_unknown_fields` and `derived_from`
    /// defaults — so a document carrying `parentPassportRef` loaded
    /// *successfully*, silently arriving with no lineage edge at all.
    ///
    /// That silence is now closed: [`REMOVED_ENVELOPE_KEYS`] records the old key
    /// and [`Passport::from_stored`] refuses any document carrying one, so both
    /// renames fail loudly on the supported read path.
    ///
    /// Both were taken while this project has no published passports to strand.
    /// **That licence ends the moment one exists.** After that, an envelope
    /// rename has to carry the old key or a one-time document rewrite in the
    /// publish pipeline. Refusing to read a document is the right failure, but
    /// it is still a failure: a rewritten document no longer verifies against a
    /// signature that covers the old key names, so there is no version of this
    /// that a real passport survives without a migration written for it.
    pub schema_version: String,
    /// Set to `true` permanently on first publish; never unset thereafter.
    ///
    /// Retention-locked passports must remain publicly accessible for the period
    /// defined in the applicable EU ESPR delegated act (typically 10–15 years after
    /// the product's end of life).
    #[serde(default)]
    pub retention_locked: bool,

    // ── 0.2 data-model fields ──────────────────────────────────────────────
    /// Monotonically increasing version counter. `1` on first publish; increments
    /// each time a new passport version supersedes this one (set on the successor).
    #[serde(default = "default_version")]
    pub version: u32,
    /// The passport ID this record supersedes. `None` for first-version passports.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub supersedes_id: Option<PassportId>,
    /// Cross-operator references to the predecessors this passport derives from
    /// (second-life successor linkage), each typed with the operation that
    /// produced this unit from it. Empty unless this record was issued as a
    /// successor citing source passports held by other operators.
    ///
    /// ✅ COMPLIANCE-PIN: EU 2023/1542, Art. 77(7) (OJ L 191, 28.7.2023, p. 73)
    /// — "linked to the battery passport **or passports** of the original
    /// battery **or batteries**". Plural on both sides: one second-life unit may
    /// derive from several predecessors, which is why this is a `Vec`. See
    /// [`DerivationRef`].
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub derived_from: Vec<DerivationRef>,
    /// Cross-operator references to the constituent passports this product is
    /// assembled from — its bill of materials, each qualified by how much and in
    /// what role. Empty for a unit with no modelled sub-assemblies. The inverse
    /// edge of `derived_from`: `component_refs` point down to the constituents,
    /// `derived_from` points up to the predecessors.
    ///
    /// Distinct from [`Passport::materials`], which lists *substances* by weight.
    /// These point at other products that have passports of their own.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub component_refs: Vec<ComponentRef>,
    /// Where this unit sits in its product life — original, or the second-life
    /// operation that produced it, or waste.
    ///
    /// ✅ COMPLIANCE-PIN: EU 2023/1542, Annex XIII point 4(c)
    /// (OJ L 191, 28.7.2023, p. 109). Orthogonal to [`Passport::status`], which
    /// is the *publication* lifecycle: a repurposed unit's passport is
    /// `Published`. See [`LifeStatus`], which also explains why the field is
    /// classified `Individual` and why `Waste` is the one value that is a
    /// transition rather than a create-time property.
    ///
    /// `None` where the product group does not call for one. Only Reg. (EU)
    /// 2023/1542 defines this vocabulary, and a textile passport asserting
    /// `'original'` would be borrowing a battery term for a question its own
    /// instrument does not ask.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub life_status: Option<LifeStatus>,
    /// Deadline by which this record must remain accessible. Confirmed against the
    /// verbatim OJ text (Regulation (EU) 2024/1781): **Art. 9(2)(i)** requires the
    /// delegated act to specify "the period during which the digital product
    /// passport is to remain available, which shall correspond to at least the
    /// expected lifetime of a specific product"; **Art. 11(e)** restates this as an
    /// essential requirement, available "including after an insolvency, a
    /// liquidation or a cessation of activity" of the responsible operator. The
    /// separate back-up-copy obligation (via a DPP service provider) is **Art.
    /// 10(4)**, not the retention period itself.
    /// Computed at publish time from `ProductGroupCatalog::retention_years` for the
    /// product group — the single source of the retention obligation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retention_until: Option<DateTime<Utc>>,
    /// Opaque link to an internal product-template record. Not a legal identifier.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub product_id: Option<Uuid>,
    /// Customs tariff classification (HS-6, CN-8 or TARIC-10).
    ///
    /// Registration data the EU registry stores and verifies against the ranges
    /// its product group permits. `None` where the product group does not call
    /// for one — the regulation qualifies it "where relevant" — and a registry
    /// that requires it will refuse the registration rather than this node
    /// inventing a classification it cannot derive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commodity_code: Option<crate::identifier::CommodityCode>,
    /// EORI or national economic-operator identifier for the responsible party.
    /// Confirmed against the verbatim OJ text (Regulation (EU) 2024/1781):
    /// **Annex III, point (k)** is the data-content basis — "the name, contact
    /// details and unique operator identifier of the economic operator established
    /// in the Union responsible for carrying out the tasks set out in Article 4 of
    /// Regulation (EU) 2019/1020 **or Article 15 of Regulation (EU) 2023/988, or
    /// similar tasks pursuant to other Union law applicable to the product**";
    /// the identifier-issuance mechanics are **Art. 12**. (**Art. 13** governs
    /// uploading identifiers to the EU registry — a related but distinct
    /// obligation, not the field's basis.) Populated by the host from
    /// `operator_config`.
    ///
    /// The emphasised limbs were previously elided behind a `[...]`, and they
    /// are not decorative: **Art. 4(5) of Regulation (EU) 2019/1020** limits
    /// that article to a closed list of instruments which reaches only two of
    /// the product groups this crate models. Which basis applies is recorded on
    /// [`responsible_operator`](Self::responsible_operator), not assumed here.
    ///
    /// **This is the operator that published the passport, frozen at publish —
    /// not necessarily the operator responsible for it now.** A transfer of
    /// responsibility moves the current operator, and the authoritative record
    /// of that is the passport's [`TransferChain`](crate::transfer::TransferChain)
    /// via `current_operator()`. This field is not rewritten by a transfer and
    /// cannot be: a published passport's content is immutable and this value is
    /// covered by the signature over it. Reading it as "who is responsible
    /// today" is wrong for any passport that has changed hands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub operator_identifier: Option<String>,
    /// The economic operator answerable for this product, and the law that makes
    /// it so — Annex III, point (k)'s three elements, carried by value.
    ///
    /// Distinct from [`operator_identifier`](Self::operator_identifier) in both
    /// content and time. That field is an identifier for the *publisher*, frozen
    /// at publish; this is name, contact details **and** identifier for whoever
    /// is *responsible*, which point (k) is actually asking for and which
    /// **Art. 9(1)** requires to be "accurate, complete and up to date".
    ///
    /// Copied by value rather than referring to the transfer chain, the same
    /// choice [`facility`](Self::facility) makes: a signed passport stays a
    /// complete record independent of a registry that can move underneath it.
    /// Keeping it up to date across a transfer therefore means issuing a
    /// corrected successor, not rewriting a published record — the chain remains
    /// the history, this is the statement.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub responsible_operator: Option<crate::operator::ResponsibleOperatorSnapshot>,
    /// Snapshot of the Annex III facility where this product was manufactured or
    /// processed, copied by value at create time. Self-contained so the signed
    /// passport stays a complete record independent of the operator's mutable
    /// facility registry (a retired facility never orphans a published passport).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub facility: Option<FacilitySnapshot>,
    /// The eIDAS qualified electronic seal applied to this passport, if any.
    /// `placeholder: true` on the envelope means no legally valid seal exists yet —
    /// consumers must check this flag rather than inferring validity from presence.
    /// `None` until a seal (real or placeholder) has been applied.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seal: Option<SealedEnvelope>,
}

fn default_version() -> u32 {
    1
}

/// Envelope keys this build has removed, each paired with what replaced it.
///
/// # Why this has to exist
///
/// [`Passport`] deliberately does **not** set `deny_unknown_fields`. A document
/// written by a newer build must stay readable by an older one — that is the
/// point of the envelope's additive-only rule — so an unrecognised key is
/// ignored rather than refused. The cost is that a key which was *removed* is
/// indistinguishable from one this build simply has not learned about yet.
///
/// For a renamed field that is the worst available outcome, and it is not
/// hypothetical: `parentPassportRef` → `derivedFrom` left a stored document
/// carrying the old key deserializing **successfully**, with the new field at
/// its default. The record loads, reports no error, and has silently lost a
/// value its signature still covers — a second-life passport that quietly
/// forgets the predecessors Art. 77(7) requires it to link to.
///
/// [`Passport::from_stored`] checks this list before anything else so that
/// document is refused out loud. A direct `serde_json::from_value::<Passport>`
/// that bypasses `from_stored` still drops the key silently; `from_stored` is
/// the supported way to read a stored document, and this is one of the reasons.
///
/// Entries are permanent. A key removed two renames ago is still a key some
/// stored document may carry.
pub const REMOVED_ENVELOPE_KEYS: &[(&str, &str)] = &[("parentPassportRef", "derivedFrom")];

/// Every key [`Passport`] serialises to, in declaration order.
///
/// # Why this exists
///
/// A consumer that addresses passport JSON *by key* — a JSONB query, an index
/// expression, a database trigger — cannot use the Rust field name and cannot
/// read a Rust constant into SQL. It types the camelCase string literally, and
/// a literal has no relationship to the field it names: rename the field here
/// and the query keeps parsing, keeps running, and silently returns NULL for
/// every row. Nothing fails.
///
/// This is the vocabulary those consumers must check themselves against.
/// `passport_wire_keys_tests` proves it is complete against a fully-populated
/// instance, so a field added or renamed here changes this list, and a consumer
/// gate that compares against it then names the file to fix.
///
/// Serialized (camelCase) names, matching the `Passport` JSON representation.
pub const PASSPORT_WIRE_KEYS: &[&str] = &[
    "id",
    "batchId",
    "serialNumber",
    "productName",
    "productGroup",
    "applicableInstruments",
    "granularity",
    "manufacturer",
    "materials",
    "co2ePerUnit",
    "repairabilityScore",
    "complianceResult",
    "lintResult",
    "productGroupData",
    "status",
    "qrCodeUrl",
    "carrierSerial",
    "jwsSignature",
    "publicJwsSignature",
    "disclosureSignatures",
    "createdAt",
    "updatedAt",
    "publishedAt",
    "placedOnMarketDate",
    "schemaVersion",
    "retentionLocked",
    "version",
    "supersedesId",
    "derivedFrom",
    "componentRefs",
    "lifeStatus",
    "retentionUntil",
    "productId",
    "commodityCode",
    "operatorIdentifier",
    "responsibleOperator",
    "facility",
    "seal",
];

/// The [`Passport`] keys that carry a **proof**, never product content.
///
/// # Why these are not a disclosure class
///
/// The obvious modelling is to give each of these an [`crate::disclosure::Audience`] in
/// [`crate::disclosure::PASSPORT_FIELD_DISCLOSURE`] and let redaction handle them like any other
/// field. That was tried and it is a category error, which produced two
/// different bugs at once:
///
/// - `seal`, `publicJwsSignature` and `disclosureSignatures` had **no** entry,
///   so they defaulted to `Public` and a public view carried all three. The last
///   is the damaging one: those are *attached* compact JWS, so each embeds the
///   full redacted body for its own audience — handing an anonymous reader the
///   `public+restricted+individual` entry hands over the restricted payload
///   itself, not a signature over it.
/// - `jwsSignature` **had** an entry, `Conformity`, so an authority received it
///   attached to a body with individual-item data already removed. It covers the
///   *full* payload, so it verifies against nothing that reader was given. The
///   class did not make the answer safer; it made it wrong.
///
/// These are also classed `Conformity` in [`crate::disclosure::PASSPORT_FIELD_DISCLOSURE`] as
/// defence in depth, so a consumer driving the raw filter fails safe. That is a
/// backstop, not the answer: a class can only choose *which* audiences see a
/// field, and the correct answer here is none of them.
///
/// A proof is not data about the product that some audiences may see. It is a
/// statement about a specific sequence of bytes. The rule that actually holds is:
/// **a view is a payload, and whoever serves it attaches the one proof that
/// covers exactly the bytes being sent.** No audience arithmetic can express
/// that, so redaction removes every one of these unconditionally and leaves
/// attaching the right one to the serving layer.
///
/// `passport_every_wire_key_is_classified` fails the build if a new key is added
/// to [`Passport`] without being placed here, in [`crate::disclosure::PASSPORT_FIELD_DISCLOSURE`],
/// or on that test's explicit public allowlist — because a name list is exactly
/// how `seal` came to be missed in the first place.
///
/// Serialized (camelCase) names, matching the `Passport` JSON representation.
pub const PASSPORT_PROOF_FIELDS: &[&str] = &[
    "jwsSignature",
    "publicJwsSignature",
    "disclosureSignatures",
    "seal",
];

/// The only [`Passport`] keys that may change once `retention_locked` is set.
///
/// Retention-locking a passport freezes its *content* (ESPR: the record must
/// remain available and unaltered for the retention period). It does not freeze
/// the record entirely — a passport is still suspended, sealed, re-linted and
/// re-signed after publication, and each of those writes a key here.
///
/// # Why this belongs in core
///
/// Which fields survive the freeze is a statement about what a retained passport
/// guarantees, so it changes when the domain changes — the Golden Rule's test
/// for core ownership. It was previously stated only inside a PostgreSQL trigger
/// and had to be re-typed **in full, five times** as fields were added
/// (`publicJwsSignature`, `lintResult`, `disclosureSignatures`, `seal`), each
/// time as a fresh migration transcribing all ten strings. Nothing said when a
/// sixth was due: a new post-publish-mutable field simply failed at runtime with
/// `ODAL_RETENTION` the first time something tried to write it on a published
/// record.
///
/// A backend enforcing the freeze must derive its list from this value. SQL
/// cannot read a Rust constant, so a trigger necessarily restates it — but a
/// restatement that is *checked against this* is a copy with a guard, not a
/// second source of truth.
///
/// Serialized (camelCase) names, matching the `Passport` JSON representation.
pub const RETENTION_MUTABLE_FIELDS: &[&str] = &[
    // Lifecycle: suspension and archival are lawful after publication.
    "status",
    "publishedAt",
    "retentionLocked",
    "updatedAt",
    // Proofs: re-signing and sealing land after the content is frozen, and each
    // covers the frozen content rather than altering it.
    "jwsSignature",
    "publicJwsSignature",
    "disclosureSignatures",
    "seal",
    // Serving metadata, not passport content.
    "qrCodeUrl",
    // Advisory plausibility output, explicitly re-computable after publish.
    "lintResult",
];

/// The catalog product group key and recorded schema version a stored document's
/// `productGroupData` was written under, read without assuming the document
/// deserializes into the current shape. `None` if either is absent or
/// malformed — [`Passport::from_stored`] then skips upcasting and lets the
/// final deserialize surface whatever is actually wrong.
fn stored_product_group_version(doc: &serde_json::Value) -> Option<(String, String)> {
    let product_group_data = doc.get("productGroupData")?;
    let tag = product_group_data.get("productGroup")?.as_str()?;
    let product_group_key = ProductGroup::from_wire_tag(tag).catalog_key().to_owned();
    let recorded = doc.get("schemaVersion")?.as_str()?.to_owned();
    Some((product_group_key, recorded))
}

/// Why a value a carrier prints fails GS1 `ai`, for a validation message.
fn gs1_rejection_reason(
    rejection: dpp_rules::common::identifier::Gs1ValueRejection,
    ai: &str,
    max: usize,
) -> String {
    use dpp_rules::common::identifier::Gs1ValueRejection;
    match rejection {
        Gs1ValueRejection::Empty => "must not be empty".to_owned(),
        Gs1ValueRejection::TooLong { chars } => {
            format!("has {chars} characters; GS1 AI {ai} allows at most {max}")
        }
        Gs1ValueRejection::OutsideCset82(c) => {
            format!("contains {c:?}, which is outside GS1 CSET 82")
        }
    }
}

impl Passport {
    /// Deserialize a passport as it was actually stored. Tries the direct,
    /// current-shape deserialize first — most schema evolution is additive
    /// and a document written under an older `schemaVersion` reads directly
    /// with no transform needed, exactly as before this method existed. Only
    /// on failure does it fall back to upcasting `productGroupData` through the
    /// registered lens chain and retrying, so a version gap that needs no
    /// lens (the common case) never pays for one.
    ///
    /// The fallback upcasts as far toward the product group's current version as the
    /// registered lenses reach ([`crate::schemas::lens::LensRegistry::upcast_toward`]),
    /// not to a chain landing on it exactly. A product group whose schema has moved on
    /// additively since its last lens has no hop ending at the current version,
    /// and requiring one would refuse every document the lenses it *does* have
    /// would have made readable. The additive remainder needs no transform by
    /// definition, so the deserialize below closes it.
    ///
    /// **Envelope fields (everything outside `productGroupData`) are not lensed —
    /// deliberately, not an oversight.** A lens transforms one product group's
    /// sub-object; an envelope field is shared by every product group's documents, so
    /// a non-additive envelope change would need a transform over the *whole*
    /// document, and getting that wrong silently corrupts every product group at
    /// once rather than one. The envelope's compatibility rule is simpler and
    /// stricter instead: additive only — see [`Passport::schema_version`]'s doc
    /// comment, which also records the two renames that have broken it and why
    /// that licence is temporary. A stored document that
    /// still fails to deserialize after its `productGroupData` has been upcast is
    /// therefore either genuinely malformed or violates that rule, and this
    /// method does not try to guess which.
    ///
    /// Two distinct failure shapes, both typed rather than a generic error:
    /// - [`crate::error::dpp::DppError::SchemaIncompatible`] — the recorded `schemaVersion` is
    ///   older than current and no registered lens bridges any of the gap.
    ///   This is not always fixable by writing one: a required field the
    ///   document predates (no source data anywhere in the document to derive
    ///   it from) has no honest transform, and this crate will not synthesize
    ///   one.
    /// - [`crate::error::dpp::DppError::Serialisation`] — the direct attempt failed for a reason
    ///   unrelated to a bridgeable version gap (no product group data, product group
    ///   unknown to the catalog, already at the current version, or the
    ///   upcast document still does not match the current shape).
    pub fn from_stored(
        doc: serde_json::Value,
        lenses: &crate::schemas::lens::LensRegistry,
        catalog: &crate::catalog::ProductGroupCatalog,
    ) -> Result<Self, crate::error::dpp::DppError> {
        use crate::error::dpp::DppError;
        use serde::Deserialize as _;

        // Before the direct attempt, not after: a document carrying a removed
        // key deserializes *successfully*, so anything downstream of the happy
        // path would never see it.
        if let Some(object) = doc.as_object() {
            for &(removed, replacement) in REMOVED_ENVELOPE_KEYS {
                if object.contains_key(removed) {
                    return Err(DppError::RemovedEnvelopeKey {
                        removed,
                        replacement,
                    });
                }
            }
        }

        let direct_err = match Self::deserialize(&doc) {
            Ok(passport) => return Ok(passport),
            Err(e) => e,
        };

        let Some((product_group_key, recorded)) = stored_product_group_version(&doc) else {
            return Err(DppError::Serialisation(direct_err.to_string()));
        };
        let Some(current) = catalog.current_schema_version(&product_group_key) else {
            return Err(DppError::Serialisation(direct_err.to_string()));
        };
        if recorded == current {
            return Err(DppError::Serialisation(direct_err.to_string()));
        }

        let product_group_data = doc["productGroupData"].clone();
        let derived = lenses.upcast_str_toward(
            &product_group_key,
            &product_group_data,
            &recorded,
            current,
        )?;
        let mut doc = doc;
        doc["productGroupData"] = derived.data;

        serde_json::from_value(doc).map_err(|e| DppError::Serialisation(e.to_string()))
    }

    /// The serial this passport's data carrier prints in AI 21: the one the
    /// operator attributed, or [`PassportId::default_carrier_serial`] when it
    /// attributed none.
    ///
    /// This is the value a carrier is built from and the value a printed label
    /// is resolved by, and both read it here so that they cannot disagree.
    #[must_use]
    pub fn effective_carrier_serial(&self) -> std::borrow::Cow<'_, str> {
        match &self.carrier_serial {
            Some(attributed) => std::borrow::Cow::Borrowed(attributed),
            None => std::borrow::Cow::Owned(self.id.default_carrier_serial()),
        }
    }

    /// What this passport's data carrier prints after its GTIN, chosen by its
    /// [`granularity`](Self::granularity).
    ///
    /// | `granularity` | Carrier |
    /// |---|---|
    /// | `Model` | `/01/{gtin}` |
    /// | `Batch` | `/01/{gtin}/10/{batch_id}` |
    /// | `Item` | `/01/{gtin}/21/{carrier serial}` |
    /// | `None` | `/01/{gtin}/21/{carrier serial}` |
    ///
    /// **`None` prints a serial**, as every carrier did before the level was
    /// consulted. `None` means the level is not stated, not that it is model or
    /// batch, so this does not claim a level the record denies. It also keeps
    /// every such carrier naming exactly one passport, which the GTIN alone
    /// would not once two passports share it. The AAS projection treats an
    /// unstated level the same way.
    ///
    /// `None` when the passport states batch level and has no `batch_id`: it
    /// has no lot to print. [`Self::validate`] refuses that record, so this
    /// is only reachable for one that never went through it.
    ///
    /// This is the value a carrier is built from and the value a printed label
    /// is resolved by — see
    /// [`find_by_carrier`](crate::ports::passport_repo::PassportRepository::find_by_carrier).
    #[must_use]
    pub fn carrier_qualifier(&self) -> Option<super::CarrierQualifier<'_>> {
        use super::CarrierQualifier;
        match self.granularity {
            Some(Granularity::Model) => Some(CarrierQualifier::Model),
            Some(Granularity::Batch) => self
                .batch_id
                .as_deref()
                .map(|batch| CarrierQualifier::Batch(std::borrow::Cow::Borrowed(batch))),
            Some(Granularity::Item) | None => {
                Some(CarrierQualifier::Serial(self.effective_carrier_serial()))
            }
        }
    }

    /// Validate the passport's own field invariants.
    ///
    /// Checks:
    /// - `carrier_serial`, if attributed, is one to twenty CSET 82 characters
    ///   (GS1 AI 21)
    /// - a stated `granularity` agrees with the identifiers the record carries:
    ///   a model-level passport has no `batch_id` or `serial_number`, and a
    ///   batch-level one has a `batch_id` its carrier can print in GS1 AI 10
    ///   and no `serial_number`
    /// - `product_name` is non-empty
    /// - `manufacturer.name` is non-empty
    /// - `manufacturer.address` is non-empty
    /// - `manufacturer.country`, if present, is an assigned ISO 3166-1 alpha-2 code
    /// - `schema_version` follows semver pattern (x.y.z)
    /// - `co2e_per_unit` is non-negative if present
    /// - `repairability_score` is in range [0.0, 10.0] if present
    /// - `product_group_data.product group()` matches `self.product_group` if present
    /// - for `ProductGroup::UnsoldGoods`, the disclosure carries at least one
    ///   product line (Impl. Reg. (EU) 2026/2 Annex I). No Annex VII scope check:
    ///   that is Art. 25's destruction ban, not Art. 24's disclosure duty
    ///
    /// **A write-time check, not a verdict on a fetched passport.** These are
    /// the invariants a record must meet to be created or published. A passport
    /// fetched from another operator is signed and cannot be rewritten by
    /// anyone, so a rule added here later — the granularity check is one —
    /// would turn a record that was valid when it was signed into a false
    /// failure. Judge a fetched passport by verifying its signature, not with
    /// this.
    ///
    /// **This does not validate `product_group_data` against its JSON Schema**,
    /// and does not run the cross-field regulatory rules. That pass needs the
    /// versioned schema registry — and through it `jsonschema` and a blocking
    /// HTTP client — which an aggregate stating its own invariants must not
    /// depend on. It is why this method is the same on every target, `wasm32`
    /// included.
    ///
    /// **For both halves, call [`crate::validation::validate_passport`]**, which
    /// runs this and then [`crate::validation::validate_product_group_data`].
    pub fn validate(&self) -> Result<(), crate::error::dpp::DppError> {
        use crate::field_error::{FieldError, ValidationErrors};

        let mut errors: Vec<FieldError> = Vec::new();

        if let Some(serial) = &self.carrier_serial
            && let Err(rejection) = dpp_rules::common::identifier::check_gs1_serial(serial)
        {
            errors.push(FieldError {
                field: "/carrierSerial".to_owned(),
                message: format!(
                    "carrier_serial {}",
                    gs1_rejection_reason(
                        rejection,
                        "21",
                        dpp_rules::common::identifier::MAX_GS1_SERIAL_CHARS
                    )
                ),
            });
        }

        self.check_granularity_carries_its_identifiers(&mut errors);

        if self.product_name.trim().is_empty() {
            errors.push(FieldError {
                field: "/productName".to_owned(),
                message: "product_name must not be empty".to_owned(),
            });
        }
        if self.manufacturer.name.trim().is_empty() {
            errors.push(FieldError {
                field: "/manufacturer/name".to_owned(),
                message: "manufacturer.name must not be empty".to_owned(),
            });
        }
        if self.manufacturer.address.trim().is_empty() {
            errors.push(FieldError {
                field: "/manufacturer/address".to_owned(),
                message: "manufacturer.address must not be empty".to_owned(),
            });
        }
        if let Err(code) = self.manufacturer.validate_country() {
            errors.push(FieldError {
                field: "/manufacturer/country".to_owned(),
                message: format!(
                    "manufacturer.country must be an ISO 3166-1 alpha-2 code, got {code:?}"
                ),
            });
        }

        // Must parse as strict semver (major.minor.patch, optional pre-release
        // / build metadata). A hand-rolled digit check let ".5.0" (empty major)
        // and "1.0.abc" (non-numeric patch) through — both then fail
        // `semver::Version` parsing at schema resolution and silently skip
        // schema validation, so reject them here rather than downstream.
        if self.schema_version.parse::<semver::Version>().is_err() {
            errors.push(FieldError {
                field: "/schemaVersion".to_owned(),
                message: "schema_version must be valid semver (e.g. 1.0.0)".to_owned(),
            });
        }

        if let Some(ref cf) = self.co2e_per_unit
            && cf.value_kg < 0.0
        {
            errors.push(FieldError {
                field: "/co2ePerUnit".to_owned(),
                message: "co2e_per_unit must not be negative".to_owned(),
            });
        }

        if let Some(ref rs) = self.repairability_score
            && !(0.0..=10.0).contains(&rs.overall)
        {
            errors.push(FieldError {
                field: "/repairabilityScore".to_owned(),
                message: "repairability_score must be between 0.0 and 10.0".to_owned(),
            });
        }

        // The declared product group must match the product group of the typed data, if present.
        if let Some(ref data) = self.product_group_data
            && data.product_group() != self.product_group
        {
            errors.push(FieldError {
                field: "/product_group".to_owned(),
                message: "product_group must match product_group_data's product_group".to_owned(),
            });
        }

        // Two fields carry the placing-on-market date: this envelope one, which
        // every product group has and which a determination reads, and battery's own,
        // which shipped first and is in released schemas. They must not
        // disagree — the date selects which law binds the product, so two
        // answers is two different sets of obligations, and nothing downstream
        // can tell which was meant.
        //
        // Not a duplicated *regulated* field: `placedOnMarketDate` is absent
        // from the Commission's battery data-point guidance. It was added to
        // drive the Art. 8 phase determination, which is why promoting it here
        // costs no Annex XIII coverage.
        if let Some(ProductGroupData::Battery(battery)) = &self.product_group_data
            && let (Some(envelope), Some(product_group)) =
                (self.placed_on_market_date, battery.placed_on_market_date)
            && envelope != product_group
        {
            errors.push(FieldError {
                field: "/productGroupData/placedOnMarketDate".to_owned(),
                message: format!(
                    "placed_on_market_date disagrees with the passport's own \
                     ({product_group} vs {envelope}); the date fixes which law governs \
                     this battery, so it cannot have two values"
                ),
            });
        }

        // An unsold-goods record is a disclosure by an undertaking over a
        // financial year, not a product placed on the market, so the envelope's
        // `commodity_code` has nothing to describe: the categories are on the
        // lines, and there are many of them.
        //
        // This deliberately no longer requires Annex VII scope. **Art. 24
        // (disclosure) and Art. 25 (destruction ban) have different scopes** —
        // the ban reaches Annex VII's apparel and footwear, while the disclosure
        // reaches discarded unsold *consumer products* generally, which Impl.
        // Reg. (EU) 2026/2 Annex II illustrates across 45 CN headings from soap
        // to refrigerators. Requiring Annex VII here rejected every lawful
        // disclosure outside apparel and footwear.
        if self.product_group == ProductGroup::UnsoldGoods
            && let Some(ProductGroupData::UnsoldGoods(report)) = &self.product_group_data
            && report.lines.is_empty()
        {
            errors.push(FieldError {
                field: "/productGroupData/lines".to_owned(),
                message: "an unsold-goods disclosure must carry at least one product line \
                          (Impl. Reg. (EU) 2026/2 Annex I)"
                    .to_owned(),
            });
        }

        // Schema conformance is deliberately NOT checked here. It needs the
        // versioned schema registry, which drags `jsonschema` and through it a
        // blocking HTTP client, and an aggregate that cannot state its own
        // invariants without a network stack in the tree is the wrong shape.
        // `validation::validate_passport` runs both halves; this method is the
        // invariants alone, and is the same on every target.
        if errors.is_empty() {
            Ok(())
        } else {
            Err(crate::error::dpp::DppError::Validation(ValidationErrors {
                errors,
            }))
        }
    }

    /// The level-dependent half of [`Self::validate`]: a stated
    /// [`granularity`](Self::granularity) and the identifiers the record
    /// carries must agree, because the carrier is built from the two together.
    ///
    /// - **Model** covers every batch and every unit of the model, so it carries
    ///   neither a `batch_id` nor a `serial_number`. A model-level record naming
    ///   one batch describes that batch, not the model — and the EU registry
    ///   confirms a passport's conformity with its granularity level on
    ///   submission (Implementing Regulation (EU) 2026/1778 Art. 8(7)(c)).
    /// - **Batch** carries the `batch_id` its carrier prints in AI 10 — one to
    ///   twenty CSET 82 characters — and no `serial_number`, which would name
    ///   one unit of the run.
    /// - **Item**, and a level not stated, add nothing here. An item may carry
    ///   its batch: Art. 8(4) of that Regulation links an item-level passport
    ///   to its batch and model. And `batch_id` stays free text at those
    ///   levels, because no carrier prints it.
    fn check_granularity_carries_its_identifiers(
        &self,
        errors: &mut Vec<crate::field_error::FieldError>,
    ) {
        use crate::field_error::FieldError;

        let finer_than_the_level = |field: &str, level: &str| FieldError {
            field: format!("/{field}"),
            message: format!(
                "{field} names something finer than a {level}-level passport covers; \
                 it belongs on a finer-grained passport"
            ),
        };

        match self.granularity {
            Some(Granularity::Model) => {
                if self.batch_id.is_some() {
                    errors.push(finer_than_the_level("batchId", "model"));
                }
                if self.serial_number.is_some() {
                    errors.push(finer_than_the_level("serialNumber", "model"));
                }
            }
            Some(Granularity::Batch) => {
                match self.batch_id.as_deref() {
                    None => errors.push(FieldError {
                        field: "/batchId".to_owned(),
                        message: "a batch-level passport must carry the batch_id its \
                                  carrier prints in GS1 AI 10"
                            .to_owned(),
                    }),
                    Some(batch) => {
                        if let Err(rejection) = dpp_rules::common::identifier::check_gs1_lot(batch)
                        {
                            errors.push(FieldError {
                                field: "/batchId".to_owned(),
                                message: format!(
                                    "batch_id {}",
                                    gs1_rejection_reason(
                                        rejection,
                                        "10",
                                        dpp_rules::common::identifier::MAX_GS1_LOT_CHARS
                                    )
                                ),
                            });
                        }
                    }
                }
                if self.serial_number.is_some() {
                    errors.push(finer_than_the_level("serialNumber", "batch"));
                }
            }
            Some(Granularity::Item) | None => {}
        }
    }

    /// Transition the passport to a new status, enforcing the state machine.
    ///
    /// Valid transitions — the whole table, since this method decides nothing
    /// itself and defers to [`PassportStatus::can_transition_to`]:
    /// ```text
    /// Draft     → Published | Retired
    /// Published → Suspended | Retired | Superseded | Deactivated
    /// Suspended → Published  | Retired | Deactivated
    /// ```
    /// `Retired`, `Superseded` and `Deactivated` are terminal.
    ///
    /// On the first `Draft → Published` transition this method also:
    /// - Sets `retention_locked = true` (ESPR retention obligation).
    /// - Sets `published_at` to the current timestamp.
    /// - Updates `updated_at`.
    pub fn transition_to(
        &mut self,
        next: PassportStatus,
    ) -> Result<(), crate::error::dpp::DppError> {
        if !self.status.can_transition_to(&next) {
            return Err(crate::error::dpp::DppError::InvalidTransition {
                current: self.status.to_string(),
                required: next.to_string(),
            });
        }

        let now = chrono::Utc::now();

        // First publish: gate on mandatory content, then lock retention and
        // record the timestamp.
        if next == PassportStatus::Published && self.published_at.is_none() {
            self.check_mandatory_content()?;
            self.retention_locked = true;
            self.published_at = Some(now);
        }

        self.status = next;
        self.updated_at = now;
        Ok(())
    }

    /// Refuse a first publish that omits content the battery's category makes
    /// mandatory — **where Art. 77(1) reaches the record at all**.
    ///
    /// # Two questions, asked in order
    ///
    /// *Is a passport owed?* comes first, and only then *what must it contain?*
    /// [`dpp_rules::batteries::passport_scope`] answers the first from the
    /// category, the capacity and the placing date; this function answers the
    /// second. Asking only the second holds an industrial battery at or below
    /// 2 kWh to the full industrial content list, which Art. 77(1) does not
    /// impose on it — the article reaches industrial batteries with "a capacity
    /// **greater than** 2 kWh", and a category-keyed table cannot see the
    /// difference.
    ///
    /// [`check_category_content`](Self::check_category_content) asks the second
    /// question alone, for a caller holding a voluntary passport to its
    /// category's content anyway.
    ///
    /// # Asking without attempting
    ///
    /// Public so a caller can *preview* the gate. [`transition_to`] runs this
    /// same function, so a preview and the refusal it predicts cannot drift —
    /// and because it takes `&self` and returns the same [`DppError`], a
    /// consumer can ask the question without a state change and render the
    /// answer byte-identically.
    ///
    /// Being able to ask is not being able to decline: the gate still runs
    /// inside `transition_to`, and `status`/`published_at` remain unsettable by
    /// hand, so there is no path to publishing that skips it.
    ///
    /// A failure names **every** missing field at once rather than the first,
    /// so one call is a complete answer.
    ///
    /// [`transition_to`]: Passport::transition_to
    /// [`DppError`]: crate::error::dpp::DppError
    ///
    /// # Why this is a hard gate and not a lint
    ///
    /// A passport missing content the law requires is not a passport with a
    /// quality problem — it is one that should not exist. Putting the check in
    /// `dpp-domain` rather than in a consumer means no caller can opt out of
    /// it: a check in one consumer would be bypassed by the next.
    ///
    /// # Why only on the *first* publish
    ///
    /// `transition_to` also runs on `Suspended → Published`. Gating a republish
    /// would let a later change to the requirements table strand a passport
    /// that was lawfully published under the earlier one — the same hazard as a
    /// lens that refuses, and worse, because the operator cannot fix a
    /// retention-locked document. The content is fixed at first publish; that is
    /// where it is judged.
    ///
    /// # Scope
    ///
    /// Battery only, and only for the three categories the source covers. A
    /// portable or SLI battery is **ungated** — the Commission's guidance says
    /// nothing about them, and inventing a requirement it declines to state
    /// would be the defect this crate exists to avoid. That is a real hole and
    /// it is deliberate; it closes when a source covering those categories
    /// exists.
    pub fn check_mandatory_content(&self) -> Result<(), crate::error::dpp::DppError> {
        if self.art_77_1_exempts_this_record() {
            return Ok(());
        }
        self.check_category_content()
    }

    /// Whether Art. 77(1) can be shown **not** to reach this record.
    ///
    /// Deliberately phrased as "can be shown not to", not "does not". Every
    /// path that cannot establish an exemption answers `false`, so the content
    /// gate runs. Three of those paths are worth naming, because each is a way
    /// a statutory gate could otherwise switch itself off in silence:
    ///
    /// - **An unstated capacity is not an exemption.**
    ///   [`PassportScope::CapacityUnknown`] says so in its own documentation —
    ///   the obligation turns on a number the record does not carry, and reading
    ///   the absence as "under the threshold" would exempt a battery on the
    ///   strength of a missing field.
    /// - **An unstated placing date is not an exemption either.** The date is
    ///   substituted with [`PASSPORT_REQUIRED_FROM`] rather than with today,
    ///   which is not a determination of when the product was placed on the
    ///   market — it is the one value that makes the date limb a no-op, leaving
    ///   only the two date-independent exemptions (`NotCovered`,
    ///   `BelowThreshold`) reachable. `NotYetBinding` can never be concluded
    ///   from a date nobody stated, which is the point: a draft for a product
    ///   not yet on the market carries no date, and reading that as "before
    ///   2027" would exempt every draft.
    /// - **A variant added to [`PassportScope`] later is not an exemption.**
    ///   The type is `#[non_exhaustive]`, so the match needs a catch-all, and
    ///   the catch-all gates. A new outcome that *should* exempt then has to say
    ///   so here on purpose, which is the direction that fails safely.
    ///
    /// Answers `false` for a non-battery record and for a battery carrying no
    /// product group data, leaving both to
    /// [`check_category_content`](Self::check_category_content) — the first is
    /// waved through there and the second is a refusal, and neither is Art.
    /// 77(1)'s question to answer.
    ///
    /// [`PassportScope`]: dpp_rules::batteries::passport_scope::PassportScope
    /// [`PassportScope::CapacityUnknown`]: dpp_rules::batteries::passport_scope::PassportScope::CapacityUnknown
    /// [`PASSPORT_REQUIRED_FROM`]: dpp_rules::batteries::passport_scope::PASSPORT_REQUIRED_FROM
    fn art_77_1_exempts_this_record(&self) -> bool {
        use chrono::Datelike;
        use dpp_rules::batteries::passport_scope::{
            PASSPORT_REQUIRED_FROM, PassportScope, passport_scope,
        };
        use dpp_rules::common::date::CalendarDate;

        let Some(crate::product_group::ProductGroupData::Battery(battery)) =
            self.product_group_data.as_ref()
        else {
            return false;
        };

        // 🚨 Two dates, and this path cannot assume they have been reconciled.
        //
        // `validate` does refuse a record whose envelope and battery placing
        // dates disagree — but `transition_to` never calls it. It calls
        // `check_mandatory_content` directly, so a record with two answers
        // reaches here unchecked, and `.or(…)` silently picked the envelope's.
        // An envelope date before 18 February 2027 beside a later battery date
        // therefore produced `NotYetBinding`, and the content gate a published
        // battery has to pass was skipped — an exemption obtained by the record
        // disagreeing with itself.
        //
        // Fail closed instead: disagreement is not an exemption. The record is
        // still refused by `validate` wherever that runs, and until it does, the
        // strict content check is the safe side to be on.
        let placed = match (self.placed_on_market_date, battery.placed_on_market_date) {
            (Some(envelope), Some(product_group)) if envelope != product_group => return false,
            (envelope, product_group) => envelope
                .or(product_group)
                .map_or(PASSPORT_REQUIRED_FROM, |d| {
                    CalendarDate::new(d.year(), d.month() as u8, d.day() as u8)
                }),
        };

        match passport_scope(
            battery.battery_type.wire_str(),
            battery.rated_capacity_kwh,
            placed,
        ) {
            PassportScope::NotCovered
            | PassportScope::BelowThreshold
            | PassportScope::NotYetBinding => true,
            // `Required`, `CapacityUnknown`, and anything added later.
            _ => false,
        }
    }

    /// Refuse a first publish that omits content the battery's category makes
    /// mandatory, **without** asking whether Art. 77(1) reaches the record.
    ///
    /// The strict answer, and the behaviour
    /// [`check_mandatory_content`](Self::check_mandatory_content) had before it
    /// learned to consult scope. It exists because a node may reasonably hold a
    /// *voluntary* passport to its category's content — an industrial battery
    /// at 1,5 kWh owes no passport, and an operator who publishes one anyway is
    /// better served by a complete one than by an unchecked one. That is the
    /// operator's call, so it is a separate function rather than the default.
    ///
    /// Everything `check_mandatory_content` documents about previewing, about
    /// naming every missing field at once, and about the portable/SLI hole
    /// applies here unchanged — this is the same body.
    pub fn check_category_content(&self) -> Result<(), crate::error::dpp::DppError> {
        use crate::field_error::{FieldError, ValidationErrors};

        if self.product_group != crate::product_group::ProductGroup::Battery {
            return Ok(());
        }
        let Some(data) = self.product_group_data.as_ref() else {
            return Err(crate::error::dpp::DppError::Validation(ValidationErrors {
                errors: vec![FieldError {
                    field: "/productGroupData".to_owned(),
                    message: "a battery passport cannot be published without product_group data"
                        .to_owned(),
                }],
            }));
        };
        let Ok(value) = serde_json::to_value(data) else {
            return Ok(());
        };
        let Some(battery_type) = value.get("batteryType").and_then(serde_json::Value::as_str)
        else {
            // batteryType is required by the schema from v2.5.0; if it is absent
            // here the schema check is the right place to say so.
            return Ok(());
        };

        // A key present but null is absent: `skip_serializing_if` means a `None`
        // never reaches the wire, so an explicit null came from somewhere else
        // and carries no value either way.
        let missing: Vec<FieldError> =
            dpp_rules::batteries::passport_content::mandatory_fields(battery_type)
                .filter(|f| value.get(*f).is_none_or(serde_json::Value::is_null))
                .map(|f| FieldError {
                    field: format!("/productGroupData/{f}"),
                    message: format!(
                        "'{f}' is mandatory for a '{battery_type}' battery and is absent; \
                         a passport omitting it does not carry the content the Battery \
                         Regulation requires of this category"
                    ),
                })
                .collect();

        if missing.is_empty() {
            Ok(())
        } else {
            Err(crate::error::dpp::DppError::Validation(ValidationErrors {
                errors: missing,
            }))
        }
    }
}