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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +455 -42
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +257 -18
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +464 -33
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
- package/dashboard/static/assets/index-BKITk1CT.css +0 -1
|
@@ -1,50 +1,46 @@
|
|
|
1
|
-
"""Pure outcome quarantine and
|
|
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
|
|
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
|
-
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
|
-
|
|
57
|
-
|
|
58
|
-
#:
|
|
59
|
-
#:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
114
|
+
"""One Claude rate-limit observation as the detectors and the witness
|
|
115
|
+
predicates see it.
|
|
94
116
|
|
|
95
|
-
``
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
119
|
-
"""
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) -> "
|
|
190
|
-
|
|
191
|
-
|
|
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 ("
|
|
201
|
-
if doc[key] is not
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
330
|
-
return
|
|
331
|
-
self,
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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) -> "
|
|
348
|
-
if not
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
395
|
-
"""
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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) -> "
|
|
468
|
+
def from_document(doc) -> "ControlWitness | None":
|
|
407
469
|
if (not isinstance(doc, dict)
|
|
408
|
-
or set(doc) != {"observationId", "
|
|
470
|
+
or set(doc) != {"observationId", "captureEpoch", "axes",
|
|
471
|
+
"weeklyEndEpoch"}
|
|
409
472
|
or not isinstance(doc["observationId"], str)
|
|
410
|
-
or
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
@
|
|
446
|
-
|
|
447
|
-
|
|
505
|
+
@property
|
|
506
|
+
def key(self) -> tuple:
|
|
507
|
+
return (self.opener_segment, self.opener_offset)
|
|
448
508
|
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def __init__(self, *,
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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.
|
|
558
|
+
self.dirty_episodes: set = set()
|
|
559
|
+
self.dirty_bounds: set = set()
|
|
473
560
|
self.dirty_accounts: set = set()
|
|
474
|
-
|
|
475
|
-
|
|
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.
|
|
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
|
|
497
|
-
return
|
|
609
|
+
def released(self, account_key: str, axis: str) -> bool:
|
|
610
|
+
return self.axis(account_key, axis).state == STATE_RELEASED
|
|
498
611
|
|
|
499
|
-
def
|
|
500
|
-
"""
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
521
|
-
|
|
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
|
|
525
|
-
if self.
|
|
526
|
-
self.
|
|
527
|
-
self.
|
|
528
|
-
|
|
529
|
-
def
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
self.
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
self.
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
rec.
|
|
639
|
-
|
|
640
|
-
|
|
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"
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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.
|
|
994
|
+
self.revoke_all(REVOKE_NON_INERT_FALLBACK, at=at)
|
|
653
995
|
else:
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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)
|