cctally 1.116.1 → 1.117.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -1,50 +1,46 @@
1
- """Pure outcome quarantine and certified account recovery (#825/#832, spec 5.6).
1
+ """Pure outcome quarantine and per-axis automatic recovery (#825/#832 spec
2
+ 5.6; #948).
2
3
 
3
4
  After the account-bound cutover, an older cctally process can still write.
4
- Its records are excluded from current accounting (spec 5.3), but excluding a
5
- reading changes what the detectors see: even an excluded reading that would
6
- not itself have produced an outcome can change a LATER detection. So every
7
- excluded record and every legacy spool change is an uncertainty TRIGGER that
8
- quarantines every Claude account: automatic in-place credits, five-hour
9
- credits, provider-moved week-end transitions and drop consensus are withheld
10
- until the account holds a recovery certificate.
11
-
12
- This module is the pure half:
13
-
14
- * `Reading` — one Claude rate-limit observation as the detectors see it.
15
- * `Envelope` — monotone running bounds over the WHOLE journal prefix (never
16
- truncated at a trigger or a revocation): the greatest week-start date key,
17
- five-hour key, raw five-hour reset, weekly end, stored capture ordering and
18
- effective instant any reading or effect in that prefix could have stored.
19
- * `certify` — the seven clauses of spec 5.6 item 4, every one evaluated, so a
20
- refusal names ALL failed clauses.
21
- * `Certificate` / `continuing_validity` — a certificate is revocable: backward
22
- input, a clock or ordering regression, or a fallback read that reaches the
23
- uncertain prefix through a cross-period access not registered inert revokes
24
- it before any automatic outcome.
25
- * `QuarantineState` — the deterministic fold over journal order that live
26
- ingest, `db rebuild --db stats` and the `db rederive` scratch replay all run,
27
- so triggers, envelopes and certificates are a function of the journal alone.
28
- * The unresolved spool marker (spec revision 3, section 5.6 item 1(b); Codex
29
- design-fix-round-4 Q6). A `legacy_spool_activity` op records only digests,
30
- never a legacy value, so the candidate it stands for contributes no bound to
31
- any envelope, and a fresh-looking reading cannot certify against the bounds
32
- the envelope does hold. Every such trigger therefore persists a marker keyed
33
- by its journal coordinate and `at`, and while any marker exists no account —
34
- one first seen later included — holds an accounting certificate. Nothing
35
- clears a marker in this outcome (automatic recovery is follow-up #948);
36
- the control certificate still reconciles on its own.
37
-
38
- Deliberately absent (the design consultation rejected each): a taint map, a
39
- timer, and an irrevocable clean flag. Diagnostic quiet time never decides
40
- recovery.
41
-
42
- Pure: no I/O, no clock.
5
+ Its records are excluded from current accounting, but excluding a reading
6
+ changes what the detectors see, and an older process's status-line render can
7
+ drive a publication nobody journals. So every excluded record and every legacy
8
+ spool change is an uncertainty TRIGGER that pauses automatic in-place credits,
9
+ early resets, five-hour credits and status-line drop publications for every
10
+ Claude account.
11
+
12
+ #948 ends that pause on its own, per account and per axis (weekly
13
+ ``sevenDay``, five-hour ``fiveHour``), through a journaled barrier (spec 6.1):
14
+
15
+ 1. every incomplete-inventory EPISODE closed by a journaled complete scan;
16
+ 2. the unknown candidate COHORT's expiry, from retained clock instants;
17
+ 3. a completed automatic OAuth reconciliation for the account after expiry
18
+ and after the trigger, covering the axis (`outcome_authority_completion`);
19
+ 4. a genuinely observed first entry into a fresh period of the axis — the
20
+ WITNESS — checked against bounds over the prefix before it;
21
+ 5. one `outcome_axis_release` record, appended immediately after the witness,
22
+ that opens the axis's outcomes and its drop consensus together.
23
+
24
+ This module is the pure half: `Reading`, `Cohort`, the per-axis bounds
25
+ (`WeeklyBounds`, `FiveHourBounds`), the witness predicates and
26
+ `QuarantineState`, the deterministic fold over journal order that live
27
+ ingest, `db rebuild --db stats`, the `db rederive` scratch replay and the
28
+ derivation-completion scratch replay all run, so every recovery fact is a
29
+ function of the journal alone. Nothing here uses a TTL, quiet time, spool
30
+ absence or an OAuth reading alone as a release condition.
31
+
32
+ Pure: no I/O, no clock, no SQLite.
43
33
  """
44
34
  from __future__ import annotations
45
35
 
46
36
  import dataclasses
37
+ import datetime as _dt
47
38
  import json
39
+ from typing import Callable
40
+
41
+ WEEKLY = "sevenDay"
42
+ FIVE_HOUR = "fiveHour"
43
+ AXES = (WEEKLY, FIVE_HOUR)
48
44
 
49
45
  #: `bin/_cctally_record.py` `_derive_5h_window_key`: the nearest
50
46
  #: `five_hour_blocks` raw reset within 1,800 s anchors the window.
@@ -52,32 +48,52 @@ TOLERANCE_BLOCK_SEARCH_S = 1800
52
48
  #: `bin/_lib_five_hour.py` `_FIVE_HOUR_JITTER_FLOOR_SECONDS`: the rolling
53
49
  #: canonicalizer's jitter tolerance.
54
50
  TOLERANCE_CANONICAL_S = 600
55
-
56
- #: The envelope scope whose bounds apply to EVERY account: excluded readings
57
- #: and effects under every reference interpretation (absent, or any Claude
58
- #: account including `unattributed` and accounts first seen later), plus
59
- #: admitted historical readings that carry no account at all.
51
+ #: Anchor separation beyond both tolerances (spec 6.5, 6.6).
52
+ FIVE_HOUR_SEPARATION_S = TOLERANCE_BLOCK_SEARCH_S + TOLERANCE_CANONICAL_S
53
+
54
+ #: The clock model's receipt skew between a legacy write and its detection
55
+ #: (D4 (3)), and the candidate activity window of the reducer: a candidate
56
+ #: received at R is inactive at every evaluation from R + 90 s on.
57
+ RECEIPT_SKEW_S = 5
58
+ CANDIDATE_ACTIVE_S = 90
59
+ #: A cohort's bounds (spec 6.3).
60
+ COHORT_WEEKLY_OFFSET_S = 8 * 86400
61
+ COHORT_FIVE_HOUR_OFFSET_S = 6 * 3600
62
+
63
+ #: The bounds scope whose summaries apply to EVERY account: excluded records
64
+ #: under every reference interpretation, plus admitted historical records that
65
+ #: carry no account.
60
66
  EXCLUDED_SCOPE = "excluded"
61
-
62
- INELIGIBLE_REASONS = (
63
- "projection-fill", "retained-low", "reviewed-hold", "manual-synthetic",
64
- )
65
- #: A witness that is not eligible at all (spec 5.6 item 4 preamble).
66
- INELIGIBLE_SINGLE_AXIS = "single-axis"
67
- INELIGIBLE_UNBOUND = "unbound"
68
-
69
- REVOCATION_REASONS = (
70
- "backward-key", "clock-regression", "order-regression",
71
- "non-inert-fallback",
72
- )
73
-
74
- CLAUSES = (1, 2, 3, 4, 5, 6, 7)
75
-
76
- #: The trigger kind of a current `legacy_spool_activity` op. Each one leaves
77
- #: an unresolved spool marker (spec revision 3, section 5.6 item 1(b)).
67
+ #: The bounds scope of every spool cohort (applies to every account).
68
+ COHORT_SCOPE = "cohorts"
69
+
70
+ STATE_PAUSED = "paused"
71
+ STATE_RELEASED = "recovered"
72
+
73
+ WAIT_INVENTORY = "inventory"
74
+ WAIT_EXPIRY = "expiry"
75
+ WAIT_USAGE_REFRESH = "usage-refresh"
76
+ WAIT_FRESH_PERIOD = "fresh-period"
77
+ WAITING_FOR = (WAIT_INVENTORY, WAIT_EXPIRY, WAIT_USAGE_REFRESH,
78
+ WAIT_FRESH_PERIOD)
79
+
80
+ CAUSE_LEGACY_ACTIVITY = "legacy-spool-activity"
81
+ CAUSE_INVENTORY_INCOMPLETE = "inventory-incomplete"
82
+ CAUSE_EXCLUDED_WRITE = "excluded-write"
83
+
84
+ #: Why a released axis returned to paused (spec 6.9).
85
+ REVOKE_BACKWARD_KEY = "backward-key"
86
+ REVOKE_ORDER_REGRESSION = "order-regression"
87
+ REVOKE_NON_INERT_FALLBACK = "non-inert-fallback"
88
+ REVOKE_ADMINISTRATIVE = "administrative"
89
+ REVOKE_MANUAL_CREDIT = "manual-credit"
90
+ REVOCATION_REASONS = (REVOKE_BACKWARD_KEY, REVOKE_ORDER_REGRESSION,
91
+ REVOKE_NON_INERT_FALLBACK, REVOKE_ADMINISTRATIVE,
92
+ REVOKE_MANUAL_CREDIT)
93
+
94
+ #: Trigger kinds (the `outcome_quarantine_triggers.kind` column).
95
+ TRIGGER_KIND_EXCLUDED = "excluded"
78
96
  TRIGGER_KIND_LEGACY_SPOOL = "legacy-spool-activity"
79
- #: The gate reason while a spool marker is unresolved.
80
- GATE_REASON_SPOOL_MARKER = "unresolved-spool-marker"
81
97
 
82
98
 
83
99
  def _max(a, b):
@@ -88,20 +104,32 @@ def _max(a, b):
88
104
  return a if a >= b else b
89
105
 
90
106
 
107
+ def _date_of(epoch: int) -> str:
108
+ return _dt.datetime.fromtimestamp(int(epoch), tz=_dt.timezone.utc).date(
109
+ ).isoformat()
110
+
111
+
91
112
  @dataclasses.dataclass(frozen=True)
92
113
  class Reading:
93
- """One observation as the detector sees it.
114
+ """One Claude rate-limit observation as the detectors and the witness
115
+ predicates see it.
94
116
 
95
- ``captured_at_utc`` is the value the snapshot row stores in
117
+ ``capture_text`` is the value the snapshot row stores in
96
118
  ``captured_at_utc`` (the payload's capture stamp verbatim), because the
97
- cross-period reads order on that TEXT column lexically.
98
- ``detection_epoch`` is the record's ``at`` (the detection clock).
99
- ``five_hour_key`` is the planned account-local key a current producer
100
- journaled, else the pure canonical floor of the raw reset."""
119
+ cross-period reads order on that TEXT column lexically; ``capture_epoch``
120
+ its instant (the frozen evaluation instant of a reducer publication, the
121
+ fetch instant of an OAuth reading). ``detection_epoch`` is the record's
122
+ ``at`` (the detection clock). ``five_hour_key`` is the planned
123
+ account-local key a current producer journaled, else the pure canonical
124
+ floor of the raw reset.
125
+
126
+ ``observed_axes`` are the axes the detector mask treats as a reading of
127
+ their source; ``eligible_axes`` those `witness_eligible` accepts (spec
128
+ 6.6); ``projection_axes`` those a publication filled from the store."""
101
129
 
102
130
  observation_id: str
103
131
  account_key: "str | None"
104
- captured_at_utc: str
132
+ capture_text: str
105
133
  capture_epoch: int
106
134
  detection_epoch: int
107
135
  week_start_date: "str | None"
@@ -109,318 +137,347 @@ class Reading:
109
137
  weekly_end_epoch: "int | None"
110
138
  five_hour_key: "int | None"
111
139
  raw_five_hour_reset: "int | None"
112
- two_axis: bool
113
- ineligible_reason: "str | None" = None
140
+ observed_axes: frozenset = frozenset(AXES)
141
+ eligible_axes: frozenset = frozenset()
142
+ projection_axes: frozenset = frozenset()
114
143
  stamped: bool = True
115
144
 
145
+ @property
146
+ def captured_at_utc(self) -> str:
147
+ return self.capture_text
148
+
149
+ def bears(self, axis: str) -> bool:
150
+ if axis == WEEKLY:
151
+ return self.week_start_date is not None
152
+ return self.five_hour_key is not None
153
+
116
154
 
117
155
  @dataclasses.dataclass(frozen=True)
118
- class Envelope:
119
- """Monotone running bounds over the WHOLE prefix (never truncated)."""
120
-
121
- max_week_start_date: "str | None" = None
122
- max_five_hour_key: "int | None" = None
123
- max_raw_five_hour_reset: "int | None" = None
124
- max_weekly_end_epoch: "int | None" = None
125
- #: Lexical, as stored in `captured_at_utc`.
126
- max_capture_at_utc: "str | None" = None
127
- #: Reset/credit effective instants AND every capture and detection
128
- #: instant: an automatic credit's effective instant is at or before its
129
- #: arming tick, so absorbing every reading's instants bounds them
130
- #: conservatively, including effects no surviving row records.
131
- max_effective_instant_epoch: "int | None" = None
132
-
133
- def absorb_reading(self, r: Reading) -> "Envelope":
134
- return Envelope(
135
- max_week_start_date=_max(self.max_week_start_date, r.week_start_date),
136
- max_five_hour_key=_max(self.max_five_hour_key, r.five_hour_key),
137
- max_raw_five_hour_reset=_max(
138
- self.max_raw_five_hour_reset, r.raw_five_hour_reset),
139
- max_weekly_end_epoch=_max(self.max_weekly_end_epoch, r.weekly_end_epoch),
140
- max_capture_at_utc=_max(self.max_capture_at_utc, r.captured_at_utc),
141
- max_effective_instant_epoch=_max(
142
- self.max_effective_instant_epoch,
143
- _max(r.capture_epoch, r.detection_epoch)),
144
- )
156
+ class Cohort:
157
+ """The unknown candidates' bounds at uncertainty position P (spec 6.3)."""
145
158
 
146
- def absorb_effect(self, *, effective_epoch: "int | None",
147
- week_start_date: "str | None" = None,
148
- five_hour_key: "int | None" = None,
149
- captured_at_utc: "str | None" = None,
150
- weekly_end_epoch: "int | None" = None,
151
- raw_five_hour_reset: "int | None" = None) -> "Envelope":
152
- """An effect any reference could hold: an event's effective instant, a
153
- manual floor's date key, a synthetic or exported snapshot's keys."""
154
- return Envelope(
155
- max_week_start_date=_max(self.max_week_start_date, week_start_date),
156
- max_five_hour_key=_max(self.max_five_hour_key, five_hour_key),
157
- max_raw_five_hour_reset=_max(
158
- self.max_raw_five_hour_reset, raw_five_hour_reset),
159
- max_weekly_end_epoch=_max(self.max_weekly_end_epoch, weekly_end_epoch),
160
- max_capture_at_utc=_max(self.max_capture_at_utc, captured_at_utc),
161
- max_effective_instant_epoch=_max(
162
- self.max_effective_instant_epoch, effective_epoch),
163
- )
159
+ h: int
160
+ r: int
161
+ e: int
162
+ u7_end: int
163
+ u7_date: str
164
+ u5_raw: int
165
+ u5_key: int
164
166
 
165
167
  @staticmethod
166
- def merge(a: "Envelope", b: "Envelope") -> "Envelope":
167
- return Envelope(
168
- max_week_start_date=_max(a.max_week_start_date, b.max_week_start_date),
169
- max_five_hour_key=_max(a.max_five_hour_key, b.max_five_hour_key),
170
- max_raw_five_hour_reset=_max(
171
- a.max_raw_five_hour_reset, b.max_raw_five_hour_reset),
172
- max_weekly_end_epoch=_max(a.max_weekly_end_epoch, b.max_weekly_end_epoch),
173
- max_capture_at_utc=_max(a.max_capture_at_utc, b.max_capture_at_utc),
174
- max_effective_instant_epoch=_max(
175
- a.max_effective_instant_epoch, b.max_effective_instant_epoch),
176
- )
168
+ def from_h(h_epoch: int, *,
169
+ normalize_week_end: "Callable[[int], int]") -> "Cohort":
170
+ h = int(h_epoch)
171
+ r = h + RECEIPT_SKEW_S
172
+ e = r + CANDIDATE_ACTIVE_S
173
+ u7_end = int(normalize_week_end(e + COHORT_WEEKLY_OFFSET_S))
174
+ u5_raw = e + COHORT_FIVE_HOUR_OFFSET_S
175
+ return Cohort(h=h, r=r, e=e, u7_end=u7_end,
176
+ u7_date=_date_of(u7_end - 7 * 86400),
177
+ u5_raw=u5_raw, u5_key=u5_raw + TOLERANCE_BLOCK_SEARCH_S)
177
178
 
178
179
  def to_document(self) -> dict:
179
- return {
180
- "maxWeekStartDate": self.max_week_start_date,
181
- "maxFiveHourKey": self.max_five_hour_key,
182
- "maxRawFiveHourReset": self.max_raw_five_hour_reset,
183
- "maxWeeklyEndEpoch": self.max_weekly_end_epoch,
184
- "maxCaptureAtUtc": self.max_capture_at_utc,
185
- "maxEffectiveInstantEpoch": self.max_effective_instant_epoch,
186
- }
180
+ return dataclasses.asdict(self)
187
181
 
188
182
  @staticmethod
189
- def from_document(doc) -> "Envelope | None":
190
- """The envelope a document describes, or None when it is not a valid
191
- envelope document. An unverifiable envelope must never read as an
192
- empty (permissive) one, so callers treat None as quarantined."""
193
- if not isinstance(doc, dict):
194
- return None
195
- keys = {"maxWeekStartDate", "maxFiveHourKey", "maxRawFiveHourReset",
196
- "maxWeeklyEndEpoch", "maxCaptureAtUtc",
197
- "maxEffectiveInstantEpoch"}
198
- if set(doc) != keys:
183
+ def from_document(doc) -> "Cohort | None":
184
+ if not isinstance(doc, dict) or set(doc) != {
185
+ "h", "r", "e", "u7_end", "u7_date", "u5_raw", "u5_key"}:
199
186
  return None
200
- for key in ("maxWeekStartDate", "maxCaptureAtUtc"):
201
- if doc[key] is not None and not isinstance(doc[key], str):
202
- return None
203
- for key in ("maxFiveHourKey", "maxRawFiveHourReset",
204
- "maxWeeklyEndEpoch", "maxEffectiveInstantEpoch"):
205
- if doc[key] is not None and type(doc[key]) is not int:
187
+ for key in ("h", "r", "e", "u7_end", "u5_raw", "u5_key"):
188
+ if type(doc[key]) is not int:
206
189
  return None
207
- return Envelope(
208
- max_week_start_date=doc["maxWeekStartDate"],
209
- max_five_hour_key=doc["maxFiveHourKey"],
210
- max_raw_five_hour_reset=doc["maxRawFiveHourReset"],
211
- max_weekly_end_epoch=doc["maxWeeklyEndEpoch"],
212
- max_capture_at_utc=doc["maxCaptureAtUtc"],
213
- max_effective_instant_epoch=doc["maxEffectiveInstantEpoch"],
214
- )
190
+ if not isinstance(doc["u7_date"], str):
191
+ return None
192
+ return Cohort(**doc)
215
193
 
216
194
 
217
- @dataclasses.dataclass(frozen=True)
218
- class CertificateDecision:
219
- certified: bool
220
- #: Subset of 1..7, empty when certified or when the witness is ineligible.
221
- failed_clauses: "tuple[int, ...]"
222
- ineligible: "str | None" = None
223
-
224
-
225
- def witness_ineligibility(witness: Reading) -> "str | None":
226
- """Why ``witness`` can never certify, or None (spec 5.6 item 4)."""
227
- if witness.ineligible_reason is not None:
228
- return witness.ineligible_reason
229
- if (not witness.stamped or witness.account_key is None):
230
- return INELIGIBLE_UNBOUND
231
- if (not witness.two_axis or witness.week_start_date is None
232
- or witness.five_hour_key is None
233
- or witness.raw_five_hour_reset is None
234
- or witness.week_start_at_epoch is None):
235
- return INELIGIBLE_SINGLE_AXIS
236
- return None
237
-
238
-
239
- def _debounce_could_match(witness: Reading, envelope: Envelope) -> bool:
240
- """Clause 6, by the comparisons the detector performs.
241
-
242
- The weekly debounce state is ONE row per account; the detector treats it
243
- as armed for a reading iff the stored `week_start_date` equals the
244
- reading's and the stored end equals its end (`detect_reset_and_credit`).
245
- Every state the prefix could have armed carries a date key the prefix
246
- produced, so it is bounded by `max_week_start_date`; a witness date beyond
247
- that bound can match none of them, whatever its end. The five-hour
248
- confirmation state is keyed `(account, window key, source)`, so a witness
249
- key beyond `max_five_hour_key` reads no prefix row."""
250
- if (envelope.max_week_start_date is not None
251
- and witness.week_start_date <= envelope.max_week_start_date):
252
- return True
253
- if (envelope.max_five_hour_key is not None
254
- and witness.five_hour_key <= envelope.max_five_hour_key):
255
- return True
256
- return False
257
-
258
-
259
- def certify(witness: Reading, envelope: Envelope, *,
260
- seen_identities=frozenset()) -> CertificateDecision:
261
- """Evaluate every clause of spec 5.6 item 4 against ``envelope`` (the
262
- merge of the account's and the excluded scope's bounds) and return ALL
263
- failed clauses, never just the first."""
264
- ineligible = witness_ineligibility(witness)
265
- if ineligible is not None:
266
- return CertificateDecision(False, (), ineligible)
267
- failed = []
268
- env = envelope
269
- # 1. Period-keyed freshness: the date key and the five-hour key lie
270
- # beyond every possible prefix key. Comparing normalized ends is not
271
- # enough, because distinct ends share one date key.
272
- if ((env.max_week_start_date is not None
273
- and witness.week_start_date <= env.max_week_start_date)
274
- or (env.max_five_hour_key is not None
275
- and witness.five_hour_key <= env.max_five_hour_key)):
276
- failed.append(1)
277
- # 2. Anchor selection: no prefix raw reset inside either tolerance of the
278
- # witness's, so every possible prefix anchor yields the witness's own
279
- # fresh key (conservatively the SUM of both tolerances).
280
- if (env.max_raw_five_hour_reset is not None
281
- and witness.raw_five_hour_reset - env.max_raw_five_hour_reset
282
- <= TOLERANCE_BLOCK_SEARCH_S + TOLERANCE_CANONICAL_S):
283
- failed.append(2)
284
- # 3. Range-keyed reads: no prefix effective instant inside the witness's
285
- # weekly interval (the sufficient check: its start exceeds them all).
286
- if (env.max_effective_instant_epoch is not None
287
- and witness.week_start_at_epoch <= env.max_effective_instant_epoch):
288
- failed.append(3)
289
- # 4. Cross-period ordering under the actual SQL ordering
290
- # (`captured_at_utc DESC, id DESC`, lexical TEXT comparison).
291
- if (env.max_capture_at_utc is not None
292
- and witness.captured_at_utc <= env.max_capture_at_utc):
293
- failed.append(4)
294
- # 5. Detection clock later than every predecessor's weekly end and raw
295
- # five-hour reset — separate from capture ordering.
296
- latest_end = _max(env.max_weekly_end_epoch, env.max_raw_five_hour_reset)
297
- if latest_end is not None and witness.detection_epoch <= latest_end:
298
- failed.append(5)
299
- # 6. Singletons: the weekly debounce row and the five-hour confirmation
300
- # state cannot match the witness.
301
- if _debounce_could_match(witness, env):
302
- failed.append(6)
303
- # 7. Logical identity: the witness is not a replay of a seen observation.
304
- if witness.observation_id in seen_identities:
305
- failed.append(7)
306
- return CertificateDecision(not failed, tuple(failed), None)
195
+ def _check_doc(doc, ints, strs):
196
+ if not isinstance(doc, dict) or set(doc) != set(ints) | set(strs):
197
+ return False
198
+ for key in ints:
199
+ if doc[key] is not None and type(doc[key]) is not int:
200
+ return False
201
+ for key in strs:
202
+ if doc[key] is not None and not isinstance(doc[key], str):
203
+ return False
204
+ return True
307
205
 
308
206
 
309
207
  @dataclasses.dataclass(frozen=True)
310
- class Certificate:
311
- witness_observation_id: str
312
- witness_week_start_date: str
313
- witness_five_hour_key: int
314
- witness_captured_at_utc: str
315
- last_capture_at_utc: str
316
- last_detection_epoch: int
208
+ class WeeklyBounds:
209
+ """The weekly summaries of one scope over the WHOLE retained prefix
210
+ (spec 6.5): structural ``s7_date``/``s7_end``, capture order
211
+ ``k7``/``t7``, the closed effect bound ``c7`` and the exclusive effect
212
+ bound ``x7`` (strict-before: a witness's week start may EQUAL it)."""
213
+
214
+ s7_date: "str | None" = None
215
+ s7_end: "int | None" = None
216
+ k7: "int | None" = None
217
+ t7: "str | None" = None
218
+ c7: "int | None" = None
219
+ x7: "int | None" = None
220
+
221
+ def absorb_reading(self, r: Reading, *, possible_effect: bool) -> "WeeklyBounds":
222
+ if r.week_start_date is None:
223
+ return self
224
+ return WeeklyBounds(
225
+ s7_date=_max(self.s7_date, r.week_start_date),
226
+ s7_end=_max(self.s7_end, r.weekly_end_epoch),
227
+ k7=_max(self.k7, r.capture_epoch),
228
+ t7=_max(self.t7, r.capture_text),
229
+ c7=_max(self.c7, r.capture_epoch) if possible_effect else self.c7,
230
+ x7=self.x7,
231
+ )
317
232
 
318
- @staticmethod
319
- def from_witness(r: Reading) -> "Certificate":
320
- return Certificate(
321
- witness_observation_id=r.observation_id,
322
- witness_week_start_date=r.week_start_date,
323
- witness_five_hour_key=r.five_hour_key,
324
- witness_captured_at_utc=r.captured_at_utc,
325
- last_capture_at_utc=r.captured_at_utc,
326
- last_detection_epoch=r.detection_epoch,
233
+ def absorb_effect(self, *, effective_epoch=None, week_start_date=None,
234
+ week_end_epoch=None, capture_epoch=None,
235
+ capture_text=None, exclusive_end=None) -> "WeeklyBounds":
236
+ return WeeklyBounds(
237
+ s7_date=_max(self.s7_date, week_start_date),
238
+ s7_end=_max(self.s7_end, week_end_epoch),
239
+ k7=_max(self.k7, capture_epoch),
240
+ t7=_max(self.t7, capture_text),
241
+ c7=_max(self.c7, effective_epoch),
242
+ x7=_max(self.x7, exclusive_end),
327
243
  )
328
244
 
329
- def advance(self, r: Reading) -> "Certificate":
330
- return dataclasses.replace(
331
- self,
332
- last_capture_at_utc=_max(self.last_capture_at_utc, r.captured_at_utc),
333
- last_detection_epoch=_max(self.last_detection_epoch, r.detection_epoch),
245
+ def absorb_cohort(self, c: Cohort) -> "WeeklyBounds":
246
+ return WeeklyBounds(
247
+ s7_date=_max(self.s7_date, c.u7_date),
248
+ s7_end=_max(self.s7_end, c.u7_end),
249
+ k7=self.k7, t7=self.t7,
250
+ c7=_max(self.c7, c.e),
251
+ x7=_max(self.x7, c.u7_end),
334
252
  )
335
253
 
254
+ @staticmethod
255
+ def merge(*items: "WeeklyBounds") -> "WeeklyBounds":
256
+ out = WeeklyBounds()
257
+ for b in items:
258
+ out = WeeklyBounds(
259
+ s7_date=_max(out.s7_date, b.s7_date),
260
+ s7_end=_max(out.s7_end, b.s7_end),
261
+ k7=_max(out.k7, b.k7), t7=_max(out.t7, b.t7),
262
+ c7=_max(out.c7, b.c7), x7=_max(out.x7, b.x7))
263
+ return out
264
+
336
265
  def to_document(self) -> dict:
337
- return {
338
- "witnessObservationId": self.witness_observation_id,
339
- "witnessWeekStartDate": self.witness_week_start_date,
340
- "witnessFiveHourKey": self.witness_five_hour_key,
341
- "witnessCapturedAtUtc": self.witness_captured_at_utc,
342
- "lastCaptureAtUtc": self.last_capture_at_utc,
343
- "lastDetectionEpoch": self.last_detection_epoch,
344
- }
266
+ return {"s7Date": self.s7_date, "s7End": self.s7_end, "k7": self.k7,
267
+ "t7": self.t7, "c7": self.c7, "x7": self.x7}
345
268
 
346
269
  @staticmethod
347
- def from_document(doc) -> "Certificate | None":
348
- if not isinstance(doc, dict) or set(doc) != {
349
- "witnessObservationId", "witnessWeekStartDate",
350
- "witnessFiveHourKey", "witnessCapturedAtUtc",
351
- "lastCaptureAtUtc", "lastDetectionEpoch"}:
270
+ def from_document(doc) -> "WeeklyBounds | None":
271
+ if not _check_doc(doc, ("s7End", "k7", "c7", "x7"), ("s7Date", "t7")):
352
272
  return None
353
- for key in ("witnessObservationId", "witnessWeekStartDate",
354
- "witnessCapturedAtUtc", "lastCaptureAtUtc"):
355
- if not isinstance(doc[key], str) or not doc[key]:
356
- return None
357
- for key in ("witnessFiveHourKey", "lastDetectionEpoch"):
358
- if type(doc[key]) is not int:
359
- return None
360
- return Certificate(
361
- witness_observation_id=doc["witnessObservationId"],
362
- witness_week_start_date=doc["witnessWeekStartDate"],
363
- witness_five_hour_key=doc["witnessFiveHourKey"],
364
- witness_captured_at_utc=doc["witnessCapturedAtUtc"],
365
- last_capture_at_utc=doc["lastCaptureAtUtc"],
366
- last_detection_epoch=doc["lastDetectionEpoch"],
273
+ return WeeklyBounds(s7_date=doc["s7Date"], s7_end=doc["s7End"],
274
+ k7=doc["k7"], t7=doc["t7"], c7=doc["c7"],
275
+ x7=doc["x7"])
276
+
277
+
278
+ @dataclasses.dataclass(frozen=True)
279
+ class FiveHourBounds:
280
+ """The five-hour summaries of one scope (spec 6.5): the maximum canonical
281
+ key ``k5``; ``raw_upper5`` = max(every raw reset, every cohort U5.raw,
282
+ every key + 2,400 s); capture order ``k5c``/``t5``; and ``detect_floor``,
283
+ the maximum of every cohort's U5.raw and every excluded record's raw
284
+ reset, which a witness's detection must exceed. Effects are key-scoped
285
+ (old-key confirmation state is inert by key mismatch), so there is no
286
+ effect bound."""
287
+
288
+ k5: "int | None" = None
289
+ raw_upper5: "int | None" = None
290
+ k5c: "int | None" = None
291
+ t5: "str | None" = None
292
+ detect_floor: "int | None" = None
293
+
294
+ def absorb_reading(self, r: Reading, *, excluded: bool = False) -> "FiveHourBounds":
295
+ if r.five_hour_key is None:
296
+ return self
297
+ upper = _max(r.raw_five_hour_reset, r.five_hour_key + FIVE_HOUR_SEPARATION_S)
298
+ return FiveHourBounds(
299
+ k5=_max(self.k5, r.five_hour_key),
300
+ raw_upper5=_max(self.raw_upper5, upper),
301
+ k5c=_max(self.k5c, r.capture_epoch),
302
+ t5=_max(self.t5, r.capture_text),
303
+ detect_floor=(_max(self.detect_floor, r.raw_five_hour_reset)
304
+ if excluded else self.detect_floor),
367
305
  )
368
306
 
307
+ def absorb_effect(self, *, five_hour_key=None, raw_reset=None,
308
+ capture_epoch=None, capture_text=None) -> "FiveHourBounds":
309
+ upper = raw_reset
310
+ if five_hour_key is not None:
311
+ upper = _max(upper, five_hour_key + FIVE_HOUR_SEPARATION_S)
312
+ return FiveHourBounds(
313
+ k5=_max(self.k5, five_hour_key),
314
+ raw_upper5=_max(self.raw_upper5, upper),
315
+ k5c=_max(self.k5c, capture_epoch),
316
+ t5=_max(self.t5, capture_text),
317
+ detect_floor=self.detect_floor,
318
+ )
369
319
 
370
- def continuing_validity(cert: Certificate, r: Reading, *,
371
- fallback_rows_captured_at=()) -> "str | None":
372
- """None while ``r`` stays inside the certified forward domain, else the
373
- revocation reason (spec 5.6 item 5).
374
-
375
- ``fallback_rows_captured_at`` are the capture instants of the rows the
376
- decision's NON-INERT cross-period reads returned (the access registry
377
- decides which reads are inert); a row from before the witness means the
378
- read fell back into the uncertain prefix."""
379
- if r.week_start_date is None or r.week_start_date < cert.witness_week_start_date:
380
- return "backward-key"
381
- if r.five_hour_key is not None and r.five_hour_key < cert.witness_five_hour_key:
382
- return "backward-key"
383
- if r.detection_epoch < cert.last_detection_epoch:
384
- return "clock-regression"
385
- if r.captured_at_utc < cert.last_capture_at_utc:
386
- return "order-regression"
387
- for captured in fallback_rows_captured_at:
388
- if captured is not None and captured < cert.witness_captured_at_utc:
389
- return "non-inert-fallback"
390
- return None
320
+ def absorb_cohort(self, c: Cohort) -> "FiveHourBounds":
321
+ return FiveHourBounds(
322
+ k5=_max(self.k5, c.u5_key),
323
+ raw_upper5=_max(self.raw_upper5,
324
+ max(c.u5_raw, c.u5_key + FIVE_HOUR_SEPARATION_S)),
325
+ k5c=self.k5c, t5=self.t5,
326
+ detect_floor=_max(self.detect_floor, c.u5_raw),
327
+ )
328
+
329
+ @staticmethod
330
+ def merge(*items: "FiveHourBounds") -> "FiveHourBounds":
331
+ out = FiveHourBounds()
332
+ for b in items:
333
+ out = FiveHourBounds(
334
+ k5=_max(out.k5, b.k5),
335
+ raw_upper5=_max(out.raw_upper5, b.raw_upper5),
336
+ k5c=_max(out.k5c, b.k5c), t5=_max(out.t5, b.t5),
337
+ detect_floor=_max(out.detect_floor, b.detect_floor))
338
+ return out
339
+
340
+ def to_document(self) -> dict:
341
+ return {"k5": self.k5, "rawUpper5": self.raw_upper5, "k5c": self.k5c,
342
+ "t5": self.t5, "detectFloor": self.detect_floor}
343
+
344
+ @staticmethod
345
+ def from_document(doc) -> "FiveHourBounds | None":
346
+ if not _check_doc(doc, ("k5", "rawUpper5", "k5c", "detectFloor"),
347
+ ("t5",)):
348
+ return None
349
+ return FiveHourBounds(k5=doc["k5"], raw_upper5=doc["rawUpper5"],
350
+ k5c=doc["k5c"], t5=doc["t5"],
351
+ detect_floor=doc["detectFloor"])
352
+
353
+
354
+ def _capture_order_ok(r: Reading, k, t) -> bool:
355
+ """Spec 6.6 *Capture order and ties*: the capture is at least the
356
+ maximum instant AND its stored text at least the lexical maximum. Derived
357
+ rows of a later journal record always carry a larger id, so an equal
358
+ capture (instant and text) sorts the witness first under
359
+ `ORDER BY captured_at_utc DESC, id DESC`; a chronologically later or
360
+ equal but lexically earlier text would let predecessor reads select an
361
+ older row, and never qualifies."""
362
+ if k is not None and r.capture_epoch < k:
363
+ return False
364
+ if t is not None and r.capture_text < t:
365
+ return False
366
+ return True
367
+
368
+
369
+ def weekly_witness_qualifies(r: Reading, b: WeeklyBounds) -> bool:
370
+ """Spec 6.6 *Weekly*, against summaries over the prefix strictly before
371
+ ``r``. The first-entry condition and "no pending debounce matches r's
372
+ week" are implied: every prefix weekly row, and so every armed debounce
373
+ state, carries a date key at most ``s7_date``, and ``r``'s is greater."""
374
+ if (r.week_start_date is None or r.weekly_end_epoch is None
375
+ or r.week_start_at_epoch is None):
376
+ return False
377
+ if b.s7_date is not None and not r.week_start_date > b.s7_date:
378
+ return False
379
+ if b.s7_end is not None and not r.weekly_end_epoch > b.s7_end:
380
+ return False
381
+ if b.c7 is not None and not r.week_start_at_epoch > b.c7:
382
+ return False
383
+ if b.x7 is not None and not r.week_start_at_epoch >= b.x7:
384
+ return False
385
+ if not _capture_order_ok(r, b.k7, b.t7):
386
+ return False
387
+ if b.s7_end is not None and not r.detection_epoch > b.s7_end:
388
+ return False
389
+ return True
390
+
391
+
392
+ def five_hour_witness_qualifies(r: Reading, b: FiveHourBounds, *,
393
+ detection_floor: "int | None") -> bool:
394
+ """Spec 6.6 *Five-hour*. ``detection_floor`` is the maximum of every
395
+ cohort's U5.raw and every excluded record's raw reset (None when none
396
+ exists). "No confirmation state for (Y, r.key)" is implied by the key
397
+ freshness: confirmation state is keyed by window key, and every prefix
398
+ key is at most ``k5``."""
399
+ if r.five_hour_key is None or r.raw_five_hour_reset is None:
400
+ return False
401
+ if b.k5 is not None and not r.five_hour_key > b.k5:
402
+ return False
403
+ if (b.raw_upper5 is not None
404
+ and not r.raw_five_hour_reset > b.raw_upper5 + FIVE_HOUR_SEPARATION_S):
405
+ return False
406
+ if not _capture_order_ok(r, b.k5c, b.t5):
407
+ return False
408
+ if detection_floor is not None and not r.detection_epoch > detection_floor:
409
+ return False
410
+ return True
391
411
 
392
412
 
393
413
  @dataclasses.dataclass(frozen=True)
394
- class ControlCertificate:
395
- """A bound two-axis authoritative OAuth publication for one account
396
- (spec 5.6 item 7): the journal observation that witnesses it."""
414
+ class GateDecision:
415
+ """Whether automatic outcomes may be minted (or a drop published) for
416
+ one axis."""
417
+
418
+ allowed: bool
419
+ reason: str
420
+
421
+
422
+ GATE_NO_TRIGGER = GateDecision(True, "no-trigger")
423
+ GATE_RELEASED = GateDecision(True, "released")
424
+
425
+
426
+ def all_axes(decision: GateDecision) -> dict:
427
+ return {axis: decision for axis in AXES}
428
+
429
+
430
+ @dataclasses.dataclass
431
+ class AxisRecovery:
432
+ """One (generation, account, axis): paused or released, what it waits
433
+ for, the completion covering it, its witness (pending or released), its
434
+ release, the reason of its last revocation and the times doctor shows.
435
+ ``not_before`` is the detection lower bound snapshotted over the prefix
436
+ strictly before a pending witness (spec 7.1); None otherwise."""
437
+
438
+ state: str = STATE_PAUSED
439
+ waiting_for: "str | None" = None
440
+ coverage_id: "str | None" = None
441
+ witness_id: "str | None" = None
442
+ release_id: "str | None" = None
443
+ revocation: "str | None" = None
444
+ since: "str | None" = None
445
+ recovered_at: "str | None" = None
446
+ not_before: "int | None" = None
447
+ #: The witness's period key and capture order, kept for revocation.
448
+ witness_key: "str | int | None" = None
449
+
450
+
451
+ @dataclasses.dataclass(frozen=True)
452
+ class ControlWitness:
453
+ """The latest control-witness observation of an account in the current
454
+ generation: what an authority completion must name."""
397
455
 
398
456
  observation_id: str
399
- captured_at_utc: str
457
+ capture_epoch: int
458
+ axes: frozenset
459
+ weekly_end_epoch: "int | None" = None
400
460
 
401
461
  def to_document(self) -> dict:
402
462
  return {"observationId": self.observation_id,
403
- "capturedAtUtc": self.captured_at_utc}
463
+ "captureEpoch": self.capture_epoch,
464
+ "axes": sorted(self.axes),
465
+ "weeklyEndEpoch": self.weekly_end_epoch}
404
466
 
405
467
  @staticmethod
406
- def from_document(doc) -> "ControlCertificate | None":
468
+ def from_document(doc) -> "ControlWitness | None":
407
469
  if (not isinstance(doc, dict)
408
- or set(doc) != {"observationId", "capturedAtUtc"}
470
+ or set(doc) != {"observationId", "captureEpoch", "axes",
471
+ "weeklyEndEpoch"}
409
472
  or not isinstance(doc["observationId"], str)
410
- or not isinstance(doc["capturedAtUtc"], str)):
473
+ or type(doc["captureEpoch"]) is not int
474
+ or not isinstance(doc["axes"], list)
475
+ or not set(doc["axes"]) <= set(AXES)
476
+ or (doc["weeklyEndEpoch"] is not None
477
+ and type(doc["weeklyEndEpoch"]) is not int)):
411
478
  return None
412
- return ControlCertificate(doc["observationId"], doc["capturedAtUtc"])
413
-
414
-
415
- @dataclasses.dataclass
416
- class AccountRecovery:
417
- """One account's certificates and the trigger sequence each was granted
418
- against. A certificate granted against an older sequence is void."""
419
-
420
- accounting: "Certificate | None" = None
421
- accounting_trigger_seq: "int | None" = None
422
- control: "ControlCertificate | None" = None
423
- control_trigger_seq: "int | None" = None
479
+ return ControlWitness(doc["observationId"], doc["captureEpoch"],
480
+ frozenset(doc["axes"]), doc["weeklyEndEpoch"])
424
481
 
425
482
 
426
483
  @dataclasses.dataclass(frozen=True)
@@ -433,258 +490,564 @@ class Trigger:
433
490
  at: "str | None"
434
491
 
435
492
 
436
- def spool_marker(trigger: Trigger) -> dict:
437
- """The unresolved structural-envelope marker a spool trigger leaves: its
438
- journal coordinate and `at` (spec revision 3), and its reason — spec
439
- revision 5 words the marker of an incomplete inventory differently."""
440
- return {"seq": trigger.seq, "segment": trigger.segment,
441
- "offset": trigger.offset, "at": trigger.at,
442
- "reason": trigger.reason}
493
+ @dataclasses.dataclass
494
+ class Episode:
495
+ """An incomplete-inventory episode, identified by the coordinate of the
496
+ record that opened it (spec 6.3)."""
443
497
 
498
+ opener_segment: "str | None"
499
+ opener_offset: "int | None"
500
+ opened_at: "str | None"
501
+ closer_segment: "str | None" = None
502
+ closer_offset: "int | None" = None
503
+ closed_at: "str | None" = None
444
504
 
445
- @dataclasses.dataclass(frozen=True)
446
- class GateDecision:
447
- """Whether automatic outcomes may be minted for one reading."""
505
+ @property
506
+ def key(self) -> tuple:
507
+ return (self.opener_segment, self.opener_offset)
448
508
 
449
- allowed: bool
450
- reason: str
509
+ @property
510
+ def open(self) -> bool:
511
+ return self.closed_at is None and self.closer_segment is None
451
512
 
452
513
 
453
- GATE_NO_TRIGGER = GateDecision(True, "no-trigger")
454
- GATE_CERTIFIED = GateDecision(True, "certified")
514
+ @dataclasses.dataclass
515
+ class ScopeBounds:
516
+ weekly: WeeklyBounds = dataclasses.field(default_factory=WeeklyBounds)
517
+ five_hour: FiveHourBounds = dataclasses.field(default_factory=FiveHourBounds)
518
+
519
+
520
+ def cause_of(trigger: "Trigger | None", inventory_reasons=()) -> "str | None":
521
+ """The doctor cause of the pause a trigger started."""
522
+ if trigger is None:
523
+ return None
524
+ if trigger.kind == TRIGGER_KIND_EXCLUDED:
525
+ return CAUSE_EXCLUDED_WRITE
526
+ if trigger.reason in inventory_reasons:
527
+ return CAUSE_INVENTORY_INCOMPLETE
528
+ return CAUSE_LEGACY_ACTIVITY
455
529
 
456
530
 
457
531
  class QuarantineState:
458
532
  """The deterministic quarantine fold over journal order.
459
533
 
460
- Every consumer — live ingest, rebuild, the rederive scratch replay — feeds
461
- the same events in journal order, so the persisted state is a function of
462
- the journal alone. Methods mutate in place and record which rows changed
463
- so a caller persists only those."""
464
-
465
- def __init__(self, *, latest_trigger_seq: int = 0, envelopes=None,
466
- accounts=None, non_inert_cross_period: bool = False,
467
- spool_markers=()):
468
- self.latest_trigger_seq = int(latest_trigger_seq)
469
- self.envelopes: dict = dict(envelopes or {})
470
- self.accounts: dict = dict(accounts or {})
534
+ Every consumer feeds the same events in journal order, so the persisted
535
+ state is a function of the journal alone. Methods mutate in place and
536
+ record which rows changed so a caller persists only those. The glue
537
+ (`_cctally_quarantine.QuarantineFold`) maps records to these events."""
538
+
539
+ def __init__(self, *, generation: int = 0,
540
+ latest_trigger: "Trigger | None" = None,
541
+ episodes=(), cohort: "Cohort | None" = None,
542
+ expired: bool = False, max_admitted_at: "int | None" = None,
543
+ bounds=None, accounts=None, control=None,
544
+ non_inert_cross_period: bool = False):
545
+ self.generation = int(generation)
546
+ self.latest_trigger = latest_trigger
547
+ self.episodes: list = list(episodes)
548
+ #: The LATEST spool cohort (set at P), and whether its expiry has
549
+ #: been established after P.
550
+ self.cohort = cohort
551
+ self.expired = bool(expired)
552
+ self.max_admitted_at = max_admitted_at
553
+ self.bounds: dict = dict(bounds or {})
554
+ self.accounts: dict = {k: dict(v) for k, v in (accounts or {}).items()}
555
+ self.control: dict = dict(control or {})
556
+ self.non_inert_cross_period = bool(non_inert_cross_period)
471
557
  self.new_triggers: list = []
472
- self.dirty_envelopes: set = set()
558
+ self.dirty_episodes: set = set()
559
+ self.dirty_bounds: set = set()
473
560
  self.dirty_accounts: set = set()
474
- #: Every spool trigger's unresolved marker, in journal order. None is
475
- #: ever removed: a later trigger adds a generation and keeps the
476
- #: earlier uncertainty (spec revision 3).
477
- self.spool_markers: list = [dict(m) for m in spool_markers]
478
- if self.spool_markers:
479
- # A certificate persisted while a marker exists was issued
480
- # without the marker check (the rule before spec revision 3), so
481
- # it is invalidated here, never honored or advanced; a rebuild
482
- # likewise grants none while the retained triggers hold a marker.
483
- self.revoke_all_accounting()
484
- #: Session-local seen observation identities per scope (clause 7).
485
- #: Not persisted: a replayed identity always fails clause 1 as well,
486
- #: because its own first copy is inside the envelope.
487
- self.seen: dict = {}
488
- #: True when some registered cross-period access has no inert rule;
489
- #: then a deletion outcome revokes the account at its journal position.
490
- self.non_inert_cross_period = bool(non_inert_cross_period)
561
+ self.dirty_meta = False
562
+ self.dirty_control: set = set()
491
563
 
492
564
  # -- queries ---------------------------------------------------------
493
565
  def has_trigger(self) -> bool:
494
- return self.latest_trigger_seq > 0
566
+ return self.generation > 0
567
+
568
+ @property
569
+ def latest_trigger_seq(self) -> int:
570
+ return self.generation
571
+
572
+ def open_episode(self) -> "Episode | None":
573
+ for episode in reversed(self.episodes):
574
+ if episode.open:
575
+ return episode
576
+ return None
577
+
578
+ def scope(self, name: str) -> ScopeBounds:
579
+ found = self.bounds.get(name)
580
+ return found if found is not None else ScopeBounds()
581
+
582
+ def merged(self, account_key: "str | None") -> ScopeBounds:
583
+ """The summaries a witness of ``account_key`` is judged against: its
584
+ own, the excluded scope's and every cohort's."""
585
+ names = [EXCLUDED_SCOPE, COHORT_SCOPE]
586
+ if account_key is not None:
587
+ names.append(account_key)
588
+ parts = [self.scope(n) for n in names]
589
+ return ScopeBounds(
590
+ weekly=WeeklyBounds.merge(*(p.weekly for p in parts)),
591
+ five_hour=FiveHourBounds.merge(*(p.five_hour for p in parts)))
592
+
593
+ def axis(self, account_key: str, axis: str) -> AxisRecovery:
594
+ found = self.accounts.get(account_key, {}).get(axis)
595
+ if found is not None:
596
+ return found
597
+ since = None if self.latest_trigger is None else self.latest_trigger.at
598
+ return AxisRecovery(since=since)
599
+
600
+ def _axis_mut(self, account_key: str, axis: str) -> AxisRecovery:
601
+ per = self.accounts.setdefault(account_key, {})
602
+ rec = per.get(axis)
603
+ if rec is None:
604
+ rec = self.axis(account_key, axis)
605
+ per[axis] = rec
606
+ self.dirty_accounts.add(account_key)
607
+ return rec
495
608
 
496
- def has_unresolved_spool_marker(self) -> bool:
497
- return bool(self.spool_markers)
609
+ def released(self, account_key: str, axis: str) -> bool:
610
+ return self.axis(account_key, axis).state == STATE_RELEASED
498
611
 
499
- def accounting_certified(self, account_key: str) -> bool:
500
- """Whether ``account_key`` holds a current accounting certificate.
501
- Never while a spool marker is unresolved, whatever a persisted row
502
- says (spec revision 3)."""
503
- if self.spool_markers:
612
+ def barrier_open(self) -> bool:
613
+ """Steps 1–2 of the barrier: no episode open, and the latest spool
614
+ cohort (when one exists) has expired."""
615
+ if self.open_episode() is not None:
504
616
  return False
505
- rec = self.accounts.get(account_key)
506
- return (rec is not None and rec.accounting is not None
507
- and rec.accounting_trigger_seq == self.latest_trigger_seq)
508
-
509
- def control_certified(self, account_key: str) -> bool:
510
- rec = self.accounts.get(account_key)
511
- return (rec is not None and rec.control is not None
512
- and rec.control_trigger_seq == self.latest_trigger_seq)
513
-
514
- def accounting_quarantined(self, account_key: str) -> bool:
515
- return self.has_trigger() and not self.accounting_certified(account_key)
516
-
517
- def control_quarantined(self, account_key: str) -> bool:
518
- return self.has_trigger() and not self.control_certified(account_key)
617
+ return self.cohort is None or self.expired
519
618
 
520
- def envelope(self, scope: str) -> Envelope:
521
- return self.envelopes.get(scope) or Envelope()
619
+ def waiting_for(self, account_key: str, axis: str) -> "str | None":
620
+ if not self.has_trigger() or self.released(account_key, axis):
621
+ return None
622
+ if self.open_episode() is not None:
623
+ return WAIT_INVENTORY
624
+ if self.cohort is not None and not self.expired:
625
+ return WAIT_EXPIRY
626
+ if self.axis(account_key, axis).coverage_id is None:
627
+ return WAIT_USAGE_REFRESH
628
+ return WAIT_FRESH_PERIOD
629
+
630
+ def axis_gate(self, account_key: "str | None", axis: str) -> GateDecision:
631
+ if not self.has_trigger():
632
+ return GATE_NO_TRIGGER
633
+ if account_key is not None and self.released(account_key, axis):
634
+ return GATE_RELEASED
635
+ reason = (self.waiting_for(account_key, axis)
636
+ if account_key is not None else None)
637
+ return GateDecision(False, f"paused:{reason or 'unbound'}")
638
+
639
+ def gates(self, account_key: "str | None") -> dict:
640
+ return {axis: self.axis_gate(account_key, axis) for axis in AXES}
641
+
642
+ def pending_witness(self, account_key: str, axis: str) -> "str | None":
643
+ rec = self.axis(account_key, axis)
644
+ if (rec.state == STATE_PAUSED and rec.witness_id is not None
645
+ and rec.release_id is None):
646
+ return rec.witness_id
647
+ return None
648
+
649
+ def not_before(self, account_key: str, axis: str) -> "int | None":
650
+ """A guaranteed lower bound on the axis's release instant from the
651
+ detection clauses of spec 6.6 (spec 7.1): weekly ``S7.end``; five-hour
652
+ the detection floor. Computed over the prefix strictly before a
653
+ pending witness."""
654
+ if not self.has_trigger() or self.released(account_key, axis):
655
+ return None
656
+ rec = self.axis(account_key, axis)
657
+ if self.pending_witness(account_key, axis) is not None:
658
+ return rec.not_before
659
+ merged = self.merged(account_key)
660
+ if axis == WEEKLY:
661
+ return merged.weekly.s7_end
662
+ return merged.five_hour.detect_floor
663
+
664
+ def coverable_axes(self, account_key: str, observation_id: str) -> tuple:
665
+ """The axes an `outcome_authority_completion` naming
666
+ ``observation_id`` would newly cover now: paused, uncovered, observed
667
+ by that observation (the account's latest control witness of the
668
+ current generation), with the barrier's first two steps done and the
669
+ fetch at or after the latest spool cohort's E (spec 6.4)."""
670
+ if not self.has_trigger() or self.open_episode() is not None:
671
+ return ()
672
+ witness = self.control.get(account_key)
673
+ if witness is None or witness.observation_id != observation_id:
674
+ return ()
675
+ if self.cohort is not None and witness.capture_epoch < self.cohort.e:
676
+ return ()
677
+ return tuple(
678
+ axis for axis in AXES
679
+ if axis in witness.axes and not self.released(account_key, axis)
680
+ and self.axis(account_key, axis).coverage_id is None)
522
681
 
523
682
  # -- transitions -----------------------------------------------------
524
- def _set_envelope(self, scope: str, value: Envelope) -> None:
525
- if self.envelopes.get(scope) != value:
526
- self.envelopes[scope] = value
527
- self.dirty_envelopes.add(scope)
528
-
529
- def _account(self, account_key: str) -> AccountRecovery:
530
- rec = self.accounts.get(account_key)
531
- if rec is None:
532
- rec = AccountRecovery()
533
- self.accounts[account_key] = rec
534
- return rec
683
+ def _set_scope(self, name: str, value: ScopeBounds) -> None:
684
+ if self.bounds.get(name) != value:
685
+ self.bounds[name] = value
686
+ self.dirty_bounds.add(name)
687
+
688
+ def _touch_all_accounts(self) -> None:
689
+ self.dirty_accounts.update(self.accounts)
690
+
691
+ def note_admitted(self, at_epoch: "int | None") -> None:
692
+ """Every admitted record's `at` (H tracks the greatest)."""
693
+ if at_epoch is not None and (self.max_admitted_at is None
694
+ or at_epoch > self.max_admitted_at):
695
+ self.max_admitted_at = at_epoch
696
+ self.dirty_meta = True
697
+
698
+ def note_clock(self, clock_epoch: "int | None") -> None:
699
+ """An admitted record after P whose clock instant is at or after E
700
+ establishes expiry (spec 6.3). The caller passes the clock instant
701
+ of observations and ops only, and only for records after P."""
702
+ if (self.cohort is not None and not self.expired
703
+ and self.open_episode() is None
704
+ and clock_epoch is not None and clock_epoch >= self.cohort.e):
705
+ self.expired = True
706
+ self.dirty_meta = True
707
+ self._touch_all_accounts()
535
708
 
536
709
  def on_trigger(self, *, kind: str, reason: str, segment=None, offset=None,
537
710
  at=None) -> Trigger:
538
- """A trigger quarantines EVERY account and voids every certificate
539
- (its sequence moves past them). The account a record names never
540
- narrows it."""
541
- self.latest_trigger_seq += 1
542
- trig = Trigger(self.latest_trigger_seq, segment, offset, kind, reason, at)
711
+ """A trigger starts a new generation: every release and every
712
+ coverage of every account becomes void (spec 6.9)."""
713
+ self.generation += 1
714
+ trig = Trigger(self.generation, segment, offset, kind, reason, at)
715
+ self.latest_trigger = trig
543
716
  self.new_triggers.append(trig)
544
- if kind == TRIGGER_KIND_LEGACY_SPOOL:
545
- self.spool_markers.append(spool_marker(trig))
717
+ # The new generation's rows replace every account's.
718
+ known = set(self.accounts)
719
+ self.accounts = {key: {} for key in known}
720
+ self.control = {}
721
+ self.dirty_control.update(known)
722
+ self.dirty_accounts.update(known)
723
+ self.dirty_meta = True
546
724
  return trig
547
725
 
548
- def absorb_excluded_reading(self, r: Reading) -> None:
549
- self._set_envelope(
550
- EXCLUDED_SCOPE, self.envelope(EXCLUDED_SCOPE).absorb_reading(r))
551
- self.seen.setdefault(EXCLUDED_SCOPE, set()).add(r.observation_id)
552
-
553
- def absorb_effect(self, scope: str, **effect) -> None:
554
- self._set_envelope(scope, self.envelope(scope).absorb_effect(**effect))
555
-
556
- def revoke_accounting(self, account_key: str) -> None:
557
- """Drop the accounting certificate. The sequence it was granted
558
- against is kept, so `doctor` can say since when the account has been
559
- paused; without a certificate it never certifies anything."""
560
- rec = self.accounts.get(account_key)
561
- if rec is not None and rec.accounting is not None:
562
- rec.accounting = None
563
- self.dirty_accounts.add(account_key)
726
+ def set_position(self, normalize_week_end, *, include_at=None) -> Cohort:
727
+ """P is here: the latest spool cohort is computed from H (which the
728
+ caller already advanced over the record at P) and absorbed into the
729
+ cohort scope's summaries; its expiry is not yet established."""
730
+ h = _max(self.max_admitted_at, include_at)
731
+ if h is None:
732
+ h = 0
733
+ cohort = Cohort.from_h(h, normalize_week_end=normalize_week_end)
734
+ self.cohort = cohort
735
+ self.expired = False
736
+ scope = self.scope(COHORT_SCOPE)
737
+ self._set_scope(COHORT_SCOPE, ScopeBounds(
738
+ weekly=scope.weekly.absorb_cohort(cohort),
739
+ five_hour=scope.five_hour.absorb_cohort(cohort)))
740
+ self.dirty_meta = True
741
+ self._touch_all_accounts()
742
+ return cohort
743
+
744
+ def on_episode_open(self, *, segment, offset, at) -> Episode:
745
+ """An episode opener. A later opener while one is open supersedes it
746
+ (the journal's inventory pass names only the latest)."""
747
+ current = self.open_episode()
748
+ if current is not None:
749
+ current.closer_segment, current.closer_offset = segment, offset
750
+ current.closed_at = at
751
+ self.dirty_episodes.add(current.key)
752
+ for existing in self.episodes:
753
+ if existing.key == (segment, offset):
754
+ existing.closer_segment = existing.closer_offset = None
755
+ existing.closed_at = None
756
+ self.dirty_episodes.add(existing.key)
757
+ self.cohort = None
758
+ self.expired = False
759
+ self.dirty_meta = True
760
+ self._touch_all_accounts()
761
+ return existing
762
+ episode = Episode(segment, offset, at)
763
+ self.episodes.append(episode)
764
+ self.dirty_episodes.add(episode.key)
765
+ self.cohort = None
766
+ self.expired = False
767
+ self.dirty_meta = True
768
+ self._touch_all_accounts()
769
+ return episode
770
+
771
+ def on_episode_continue(self, *, segment, offset) -> bool:
772
+ """A revision op naming an open episode: no trigger, no episode."""
773
+ current = self.open_episode()
774
+ return current is not None and current.key == (segment, offset)
775
+
776
+ def on_episode_close(self, *, segment, offset, at, episode=None) -> bool:
777
+ """Close the open episode at a journaled complete scan. ``episode``
778
+ is the coordinate an inventory-completion op names; it closes only
779
+ that episode. Closing never erases a trigger or a cohort."""
780
+ current = self.open_episode()
781
+ if current is None:
782
+ return False
783
+ if (episode is not None and current.opener_segment is not None
784
+ and tuple(episode) != current.key):
785
+ return False
786
+ current.closer_segment, current.closer_offset = segment, offset
787
+ current.closed_at = at
788
+ self.dirty_episodes.add(current.key)
789
+ self._touch_all_accounts()
790
+ return True
564
791
 
565
- def revoke_control(self, account_key: str) -> None:
566
- rec = self.accounts.get(account_key)
567
- if rec is not None and rec.control is not None:
568
- rec.control = None
569
- self.dirty_accounts.add(account_key)
792
+ def absorb_reading(self, scope_name: str, r: Reading, *,
793
+ possible_effect: bool, excluded: bool = False) -> None:
794
+ scope = self.scope(scope_name)
795
+ self._set_scope(scope_name, ScopeBounds(
796
+ weekly=scope.weekly.absorb_reading(r, possible_effect=possible_effect),
797
+ five_hour=scope.five_hour.absorb_reading(r, excluded=excluded)))
798
+
799
+ def absorb_weekly_effect(self, scope_name: str, **effect) -> None:
800
+ scope = self.scope(scope_name)
801
+ self._set_scope(scope_name, ScopeBounds(
802
+ weekly=scope.weekly.absorb_effect(**effect),
803
+ five_hour=scope.five_hour))
804
+
805
+ def absorb_five_hour_effect(self, scope_name: str, **effect) -> None:
806
+ scope = self.scope(scope_name)
807
+ self._set_scope(scope_name, ScopeBounds(
808
+ weekly=scope.weekly,
809
+ five_hour=scope.five_hour.absorb_effect(**effect)))
810
+
811
+ def weekly_possible_effect(self, account_key: "str | None",
812
+ r: Reading) -> bool:
813
+ """Spec 6.5 C7 (b): a reading whose weekly axis is a genuine reading
814
+ contributes its capture when some permitted predecessor end is later
815
+ than its detection (the detector's live-end condition)."""
816
+ if WEEKLY not in r.observed_axes or r.week_start_date is None:
817
+ return False
818
+ end = self.merged(account_key).weekly.s7_end
819
+ return end is not None and end > r.detection_epoch
570
820
 
571
- def revoke_all_accounting(self) -> None:
572
- for account_key in sorted(self.accounts):
573
- self.revoke_accounting(account_key)
574
-
575
- def on_reading(self, account_key: "str | None", r: Reading, *,
576
- fallback_rows_captured_at=()) -> GateDecision:
577
- """Fold one admitted reading. Certification is evaluated BEFORE the
578
- reading is absorbed, against the whole prefix; continuing validity is
579
- checked before any outcome; the reading is then absorbed into its
580
- account's envelope. Returns the gate for the reading's outcomes."""
821
+ def on_control_witness(self, account_key: str, witness: ControlWitness) -> None:
822
+ if not self.has_trigger():
823
+ return
824
+ if self.control.get(account_key) != witness:
825
+ self.control[account_key] = witness
826
+ self.dirty_control.add(account_key)
827
+
828
+ def on_authority_completion(self, account_key: str, completion_id: str, *,
829
+ generation: int, observation_id: str,
830
+ axes) -> bool:
831
+ """Validate a completion op (spec 6.4 *Validity*) and cover each
832
+ listed axis it newly covers. Returns whether it was valid."""
833
+ if generation != self.generation or not self.has_trigger():
834
+ return False
835
+ axes = tuple(axes)
836
+ if not axes or not set(axes) <= set(AXES):
837
+ return False
838
+ coverable = set(self.coverable_axes(account_key, observation_id))
839
+ witness = self.control.get(account_key)
840
+ if witness is None or not set(axes) <= witness.axes:
841
+ return False
842
+ newly = [axis for axis in axes if axis in coverable]
843
+ if not newly:
844
+ return False
845
+ for axis in newly:
846
+ rec = self._axis_mut(account_key, axis)
847
+ rec.coverage_id = completion_id
848
+ if WEEKLY in newly and witness.weekly_end_epoch is not None:
849
+ # Spec 6.5 X7: reference-only drop publications continue in the
850
+ # covered week until its end.
851
+ self.absorb_weekly_effect(account_key,
852
+ exclusive_end=witness.weekly_end_epoch)
853
+ return True
854
+
855
+ def evaluate_reading(self, account_key: "str | None", r: Reading, *,
856
+ normalize_week_end=None) -> dict:
857
+ """Fold one admitted reading (spec 6.6, 6.7, 6.9): revoke a released
858
+ axis on backward input, forfeit a pending witness on a later
859
+ X-bearing record, evaluate the gate, evaluate each paused covered
860
+ axis's witness against the prefix strictly before ``r``, then absorb
861
+ ``r``. Returns the per-axis gate for ``r``'s own outcomes — evaluated
862
+ before any witness, so a witness's own derivation runs closed."""
581
863
  if account_key is None:
582
- # An admitted historical reading with no account: its bounds
583
- # apply to every account.
584
- self._set_envelope(
585
- EXCLUDED_SCOPE, self.envelope(EXCLUDED_SCOPE).absorb_reading(r))
586
- self.seen.setdefault(EXCLUDED_SCOPE, set()).add(r.observation_id)
587
- return GATE_NO_TRIGGER if not self.has_trigger() else GateDecision(
588
- False, "quarantined")
589
- gate = GATE_NO_TRIGGER
864
+ self.absorb_reading(EXCLUDED_SCOPE, r,
865
+ possible_effect=self.weekly_possible_effect(None, r))
866
+ return {axis: self.axis_gate(None, axis) for axis in AXES}
867
+ own = self.scope(account_key)
590
868
  if self.has_trigger():
591
- if self.accounting_certified(account_key):
592
- rec = self.accounts[account_key]
593
- reason = continuing_validity(
594
- rec.accounting, r,
595
- fallback_rows_captured_at=fallback_rows_captured_at)
596
- if reason is not None:
597
- self.revoke_accounting(account_key)
598
- gate = GateDecision(False, f"revoked:{reason}")
599
- else:
600
- advanced = rec.accounting.advance(r)
601
- if advanced != rec.accounting:
602
- rec.accounting = advanced
603
- self.dirty_accounts.add(account_key)
604
- gate = GATE_CERTIFIED
605
- elif self.spool_markers:
606
- # The omitted spool contributor bounds nothing, so no reading
607
- # certifies against the envelope while a marker is unresolved.
608
- gate = GateDecision(False, GATE_REASON_SPOOL_MARKER)
609
- else:
610
- merged = Envelope.merge(self.envelope(account_key),
611
- self.envelope(EXCLUDED_SCOPE))
612
- seen = (self.seen.get(account_key, set())
613
- | self.seen.get(EXCLUDED_SCOPE, set()))
614
- decision = certify(r, merged, seen_identities=seen)
615
- if decision.certified:
616
- rec = self._account(account_key)
617
- rec.accounting = Certificate.from_witness(r)
618
- rec.accounting_trigger_seq = self.latest_trigger_seq
619
- self.dirty_accounts.add(account_key)
620
- gate = GateDecision(True, "witness")
869
+ for axis in AXES:
870
+ if not r.bears(axis):
871
+ continue
872
+ rec = self.axis(account_key, axis)
873
+ if rec.state == STATE_RELEASED:
874
+ reason = self._revocation(own, axis, r, rec)
875
+ if reason is not None:
876
+ self.revoke_axis(account_key, axis, reason,
877
+ at=_iso(r.detection_epoch))
878
+ elif self.pending_witness(account_key, axis) is not None:
879
+ # Spec 6.7: an X-bearing record between witness and
880
+ # release forfeits the period.
881
+ rec = self._axis_mut(account_key, axis)
882
+ rec.witness_id = None
883
+ rec.witness_key = None
884
+ rec.not_before = None
885
+ gates = self.gates(account_key)
886
+ if self.has_trigger() and self.barrier_open():
887
+ merged = self.merged(account_key)
888
+ for axis in AXES:
889
+ rec = self.axis(account_key, axis)
890
+ if (rec.state != STATE_PAUSED or rec.coverage_id is None
891
+ or rec.witness_id is not None
892
+ or axis not in r.eligible_axes):
893
+ continue
894
+ if axis == WEEKLY:
895
+ ok = weekly_witness_qualifies(r, merged.weekly)
896
+ snapshot = merged.weekly.s7_end
897
+ key = r.week_start_date
621
898
  else:
622
- gate = GateDecision(False, "quarantined")
623
- self._set_envelope(account_key, self.envelope(account_key).absorb_reading(r))
624
- self.seen.setdefault(account_key, set()).add(r.observation_id)
625
- return gate
626
-
627
- def on_control_witness(self, account_key: str, *, observation_id: str,
628
- captured_at_utc: str) -> None:
629
- """A bound two-axis authoritative OAuth publication for one account,
630
- positioned after the latest trigger. Its control transition must ALSO
631
- record the commit before drop consensus resumes (checked by the
632
- statusline reducer)."""
633
- if not self.has_trigger():
899
+ floor = merged.five_hour.detect_floor
900
+ ok = five_hour_witness_qualifies(
901
+ r, merged.five_hour, detection_floor=floor)
902
+ snapshot = floor
903
+ key = r.five_hour_key
904
+ if ok:
905
+ rec = self._axis_mut(account_key, axis)
906
+ rec.witness_id = r.observation_id
907
+ rec.witness_key = key
908
+ rec.not_before = snapshot
909
+ self.absorb_reading(account_key, r,
910
+ possible_effect=self.weekly_possible_effect(account_key, r))
911
+ return gates
912
+
913
+ @staticmethod
914
+ def _revocation(own: ScopeBounds, axis: str, r: Reading,
915
+ rec: AxisRecovery) -> "str | None":
916
+ if axis == WEEKLY:
917
+ if rec.witness_key is not None and r.week_start_date < rec.witness_key:
918
+ return REVOKE_BACKWARD_KEY
919
+ k, t = own.weekly.k7, own.weekly.t7
920
+ else:
921
+ if rec.witness_key is not None and r.five_hour_key < rec.witness_key:
922
+ return REVOKE_BACKWARD_KEY
923
+ k, t = own.five_hour.k5c, own.five_hour.t5
924
+ if (k is not None and r.capture_epoch < k) or (
925
+ t is not None and r.capture_text < t):
926
+ return REVOKE_ORDER_REGRESSION
927
+ return None
928
+
929
+ def on_release(self, account_key: str, axis: str, *, generation: int,
930
+ witness_id: str, completion_id: str, release_id: str,
931
+ at: "str | None") -> bool:
932
+ """Validate an `outcome_axis_release` (spec 6.7 *Validity*): current
933
+ generation, the completion that covers the axis, the axis's pending
934
+ witness (no X-bearing record of the account since). Invalid → ignored."""
935
+ if (generation != self.generation or axis not in AXES
936
+ or not self.has_trigger()):
937
+ return False
938
+ rec = self.axis(account_key, axis)
939
+ if (rec.state != STATE_PAUSED or rec.coverage_id is None
940
+ or rec.coverage_id != completion_id
941
+ or self.pending_witness(account_key, axis) != witness_id):
942
+ return False
943
+ rec = self._axis_mut(account_key, axis)
944
+ rec.state = STATE_RELEASED
945
+ rec.release_id = release_id
946
+ rec.recovered_at = at
947
+ rec.revocation = None
948
+ rec.not_before = None
949
+ return True
950
+
951
+ def revoke_axis(self, account_key: str, axis: str, reason: str, *,
952
+ at: "str | None" = None, void_coverage: bool = False) -> None:
953
+ """A released axis returns to paused with its reason and needs a new
954
+ witness in a later period; a pending witness is dropped. Coverage
955
+ stays valid unless ``void_coverage`` (an administrative op)."""
956
+ rec = self.axis(account_key, axis)
957
+ if (rec.state != STATE_RELEASED and rec.witness_id is None
958
+ and not (void_coverage and rec.coverage_id is not None)):
634
959
  return
635
- rec = self._account(account_key)
636
- cert = ControlCertificate(observation_id, captured_at_utc)
637
- if rec.control != cert or rec.control_trigger_seq != self.latest_trigger_seq:
638
- rec.control = cert
639
- rec.control_trigger_seq = self.latest_trigger_seq
640
- self.dirty_accounts.add(account_key)
960
+ rec = self._axis_mut(account_key, axis)
961
+ if rec.state == STATE_RELEASED:
962
+ rec.revocation = reason
963
+ rec.since = at or rec.since
964
+ rec.state = STATE_PAUSED
965
+ rec.witness_id = None
966
+ rec.witness_key = None
967
+ rec.release_id = None
968
+ rec.recovered_at = None
969
+ rec.not_before = None
970
+ if void_coverage:
971
+ rec.coverage_id = None
972
+
973
+ def revoke_all(self, reason: str, *, at=None, axes=AXES,
974
+ void_coverage: bool = False) -> None:
975
+ for account_key in sorted(self.accounts):
976
+ for axis in axes:
977
+ self.revoke_axis(account_key, axis, reason, at=at,
978
+ void_coverage=void_coverage)
641
979
 
642
- def on_deletion_outcome(self, account_key: "str | None") -> None:
980
+ def on_deletion_outcome(self, account_key: "str | None", axis: str, *,
981
+ at=None) -> None:
643
982
  """A journaled automatic outcome that deleted snapshot rows. While
644
983
  every registered cross-period fallback read is inert this revokes
645
- nothing (spec 5.6 item 5: the certificate survives where the fallback
646
- read is registered inert); otherwise the affected account — every
647
- account when the event names none — is revoked at the event's journal
648
- position."""
984
+ nothing; otherwise BOTH axes — of every account when the event names
985
+ none — are revoked (spec 6.9). ``axis`` is the outcome's own axis,
986
+ but the rows it deletes are shared snapshot rows: a five-hour credit
987
+ that deletes the common latest row sends the weekly detector's
988
+ moved-end predecessor lookup or debounce singleton back into the
989
+ paused prefix, so the reads that can fall back are either axis's."""
990
+ del axis
649
991
  if not self.non_inert_cross_period:
650
992
  return
651
993
  if account_key is None:
652
- self.revoke_all_accounting()
994
+ self.revoke_all(REVOKE_NON_INERT_FALLBACK, at=at)
653
995
  else:
654
- self.revoke_accounting(account_key)
655
-
656
-
657
- def certificate_row(rec: AccountRecovery) -> tuple:
658
- """The persisted columns of one account's recovery state."""
659
- return (
660
- None if rec.accounting is None else json.dumps(
661
- rec.accounting.to_document(), sort_keys=True, separators=(",", ":")),
662
- rec.accounting_trigger_seq,
663
- None if rec.control is None else json.dumps(
664
- rec.control.to_document(), sort_keys=True, separators=(",", ":")),
665
- rec.control_trigger_seq,
666
- )
667
-
668
-
669
- def account_from_row(accounting_json, accounting_seq, control_json,
670
- control_seq) -> AccountRecovery:
671
- """The recovery state a persisted row describes. Missing or unverifiable
672
- certificate evidence reads as NO certificate (quarantined)."""
673
- def _load(text, loader):
674
- if not text:
675
- return None
676
- try:
677
- return loader(json.loads(text))
678
- except (TypeError, ValueError):
679
- return None
996
+ for each in AXES:
997
+ self.revoke_axis(account_key, each, REVOKE_NON_INERT_FALLBACK,
998
+ at=at)
999
+
1000
+ def ensure_account(self, account_key: str) -> None:
1001
+ """Materialize an account's rows in the current generation."""
1002
+ if account_key not in self.accounts:
1003
+ self.accounts[account_key] = {}
1004
+ self.dirty_accounts.add(account_key)
1005
+
680
1006
 
681
- accounting = _load(accounting_json, Certificate.from_document)
682
- control = _load(control_json, ControlCertificate.from_document)
683
- return AccountRecovery(
684
- accounting=accounting,
685
- accounting_trigger_seq=(
686
- accounting_seq if type(accounting_seq) is int else None),
687
- control=control,
688
- control_trigger_seq=(
689
- control_seq if type(control_seq) is int else None),
690
- )
1007
+ def _iso(epoch: "int | None") -> "str | None":
1008
+ if epoch is None:
1009
+ return None
1010
+ return _dt.datetime.fromtimestamp(int(epoch), tz=_dt.timezone.utc).strftime(
1011
+ "%Y-%m-%dT%H:%M:%SZ")
1012
+
1013
+
1014
+ def iso(epoch: "int | None") -> "str | None":
1015
+ return _iso(epoch)
1016
+
1017
+
1018
+ def axis_row(state: QuarantineState, account_key: str, axis: str) -> tuple:
1019
+ """The persisted columns of one (account, axis) in the current
1020
+ generation, in `outcome_quarantine_axis_state` column order after
1021
+ ``(generation, account_key, axis)``."""
1022
+ rec = state.axis(account_key, axis)
1023
+ return (rec.state, state.waiting_for(account_key, axis), rec.coverage_id,
1024
+ rec.witness_id, rec.release_id, rec.revocation, rec.since,
1025
+ rec.recovered_at, rec.not_before,
1026
+ None if rec.witness_key is None else json.dumps(rec.witness_key))
1027
+
1028
+
1029
+ def axis_from_row(row) -> "AxisRecovery | None":
1030
+ """The `AxisRecovery` a persisted row describes, or None when the row is
1031
+ unverifiable (the caller then reads the axis as paused)."""
1032
+ (state, waiting, coverage, witness, release, revocation, since,
1033
+ recovered, not_before, witness_key) = row
1034
+ if state not in (STATE_PAUSED, STATE_RELEASED):
1035
+ return None
1036
+ if waiting is not None and waiting not in WAITING_FOR:
1037
+ return None
1038
+ if not_before is not None and type(not_before) is not int:
1039
+ return None
1040
+ try:
1041
+ key = None if witness_key is None else json.loads(witness_key)
1042
+ except (TypeError, ValueError):
1043
+ return None
1044
+ if key is not None and not isinstance(key, (str, int)):
1045
+ return None
1046
+ if state == STATE_RELEASED and (release is None or witness is None
1047
+ or coverage is None):
1048
+ return None
1049
+ return AxisRecovery(state=state, waiting_for=waiting, coverage_id=coverage,
1050
+ witness_id=witness, release_id=release,
1051
+ revocation=revocation, since=since,
1052
+ recovered_at=recovered, not_before=not_before,
1053
+ witness_key=key)