cctally 1.114.0 → 1.115.0
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 +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""Outcome quarantine glue (#825/#832, spec 5.6): the journal-order fold over
|
|
2
|
+
records, its persistence in the disposable stats index, and the read side
|
|
3
|
+
`doctor` and the statusline reducer consume.
|
|
4
|
+
|
|
5
|
+
Every consumer folds the SAME records in the SAME canonical journal order:
|
|
6
|
+
|
|
7
|
+
* live ingest (`_cctally_journal._run_cycle`) over each cycle's decoded batch,
|
|
8
|
+
excluded records included, with the pipeline hooks of a record running only
|
|
9
|
+
after the fold has advanced through it;
|
|
10
|
+
* `db rebuild --db stats` over the whole pinned prefix, inline in its read
|
|
11
|
+
pass;
|
|
12
|
+
* the `db rederive` scratch replay, over the planner's record list, whose
|
|
13
|
+
excluded slots carry the original record for exactly this purpose.
|
|
14
|
+
|
|
15
|
+
So triggers, envelopes and certificates are a function of the journal alone.
|
|
16
|
+
|
|
17
|
+
What each record contributes:
|
|
18
|
+
|
|
19
|
+
* an EXCLUDED record — any type, any family — is a trigger at its coordinate;
|
|
20
|
+
an excluded Claude reading, event or credit also widens the "excluded"
|
|
21
|
+
envelope, which applies to every account (the account an excluded record
|
|
22
|
+
names never narrows anything);
|
|
23
|
+
* a current `legacy_spool_activity` op is a trigger, and it leaves an
|
|
24
|
+
unresolved spool marker that withholds every account's accounting
|
|
25
|
+
certificate; nothing clears it (spec revision 3, section 5.6 item 1(b)).
|
|
26
|
+
Spec revision 5: its `inventory-incomplete` reason, and a cutover op that
|
|
27
|
+
recorded its inventory as incomplete, are such triggers too;
|
|
28
|
+
* an admitted Claude rate-limit reading is certified, checked for continuing
|
|
29
|
+
validity, and absorbed into its account's envelope; an UNSTAMPED admitted
|
|
30
|
+
reading (journal history) is absorbed into the "excluded" scope, because its
|
|
31
|
+
account is whatever an older binary resolved;
|
|
32
|
+
* a bound two-axis authoritative OAuth reading is the journal half of a
|
|
33
|
+
control witness (`is_control_witness`); drop eligibility and doctor also
|
|
34
|
+
require the account's reconciled transition to record its commit;
|
|
35
|
+
* a manual `record-credit` revokes its account's accounting certificate before
|
|
36
|
+
its effects; a correction, reviewed decision or protocol resolution revokes
|
|
37
|
+
every account's (administrative records never erase a trigger);
|
|
38
|
+
* admitted snapshot, reset and credit events widen their account's envelope.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import json
|
|
44
|
+
import sqlite3
|
|
45
|
+
|
|
46
|
+
import math
|
|
47
|
+
|
|
48
|
+
import _cctally_core
|
|
49
|
+
import _lib_account_binding
|
|
50
|
+
import _lib_account_protocol
|
|
51
|
+
import _lib_accounts
|
|
52
|
+
import _lib_journal
|
|
53
|
+
import _lib_outcome_quarantine as Q
|
|
54
|
+
|
|
55
|
+
#: The `src` values of a Claude rate-limit observation; must equal
|
|
56
|
+
#: `_cctally_record._CLAUDE_OBS_SRCS` (pinned by a test).
|
|
57
|
+
CLAUDE_OBS_SRCS = frozenset(
|
|
58
|
+
{"statusline", "hook-tick", "refresh-usage", "record-usage"})
|
|
59
|
+
|
|
60
|
+
#: Administrative record kinds that can change strict dependencies. They never
|
|
61
|
+
#: erase a trigger; they revoke every accounting certificate at their position
|
|
62
|
+
#: (spec 5.6 item 8). Every version of each counts — the previous release's
|
|
63
|
+
#: exact-key kinds AND the versioned kinds the current binary emits — so the
|
|
64
|
+
#: repair kinds come from the journal's own shared set.
|
|
65
|
+
_ADMIN_OP_KINDS = frozenset({
|
|
66
|
+
"claude_weekly_observation_decision",
|
|
67
|
+
_lib_account_protocol.WEEKLY_OBSERVATION_DECISION_V2_KIND,
|
|
68
|
+
}) | _lib_journal.PROTOCOL_RESOLUTION_KINDS
|
|
69
|
+
_ADMIN_RECORD_TYPES = frozenset({"correction", "correction_batch"})
|
|
70
|
+
|
|
71
|
+
TRIGGER_EXCLUDED = "excluded"
|
|
72
|
+
TRIGGER_LEGACY_SPOOL = Q.TRIGGER_KIND_LEGACY_SPOOL
|
|
73
|
+
#: `report` row ``spoolMarkerCause`` values (spec section 8): which approved
|
|
74
|
+
#: wording an unresolved spool marker takes.
|
|
75
|
+
LEGACY_ACTIVITY_CAUSE = "legacy-spool-activity"
|
|
76
|
+
INVENTORY_INCOMPLETE_CAUSE = "inventory-incomplete"
|
|
77
|
+
|
|
78
|
+
_FIVE_HOUR_FLOOR = 600
|
|
79
|
+
#: The recording pipeline honors a producer's planned five-hour key only
|
|
80
|
+
#: within three jitter floors of the raw reset (`_pipeline_claude_usage`).
|
|
81
|
+
_PLANNED_KEY_TOLERANCE = 3 * _FIVE_HOUR_FLOOR
|
|
82
|
+
|
|
83
|
+
#: Why a reading whose planned key the pipeline would reject never witnesses.
|
|
84
|
+
INELIGIBLE_INVALID_PLANNED_KEY = "invalid-planned-key"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _epoch(value) -> "int | None":
|
|
88
|
+
if not isinstance(value, str) or not value:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
if parsed.tzinfo is None:
|
|
95
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
96
|
+
return int(parsed.timestamp())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _week_window(resets_at: int):
|
|
100
|
+
end = _cctally_core._normalize_week_boundary_dt(
|
|
101
|
+
dt.datetime.fromtimestamp(int(resets_at), tz=dt.timezone.utc))
|
|
102
|
+
start = end - dt.timedelta(days=7)
|
|
103
|
+
return start.date().isoformat(), int(start.timestamp()), int(end.timestamp())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _provenance_ineligibility(payload) -> "str | None":
|
|
107
|
+
provenance = payload.get("publication")
|
|
108
|
+
if not isinstance(provenance, dict):
|
|
109
|
+
return None
|
|
110
|
+
for axis in ("sevenDay", "fiveHour"):
|
|
111
|
+
source = provenance.get(axis)
|
|
112
|
+
if not isinstance(source, dict):
|
|
113
|
+
continue
|
|
114
|
+
kind = source.get("kind")
|
|
115
|
+
if kind == "projection":
|
|
116
|
+
return "projection-fill"
|
|
117
|
+
if kind == "retained_low":
|
|
118
|
+
return "retained-low"
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_claude_reading(rec) -> bool:
|
|
123
|
+
if not isinstance(rec, dict) or rec.get("t") != "obs":
|
|
124
|
+
return False
|
|
125
|
+
if rec.get("provider") != "claude" or rec.get("src") not in CLAUDE_OBS_SRCS:
|
|
126
|
+
return False
|
|
127
|
+
payload = rec.get("payload")
|
|
128
|
+
return (isinstance(payload, dict) and "resets_at" in payload
|
|
129
|
+
and "weekly_percent" in payload)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def reading_from_record(rec, *, held_ids=frozenset()) -> "Q.Reading | None":
|
|
133
|
+
"""The `Reading` one Claude rate-limit observation contributes, or None.
|
|
134
|
+
|
|
135
|
+
Pure over the record: the week window is the normalized boundary the
|
|
136
|
+
pipeline derives, the five-hour key is the producer's planned
|
|
137
|
+
account-local key (a stamped record only, as the pipeline honors it,
|
|
138
|
+
within the pipeline's 1,800 s tolerance of the raw reset) or the pure
|
|
139
|
+
canonical floor, and the stored capture text is the payload's capture
|
|
140
|
+
stamp verbatim (the pipeline's `captured_at_utc`). A planned key outside
|
|
141
|
+
that tolerance is a corrupt plan: it is not honored, and the reading can
|
|
142
|
+
never be a witness."""
|
|
143
|
+
if not is_claude_reading(rec):
|
|
144
|
+
return None
|
|
145
|
+
payload = rec["payload"]
|
|
146
|
+
detection = _epoch(rec.get("at"))
|
|
147
|
+
captured = payload.get("captured_at") or rec.get("at")
|
|
148
|
+
capture_epoch = _epoch(captured)
|
|
149
|
+
if detection is None or capture_epoch is None:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
week_start_date, week_start_epoch, week_end_epoch = _week_window(
|
|
153
|
+
int(payload["resets_at"]))
|
|
154
|
+
except (TypeError, ValueError, OverflowError, OSError):
|
|
155
|
+
return None
|
|
156
|
+
stamped = _lib_account_protocol.is_stamped(rec)
|
|
157
|
+
raw5 = _epoch(payload.get("five_hour_resets_at"))
|
|
158
|
+
key = None
|
|
159
|
+
invalid_plan = False
|
|
160
|
+
if raw5 is not None:
|
|
161
|
+
planned = payload.get("five_hour_window_key")
|
|
162
|
+
if stamped and planned is not None:
|
|
163
|
+
if (type(planned) is int
|
|
164
|
+
and abs(planned - raw5) <= _PLANNED_KEY_TOLERANCE):
|
|
165
|
+
key = planned
|
|
166
|
+
else:
|
|
167
|
+
invalid_plan = True
|
|
168
|
+
if key is None:
|
|
169
|
+
key = raw5 - raw5 % _FIVE_HOUR_FLOOR
|
|
170
|
+
two_axis = (raw5 is not None
|
|
171
|
+
and payload.get("five_hour_percent") is not None)
|
|
172
|
+
ineligible = _provenance_ineligibility(payload)
|
|
173
|
+
if ineligible is None and invalid_plan:
|
|
174
|
+
ineligible = INELIGIBLE_INVALID_PLANNED_KEY
|
|
175
|
+
if ineligible is None and rec.get("id") in held_ids:
|
|
176
|
+
ineligible = "reviewed-hold"
|
|
177
|
+
account = None
|
|
178
|
+
if stamped:
|
|
179
|
+
account = rec.get("account") or _lib_accounts.UNATTRIBUTED
|
|
180
|
+
return Q.Reading(
|
|
181
|
+
observation_id=str(rec.get("id")),
|
|
182
|
+
account_key=account,
|
|
183
|
+
captured_at_utc=str(captured),
|
|
184
|
+
capture_epoch=capture_epoch,
|
|
185
|
+
detection_epoch=detection,
|
|
186
|
+
week_start_date=week_start_date,
|
|
187
|
+
week_start_at_epoch=week_start_epoch,
|
|
188
|
+
weekly_end_epoch=week_end_epoch,
|
|
189
|
+
five_hour_key=key,
|
|
190
|
+
raw_five_hour_reset=raw5,
|
|
191
|
+
two_axis=two_axis,
|
|
192
|
+
ineligible_reason=ineligible,
|
|
193
|
+
stamped=stamped,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _valid_percent(value) -> bool:
|
|
198
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
199
|
+
and math.isfinite(float(value)))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_control_witness(rec, *, held_ids=frozenset()) -> bool:
|
|
203
|
+
"""The journal half of a control witness (spec 5.6 item 7; #825/#832
|
|
204
|
+
fix-round-1 Q6): an admitted, current-stamped Claude reading with
|
|
205
|
+
`src="record-usage"` and payload `source="api"` whose binding validates
|
|
206
|
+
(`_lib_account_binding.load`), names the observation's effective account
|
|
207
|
+
and carries valid, non-null credential evidence — the OAuth-bound
|
|
208
|
+
capture, which the CLI adapter's binding never has — with both valid raw
|
|
209
|
+
utilization/reset axes, and no statusline publication, projection-filled
|
|
210
|
+
axis, retained low, invalid planned key or reviewed hold.
|
|
211
|
+
|
|
212
|
+
Credential evidence proves an OAuth-bound reading, not that control
|
|
213
|
+
reconciliation succeeded after its append: drop eligibility and doctor
|
|
214
|
+
also require the account's reconciled transition to record
|
|
215
|
+
`authorityCommit == observation.id`."""
|
|
216
|
+
if not is_claude_reading(rec) or not _lib_account_protocol.is_stamped(rec):
|
|
217
|
+
return False
|
|
218
|
+
if rec.get("src") != "record-usage":
|
|
219
|
+
return False
|
|
220
|
+
payload = rec["payload"]
|
|
221
|
+
if payload.get("source") != "api" or "publication_id" in payload:
|
|
222
|
+
return False
|
|
223
|
+
try:
|
|
224
|
+
binding = _lib_account_binding.load(payload.get("account_binding"))
|
|
225
|
+
except (TypeError, ValueError, KeyError):
|
|
226
|
+
return False
|
|
227
|
+
if binding.credential_evidence is None:
|
|
228
|
+
return False
|
|
229
|
+
if binding.account_key != (rec.get("account") or _lib_accounts.UNATTRIBUTED):
|
|
230
|
+
return False
|
|
231
|
+
if not (_valid_percent(payload.get("weekly_percent"))
|
|
232
|
+
and _valid_percent(payload.get("five_hour_percent"))):
|
|
233
|
+
return False
|
|
234
|
+
reading = reading_from_record(rec, held_ids=held_ids)
|
|
235
|
+
return (reading is not None and reading.ineligible_reason is None
|
|
236
|
+
and reading.two_axis and reading.week_start_date is not None
|
|
237
|
+
and reading.raw_five_hour_reset is not None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _scope_of(rec, payload) -> str:
|
|
241
|
+
"""The envelope scope an admitted event or op widens: its account when a
|
|
242
|
+
current binary stamped it, else the every-account scope."""
|
|
243
|
+
if not _lib_account_protocol.is_stamped(rec):
|
|
244
|
+
return Q.EXCLUDED_SCOPE
|
|
245
|
+
return payload.get("account_key") or _lib_accounts.UNATTRIBUTED
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _absorb_event(state, scope, rec) -> None:
|
|
249
|
+
payload = rec.get("payload") if isinstance(rec.get("payload"), dict) else {}
|
|
250
|
+
kind = payload.get("kind")
|
|
251
|
+
if kind == "snapshot_accept":
|
|
252
|
+
captured = payload.get("captured_at_utc")
|
|
253
|
+
state.absorb_effect(
|
|
254
|
+
scope,
|
|
255
|
+
effective_epoch=_epoch(captured),
|
|
256
|
+
week_start_date=payload.get("week_start_date"),
|
|
257
|
+
five_hour_key=(payload.get("five_hour_window_key")
|
|
258
|
+
if type(payload.get("five_hour_window_key")) is int
|
|
259
|
+
else None),
|
|
260
|
+
captured_at_utc=captured if isinstance(captured, str) else None,
|
|
261
|
+
weekly_end_epoch=_epoch(payload.get("week_end_at")),
|
|
262
|
+
raw_five_hour_reset=_epoch(payload.get("five_hour_resets_at")),
|
|
263
|
+
)
|
|
264
|
+
elif kind == "week_reset":
|
|
265
|
+
state.absorb_effect(
|
|
266
|
+
scope,
|
|
267
|
+
effective_epoch=_epoch(payload.get("effective_reset_at_utc")),
|
|
268
|
+
weekly_end_epoch=_epoch(payload.get("new_week_end_at")),
|
|
269
|
+
)
|
|
270
|
+
elif kind == "five_hour_credit":
|
|
271
|
+
key = payload.get("five_hour_window_key")
|
|
272
|
+
state.absorb_effect(
|
|
273
|
+
scope,
|
|
274
|
+
effective_epoch=_epoch(payload.get("effective_reset_at_utc")),
|
|
275
|
+
five_hour_key=key if type(key) is int else None,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_deletion_outcome(payload) -> bool:
|
|
280
|
+
kind = payload.get("kind")
|
|
281
|
+
if kind == "week_reset":
|
|
282
|
+
return payload.get("observed_pre_credit_pct") is not None
|
|
283
|
+
if kind == "five_hour_credit":
|
|
284
|
+
return True
|
|
285
|
+
if kind == "weekly_credit_effects":
|
|
286
|
+
return bool(payload.get("suppression"))
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _absorb_credit_op(state, scope, payload) -> None:
|
|
291
|
+
plan = payload.get("plan") if isinstance(payload.get("plan"), dict) else {}
|
|
292
|
+
captured = plan.get("captured_iso")
|
|
293
|
+
state.absorb_effect(
|
|
294
|
+
scope,
|
|
295
|
+
effective_epoch=_max_epoch(payload.get("effective_at_utc"), captured),
|
|
296
|
+
week_start_date=payload.get("week_start_date"),
|
|
297
|
+
captured_at_utc=captured if isinstance(captured, str) else None,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _max_epoch(*values):
|
|
302
|
+
epochs = [e for e in (_epoch(v) for v in values) if e is not None]
|
|
303
|
+
return max(epochs) if epochs else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class QuarantineFold:
|
|
307
|
+
"""One consumer's pass over journal records (see the module docstring).
|
|
308
|
+
|
|
309
|
+
``held_ids`` are the reviewed weekly holds of a rederive scratch replay
|
|
310
|
+
(never witnesses). ``on_record`` returns the gate for a Claude reading's
|
|
311
|
+
automatic outcomes, else None."""
|
|
312
|
+
|
|
313
|
+
def __init__(self, state: Q.QuarantineState, *, held_ids=frozenset()):
|
|
314
|
+
self.state = state
|
|
315
|
+
self.held_ids = frozenset(held_ids)
|
|
316
|
+
#: Live-only conservative withholding: accounts whose deletion outcome
|
|
317
|
+
#: this pass emitted and whose event has not been folded yet. Only used
|
|
318
|
+
#: while some registered cross-period read is not inert.
|
|
319
|
+
self.pending_deletion: set = set()
|
|
320
|
+
|
|
321
|
+
# -- construction / persistence --------------------------------------
|
|
322
|
+
@classmethod
|
|
323
|
+
def empty(cls, **kwargs) -> "QuarantineFold":
|
|
324
|
+
return cls(Q.QuarantineState(
|
|
325
|
+
non_inert_cross_period=_non_inert_cross_period()), **kwargs)
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
def load(cls, conn, **kwargs) -> "QuarantineFold":
|
|
329
|
+
return cls(load_state(conn), **kwargs)
|
|
330
|
+
|
|
331
|
+
def persist(self, conn) -> None:
|
|
332
|
+
persist_state(conn, self.state)
|
|
333
|
+
|
|
334
|
+
# -- the fold ---------------------------------------------------------
|
|
335
|
+
def on_excluded(self, rec, *, segment=None, offset=None, family=None):
|
|
336
|
+
"""An excluded record: a trigger at its coordinate; its readings and
|
|
337
|
+
effects widen the every-account envelope."""
|
|
338
|
+
if family is None and isinstance(rec, dict):
|
|
339
|
+
family = _lib_account_protocol.record_family(rec)
|
|
340
|
+
self.state.on_trigger(
|
|
341
|
+
kind=TRIGGER_EXCLUDED, reason=str(family or "unknown"),
|
|
342
|
+
segment=segment, offset=offset,
|
|
343
|
+
at=rec.get("at") if isinstance(rec, dict) else None)
|
|
344
|
+
if not isinstance(rec, dict):
|
|
345
|
+
return
|
|
346
|
+
reading = reading_from_record(rec)
|
|
347
|
+
if reading is not None:
|
|
348
|
+
self.state.absorb_excluded_reading(reading)
|
|
349
|
+
return
|
|
350
|
+
payload = rec.get("payload") if isinstance(rec.get("payload"), dict) else {}
|
|
351
|
+
if rec.get("t") == "evt":
|
|
352
|
+
_absorb_event(self.state, Q.EXCLUDED_SCOPE, rec)
|
|
353
|
+
elif rec.get("t") == "op" and payload.get("kind") == "weekly_credit_floor":
|
|
354
|
+
_absorb_credit_op(self.state, Q.EXCLUDED_SCOPE, payload)
|
|
355
|
+
|
|
356
|
+
def on_record(self, rec, *, segment=None, offset=None):
|
|
357
|
+
"""An admitted record."""
|
|
358
|
+
if not isinstance(rec, dict):
|
|
359
|
+
return None
|
|
360
|
+
t = rec.get("t")
|
|
361
|
+
payload = rec.get("payload") if isinstance(rec.get("payload"), dict) else {}
|
|
362
|
+
if t == "obs":
|
|
363
|
+
reading = reading_from_record(rec, held_ids=self.held_ids)
|
|
364
|
+
if reading is None:
|
|
365
|
+
return None
|
|
366
|
+
gate = self.state.on_reading(reading.account_key, reading)
|
|
367
|
+
if (reading.account_key is not None
|
|
368
|
+
and reading.account_key in self.pending_deletion):
|
|
369
|
+
gate = Q.GateDecision(False, "pending-revocation")
|
|
370
|
+
if is_control_witness(rec, held_ids=self.held_ids):
|
|
371
|
+
self.state.on_control_witness(
|
|
372
|
+
reading.account_key, observation_id=reading.observation_id,
|
|
373
|
+
captured_at_utc=reading.captured_at_utc)
|
|
374
|
+
return gate
|
|
375
|
+
if t == "op":
|
|
376
|
+
kind = payload.get("kind")
|
|
377
|
+
if kind == _lib_account_protocol.LEGACY_SPOOL_ACTIVITY_KIND:
|
|
378
|
+
if _lib_account_protocol.is_stamped(rec):
|
|
379
|
+
self.state.on_trigger(
|
|
380
|
+
kind=TRIGGER_LEGACY_SPOOL,
|
|
381
|
+
reason=str(payload.get("reason") or "changed"),
|
|
382
|
+
segment=segment, offset=offset, at=rec.get("at"))
|
|
383
|
+
elif kind == _lib_account_protocol.CUTOVER_OP_KIND:
|
|
384
|
+
# Spec revision 5 (section 7, *Before the initial cutover*):
|
|
385
|
+
# a cutover that could not complete its legacy spool
|
|
386
|
+
# inventory records the uncertainty in itself, and is the
|
|
387
|
+
# same unresolved-marker trigger an `inventory-incomplete`
|
|
388
|
+
# op is, at the cutover's own position.
|
|
389
|
+
if (_lib_account_protocol.is_stamped(rec)
|
|
390
|
+
and rec.get("id") == _lib_account_protocol.CUTOVER_OP_ID
|
|
391
|
+
and payload.get("legacySpoolInventoryFailure")
|
|
392
|
+
is not None):
|
|
393
|
+
self.state.on_trigger(
|
|
394
|
+
kind=TRIGGER_LEGACY_SPOOL,
|
|
395
|
+
reason=_lib_account_protocol.INVENTORY_INCOMPLETE_REASON,
|
|
396
|
+
segment=segment, offset=offset, at=rec.get("at"))
|
|
397
|
+
elif kind == "weekly_credit_floor":
|
|
398
|
+
# A manual `record-credit` stays available as the operator's
|
|
399
|
+
# assertion, and revokes the account's certificates BEFORE its
|
|
400
|
+
# dependency-changing effects run (this fold precedes the
|
|
401
|
+
# hooks). Its synthetic snapshot is an event, never a witness.
|
|
402
|
+
scope = _scope_of(rec, payload)
|
|
403
|
+
if scope == Q.EXCLUDED_SCOPE:
|
|
404
|
+
self.state.revoke_all_accounting()
|
|
405
|
+
for account_key in sorted(self.state.accounts):
|
|
406
|
+
self.state.revoke_control(account_key)
|
|
407
|
+
else:
|
|
408
|
+
self.state.revoke_accounting(scope)
|
|
409
|
+
self.state.revoke_control(scope)
|
|
410
|
+
_absorb_credit_op(self.state, scope, payload)
|
|
411
|
+
elif kind in _ADMIN_OP_KINDS:
|
|
412
|
+
self.state.revoke_all_accounting()
|
|
413
|
+
return None
|
|
414
|
+
if t in _ADMIN_RECORD_TYPES:
|
|
415
|
+
self.state.revoke_all_accounting()
|
|
416
|
+
return None
|
|
417
|
+
if t == "evt":
|
|
418
|
+
scope = _scope_of(rec, payload)
|
|
419
|
+
_absorb_event(self.state, scope, rec)
|
|
420
|
+
if _is_deletion_outcome(payload):
|
|
421
|
+
account = None if scope == Q.EXCLUDED_SCOPE else (
|
|
422
|
+
scope if payload.get("kind") != "weekly_credit_effects"
|
|
423
|
+
else None)
|
|
424
|
+
self.state.on_deletion_outcome(account)
|
|
425
|
+
if account is None:
|
|
426
|
+
self.pending_deletion.clear()
|
|
427
|
+
else:
|
|
428
|
+
self.pending_deletion.discard(account)
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
def note_deletion_outcome(self, account_key) -> None:
|
|
432
|
+
"""The pipeline just decided a deletion outcome for ``account_key``
|
|
433
|
+
whose event folds later in journal order. While a registered
|
|
434
|
+
cross-period read is not inert, the account's later outcomes in this
|
|
435
|
+
pass are withheld until that event revokes it."""
|
|
436
|
+
if self.state.non_inert_cross_period and account_key is not None:
|
|
437
|
+
self.pending_deletion.add(account_key)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _non_inert_cross_period() -> bool:
|
|
441
|
+
"""True when the access-contract registry holds a cross-period read with
|
|
442
|
+
no inert rule (spec 5.6 items 5 and 6)."""
|
|
443
|
+
try:
|
|
444
|
+
import _lib_detector_access_registry as registry
|
|
445
|
+
except ImportError:
|
|
446
|
+
return True
|
|
447
|
+
return bool(registry.non_inert_cross_period())
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def load_state(conn) -> Q.QuarantineState:
|
|
451
|
+
"""The persisted fold state. Missing tables fail closed (raise).
|
|
452
|
+
|
|
453
|
+
The unresolved spool markers are reconstructed from the retained spool
|
|
454
|
+
triggers alone, so a reader of the persisted index and a rebuild agree."""
|
|
455
|
+
row = conn.execute(
|
|
456
|
+
"SELECT MAX(seq) FROM outcome_quarantine_triggers").fetchone()
|
|
457
|
+
latest = int(row[0]) if row and row[0] is not None else 0
|
|
458
|
+
markers = [
|
|
459
|
+
Q.spool_marker(Q.Trigger(seq, segment, offset, TRIGGER_LEGACY_SPOOL,
|
|
460
|
+
reason, at))
|
|
461
|
+
for seq, segment, offset, reason, at in conn.execute(
|
|
462
|
+
"SELECT seq, segment, byte_offset, reason, at "
|
|
463
|
+
"FROM outcome_quarantine_triggers WHERE kind = ? ORDER BY seq",
|
|
464
|
+
(TRIGGER_LEGACY_SPOOL,))]
|
|
465
|
+
envelopes = {}
|
|
466
|
+
for scope, text in conn.execute(
|
|
467
|
+
"SELECT scope, envelope_json FROM outcome_quarantine_envelope"):
|
|
468
|
+
try:
|
|
469
|
+
env = Q.Envelope.from_document(json.loads(text))
|
|
470
|
+
except (TypeError, ValueError):
|
|
471
|
+
env = None
|
|
472
|
+
if env is None:
|
|
473
|
+
raise sqlite3.DatabaseError(
|
|
474
|
+
f"outcome quarantine envelope for {scope!r} is unverifiable")
|
|
475
|
+
envelopes[scope] = env
|
|
476
|
+
accounts = {}
|
|
477
|
+
for row in conn.execute(
|
|
478
|
+
"SELECT account_key, accounting_certificate_json, "
|
|
479
|
+
"accounting_trigger_seq, control_certificate_json, "
|
|
480
|
+
"control_trigger_seq FROM outcome_quarantine_certificates"):
|
|
481
|
+
accounts[row[0]] = Q.account_from_row(row[1], row[2], row[3], row[4])
|
|
482
|
+
return Q.QuarantineState(
|
|
483
|
+
latest_trigger_seq=latest, envelopes=envelopes, accounts=accounts,
|
|
484
|
+
non_inert_cross_period=_non_inert_cross_period(),
|
|
485
|
+
spool_markers=markers)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def persist_state(conn, state: Q.QuarantineState) -> None:
|
|
489
|
+
"""Write only what changed (inside the caller's transaction)."""
|
|
490
|
+
for trig in state.new_triggers:
|
|
491
|
+
conn.execute(
|
|
492
|
+
"INSERT INTO outcome_quarantine_triggers "
|
|
493
|
+
"(seq, segment, byte_offset, kind, reason, at) "
|
|
494
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
495
|
+
(trig.seq, trig.segment, trig.offset, trig.kind, trig.reason,
|
|
496
|
+
trig.at),
|
|
497
|
+
)
|
|
498
|
+
for scope in sorted(state.dirty_envelopes):
|
|
499
|
+
conn.execute(
|
|
500
|
+
"INSERT OR REPLACE INTO outcome_quarantine_envelope "
|
|
501
|
+
"(scope, envelope_json) VALUES (?, ?)",
|
|
502
|
+
(scope, json.dumps(state.envelopes[scope].to_document(),
|
|
503
|
+
sort_keys=True, separators=(",", ":"))),
|
|
504
|
+
)
|
|
505
|
+
for account_key in sorted(state.dirty_accounts):
|
|
506
|
+
conn.execute(
|
|
507
|
+
"INSERT OR REPLACE INTO outcome_quarantine_certificates "
|
|
508
|
+
"(account_key, accounting_certificate_json, accounting_trigger_seq, "
|
|
509
|
+
" control_certificate_json, control_trigger_seq) "
|
|
510
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
511
|
+
(account_key, *Q.certificate_row(state.accounts[account_key])),
|
|
512
|
+
)
|
|
513
|
+
state.new_triggers = []
|
|
514
|
+
state.dirty_envelopes = set()
|
|
515
|
+
state.dirty_accounts = set()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# --------------------------------------------------------------------------
|
|
519
|
+
# read side
|
|
520
|
+
# --------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
def quarantine_gate(ctx, account_key, reading=None) -> Q.GateDecision:
|
|
523
|
+
"""The gate for ``account_key``'s automatic outcomes on the record whose
|
|
524
|
+
hooks are running. Without a fold (a direct caller) everything is allowed,
|
|
525
|
+
exactly as before the quarantine existed."""
|
|
526
|
+
gate = getattr(ctx, "outcome_gate", None) if ctx is not None else None
|
|
527
|
+
if gate is None:
|
|
528
|
+
return Q.GATE_NO_TRIGGER
|
|
529
|
+
return gate
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def account_status(conn, account_key: str) -> dict:
|
|
533
|
+
"""``{"accounting": bool, "control": bool}`` — whether each certificate is
|
|
534
|
+
held — read from ``conn``'s current snapshot. Raises on a missing table."""
|
|
535
|
+
state = load_state(conn)
|
|
536
|
+
return {
|
|
537
|
+
"triggered": state.has_trigger(),
|
|
538
|
+
"accounting": not state.accounting_quarantined(account_key),
|
|
539
|
+
"control": not state.control_quarantined(account_key),
|
|
540
|
+
"control_certificate": (
|
|
541
|
+
state.accounts[account_key].control
|
|
542
|
+
if state.control_certified(account_key) else None),
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def control_recovered(status: dict, authority_commit) -> bool:
|
|
547
|
+
"""Whether control recovery is COMPLETE for one account (spec 5.6 item 7;
|
|
548
|
+
fix-round-1 Q6): no trigger at all, or the account holds both
|
|
549
|
+
certificates AND its reconciled control transition records the commit of
|
|
550
|
+
the very observation that witnesses the control certificate. A crash
|
|
551
|
+
between the observation append and the control commit therefore never
|
|
552
|
+
reads as recovered. ``status`` is an `account_status` result."""
|
|
553
|
+
if not status["triggered"]:
|
|
554
|
+
return True
|
|
555
|
+
if not (status["accounting"] and status["control"]):
|
|
556
|
+
return False
|
|
557
|
+
witness = status["control_certificate"]
|
|
558
|
+
return (witness is not None and authority_commit is not None
|
|
559
|
+
and authority_commit == witness.observation_id)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _known_claude_accounts(conn) -> set:
|
|
563
|
+
"""Every Claude account the retained registry or usage history knows,
|
|
564
|
+
including an existing `unattributed` usage bucket."""
|
|
565
|
+
known = {row[0] for row in conn.execute(
|
|
566
|
+
"SELECT account_key FROM accounts WHERE provider = 'claude'")}
|
|
567
|
+
known.update(row[0] for row in conn.execute(
|
|
568
|
+
"SELECT DISTINCT account_key FROM weekly_usage_snapshots "
|
|
569
|
+
"WHERE account_key IS NOT NULL"))
|
|
570
|
+
return known
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def report(conn, *, authority_commits=None) -> dict:
|
|
574
|
+
"""The per-account quarantine report `doctor` renders: for every known
|
|
575
|
+
account that lacks a certificate, what is paused and since when (the
|
|
576
|
+
journal time of the first trigger after its last certificate).
|
|
577
|
+
|
|
578
|
+
``authority_commits`` maps an account to the `authorityCommit` its
|
|
579
|
+
reconciled statusline control transition records (absent: none). When
|
|
580
|
+
given, control counts as recovered only through `control_recovered`, the
|
|
581
|
+
same predicate drop eligibility uses.
|
|
582
|
+
|
|
583
|
+
While a spool marker is unresolved (spec revision 3) every known account
|
|
584
|
+
is reported with ``unresolvedSpoolMarker`` and the report carries the
|
|
585
|
+
markers (journal coordinate and `at`) as ``unresolvedSpoolMarkers``; both
|
|
586
|
+
keys are present only then."""
|
|
587
|
+
state = load_state(conn)
|
|
588
|
+
if not state.has_trigger():
|
|
589
|
+
return {"triggered": False, "accounts": []}
|
|
590
|
+
known = set(state.accounts)
|
|
591
|
+
known.update(scope for scope in state.envelopes
|
|
592
|
+
if scope != Q.EXCLUDED_SCOPE)
|
|
593
|
+
# A trigger quarantines EVERY account, and historical unstamped readings
|
|
594
|
+
# and events widen only the shared excluded scope: an account known only
|
|
595
|
+
# from history has no recovery record or envelope of its own (#825/#832
|
|
596
|
+
# fix round D, CD2-002).
|
|
597
|
+
known.update(_known_claude_accounts(conn))
|
|
598
|
+
rows = []
|
|
599
|
+
|
|
600
|
+
def _since(seq):
|
|
601
|
+
"""The first trigger after the account's last certificate; for a
|
|
602
|
+
certificate revoked without a new trigger (backdated input, a manual
|
|
603
|
+
credit, an administrative record), the latest trigger."""
|
|
604
|
+
found = conn.execute(
|
|
605
|
+
"SELECT at FROM outcome_quarantine_triggers WHERE seq > ? "
|
|
606
|
+
"ORDER BY seq LIMIT 1", (int(seq or 0),)).fetchone()
|
|
607
|
+
if found is None:
|
|
608
|
+
found = conn.execute(
|
|
609
|
+
"SELECT at FROM outcome_quarantine_triggers "
|
|
610
|
+
"ORDER BY seq DESC LIMIT 1").fetchone()
|
|
611
|
+
return None if found is None else found[0]
|
|
612
|
+
|
|
613
|
+
# Spec revision 5 (section 8): a marker only incomplete inventories left
|
|
614
|
+
# is worded as such; any marker an observed legacy change left keeps the
|
|
615
|
+
# revision 3 wording, which names the observed cause.
|
|
616
|
+
# Fix round K12 (K11R-4): the anchoring op that ends an uncertain
|
|
617
|
+
# cutover's episode is part of that same inventory uncertainty.
|
|
618
|
+
cause = (INVENTORY_INCOMPLETE_CAUSE
|
|
619
|
+
if state.spool_markers and all(
|
|
620
|
+
m.get("reason") in _lib_account_protocol.INVENTORY_REASONS
|
|
621
|
+
for m in state.spool_markers)
|
|
622
|
+
else LEGACY_ACTIVITY_CAUSE)
|
|
623
|
+
for account_key in sorted(known):
|
|
624
|
+
rec = state.accounts.get(account_key) or Q.AccountRecovery()
|
|
625
|
+
accounting = state.accounting_certified(account_key)
|
|
626
|
+
control = state.control_certified(account_key)
|
|
627
|
+
if control and authority_commits is not None:
|
|
628
|
+
control = (authority_commits.get(account_key)
|
|
629
|
+
== rec.control.observation_id)
|
|
630
|
+
if accounting and control:
|
|
631
|
+
continue
|
|
632
|
+
row = {
|
|
633
|
+
"account": account_key,
|
|
634
|
+
"accountingPaused": not accounting,
|
|
635
|
+
"controlPaused": not control,
|
|
636
|
+
"accountingSince": None if accounting else _since(
|
|
637
|
+
rec.accounting_trigger_seq),
|
|
638
|
+
"controlSince": None if control else _since(
|
|
639
|
+
rec.control_trigger_seq),
|
|
640
|
+
}
|
|
641
|
+
if state.has_unresolved_spool_marker():
|
|
642
|
+
row["unresolvedSpoolMarker"] = True
|
|
643
|
+
row["spoolMarkerCause"] = cause
|
|
644
|
+
rows.append(row)
|
|
645
|
+
result = {"triggered": True, "accounts": rows,
|
|
646
|
+
"latestTriggerSeq": state.latest_trigger_seq}
|
|
647
|
+
if state.has_unresolved_spool_marker():
|
|
648
|
+
result["unresolvedSpoolMarkers"] = [
|
|
649
|
+
{"segment": m["segment"], "offset": m["offset"], "at": m["at"],
|
|
650
|
+
"reason": m.get("reason")}
|
|
651
|
+
for m in state.spool_markers]
|
|
652
|
+
return result
|