File size: 53,156 Bytes
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f22968c
 
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
f22968c
 
 
 
 
 
 
 
2b8ea89
f22968c
 
 
 
 
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f0ec41
 
 
f22968c
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
8f0ec41
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f22968c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f0ec41
2b8ea89
 
 
 
 
 
8f0ec41
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f0ec41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b8ea89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f0ec41
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Validation and durable history helpers for the daily refresh pipeline.



Pure helpers only: no network calls and no Streamlit imports.

"""
from __future__ import annotations

import csv
import hashlib
import io
import json
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Mapping

from sinetti_services import certificate_succession, renewal_match
from sinetti_services.event_classification import expired_before
from sinetti_services.returning_holder import classify_returning, format_off_scheme_evidence


_SEVERITY_RANK = {"ok": 0, "warning": 1, "critical": 2}


def _utc_iso(value=None) -> str:
    if value is None:
        value = datetime.now(timezone.utc)
    if isinstance(value, str):
        return value
    if value.tzinfo is None:
        value = value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc).isoformat()


def _norm_header(value: str) -> str:
    return "".join(ch for ch in str(value or "").strip().lower() if ch.isalnum())


def _check(severity: str, code: str, message: str, **context) -> dict:
    out = {"severity": severity, "code": code, "message": message}
    if context:
        out["context"] = context
    return out


def _status(checks: list[dict]) -> str:
    worst = "ok"
    for check in checks:
        sev = check.get("severity", "ok")
        if _SEVERITY_RANK.get(sev, 0) > _SEVERITY_RANK[worst]:
            worst = sev
    return worst


def _ratio(ok: int, total: int) -> float:
    return round((ok / total), 4) if total else 0.0


def _parse_date(value: str) -> bool:
    raw = str(value or "").strip()
    if not raw:
        return False
    for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"):
        try:
            datetime.strptime(raw, fmt)
            return True
        except ValueError:
            pass
    return False


def _collapse_check(checks: list[dict], row_count: int, previous_count: int) -> None:
    if previous_count <= 0:
        return
    if row_count < max(1, int(previous_count * 0.5)):
        checks.append(_check(
            "critical", "row_count_collapse",
            "Row count collapsed versus the previous accepted snapshot.",
            previous_count=previous_count, row_count=row_count,
        ))
    elif row_count > int(previous_count * 2.0):
        checks.append(_check(
            "warning", "row_count_spike",
            "Row count is much larger than the previous accepted snapshot.",
            previous_count=previous_count, row_count=row_count,
        ))


_ISCC_HEADER_GROUPS = {
    "certificate_id": {"certificate"},
    "holder_name": {"certificateholder"},
    "country": {"country"},
    "scope": {"scope"},
    "feedstocks": {"rawmaterial"},
    "valid_from": {"validfrom"},
    "valid_until": {"validuntil"},
    "issuing_cb": {"issuingcb", "certificationbody"},
}


def validate_iscc_csv_text(csv_text: str, *, previous_count: int = 0) -> dict:
    text = str(csv_text or "").lstrip("\ufeff")
    reader = csv.DictReader(io.StringIO(text))
    headers = list(reader.fieldnames or [])
    norm_headers = {_norm_header(h) for h in headers}
    rows = list(reader)
    checks: list[dict] = []
    missing = [
        name for name, aliases in _ISCC_HEADER_GROUPS.items()
        if not (aliases & norm_headers)
    ]
    if missing:
        checks.append(_check(
            "critical", "missing_required_columns",
            "ISCC CSV is missing required columns.",
            missing_columns=missing,
        ))
    if not rows:
        checks.append(_check("critical", "empty_export", "ISCC CSV parsed zero data rows."))
    if text.lstrip().startswith("<"):
        checks.append(_check("critical", "html_response", "ISCC CSV looks like an HTML response."))
    _collapse_check(checks, len(rows), int(previous_count or 0))

    def value_for(row: dict, canonical: str) -> str:
        aliases = _ISCC_HEADER_GROUPS[canonical]
        for key, value in row.items():
            if _norm_header(key) in aliases:
                return str(value or "").strip()
        return ""

    if rows and not missing:
        id_rate = _ratio(sum(1 for r in rows if value_for(r, "certificate_id")), len(rows))
        holder_rate = _ratio(sum(1 for r in rows if value_for(r, "holder_name")), len(rows))
        date_rate = _ratio(sum(1 for r in rows if _parse_date(value_for(r, "valid_until"))), len(rows))
        if id_rate < 0.98:
            checks.append(_check("critical", "low_certificate_id_coverage",
                                 "Too many ISCC rows have no certificate id.", coverage=id_rate))
        if holder_rate < 0.95:
            checks.append(_check("critical", "low_holder_coverage",
                                 "Too many ISCC rows have no holder name.", coverage=holder_rate))
        if date_rate < 0.90:
            checks.append(_check("warning", "low_valid_until_parse_rate",
                                 "Many ISCC valid-until dates did not parse.", coverage=date_rate))
    else:
        date_rate = 0.0

    matched_headers = set().union(*_ISCC_HEADER_GROUPS.values())
    return {
        "status": _status(checks),
        "row_count": len(rows),
        "missing_columns": missing,
        "extra_columns": [h for h in headers if _norm_header(h) not in matched_headers],
        "date_parse_rate": date_rate,
        "checks": checks or [_check("ok", "contract_ok", "ISCC CSV contract passed.")],
    }


_IN_FORCE_STATUSES = frozenset({"active", "valid", "expiring"})


def validate_normalized_certs(scheme: str, certs: list[dict], *, previous_count: int = 0) -> dict:
    rows = list(certs or [])
    checks: list[dict] = []
    if not rows:
        checks.append(_check("critical", "empty_normalized_snapshot",
                             f"{scheme} normalized snapshot has zero rows."))
    _collapse_check(checks, len(rows), int(previous_count or 0))
    required = ["certificate_id", "holder_name", "holder_norm"]
    if str(scheme).lower() != "ins":
        required += ["country", "valid_until"]
    bad_fields = []
    # RSB publishes no holder country for lapsed certificates (its holder-country comes from the
    # certificate PDF, which the register only keeps for certificates still in force). Country
    # coverage for RSB is therefore judged over the in-force rows; every other rule, and every
    # other scheme, is judged over all rows. Brief 2026-09-29-rsb-register-brief.md.
    in_force_rows = [
        row for row in rows
        if str(row.get("status") or "").strip().casefold() in _IN_FORCE_STATUSES
    ]
    for field in required:
        judged = (
            in_force_rows
            if str(scheme).lower() == "rsb" and field == "country" and in_force_rows
            else rows
        )
        coverage = _ratio(sum(1 for row in judged if str(row.get(field, "")).strip()), len(judged))
        if coverage < 0.90:
            bad_fields.append(field)
            checks.append(_check(
                "critical", "low_field_coverage",
                f"{scheme} field coverage is below threshold.",
                field=field, coverage=coverage,
            ))
    statuses = sorted({str(row.get("status") or row.get("raw_status") or "").strip() for row in rows})
    return {
        "status": _status(checks),
        "row_count": len(rows),
        "bad_fields": bad_fields,
        "status_values": [s for s in statuses if s][:25],
        "checks": checks or [_check("ok", "contract_ok", f"{scheme} normalized contract passed.")],
    }


def validate_history_retention(

    scheme: str,

    old_rows: list[dict],

    new_rows: list[dict],

    *,

    today: str | None = None,

) -> dict:
    """Guard for registers that keep their expired certificates (REDcert publishes history back

    to 2010). Our snapshot write is replace-all, so if the register ever trims old rows the trim

    would silently propagate into our corpus on the next refresh. Expired rows leaving the export

    are anomalous for such a register: any disappearance is a warning, and a disappearance beyond

    max(25, 1% of the previously expired rows) is critical β€” the refresh keeps the previous

    snapshot. Not for valid-only feeds (ISCC), where expired rows leaving is normal lifecycle."""
    checks: list[dict] = []
    today = today or datetime.now(timezone.utc).date().isoformat()
    new_ids = {
        str(row.get("certificate_id") or "").strip().casefold()
        for row in (new_rows or [])
        if str(row.get("certificate_id") or "").strip()
    }
    expired_old = [
        row for row in (old_rows or [])
        if len(str(row.get("valid_until") or "").strip()) == 10
        and str(row.get("valid_until")).strip() < today
    ]
    missing = [
        row for row in expired_old
        if str(row.get("certificate_id") or "").strip().casefold() not in new_ids
    ]
    threshold = max(25, int(0.01 * len(expired_old)))
    if missing:
        severity = "critical" if len(missing) > threshold else "warning"
        checks.append(_check(
            severity, "history_trim",
            f"{scheme} export dropped previously expired certificates β€” possible register "
            "history trim.",
            missing_expired=len(missing),
            expired_previous=len(expired_old),
            threshold=threshold,
            examples=sorted(
                str(row.get("certificate_id") or "").strip() for row in missing
            )[:5],
        ))
    return {
        "status": _status(checks),
        "row_count": len(new_rows or []),
        "missing_expired": len(missing),
        "checks": checks or [_check(
            "ok", "history_retained",
            f"{scheme} export still carries all previously expired certificates.",
        )],
    }


def validate_tabular_headers(source: str, rows: list[dict], header_groups: dict[str, set[str]]) -> dict:
    rows = list(rows or [])
    checks: list[dict] = []
    if not rows:
        checks.append(_check("critical", "empty_table", f"{source} parsed zero rows."))
        headers = set()
    else:
        headers = {_norm_header(k) for row in rows[:10] for k in row}
    normalized_groups = {
        name: {_norm_header(alias) for alias in aliases}
        for name, aliases in (header_groups or {}).items()
    }
    missing = [
        name for name, aliases in normalized_groups.items()
        if not (aliases & headers)
    ]
    if missing:
        checks.append(_check(
            "critical", "missing_required_columns",
            f"{source} is missing required columns.",
            missing_columns=missing,
        ))
    return {
        "status": _status(checks),
        "row_count": len(rows),
        "missing_columns": missing,
        "checks": checks or [_check("ok", "contract_ok", f"{source} header contract passed.")],
    }


def validate_compliance_records(records: list[dict], *, previous_count: int = 0) -> dict:
    rows = list(records or [])
    checks: list[dict] = []
    _collapse_check(checks, len(rows), int(previous_count or 0))
    if not rows:
        checks.append(_check("warning", "empty_compliance_snapshot",
                             "Compliance snapshot has zero rows."))
    status_values = {str(r.get("status") or r.get("raw_status") or "").lower() for r in rows}
    unexpected = sorted(v for v in status_values if v and v not in {"suspended", "withdrawn"})
    if unexpected:
        checks.append(_check("warning", "unexpected_compliance_status",
                             "Compliance snapshot contains unexpected status values.",
                             values=unexpected[:10]))
    id_rate = _ratio(sum(1 for r in rows if str(r.get("certificate_id", "")).strip()), len(rows))
    if rows and id_rate < 0.90:
        checks.append(_check("critical", "low_certificate_id_coverage",
                             "Too many compliance rows have no certificate id.", coverage=id_rate))
    return {
        "status": _status(checks),
        "row_count": len(rows),
        "status_values": sorted(v for v in status_values if v),
        "checks": checks or [_check("ok", "contract_ok", "Compliance contract passed.")],
    }


def validate_blocklist_records(records: list[dict], *, previous_count: int = 0) -> dict:
    rows = list(records or [])
    checks: list[dict] = []
    _collapse_check(checks, len(rows), int(previous_count or 0))
    if not rows:
        checks.append(_check("warning", "empty_blocklist_snapshot",
                             "Blocklist snapshot has zero rows."))
    category_rate = _ratio(sum(1 for r in rows if str(r.get("category", "")).strip()), len(rows))
    name_rate = _ratio(sum(
        1 for r in rows
        if str(r.get("name") or r.get("holder_name") or r.get("company") or "").strip()
    ),
                       len(rows))
    if rows and category_rate < 0.95:
        checks.append(_check("critical", "low_category_coverage",
                             "Too many blocklist rows have no category.", coverage=category_rate))
    if rows and name_rate < 0.90:
        checks.append(_check("critical", "low_name_coverage",
                             "Too many blocklist rows have no name.", coverage=name_rate))
    return {
        "status": _status(checks),
        "row_count": len(rows),
        "checks": checks or [_check("ok", "contract_ok", "Blocklist contract passed.")],
    }


def validate_ins_blobs(blobs: dict) -> dict:
    checks: list[dict] = []
    counts = {k: len(blobs.get(k) or []) for k in ("operatori", "tipologie", "prodotti")}
    if counts["operatori"] <= 0:
        checks.append(_check("critical", "missing_operatori_blob",
                             "INS operator blob is missing or empty."))
    for key in ("tipologie", "prodotti"):
        if counts[key] <= 0:
            checks.append(_check("warning", f"missing_{key}_blob",
                                 f"INS {key} blob is missing or empty."))
    return {
        "status": _status(checks),
        "row_count": counts["operatori"],
        "blob_counts": counts,
        "checks": checks or [_check("ok", "contract_ok", "INS embedded blobs passed.")],
    }


def combine_validations(*reports: dict) -> dict:
    checks: list[dict] = []
    missing_columns: list[str] = []
    bad_fields: list[str] = []
    row_count = 0
    for report in reports:
        if not isinstance(report, dict):
            continue
        checks.extend(report.get("checks", []) or [])
        missing_columns.extend(report.get("missing_columns", []) or [])
        bad_fields.extend(report.get("bad_fields", []) or [])
        row_count = max(row_count, int(report.get("row_count") or 0))
    return {
        "status": _status(checks),
        "row_count": row_count,
        "missing_columns": sorted(set(missing_columns)),
        "bad_fields": sorted(set(bad_fields)),
        "checks": checks or [_check("ok", "contract_ok", "Combined contract passed.")],
    }


def validation_is_critical(report: dict) -> bool:
    return (report or {}).get("status") == "critical"


def append_jsonl(path: str | Path, record: dict) -> None:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    with p.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(record, sort_keys=True, ensure_ascii=False) + "\n")


def read_jsonl(path: str | Path, *, limit: int | None = None) -> dict:
    p = Path(path)
    records: list[dict] = []
    skipped = 0
    if not p.exists():
        return {"records": [], "skipped": 0}
    for line in p.read_text(encoding="utf-8").splitlines():
        raw = line.strip()
        if not raw:
            continue
        try:
            value = json.loads(raw)
        except json.JSONDecodeError:
            skipped += 1
            continue
        if isinstance(value, dict):
            records.append(value)
        else:
            skipped += 1
    if limit is not None:
        records = records[-max(0, int(limit)):]
    return {"records": records, "skipped": skipped}


def sha256_file(path: str | Path) -> str:
    p = Path(path)
    h = hashlib.sha256()
    with p.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def content_sha256(records: list[dict]) -> str:
    """Order-independent content hash over a list of records. Unlike sha256_file(), this hashes

    the DATA, not a container β€” a gzip file's header embeds the write timestamp, so hashing the

    file makes byte-identical content produce a different hash on every run (see

    fingerprint_record's `records` param)."""
    canon = sorted(
        json.dumps(r, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
        for r in records
    )
    return hashlib.sha256("".join(canon).encode("utf-8")).hexdigest()


def fingerprint_record(*, run_id: str, scheme: str, kind: str, path: str | Path,

                       count: int, source: str = "daily_refresh",

                       accepted_at=None, records: list[dict] | None = None) -> dict:
    return {
        "run_id": run_id,
        "scheme": scheme,
        "kind": kind,
        "count": int(count or 0),
        "sha256": content_sha256(records) if records is not None else sha256_file(path),
        "source": source,
        "accepted_at": _utc_iso(accepted_at),
    }


# A certificate that leaves a risky status for one of these did not come back β€” it ended.
_TERMINAL_STATUSES = {"expired", "terminated", "withdrawn"}

_RISKY_STATUSES = {
    "suspended": ("certificate_suspended", "high"),
    # Authority cancellation across every register (REDcert "entzogen", INS "ANNULLATO",
    # LCFS/BC/CFR "withdrawn") now lands on the single harmonised status, so it emits the
    # single event type. "certificate_revoked_or_terminated" survives for terminated only:
    # the event log is append-only, so its stored name keeps its historical spelling.
    "withdrawn": ("certificate_withdrawn", "high"),
    "terminated": ("certificate_revoked_or_terminated", "low"),
}


def _cert_key(cert: dict) -> str:
    return str(cert.get("certificate_id") or "").strip()


def _status_value(cert: dict) -> str:
    return str(cert.get("status") or cert.get("raw_status") or "").strip().lower()


def _risk_category(event_type: str, *, status: str = "") -> str:
    if event_type == "certificate_revoked_or_terminated" and status == "terminated":
        return "status_change"
    if event_type in {"certificate_suspended", "certificate_withdrawn", "certificate_revoked_or_terminated"}:
        return "compliance_risk"
    if event_type in {"certificate_removed_from_valid_list", "certificate_reinstated"}:
        return "status_change"
    if event_type in {"holder_new", "holder_returning"}:
        return "market_intelligence"
    return "operational"


def _as_str_list(value) -> list[str]:
    if value is None:
        return []
    if isinstance(value, (list, tuple, set)):
        return [str(item).strip() for item in value if str(item).strip()]
    text = str(value).strip()
    return [text] if text else []


def _cert_context(cert: dict) -> dict:
    return {
        "country": str(cert.get("country") or "").strip(),
        "scope": _as_str_list(cert.get("scope")),
        "feedstocks": _as_str_list(cert.get("feedstocks")),
        "valid_from": str(cert.get("valid_from") or "").strip(),
        "valid_until": str(cert.get("valid_until") or "").strip(),
        "issuing_cb": str(cert.get("issuing_cb") or "").strip(),
        "status": _status_value(cert),
    }


def _event(run_id: str, scheme: str, event_type: str, severity: str, confidence: str,

           cert: dict, *, detected_at, evidence: str, old=None, new=None) -> dict:
    new = new or {}
    status = str((new.get("status") if isinstance(new, dict) else "") or "").strip().lower()
    if not status:
        status = _status_value(cert)
    event = {
        "run_id": run_id,
        "detected_at": _utc_iso(detected_at),
        "scheme": scheme,
        "event_type": event_type,
        "severity": severity,
        "risk_category": _risk_category(event_type, status=status),
        "confidence": confidence,
        "certificate_id": _cert_key(cert),
        "holder_norm": str(cert.get("holder_norm", "")),
        "holder_name": str(cert.get("holder_name", "")),
        "old": old or {},
        "new": new,
        "evidence": evidence,
    }
    event.update(_cert_context(cert))
    # ISCC publishes the suspension window on the compliance register (`cert_suspended_from` /
    # `cert_suspended_until`). Keep it on the event so an episode has real dates instead of only
    # the observation interval between two nightly runs. Absent keys stay absent: every other
    # event type would otherwise gain two permanently empty fields.
    for key in ("suspended_date", "suspended_until"):
        value = str(cert.get(key) or "").strip()
        if value:
            event[key] = value
    return event

# Event types that settle a withdrawn certificate's story: once one is on the append-only log
# for a certificate, the held-withdrawal branch below must never fire for it again.
_SETTLING_EVENT_TYPES = frozenset({
    "certificate_withdrawn", "certificate_replaced", "certificate_revoked_or_terminated",
})

# A held withdrawal older than this is left alone: it predates event logging (or the log lost
# it), and the row already reads withdrawn. Keeps a first run with an incomplete log from
# emitting a night of thousands of stale high-severity events.
_HELD_WITHDRAWAL_MAX_AGE_DAYS = 60


def settled_certificate_ids(events: list[dict], scheme: str) -> set[str]:
    """Certificate ids of `scheme` that already carry a withdrawn / replaced / terminated event."""
    out: set[str] = set()
    for event in events or []:
        if not isinstance(event, dict):
            continue
        if str(event.get("scheme") or "") != scheme:
            continue
        if str(event.get("event_type") or "") not in _SETTLING_EVENT_TYPES:
            continue
        cid = str(event.get("certificate_id") or "").strip()
        if cid:
            out.add(cid)
    return out


def _days_since(day_text: object, detected_iso: str) -> int | None:
    day = certificate_succession._parse_date(day_text)
    detected = certificate_succession._parse_date(detected_iso)
    if day is None or detected is None:
        return None
    return (detected - day).days


def _replaced_event(run_id: str, scheme: str, pair, *, detected_at, old_status: str) -> dict:
    old_id = _cert_key(pair.old)
    new_id = _cert_key(pair.new)
    changes = list(pair.changes)
    change_text = "; ".join(changes) if changes else "no visible scope or feedstock change"
    event = _event(
        run_id, scheme, "certificate_replaced", "low", "inferred", pair.old,
        detected_at=detected_at,
        evidence=(
            f"{scheme} re-issued this certificate under a new number: {new_id} starts "
            f"{pair.new.get('valid_from') or '?'} ({change_text}). The register marks the old "
            f"number withdrawn when it re-issues; this is not an enforcement action."
        ),
        old={"status": old_status, "certificate_id": old_id},
        new={
            "status": _status_value(pair.old),
            "replaced_by": new_id,
            "valid_from": str(pair.new.get("valid_from") or ""),
            "valid_until": str(pair.new.get("valid_until") or ""),
            "changes": changes,
        },
    )
    event["replaced_by"] = new_id
    event["replacement_changes"] = changes
    event["replacement_gap_days"] = pair.gap_days
    return event


def build_change_events(run_id: str, scheme: str, old_certs: list[dict], new_certs: list[dict],

                        *, detected_at=None, status_authority: list[dict] | None = None,

                        authority_source: str | None = None,

                        prior_coverage: Mapping[tuple[str, str], dict] | None = None,

                        settled_certificate_ids: set[str] | None = None) -> list[dict]:
    """Diff two snapshots of one scheme into change events.



    `settled_certificate_ids` -- certificate ids that already carry a withdrawn / replaced /

    terminated event on the log (see `settled_certificate_ids()`). When given, a REDcert

    withdrawal with no successor yet is HELD for `certificate_succession.REPLACEMENT_WINDOW_DAYS`

    and fires high only once that window passes with no replacement; when None, the flip fires

    immediately as before (callers that do not keep a log)."""
    old_by_id = {_cert_key(c): c for c in (old_certs or []) if _cert_key(c)}
    new_by_id = {_cert_key(c): c for c in (new_certs or []) if _cert_key(c)}
    authority_by_id = {_cert_key(c): c for c in (status_authority or []) if _cert_key(c)}
    source_label = (authority_source or "").strip() or f"{scheme} status authority"
    old_holders = {
        str(c.get("holder_norm", "")).strip()
        for c in (old_certs or [])
        if str(c.get("holder_norm", "")).strip()
    }
    events: list[dict] = []
    detected_iso = _utc_iso(detected_at)

    # ISCC and REDcert renumber on renewal, so a routine renewal reaches us as one certificate
    # disappearing and a differently-numbered one appearing. Pair those up BEFORE classifying
    # either side: unpaired, the successor reads as a brand-new certificate and the superseded
    # certificate reads as an exit β€” a false high-severity "removed while still valid" alarm
    # whenever the renewal was issued before the old certificate expired.
    renewals = renewal_match.match_renewals(
        [old for cid, old in old_by_id.items() if cid not in new_by_id],
        [new for cid, new in new_by_id.items() if cid not in old_by_id],
    )
    renewed_from = {_cert_key(pair.new): pair.old for pair in renewals}
    superseded = {_cert_key(pair.old) for pair in renewals}

    # REDcert marks the OLD number withdrawn when it re-issues a certificate (scope change,
    # correction, early renewal) -- 2,758 of its 3,744 withdrawn certificates have a same-holder
    # successor within 3 days. Read literally, that is a high-severity enforcement signal on a
    # holder that simply got a new number. Pair withdrawn rows with their successor in the NEW
    # snapshot first; a paired withdrawal becomes one low-severity `certificate_replaced` and its
    # successor's appearance is folded into it (brief 2026-09-17).
    replacement_by_old: dict[str, certificate_succession.Replacement] = {}
    if scheme in certificate_succession.REPLACEMENT_SCHEMES:
        for pair in certificate_succession.match_replacements(list(new_by_id.values()), scheme=scheme):
            replacement_by_old[_cert_key(pair.old)] = pair
    replaced_successors = {_cert_key(pair.new) for pair in replacement_by_old.values()}
    hold_withdrawals = (
        settled_certificate_ids is not None
        and scheme in certificate_succession.REPLACEMENT_SCHEMES
    )
    window_days = certificate_succession.REPLACEMENT_WINDOW_DAYS

    for cid, old in sorted(old_by_id.items()):
        if cid not in new_by_id:
            if cid in superseded:
                continue        # not an exit β€” the holder renewed under a new number
            if expired_before(old.get("valid_until"), detected_iso):
                events.append(_event(
                    run_id, scheme, "certificate_expired", "low", "explicit", old,
                    detected_at=detected_at,
                    evidence=f"{scheme} certificate reached its expiry date",
                    old={"status": _status_value(old)}, new={},
                ))
            else:
                auth = authority_by_id.get(cid)
                auth_status = _status_value(auth) if auth else ""
                if auth is not None and auth_status in _RISKY_STATUSES:
                    event_type, severity = _RISKY_STATUSES[auth_status]
                    # Prefer old row for holder identity; overlay status fields from authority.
                    cert_for_event = dict(old)
                    for key in ("status", "raw_status", "suspended_date", "suspended_until",
                                "valid_until", "issuing_cb"):
                        if auth.get(key):
                            cert_for_event[key] = auth.get(key)
                    # Valid-only feeds (ISCC CSV) often carry raw codes like "1", not a named
                    # lifecycle label β€” omit those from old.status so digests say "suspended"
                    # rather than "1 β†’ suspended".
                    prior_status = _status_value(old)
                    if prior_status and prior_status not in _RISKY_STATUSES and prior_status not in {
                        "active", "valid", "expiring", "expired",
                    }:
                        prior_status = ""
                    events.append(_event(
                        run_id, scheme, event_type, severity, "explicit", cert_for_event,
                        detected_at=detected_at,
                        evidence=source_label,
                        old={"status": prior_status},
                        new={"status": auth_status},
                    ))
                else:
                    events.append(_event(
                        run_id, scheme, "certificate_removed_from_valid_list", "high", "inferred", old,
                        detected_at=detected_at,
                        evidence=(
                            f"{scheme} certificate left accepted snapshot while still valid "
                            f"(no terminated / suspended / withdrawn match)"
                        ),
                        old={"status": _status_value(old)}, new={},
                    ))
    for cid, new in sorted(new_by_id.items()):
        if cid not in old_by_id:
            if cid in replaced_successors:
                continue        # its appearance IS the replacement, reported on the old number
            predecessor = renewed_from.get(cid)
            if predecessor is not None:
                events.append(_event(
                    run_id, scheme, "certificate_renewed", "low", "inferred", new,
                    detected_at=detected_at,
                    evidence=(f"{scheme} certificate renewed under a new number "
                              f"(supersedes {_cert_key(predecessor)})"),
                    old={"certificate_id": _cert_key(predecessor),
                         "valid_until": predecessor.get("valid_until"),
                         "status": _status_value(predecessor)},
                    new={"valid_until": new.get("valid_until"), "status": _status_value(new)},
                ))
                continue
            holder_norm = str(new.get("holder_norm", "")).strip()
            is_new_holder = bool(holder_norm and holder_norm not in old_holders)
            if not is_new_holder:
                events.append(_event(
                    run_id, scheme, "certificate_new", "low", "explicit", new,
                    detected_at=detected_at, evidence=f"{scheme} accepted snapshot",
                    old={}, new={"status": _status_value(new)},
                ))
                continue

            prior = (prior_coverage or {}).get((scheme, holder_norm)) or {}
            decision = classify_returning(
                scheme=scheme,
                holder_norm=holder_norm,
                valid_from=str(new.get("valid_from") or ""),
                detected_at=detected_iso,
                last_coverage_until=prior.get("last_coverage_until"),
                prior_evidence=list(prior.get("prior_evidence") or []),
            )
            if decision is not None:
                evidence = format_off_scheme_evidence(
                    scheme, decision["gap_days"], decision["last_coverage_until"],
                )
                event = _event(
                    run_id, scheme, "holder_returning", decision["severity"], "inferred", new,
                    detected_at=detected_at,
                    evidence=evidence,
                    old={"last_coverage_until": decision["last_coverage_until"]},
                    new={"status": _status_value(new), "valid_from": str(new.get("valid_from") or "")},
                )
                event["gap_days"] = decision["gap_days"]
                event["prior_evidence"] = decision["prior_evidence"]
                events.append(event)
            else:
                events.append(_event(
                    run_id, scheme, "holder_new", "medium", "explicit", new,
                    detected_at=detected_at, evidence=f"{scheme} accepted snapshot",
                    old={}, new={"status": _status_value(new)},
                ))
            continue
        old = old_by_id[cid]
        old_status = _status_value(old)
        new_status = _status_value(new)
        pair = replacement_by_old.get(cid)
        if pair is not None and (old_status != new_status or _cert_key(pair.new) not in old_by_id):
            # Paired on the flip night, or paired late when the successor arrived a night or
            # two after the withdrawal. A pair whose successor was already in the previous
            # snapshot was reported when it appeared -- never again.
            events.append(_replaced_event(
                run_id, scheme, pair, detected_at=detected_at, old_status=old_status,
            ))
            continue
        if (
            hold_withdrawals
            and new_status == "withdrawn"
            and old_status == new_status
            and cid not in (settled_certificate_ids or set())
        ):
            # Held on the flip night; fire once the window has passed with no successor.
            age = _days_since(new.get("valid_until"), detected_iso)
            if age is not None and window_days <= age <= _HELD_WITHDRAWAL_MAX_AGE_DAYS:
                event_type, severity = _RISKY_STATUSES[new_status]
                events.append(_event(
                    run_id, scheme, event_type, severity, "explicit", new,
                    detected_at=detected_at,
                    evidence=(
                        f"{scheme} explicit status field; no replacement certificate appeared "
                        f"within {window_days} days of the cut-off {new.get('valid_until')}"
                    ),
                    old={"status": old_status}, new={"status": new_status},
                ))
            continue
        if new_status != old_status and new_status in _RISKY_STATUSES:
            if hold_withdrawals and new_status == "withdrawn":
                age = _days_since(new.get("valid_until"), detected_iso)
                if age is not None and age < window_days:
                    continue    # held: the successor may land in the next nightly
            event_type, severity = _RISKY_STATUSES[new_status]
            events.append(_event(
                run_id, scheme, event_type, severity, "explicit", new,
                detected_at=detected_at, evidence=f"{scheme} explicit status field",
                old={"status": old_status}, new={"status": new_status},
            ))
        elif old_status in _RISKY_STATUSES and new_status not in _RISKY_STATUSES:
            # Leaving a risky status is only a reinstatement when the certificate returns to a
            # LIVE status. suspended -> expired is the certificate reaching the end of its term
            # while suspended; labelling that "reinstated" told the reader the opposite of what
            # happened (31 of 31 stored reinstatements were REDcert suspended -> expired).
            reinstated = new_status not in _TERMINAL_STATUSES
            events.append(_event(
                run_id, scheme,
                "certificate_reinstated" if reinstated else "certificate_expired",
                "medium" if reinstated else "low",
                "explicit", new,
                detected_at=detected_at, evidence=f"{scheme} explicit status field",
                old={"status": old_status}, new={"status": new_status},
            ))
        elif old.get("valid_until") and new.get("valid_until") and old.get("valid_until") != new.get("valid_until"):
            events.append(_event(
                run_id, scheme, "certificate_renewed", "low", "explicit", new,
                detected_at=detected_at, evidence=f"{scheme} validity date changed",
                old={"valid_until": old.get("valid_until")}, new={"valid_until": new.get("valid_until")},
            ))
    return events


def build_compliance_reversal_events(

    run_id: str,

    scheme: str,

    old_authority: list[dict] | None,

    new_authority: list[dict] | None,

    valid_certs: list[dict] | None,

    *,

    detected_at=None,

    authority_source: str | None = None,

) -> list[dict]:
    """Reinstatements, read from the compliance register rather than from the valid-only feed.



    ISCC's valid CSV carries no lifecycle status (every row reads `1`), so the reinstatement

    branch in build_change_events β€” which needs a status field to change β€” can never fire for it.

    A lifted ISCC suspension therefore left no trace at all: the certificate simply reappeared in

    the valid list and the high-severity suspension stood forever. Diffing the compliance register

    against its previous accepted copy gives the missing signal directly.



    Two deliberate refusals:



    * An empty/missing `new_authority` yields nothing. It is indistinguishable from a failed

      compliance fetch, and treating it as "the register cleared everyone" would reinstate every

      suspended certificate in a single run.

    * A certificate cleared from the register but ABSENT from the valid list yields nothing. That

      shape is ambiguous β€” a withdrawal that has also aged out looks the same β€” and the exit is

      already covered by the disappearance branch.

    """
    if not new_authority:
        return []
    old_adverse = {
        _cert_key(row): row
        for row in (old_authority or [])
        if _cert_key(row) and _status_value(row) in _RISKY_STATUSES
    }
    if not old_adverse:
        return []
    still_adverse = {
        _cert_key(row)
        for row in new_authority
        if _cert_key(row) and _status_value(row) in _RISKY_STATUSES
    }
    valid_by_id = {_cert_key(row): row for row in (valid_certs or []) if _cert_key(row)}
    source_label = (authority_source or "").strip() or f"{scheme} status authority"

    events: list[dict] = []
    for cid, prior in sorted(old_adverse.items()):
        if cid in still_adverse or cid not in valid_by_id:
            continue
        prior_status = _status_value(prior)
        # Identity from the live valid row; the episode window only exists on the register row.
        cert_for_event = dict(valid_by_id[cid])
        for key in ("suspended_date", "suspended_until"):
            if prior.get(key):
                cert_for_event[key] = prior.get(key)
        events.append(_event(
            run_id, scheme, "certificate_reinstated", "medium", "explicit", cert_for_event,
            detected_at=detected_at,
            evidence=(f"{source_label} no longer lists this certificate as {prior_status}, "
                      f"and it is back in the accepted snapshot"),
            old={"status": prior_status}, new={"status": ""},
        ))
    return events


_ADVERSE_EVENT_STATUS = {
    event_type: status for status, (event_type, _severity) in _RISKY_STATUSES.items()
}

# Events that say where a certificate's lifecycle stands. Anything else on the log (renewals,
# scope or address changes, soft alerts) neither opens nor closes an adverse episode.
_LIFECYCLE_EVENT_TYPES = frozenset(_ADVERSE_EVENT_STATUS) | {
    "certificate_reinstated", "certificate_expired", "certificate_replaced",
    "certificate_removed_from_valid_list",
}

# Same horizon as a held withdrawal: past it, the log entry may predate reinstatement logging,
# and good news that late is noise rather than a correction.
_ORPHANED_EPISODE_MAX_AGE_DAYS = 60


def build_orphaned_suspension_reinstatements(

    run_id: str,

    scheme: str,

    history_events: list[dict] | None,

    new_authority: list[dict] | None,

    valid_certs: list[dict] | None,

    *,

    detected_at=None,

    authority_source: str | None = None,

    skip_certificate_ids: set[str] | None = None,

) -> list[dict]:
    """Reinstatements the register diff cannot see, read from the append-only change history.



    build_compliance_reversal_events diffs the register against its previous ACCEPTED copy. When

    the nightly fails to publish, that copy freezes while the change log keeps growing β€” so an

    episode that opens and closes inside the outage is reported as a suspension (every night) and

    never as lifted: the frozen baseline predates it. Shandong Sanju, EU-ISCC-Cert-DE129-35412331,

    suspended 2026-08-31 and lifted 2026-09-14 during the 2026-08-26 outage, left 14 suspension

    events and no reinstatement.



    The history does not freeze. A certificate whose LATEST lifecycle event is adverse, which the

    fresh register no longer lists as adverse, and which sits in the accepted valid list, has been

    reinstated. Self-limiting: the emitted event becomes the latest lifecycle event, so the next

    run is silent. Same two refusals as the register diff β€” an empty register is a failed fetch,

    and a certificate absent from the valid list is ambiguous.

    """
    if not new_authority:
        return []
    still_adverse = {
        _cert_key(row)
        for row in new_authority
        if _cert_key(row) and _status_value(row) in _RISKY_STATUSES
    }
    valid_by_id = {_cert_key(row): row for row in (valid_certs or []) if _cert_key(row)}
    skip = set(skip_certificate_ids or ())

    latest: dict[str, dict] = {}
    for event in history_events or []:
        if not isinstance(event, dict) or str(event.get("scheme") or "") != scheme:
            continue
        if str(event.get("event_type") or "") not in _LIFECYCLE_EVENT_TYPES:
            continue
        cid = _cert_key(event)
        if not cid:
            continue
        current = latest.get(cid)
        # >= so that, within one run, the later line of the append-only log wins.
        if current is None or str(event.get("detected_at") or "") >= str(current.get("detected_at") or ""):
            latest[cid] = event

    detected_iso = _utc_iso(detected_at)
    source_label = (authority_source or "").strip() or f"{scheme} status authority"
    events: list[dict] = []
    for cid, last in sorted(latest.items()):
        prior_status = _ADVERSE_EVENT_STATUS.get(str(last.get("event_type") or ""))
        if prior_status is None or cid in still_adverse or cid in skip or cid not in valid_by_id:
            continue
        last_seen = str(last.get("detected_at") or "")[:10]
        age = _days_since(last_seen, detected_iso)
        if age is None or age > _ORPHANED_EPISODE_MAX_AGE_DAYS:
            continue
        cert_for_event = dict(valid_by_id[cid])
        # Only the start survives from the log: its `suspended_until` was the SCHEDULED end while
        # the episode ran. The actual end comes from the certificate's own register record.
        cert_for_event.pop("suspended_until", None)
        if prior_status == "suspended" and last.get("suspended_date"):
            cert_for_event["suspended_date"] = last.get("suspended_date")
        events.append(_event(
            run_id, scheme, "certificate_reinstated", "medium", "explicit", cert_for_event,
            detected_at=detected_at,
            evidence=(f"{source_label} no longer lists this certificate as {prior_status} "
                      f"(last reported {prior_status} on {last_seen}), and it is in the accepted "
                      f"snapshot"),
            old={"status": prior_status}, new={"status": ""},
        ))
    return events


def build_register_diff_events(run_id: str, scheme: str, old_certs: list[dict], new_certs: list[dict],

                               diff: Mapping[str, object], *, detected_at=None) -> list[dict]:
    """Change events for registers with a plain added/removed-style diff (LCFS, EPA) --

    no certificate renumbering-on-renewal and no external status authority, unlike

    build_change_events's ISCC/REDcert reconciliation. `diff` is the diff_*_snapshot() output:

    outcome name -> set/list of certificate_id. Recognized outcomes reuse EXISTING event types

    so RED_FLAG_EVENT_TYPES and the digest labels in daily_digest.py need no changes:

    added -> certificate_new (info only), retired/withdrawn -> certificate_withdrawn (explicit

    status flip, red flag; LCFS says retired, Oregon/BC/CFR say withdrawn),

    removed/deregistered -> certificate_removed_from_valid_list (the register

    stopped listing this certificate at all, red flag).

    RenovaBio adds suspended -> certificate_suspended (red flag), terminated -> certificate_revoked_or_terminated (not a red flag), expired -> certificate_expired, renewed pairs -> certificate_renewed on the successor."""
    old_by_id = {_cert_key(c): c for c in (old_certs or []) if _cert_key(c)}
    new_by_id = {_cert_key(c): c for c in (new_certs or []) if _cert_key(c)}
    events: list[dict] = []
    for cid in sorted(diff.get("added") or ()):
        cert = new_by_id.get(cid)
        if cert is None:
            continue
        events.append(_event(
            run_id, scheme, "certificate_new", "low", "explicit", cert,
            detected_at=detected_at, evidence=f"{scheme} accepted snapshot",
            old={}, new={"status": _status_value(cert)},
        ))
    withdrawn_ids = set(diff.get("retired") or ()) | set(diff.get("withdrawn") or ())
    for cid in sorted(withdrawn_ids):
        cert = new_by_id.get(cid) or old_by_id.get(cid)
        if cert is None:
            continue
        events.append(_event(
            run_id, scheme, "certificate_withdrawn", "high", "explicit", cert,
            detected_at=detected_at, evidence=f"{scheme} status field flipped to withdrawn",
            old={"status": _status_value(old_by_id.get(cid, {}))}, new={"status": "withdrawn"},
        ))
    removed_ids = set(diff.get("removed") or ()) | set(diff.get("deregistered") or ())
    for cid in sorted(removed_ids):
        cert = old_by_id.get(cid)
        if cert is None:
            continue
        events.append(_event(
            run_id, scheme, "certificate_removed_from_valid_list", "high", "inferred", cert,
            detected_at=detected_at,
            evidence=f"{scheme} certificate disappeared from accepted snapshot",
            old={"status": _status_value(cert)}, new={},
        ))
    # Registers that publish an explicit lifecycle column per certificate (RenovaBio, spec
    # 2026-09-29). The other registers' diffs never carry these keys.
    for outcome, (event_type, severity) in (
        ("suspended", ("certificate_suspended", "high")),
        ("terminated", ("certificate_revoked_or_terminated", "low")),
        ("expired", ("certificate_expired", "low")),
    ):
        for cid in sorted(diff.get(outcome) or ()):
            cert = new_by_id.get(cid)
            if cert is None:
                continue
            events.append(_event(
                run_id, scheme, event_type, severity, "explicit", cert,
                detected_at=detected_at, evidence=f"{scheme} lists the certificate as {outcome}",
                old={"status": _status_value(old_by_id.get(cid, {}))}, new={"status": outcome},
            ))
    for old_id, new_id in diff.get("renewed") or ():
        new = new_by_id.get(new_id)
        if new is None:
            continue
        predecessor = old_by_id.get(old_id) or new_by_id.get(old_id) or {}
        events.append(_event(
            run_id, scheme, "certificate_renewed", "low", "explicit", new,
            detected_at=detected_at,
            evidence=f"{scheme} names {new_id} as the successor of {old_id}",
            old={"certificate_id": old_id, "valid_until": predecessor.get("valid_until"),
                 "status": _status_value(predecessor)},
            new={"valid_until": new.get("valid_until"), "status": _status_value(new)},
        ))
    return events


def _overall_status(report: dict) -> str:
    if report.get("status"):
        return str(report["status"])
    refresh = report.get("refresh", {})
    if isinstance(refresh, dict) and any(
        isinstance(v, dict) and v.get("status") == "error"
        for v in refresh.values()
    ):
        return "error"
    return "ok"


def _history_compact(report: dict) -> dict:
    refresh = report.get("refresh", {}) if isinstance(report.get("refresh"), dict) else {}
    decisions = {
        k: v.get("decision", "")
        for k, v in refresh.items()
        if isinstance(v, dict) and v.get("decision")
    }
    return {
        "run_id": report.get("started_at", ""),
        "started_at": report.get("started_at", ""),
        "finished_at": report.get("finished_at", ""),
        "duration_s": report.get("duration_s"),
        "status": _overall_status(report),
        "decisions": decisions,
        "geocode_status": (report.get("geocode") or {}).get("status", ""),
        "app_db_status": (report.get("app_db") or {}).get("status", ""),
        "upload_status": (report.get("upload") or {}).get("status", ""),
        # Unfetched/unparsed PDF delta-queue backlog per scheme, carried verbatim from the
        # caller-computed report["queue_depth"] (daily_refresh.py merges pdf_delta + audit_delta
        # remaining_by_scheme). Absent for callers with no PDF delta queue (INS/US-local
        # refreshes) -- {} there, never a KeyError downstream.
        "queue_depth": dict(report.get("queue_depth") or {}),
    }


def append_outcome_history(history_dir: str | Path, report: dict) -> None:
    """Append the finished run's compact history line, without repeating fingerprints.



    A dataset publish records a non-success line before the upload so a crash mid-publish

    is not a success. This second line is the outcome the monitor is allowed to trust.

    """
    append_jsonl(Path(history_dir) / "refresh_history.jsonl", _history_compact(report))


def append_history_files(history_dir: str | Path, report: dict) -> None:
    root = Path(history_dir)
    refresh = report.get("refresh", {}) if isinstance(report.get("refresh"), dict) else {}
    append_jsonl(root / "refresh_history.jsonl", _history_compact(report))

    events: list[dict] = []
    for payload in refresh.values():
        if not isinstance(payload, dict):
            continue
        for record in payload.get("fingerprints", []) or []:
            append_jsonl(root / "snapshot_history.jsonl", record)
        for record in payload.get("compliance_history", []) or []:
            append_jsonl(root / "compliance_history.jsonl", record)
        events.extend(payload.get("change_events", []) or [])
    for event in events:
        append_jsonl(root / "change_events.jsonl", event)

    by_type = Counter(str(e.get("event_type", "")) for e in events if e.get("event_type"))
    by_severity = Counter(str(e.get("severity", "")) for e in events if e.get("severity"))
    summary = {
        "run_id": report.get("started_at", ""),
        "events_total": len(events),
        "events_by_type": dict(sorted(by_type.items())),
        "events_by_severity": dict(sorted(by_severity.items())),
    }
    (root / "latest_change_summary.json").write_text(
        json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8"
    )
    _mirror_events_to_shared_log(root, events)


def _mirror_events_to_shared_log(history_dir: Path, events: list) -> None:
    """Flags and the daily mail read one log. A staging dir keeps its own copy;

    the same event dicts are appended to state_paths.refresh_dir()."""
    import state_paths

    shared = Path(state_paths.refresh_dir())
    if history_dir.resolve() == shared.resolve():
        return
    for event in events:
        if isinstance(event, dict) and event.get("event_type"):
            append_jsonl(shared / "change_events.jsonl", event)