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.
Files changed (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. 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