forked from ChelseaKR/id-churn-sentinel
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstore.py
More file actions
2301 lines (2130 loc) · 93.7 KB
/
Copy pathstore.py
File metadata and controls
2301 lines (2130 loc) · 93.7 KB
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
"""The snapshot + change store — SQLite, and the second, independent home of the
human-in-the-loop invariant.
Two tables:
* **`snapshots`** — every fetch we kept: the raw bytes, the normalized text, the sha256,
when we fetched it, and the HTTP status. We keep the last N (default 5) per source, and
we keep the *bytes*, not just the hash. That is the difference between a tool that says
"it changed" and one that can still show you *what* changed six months later, after the
state has quietly re-edited the page twice more. A diff you cannot reproduce is a claim,
not evidence.
* **`changes`** — the reviewable records.
The `changes` schema carries a `CHECK` constraint that restates, in SQL, the rule
`changes.py` enforces in Python:
CHECK (significance = 'unclassified' OR (reviewer IS NOT NULL AND reviewer <> ''))
A classified change without a named human reviewer is **rejected by the database**. The
Python types make it unrepresentable; the schema makes it un-*storable*. Two independent
enforcement points, because the failure mode this guards against — a machine asserting
that the law substantively changed — is one where being right 99% of the time is not good
enough. The same invariant-doubling pattern is useful for consent gates: enforce the rule
in both the domain type and the database constraint.
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Iterator
from dataclasses import dataclass, replace
from datetime import UTC, date, datetime, timedelta
from hashlib import sha256
from pathlib import Path
from types import TracebackType
from uuid import uuid4
from id_churn_sentinel.core.changes import (
ChangeKind,
ChangeRecord,
IndependentReviewStatus,
PublicationStatus,
ReviewStatus,
Significance,
actor_identity,
canonical_actor,
governance_reference_is_safe,
observation_fields_are_valid,
private_text_is_safe,
public_copy_is_safe,
)
from id_churn_sentinel.core.fetch import RedirectHop
from id_churn_sentinel.errors import StoreError
__all__ = [
"DEFAULT_SNAPSHOT_RETENTION",
"OBSERVATION_MEASURED",
"OBSERVATION_NOT_RETRIEVED",
"OBSERVATION_NO_TEXT",
"RUN_COMPLETE",
"RUN_FAILED",
"RUN_PARTIAL",
"RUN_QUIET",
"RUN_RUNNING",
"AttemptEvidence",
"FetchAttempt",
"RunSourceInput",
"SilenceWindow",
"Snapshot",
"SnapshotStore",
"WatchRun",
]
DEFAULT_SNAPSHOT_RETENTION = 5
RUN_RUNNING = "running"
RUN_QUIET = "quiet"
RUN_COMPLETE = "complete"
RUN_PARTIAL = "partial"
RUN_FAILED = "failed"
_TERMINAL_RUN_STATES = frozenset({RUN_QUIET, RUN_COMPLETE, RUN_PARTIAL, RUN_FAILED})
_SUCCESSFUL_RUN_STATES = frozenset({RUN_QUIET, RUN_COMPLETE})
_SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL,
url TEXT NOT NULL,
fetched_at TEXT NOT NULL,
http_status INTEGER,
content_sha256 TEXT NOT NULL,
raw_bytes BLOB NOT NULL,
normalized_text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_source
ON snapshots (source_id, snapshot_id DESC);
CREATE TABLE IF NOT EXISTS changes (
change_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
jurisdiction TEXT NOT NULL,
document_class TEXT NOT NULL,
url TEXT NOT NULL,
observed_at TEXT NOT NULL,
previous_hash TEXT NOT NULL,
new_hash TEXT NOT NULL,
diff_excerpt TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'content_drift'
CHECK (kind IN ('content_drift', 'possibly_removed')),
significance TEXT NOT NULL
CHECK (significance IN ('unclassified', 'editorial', 'substantive')),
review_status TEXT NOT NULL
CHECK (review_status IN ('unreviewed', 'confirmed', 'dismissed')),
reviewer TEXT,
reviewed_at TEXT,
review_note TEXT NOT NULL DEFAULT '',
-- The human-in-the-loop gate, in the schema. A significance other than
-- 'unclassified' REQUIRES a named reviewer. No code path, and no stray SQL, can
-- store a machine-asserted legal classification.
CHECK (significance = 'unclassified' OR (reviewer IS NOT NULL AND reviewer <> '')),
-- And a confirmed record must be classified: 'confirmed but unclassified' would sail
-- through the publisher's status filter carrying no human judgment at all.
CHECK (review_status <> 'confirmed' OR significance <> 'unclassified')
);
CREATE INDEX IF NOT EXISTS idx_changes_review
ON changes (review_status, observed_at DESC);
-- Per-source fetch health. This is the table that lets the tool tell "the server hiccuped"
-- apart from "the page is gone" — a distinction it previously could not make at all, and
-- whose absence meant a removed page held its stale baseline forever in total silence.
--
-- `consecutive_failures` is the whole mechanism: incremented on every failed fetch, reset
-- to zero on every successful one. It is deliberately NOT a count of total failures — a
-- source that fails one week in eight is a flaky server, and a source that has failed
-- eight weeks running is something else. Only the *streak* distinguishes them.
CREATE TABLE IF NOT EXISTS source_health (
source_id TEXT PRIMARY KEY,
consecutive_failures INTEGER NOT NULL DEFAULT 0
CHECK (consecutive_failures >= 0),
last_status INTEGER,
last_error TEXT,
last_failure_at TEXT,
last_success_at TEXT
);
"""
# The alpha store pre-dated schema versioning. Keep its tables in `_SCHEMA`, then apply
# every V1 addition through this ledger. A recorded checksum is verified on every open so
# changing an already-applied migration is a loud integrity failure, not an accidental second
# meaning for the same schema version.
_MIGRATIONS = (
(
1,
"v1-watch-runs-and-attempts",
"""
CREATE TABLE IF NOT EXISTS watch_runs (
run_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
completed_at TEXT,
as_of TEXT NOT NULL,
registry_version TEXT NOT NULL,
registry_revision TEXT NOT NULL,
jurisdiction TEXT,
state TEXT NOT NULL
CHECK (state IN ('running', 'quiet', 'complete', 'partial', 'failed')),
eligible_count INTEGER NOT NULL CHECK (eligible_count >= 0),
attempted_count INTEGER NOT NULL DEFAULT 0 CHECK (attempted_count >= 0),
successful_count INTEGER NOT NULL DEFAULT 0 CHECK (successful_count >= 0),
observation_count INTEGER NOT NULL DEFAULT 0 CHECK (observation_count >= 0),
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_watch_runs_started ON watch_runs (started_at DESC, run_id);
CREATE INDEX IF NOT EXISTS idx_watch_runs_state ON watch_runs (state, completed_at DESC);
CREATE TABLE IF NOT EXISTS run_sources (
run_id TEXT NOT NULL REFERENCES watch_runs(run_id) ON DELETE RESTRICT,
source_id TEXT NOT NULL,
jurisdiction TEXT NOT NULL,
document_class TEXT NOT NULL,
url TEXT NOT NULL,
authority TEXT NOT NULL,
eligible INTEGER NOT NULL CHECK (eligible IN (0, 1)),
eligibility_reasons TEXT NOT NULL,
attempted INTEGER NOT NULL DEFAULT 0 CHECK (attempted IN (0, 1)),
retrieval_success INTEGER CHECK (retrieval_success IN (0, 1)),
outcome TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
PRIMARY KEY (run_id, source_id)
);
CREATE TABLE IF NOT EXISTS fetch_attempts (
run_id TEXT NOT NULL REFERENCES watch_runs(run_id) ON DELETE RESTRICT,
source_id TEXT NOT NULL,
url TEXT NOT NULL,
attempted_at TEXT NOT NULL,
completed_at TEXT,
ok INTEGER CHECK (ok IN (0, 1)),
http_status INTEGER,
content_type TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
PRIMARY KEY (run_id, source_id),
FOREIGN KEY (run_id, source_id) REFERENCES run_sources(run_id, source_id)
ON DELETE RESTRICT
);
""",
),
(
2,
"v1-bind-observations-to-watch-runs",
"""
CREATE TABLE IF NOT EXISTS run_observations (
run_id TEXT NOT NULL REFERENCES watch_runs(run_id) ON DELETE RESTRICT,
change_id TEXT NOT NULL REFERENCES changes(change_id) ON DELETE RESTRICT,
observed_at TEXT NOT NULL,
PRIMARY KEY (run_id, change_id)
);
CREATE INDEX IF NOT EXISTS idx_run_observations_change
ON run_observations (change_id, run_id);
""",
),
(
3,
"v1-append-only-review-and-correction-events",
"""
CREATE TABLE IF NOT EXISTS review_decisions (
decision_id TEXT PRIMARY KEY,
change_id TEXT NOT NULL REFERENCES changes(change_id) ON DELETE RESTRICT,
stage TEXT NOT NULL CHECK (stage IN ('first', 'independent')),
decision TEXT NOT NULL
CHECK (decision IN ('confirmed', 'dismissed', 'returned')),
significance TEXT NOT NULL
CHECK (significance IN ('unclassified', 'editorial', 'substantive')),
actor TEXT NOT NULL
CHECK (actor = canonical_actor(actor) AND actor <> ''),
decided_at TEXT NOT NULL CHECK (julianday(decided_at) IS NOT NULL),
internal_rationale TEXT NOT NULL DEFAULT ''
CHECK (private_text_is_safe(internal_rationale)),
public_copy TEXT NOT NULL DEFAULT '',
qualification_ref TEXT NOT NULL DEFAULT '',
conflict_attestation_ref TEXT NOT NULL DEFAULT '',
UNIQUE (change_id, stage),
CHECK (
(stage = 'first'
AND decision IN ('confirmed', 'dismissed')
AND (decision <> 'confirmed' OR significance <> 'unclassified')
AND ((decision = 'confirmed' AND public_copy_is_safe(public_copy))
OR (decision = 'dismissed' AND public_copy = ''))
AND qualification_ref = ''
AND conflict_attestation_ref = '')
OR
(stage = 'independent'
AND decision IN ('confirmed', 'returned')
AND significance = 'substantive'
AND public_copy = ''
AND governance_reference_is_safe(qualification_ref)
AND governance_reference_is_safe(conflict_attestation_ref)
AND (decision <> 'returned' OR length(trim(internal_rationale)) > 0))
)
);
CREATE INDEX IF NOT EXISTS idx_review_decisions_change
ON review_decisions (change_id, stage);
CREATE TRIGGER IF NOT EXISTS trg_changes_observation_valid_on_insert
BEFORE INSERT ON changes
BEGIN
SELECT CASE WHEN NOT observation_fields_are_valid(
NEW.change_id, NEW.source_id, NEW.observed_at, NEW.previous_hash,
NEW.new_hash, NEW.diff_excerpt, NEW.kind
) THEN RAISE(ABORT, 'change observation fields are invalid') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_changes_observation_no_update
BEFORE UPDATE OF change_id, source_id, jurisdiction, document_class, url, observed_at,
previous_hash, new_hash, diff_excerpt, kind ON changes
BEGIN
SELECT RAISE(ABORT, 'change observations are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_changes_no_delete
BEFORE DELETE ON changes
BEGIN
SELECT RAISE(ABORT, 'change observations are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_review_decisions_no_update
BEFORE UPDATE ON review_decisions
BEGIN
SELECT RAISE(ABORT, 'review decisions are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_review_decisions_no_delete
BEFORE DELETE ON review_decisions
BEGIN
SELECT RAISE(ABORT, 'review decisions are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_first_review_follows_observation
BEFORE INSERT ON review_decisions
WHEN NEW.stage = 'first'
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM changes AS observation
WHERE observation.change_id = NEW.change_id
AND julianday(NEW.decided_at) >= julianday(observation.observed_at)
) THEN RAISE(ABORT, 'first review cannot precede observation') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_independent_review_requires_first
BEFORE INSERT ON review_decisions
WHEN NEW.stage = 'independent'
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM review_decisions AS first
WHERE first.change_id = NEW.change_id
AND first.stage = 'first'
AND first.decision = 'confirmed'
AND first.significance = 'substantive'
) THEN RAISE(ABORT, 'independent review requires first-confirmed substantive review') END;
SELECT CASE WHEN EXISTS (
SELECT 1 FROM review_decisions AS first
WHERE first.change_id = NEW.change_id
AND first.stage = 'first'
AND actor_identity(first.actor) = actor_identity(NEW.actor)
) THEN RAISE(ABORT, 'independent reviewer must differ from first reviewer') END;
SELECT CASE WHEN EXISTS (
SELECT 1 FROM review_decisions AS first
WHERE first.change_id = NEW.change_id
AND first.stage = 'first'
AND julianday(NEW.decided_at) < julianday(first.decided_at)
) THEN RAISE(ABORT, 'independent review cannot precede first review') END;
END;
CREATE TABLE IF NOT EXISTS change_lifecycle_events (
event_id TEXT PRIMARY KEY,
change_id TEXT NOT NULL UNIQUE
REFERENCES changes(change_id) ON DELETE RESTRICT,
action TEXT NOT NULL CHECK (action IN ('corrected', 'withdrawn')),
superseded_by_change_id TEXT REFERENCES changes(change_id) ON DELETE RESTRICT,
reason TEXT NOT NULL CHECK (reason IN (
'source_evidence_error', 'review_error', 'duplicate_record',
'privacy_or_safety', 'superseded_observation', 'other_governed_reason'
)),
actor TEXT NOT NULL
CHECK (actor = canonical_actor(actor) AND actor <> ''),
decided_at TEXT NOT NULL CHECK (julianday(decided_at) IS NOT NULL),
CHECK (
(action = 'corrected' AND superseded_by_change_id IS NOT NULL
AND superseded_by_change_id <> change_id)
OR (action = 'withdrawn' AND superseded_by_change_id IS NULL)
)
);
CREATE TRIGGER IF NOT EXISTS trg_change_lifecycle_no_update
BEFORE UPDATE ON change_lifecycle_events
BEGIN
SELECT RAISE(ABORT, 'change lifecycle events are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_change_lifecycle_no_delete
BEFORE DELETE ON change_lifecycle_events
BEGIN
SELECT RAISE(ABORT, 'change lifecycle events are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_lifecycle_requires_publishable_subject
BEFORE INSERT ON change_lifecycle_events
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM review_decisions AS first
WHERE first.change_id = NEW.change_id
AND first.stage = 'first'
AND first.decision = 'confirmed'
AND first.significance <> 'unclassified'
AND length(trim(first.public_copy)) > 0
AND (
first.significance <> 'substantive'
OR EXISTS (
SELECT 1 FROM review_decisions AS second
WHERE second.change_id = NEW.change_id
AND second.stage = 'independent'
AND second.decision = 'confirmed'
AND actor_identity(second.actor) <> actor_identity(first.actor)
)
)
) THEN RAISE(ABORT, 'lifecycle event requires publishable reviewed subject') END;
SELECT CASE WHEN EXISTS (
SELECT 1 FROM review_decisions AS decision
WHERE decision.change_id = NEW.change_id
AND julianday(NEW.decided_at) < julianday(decision.decided_at)
) THEN RAISE(ABORT, 'lifecycle event cannot precede review decisions') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_correction_requires_publishable_replacement
BEFORE INSERT ON change_lifecycle_events
WHEN NEW.action = 'corrected'
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1
FROM changes AS subject
JOIN changes AS replacement
ON replacement.change_id = NEW.superseded_by_change_id
WHERE subject.change_id = NEW.change_id
AND replacement.source_id = subject.source_id
AND replacement.jurisdiction = subject.jurisdiction
AND replacement.document_class = subject.document_class
AND replacement.url = subject.url
) THEN RAISE(ABORT, 'correction replacement source identity differs') END;
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM review_decisions AS first
WHERE first.change_id = NEW.superseded_by_change_id
AND first.stage = 'first'
AND first.decision = 'confirmed'
AND first.significance <> 'unclassified'
AND length(trim(first.public_copy)) > 0
AND (
first.significance <> 'substantive'
OR EXISTS (
SELECT 1 FROM review_decisions AS second
WHERE second.change_id = NEW.superseded_by_change_id
AND second.stage = 'independent'
AND second.decision = 'confirmed'
AND actor_identity(second.actor) <> actor_identity(first.actor)
)
)
) THEN RAISE(ABORT, 'correction replacement must be publishable and reviewed') END;
SELECT CASE WHEN EXISTS (
SELECT 1 FROM review_decisions AS decision
WHERE decision.change_id = NEW.superseded_by_change_id
AND julianday(NEW.decided_at) < julianday(decision.decided_at)
) THEN RAISE(ABORT, 'correction cannot precede replacement review') END;
SELECT CASE WHEN EXISTS (
WITH RECURSIVE successors(change_id) AS (
SELECT NEW.superseded_by_change_id
UNION ALL
SELECT event.superseded_by_change_id
FROM change_lifecycle_events AS event
JOIN successors ON event.change_id = successors.change_id
WHERE event.action = 'corrected'
AND event.superseded_by_change_id IS NOT NULL
)
SELECT 1 FROM successors WHERE change_id = NEW.change_id
) THEN RAISE(ABORT, 'correction supersession cycle') END;
END;
""",
),
(
4,
"v1-version-snapshot-representations",
"""
ALTER TABLE snapshots
ADD COLUMN normalizer_version TEXT NOT NULL DEFAULT 'legacy-unknown';
ALTER TABLE snapshots
ADD COLUMN extractor_version TEXT NOT NULL DEFAULT 'legacy-unknown';
ALTER TABLE fetch_attempts
ADD COLUMN normalizer_version TEXT NOT NULL DEFAULT '';
ALTER TABLE fetch_attempts
ADD COLUMN extractor_version TEXT NOT NULL DEFAULT '';
CREATE TABLE representation_contracts (
normalizer_version TEXT NOT NULL CHECK (trim(normalizer_version) <> ''),
extractor_version TEXT NOT NULL CHECK (trim(extractor_version) <> ''),
PRIMARY KEY (normalizer_version, extractor_version)
);
INSERT INTO representation_contracts (normalizer_version, extractor_version)
VALUES ('passage-text-v1', 'none-v1');
CREATE TRIGGER IF NOT EXISTS trg_snapshots_require_representation_versions
BEFORE INSERT ON snapshots
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM representation_contracts AS contract
WHERE contract.normalizer_version = NEW.normalizer_version
AND contract.extractor_version = NEW.extractor_version
)
THEN RAISE(ABORT, 'new snapshots require explicit representation versions') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_successful_attempts_require_representation_versions
BEFORE UPDATE OF ok, normalizer_version, extractor_version ON fetch_attempts
WHEN NEW.ok = 1
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM representation_contracts AS contract
WHERE contract.normalizer_version = NEW.normalizer_version
AND contract.extractor_version = NEW.extractor_version
)
THEN RAISE(ABORT, 'successful attempts require explicit representation versions') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_successful_attempt_inserts_require_representation_versions
BEFORE INSERT ON fetch_attempts
WHEN NEW.ok = 1
BEGIN
SELECT CASE WHEN NOT EXISTS (
SELECT 1 FROM representation_contracts AS contract
WHERE contract.normalizer_version = NEW.normalizer_version
AND contract.extractor_version = NEW.extractor_version
)
THEN RAISE(ABORT, 'successful attempts require explicit representation versions') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_representation_contracts_no_update
BEFORE UPDATE ON representation_contracts
BEGIN
SELECT RAISE(ABORT, 'representation contracts are append-only');
END;
CREATE TRIGGER IF NOT EXISTS trg_representation_contracts_no_delete
BEFORE DELETE ON representation_contracts
BEGIN
SELECT RAISE(ABORT, 'representation contracts are append-only');
END;
""",
),
(
5,
"v1-complete-fetch-attempt-evidence",
# DATA-04: the attempt row becomes the complete, restorable record of what the
# network actually did — redirect chain, final URL, distinct raw/normalized hashes,
# byte bound and truncation, MIME (already present as content_type), extraction
# outcome, and a stable error class. Pre-migration attempts are labelled
# 'legacy-unknown' rather than back-filled with invented values, mirroring the
# 'legacy-unknown' representation versions of migration 4: an old receipt keeps its
# evidentiary value precisely because we refuse to pretend it recorded things it
# did not. The triggers make incomplete evidence unstorable for every NEW terminal
# attempt, so no code path — and no stray SQL — can quietly stop recording.
"""
ALTER TABLE fetch_attempts
ADD COLUMN final_url TEXT NOT NULL DEFAULT '';
ALTER TABLE fetch_attempts
ADD COLUMN redirect_chain TEXT NOT NULL DEFAULT '[]';
ALTER TABLE fetch_attempts
ADD COLUMN raw_sha256 TEXT NOT NULL DEFAULT '';
ALTER TABLE fetch_attempts
ADD COLUMN normalized_sha256 TEXT NOT NULL DEFAULT '';
ALTER TABLE fetch_attempts
ADD COLUMN bytes_received INTEGER
CHECK (bytes_received IS NULL OR bytes_received >= 0);
ALTER TABLE fetch_attempts
ADD COLUMN byte_limit INTEGER
CHECK (byte_limit IS NULL OR byte_limit > 0);
ALTER TABLE fetch_attempts
ADD COLUMN truncated INTEGER
CHECK (truncated IN (0, 1));
ALTER TABLE fetch_attempts
ADD COLUMN extraction_outcome TEXT NOT NULL DEFAULT ''
CHECK (extraction_outcome IN
('', 'text-normalized', 'binary-no-extractor', 'legacy-unknown'));
ALTER TABLE fetch_attempts
ADD COLUMN error_class TEXT NOT NULL DEFAULT ''
CHECK (error_class IN
('', 'non-https-scheme', 'robots-disallowed', 'body-too-large',
'http-error', 'unreachable', 'legacy-unknown'));
UPDATE fetch_attempts SET extraction_outcome = 'legacy-unknown' WHERE ok = 1;
UPDATE fetch_attempts SET error_class = 'legacy-unknown' WHERE ok = 0;
CREATE TRIGGER IF NOT EXISTS trg_successful_attempts_require_fetch_evidence
BEFORE UPDATE OF ok, final_url, redirect_chain, raw_sha256, normalized_sha256,
bytes_received, byte_limit, truncated, extraction_outcome, error_class
ON fetch_attempts
WHEN NEW.ok = 1
BEGIN
SELECT CASE WHEN NOT (
NEW.final_url <> ''
AND json_valid(NEW.redirect_chain)
AND NEW.raw_sha256 <> ''
AND NEW.bytes_received IS NOT NULL
AND NEW.truncated = 0
AND NEW.error_class = ''
AND ((NEW.extraction_outcome = 'text-normalized' AND NEW.normalized_sha256 <> '')
OR (NEW.extraction_outcome = 'binary-no-extractor'
AND NEW.normalized_sha256 = ''))
) THEN RAISE(ABORT, 'successful attempts require complete fetch evidence') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_successful_attempt_inserts_require_fetch_evidence
BEFORE INSERT ON fetch_attempts
WHEN NEW.ok = 1
BEGIN
SELECT CASE WHEN NOT (
NEW.final_url <> ''
AND json_valid(NEW.redirect_chain)
AND NEW.raw_sha256 <> ''
AND NEW.bytes_received IS NOT NULL
AND NEW.truncated = 0
AND NEW.error_class = ''
AND ((NEW.extraction_outcome = 'text-normalized' AND NEW.normalized_sha256 <> '')
OR (NEW.extraction_outcome = 'binary-no-extractor'
AND NEW.normalized_sha256 = ''))
) THEN RAISE(ABORT, 'successful attempts require complete fetch evidence') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_failed_attempts_require_fetch_evidence
BEFORE UPDATE OF ok, final_url, redirect_chain, raw_sha256, normalized_sha256,
bytes_received, byte_limit, truncated, extraction_outcome, error_class
ON fetch_attempts
WHEN NEW.ok = 0
BEGIN
SELECT CASE WHEN NOT (
NEW.error_class IN ('non-https-scheme', 'robots-disallowed', 'body-too-large',
'http-error', 'unreachable')
AND NEW.final_url <> ''
AND json_valid(NEW.redirect_chain)
AND NEW.raw_sha256 = ''
AND NEW.normalized_sha256 = ''
AND NEW.extraction_outcome = ''
AND NEW.truncated IS NOT NULL
AND ((NEW.error_class = 'body-too-large' AND NEW.truncated = 1)
OR (NEW.error_class <> 'body-too-large' AND NEW.truncated = 0))
) THEN RAISE(ABORT,
'failed attempts require a stable error class and may fabricate no hashes') END;
END;
CREATE TRIGGER IF NOT EXISTS trg_failed_attempt_inserts_require_fetch_evidence
BEFORE INSERT ON fetch_attempts
WHEN NEW.ok = 0
BEGIN
SELECT CASE WHEN NOT (
NEW.error_class IN ('non-https-scheme', 'robots-disallowed', 'body-too-large',
'http-error', 'unreachable')
AND NEW.final_url <> ''
AND json_valid(NEW.redirect_chain)
AND NEW.raw_sha256 = ''
AND NEW.normalized_sha256 = ''
AND NEW.extraction_outcome = ''
AND NEW.truncated IS NOT NULL
AND ((NEW.error_class = 'body-too-large' AND NEW.truncated = 1)
OR (NEW.error_class <> 'body-too-large' AND NEW.truncated = 0))
) THEN RAISE(ABORT,
'failed attempts require a stable error class and may fabricate no hashes') END;
END;
""",
),
(
6,
"v1-normalizer-passage-text-v2",
# `passage-text-v2` fixes an end-tag match that let script/style bodies survive into
# the hashed passage text (see core/normalize.py). Because that changes the bytes a
# hash is taken over, it is a new representation contract rather than an edit to the
# old one — `representation_contracts` is append-only by trigger, and deliberately so:
# the v1 rows stay valid statements about how v1 hashes were computed. An operator's
# existing database keeps every v1 snapshot exactly as recorded; only new snapshots
# are written under v2.
#
# Retaining the old rows is what makes the transition safe rather than merely
# documented: because a v1 snapshot keeps its `raw_bytes` alongside its label, the
# detector can re-derive that baseline under v2 and compare like for like, instead of
# subtracting a v1 hash from a v2 one and calling the difference drift.
"""
INSERT INTO representation_contracts (normalizer_version, extractor_version)
VALUES ('passage-text-v2', 'none-v1');
""",
),
(
7,
"v1-record-whether-a-source-was-measured",
# Issue #19. `retrieval_success` answers "did bytes arrive?", and the run receipt had
# no way to answer the different question "did those bytes yield anything we could
# compare?". A page serving a JS shell, an empty 200 or a bot-wall answers yes to the
# first and no to the second, and with only the first recorded, a run in which nothing
# was actually observed was indistinguishable from a run in which everything was
# observed and nothing had changed — the latter being what `quiet` publishes.
#
# Four values, because absence, measurement, unmeasurability and failure are four
# different facts and collapsing any of them loses the distinction this column exists
# to make:
# '' the source was never attempted (ineligible, or the run died first)
# 'measured' bytes arrived and produced text we could compare
# 'no-text' bytes arrived and produced zero passages: nothing to compare
# 'not-retrieved' no bytes arrived
# 'legacy-unknown' attempted before this column existed; we cannot say which
#
# `unmeasured_count` on the run mirrors the exact 'no-text' set for the same reason the
# other three counters exist: a redundant counter that disagrees with its ID set is how
# a tampered or half-migrated store is caught before it can publish.
#
# The attempt row's own `extraction_outcome` deliberately still reads 'text-normalized'
# for these fetches. That is what the extractor did — it normalized the text, and there
# was none — and rewriting it to a fifth extraction outcome would restate an evidence
# field about the *body* to carry a judgment about the *observation*.
"""
ALTER TABLE run_sources
ADD COLUMN observation_outcome TEXT NOT NULL DEFAULT ''
CHECK (observation_outcome IN
('', 'measured', 'no-text', 'not-retrieved', 'legacy-unknown'));
ALTER TABLE watch_runs
ADD COLUMN unmeasured_count INTEGER NOT NULL DEFAULT 0 CHECK (unmeasured_count >= 0);
UPDATE run_sources SET observation_outcome = 'legacy-unknown' WHERE attempted = 1;
""",
),
(
8,
"v1-when-the-silence-started",
# `source_health` could say a source had failed N times running, and when it last
# failed. It could not say when the streak BEGAN — so it could not answer the one
# question `REMOVAL_THRESHOLD` is actually about: how long has this page been
# silent? A count of runs is not a duration. Three failures spread over three
# weekly runs and three failures inside one afternoon's back-to-back runs were
# recorded identically, and the escalation treated them identically.
#
# That is not hypothetical. In this repository's only retained observation session
# (2026-07-13), six sources reached `consecutive_failures = 3` inside a
# seventy-four-minute window, because the tool was run three times in one sitting.
# Under the old rule those six were "three weeks silent". They were three minutes
# silent. See `REMOVAL_THRESHOLD` in core/detect.py.
#
# Recording the start of the streak is also the minimum needed to ever MEASURE an
# outage length. `fetch_attempts` carries the per-attempt evidence, but only for as
# long as a database survives; the health row is the durable per-source summary, and
# without this column a year of faithful weekly runs still yields no outage
# durations from it.
#
# NULL is meaningful and is not backfilled: it means "this streak began before the
# column existed, so its duration is unknown". `record_failure` adopts the current
# timestamp for a NULL streak on the next failure, which starts the measured window
# now rather than inventing a start date we never observed. The conservative
# direction is deliberate — it delays an escalation rather than manufacturing one.
"""
ALTER TABLE source_health ADD COLUMN streak_started_at TEXT;
""",
),
)
# What one attempted source's bytes turned out to be worth, as a closed vocabulary. See
# migration 7 for why each value exists and why none of them may be folded into another.
OBSERVATION_MEASURED = "measured"
OBSERVATION_NO_TEXT = "no-text"
OBSERVATION_NOT_RETRIEVED = "not-retrieved"
_V1_REQUIRED_COLUMNS = {
"watch_runs": frozenset(
{
"run_id",
"started_at",
"completed_at",
"as_of",
"registry_version",
"registry_revision",
"jurisdiction",
"state",
"eligible_count",
"attempted_count",
"successful_count",
"observation_count",
"unmeasured_count",
"error",
}
),
"run_sources": frozenset(
{
"run_id",
"source_id",
"jurisdiction",
"document_class",
"url",
"authority",
"eligible",
"eligibility_reasons",
"attempted",
"retrieval_success",
"observation_outcome",
"outcome",
"error",
}
),
"fetch_attempts": frozenset(
{
"run_id",
"source_id",
"url",
"attempted_at",
"completed_at",
"ok",
"http_status",
"content_type",
"normalizer_version",
"extractor_version",
"error",
"final_url",
"redirect_chain",
"raw_sha256",
"normalized_sha256",
"bytes_received",
"byte_limit",
"truncated",
"extraction_outcome",
"error_class",
}
),
"snapshots": frozenset(
{
"snapshot_id",
"source_id",
"url",
"fetched_at",
"http_status",
"content_sha256",
"raw_bytes",
"normalized_text",
"normalizer_version",
"extractor_version",
}
),
"representation_contracts": frozenset(
{
"normalizer_version",
"extractor_version",
}
),
"run_observations": frozenset({"run_id", "change_id", "observed_at"}),
"review_decisions": frozenset(
{
"decision_id",
"change_id",
"stage",
"decision",
"significance",
"actor",
"decided_at",
"internal_rationale",
"public_copy",
"qualification_ref",
"conflict_attestation_ref",
}
),
"change_lifecycle_events": frozenset(
{
"event_id",
"change_id",
"action",
"superseded_by_change_id",
"reason",
"actor",
"decided_at",
}
),
}
@dataclass(frozen=True, slots=True)
class SilenceWindow:
"""One source's current run of failures, measured two ways at once.
`consecutive_failures` is how many times we asked and got nothing. `elapsed` is how
long the page has actually been unavailable. They are not interchangeable, and the
whole reason this type exists is that the escalation rule used to treat them as if
they were: three failures is three weeks at a weekly cadence and three minutes in a
back-to-back re-run, and only the duration can tell those apart.
`started_at` is None for a streak that began before migration 8 recorded starts, and
`elapsed` is None with it — "unknown", never "zero", because a caller that reads an
unknown duration as a short one escalates nothing and a caller that reads it as a long
one escalates everything.
"""
consecutive_failures: int
started_at: datetime | None
last_failure_at: datetime | None
@property
def elapsed(self) -> timedelta | None:
"""Wall-clock silence, or None if we cannot say.
Derived from the two recorded timestamps rather than from a live clock: both ends
are facts the store observed, so the answer is the same whenever it is asked and
does not drift while a process sits idle.
"""
if self.started_at is None or self.last_failure_at is None:
return None
return max(self.last_failure_at - self.started_at, timedelta(0))
@dataclass(frozen=True, slots=True)
class RunSourceInput:
"""The immutable source identity and eligibility decision captured for one run."""
source_id: str
jurisdiction: str
document_class: str
url: str
authority: str
eligible: bool
eligibility_reasons: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class AttemptEvidence:
"""The complete evidence one terminal fetch attempt persists (`DATA-04`/`DET-01`).
A required argument to :meth:`SnapshotStore.finish_fetch_attempt`, not an optional
enrichment: an attempt receipt that says "success" without saying which URL finally
answered, through which redirects, under what byte bound, and with which hashes, is a
receipt a later reader has to take on faith — and this store exists so nothing about a
fetch has to be taken on faith. The database triggers restate the same requirement in
SQL for writers that bypass this class.
"""
final_url: str
redirect_chain: tuple[RedirectHop, ...]
raw_sha256: str
normalized_sha256: str
bytes_received: int
byte_limit: int | None
truncated: bool
extraction_outcome: str
error_class: str
@dataclass(frozen=True, slots=True)
class FetchAttempt:
"""One persisted fetch attempt, read back with its complete evidence.
``ok`` and ``truncated`` are ``None`` for an attempt that never completed (the process
died mid-fetch); evidence fields read ``'legacy-unknown'`` for attempts recorded before
the evidence migration, which is a labelled uncertainty and never a value a new write
may claim.
"""
run_id: str
source_id: str
url: str
attempted_at: datetime
completed_at: datetime | None
ok: bool | None
http_status: int | None
content_type: str
normalizer_version: str
extractor_version: str
error: str
final_url: str
redirect_chain: tuple[RedirectHop, ...]
raw_sha256: str
normalized_sha256: str
bytes_received: int | None
byte_limit: int | None
truncated: bool | None
extraction_outcome: str
error_class: str
@dataclass(frozen=True, slots=True)
class WatchRun:
"""A persisted watcher receipt with exact numerator and denominator source sets."""
run_id: str
started_at: datetime
completed_at: datetime | None
as_of: date
registry_version: str
registry_revision: str
jurisdiction: str | None
state: str
eligible_source_ids: tuple[str, ...]
attempted_source_ids: tuple[str, ...]
successful_source_ids: tuple[str, ...]
#: Attempted sources whose retrieval succeeded and produced nothing comparable — a
#: text/HTML body that normalized to zero passages (issue #19). A subset of
#: ``successful_source_ids``: the bytes did arrive. Kept as its own set rather than
#: subtracted out of the successful one, because "we fetched it" and "we observed it" are
#: separate facts and a reader is entitled to both.
unmeasured_source_ids: tuple[str, ...]
observation_count: int
error: str
@property
def eligible_count(self) -> int:
return len(self.eligible_source_ids)
@property
def attempted_count(self) -> int:
return len(self.attempted_source_ids)
@property
def successful_count(self) -> int:
return len(self.successful_source_ids)
@property
def unmeasured_count(self) -> int:
return len(self.unmeasured_source_ids)
@property
def observed_count(self) -> int:
"""Sources this run actually managed to compare against a baseline.
Not ``successful_count``: a fetch that returned no extractable text succeeded and
observed nothing. This is the numerator a reader means by "how many sources did the
watcher actually look at this week".
"""
return len(set(self.successful_source_ids) - set(self.unmeasured_source_ids))