cctally 1.88.2 → 1.89.1

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 (37) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_cache.py +827 -37
  3. package/bin/_cctally_config.py +125 -0
  4. package/bin/_cctally_core.py +86 -2
  5. package/bin/_cctally_dashboard_cache_report.py +31 -0
  6. package/bin/_cctally_dashboard_conversation.py +26 -7
  7. package/bin/_cctally_dashboard_sources.py +51 -0
  8. package/bin/_cctally_db.py +626 -0
  9. package/bin/_cctally_doctor.py +84 -1
  10. package/bin/_cctally_journal.py +2 -1
  11. package/bin/_cctally_parser.py +42 -0
  12. package/bin/_cctally_quota.py +1358 -112
  13. package/bin/_cctally_record.py +249 -9
  14. package/bin/_cctally_setup.py +14 -5
  15. package/bin/_cctally_store.py +16 -1
  16. package/bin/_cctally_tui.py +16 -2
  17. package/bin/_cctally_update.py +9 -2
  18. package/bin/_lib_background_mcp.py +168 -0
  19. package/bin/_lib_cache_report.py +19 -2
  20. package/bin/_lib_codex_conversation.py +8 -0
  21. package/bin/_lib_codex_conversation_query.py +8 -7
  22. package/bin/_lib_conversation.py +105 -5
  23. package/bin/_lib_conversation_dispatch.py +15 -4
  24. package/bin/_lib_conversation_query.py +294 -2
  25. package/bin/_lib_dashboard_sources.py +5 -1
  26. package/bin/_lib_doctor.py +202 -1
  27. package/bin/_lib_jsonl.py +12 -0
  28. package/bin/_lib_quota_alert_axes.py +188 -0
  29. package/bin/_lib_quota_ledger.py +274 -0
  30. package/bin/_lib_snapshot_cache.py +36 -0
  31. package/bin/cctally +6 -3
  32. package/dashboard/static/assets/index-BgoYXdus.js +92 -0
  33. package/dashboard/static/assets/index-Ub8vwz1M.css +1 -0
  34. package/dashboard/static/dashboard.html +2 -2
  35. package/package.json +4 -1
  36. package/dashboard/static/assets/index-B0ZCsoxI.css +0 -1
  37. package/dashboard/static/assets/index-Bvp8mxtz.js +0 -92
@@ -29,7 +29,11 @@ CapabilityStatus = Literal[
29
29
  # (`UpdateRunningModal.tsx`, `store/sse.ts`), so an old client demonstrably
30
30
  # does meet a new server; `docs/cli-contract.md` calls changing a value's
31
31
  # meaning breaking. Version-aware client reaction is deliberately deferred.
32
- SOURCE_SCHEMA_VERSION = 2
32
+ # 2 -> 3 (public #5): the Codex source gained the additive `ingest_backlog`
33
+ # field. Additive and omitted-when-zero, so the normal payload is byte-identical
34
+ # — but the same `execvp` transition applies, so the bump ships as the signal it
35
+ # has always been rather than as a mechanism the client branches on.
36
+ SOURCE_SCHEMA_VERSION = 3
33
37
  DEFAULT_SOURCE = "claude"
34
38
  SOURCE_ORDER = ("claude", "codex", "all")
35
39
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -189,6 +189,13 @@ class DoctorState:
189
189
  # by the next whole-tree sync that defers none. None = key absent (the
190
190
  # normal state) or cache unreadable — the check degrades OK.
191
191
  codex_torn_deferred: Optional[dict] = None
192
+ # public #5 spec §5: what the hook's BUDGETED ingest leg has left to read.
193
+ # Mirrors the `codex_ingest_backlog` cache_meta record — `{files, bytes,
194
+ # since}` — or None when the backlog is empty (the record is deleted, never
195
+ # zeroed). A non-zero backlog is NORMAL right after a heavy burst, so the
196
+ # leg distinguishes draining from stuck on the `since` stamp rather than on
197
+ # the counts.
198
+ codex_ingest_backlog: Optional[dict] = None
192
199
  # The byte-zero Codex replay's stall signal. `codex_replay_pending` mirrors
193
200
  # the `codex_replay_from_zero_pending` cache_meta marker; `codex_replay_blocked`
194
201
  # is `{"at": iso, "files_failed": N, "files_deferred_torn": N}`, written by a
@@ -197,6 +204,12 @@ class DoctorState:
197
204
  # state) or cache unreadable — the check degrades OK.
198
205
  codex_replay_pending: Optional[bool] = None
199
206
  codex_replay_blocked: Optional[dict] = None
207
+ # `codex_replay_deferred` is `{"since": iso, "at": iso}`, written when a
208
+ # BUDGETED tick (the Codex hook) declined the replay outright. A hook-only
209
+ # install produces this and nothing else — the decline returns before the
210
+ # walk, so `files_failed`/`files_deferred_torn` are both zero, no `blocked`
211
+ # record is ever written, and the ingest-backlog leg reads a drained store.
212
+ codex_replay_deferred: Optional[dict] = None
200
213
  # #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
201
214
  # doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
202
215
  # rebuild loop calls the gather every rebuild and quick_check on a
@@ -229,6 +242,13 @@ class DoctorState:
229
242
  codex_quota_windows: Optional[list[dict]] = None
230
243
  codex_hook_roots: Optional[list[dict]] = None
231
244
  codex_lifecycle_activity_24h: Optional[dict] = None
245
+ # public #5: 24h outcome counts for the detached `_codex-quota-verify`
246
+ # worker, parsed from the same bounded log. The lifecycle parser above
247
+ # cannot supply these — worker lines carry no `source_root_key`, so its
248
+ # root filter drops every one. Shape:
249
+ # {"success_count_24h", "error_count_24h", "spawn_failure_count_24h",
250
+ # "last_success_at"}. None/absent degrades the check to OK.
251
+ codex_quota_verify_activity: Optional[dict] = None
232
252
  # #311: precomputed five-state classification of settings.json's
233
253
  # statusLine.refreshInterval (unavailable/absent/foreign/present/missing),
234
254
  # computed by doctor_gather_state via the setup I/O-layer classifier so the
@@ -1115,6 +1135,26 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
1115
1135
  )
1116
1136
 
1117
1137
 
1138
+ def _age_seconds(stamp: object, now_utc: dt.datetime) -> Optional[int]:
1139
+ """Seconds between an ISO-8601 UTC stamp and ``now_utc``; None if unusable."""
1140
+ if not stamp:
1141
+ return None
1142
+ try:
1143
+ parsed = dt.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
1144
+ except (ValueError, TypeError):
1145
+ return None
1146
+ if parsed.tzinfo is None:
1147
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
1148
+ return int((now_utc - parsed.astimezone(dt.timezone.utc)).total_seconds())
1149
+
1150
+
1151
+ #: How long a budgeted decline may stand before it stops being self-healing.
1152
+ #: The hook hands the drain to a detached worker on every decline (throttled
1153
+ #: hourly), so a deferral older than this means the hand-off is not landing.
1154
+ #: Same hour as `CODEX_INGEST_BACKLOG_STUCK_SECONDS`, for the same reason.
1155
+ CODEX_REPLAY_DEFERRED_WARN_SECONDS = 3600
1156
+
1157
+
1118
1158
  def _check_data_codex_replay(s: DoctorState) -> CheckResult:
1119
1159
  """WARN while a byte-zero Codex replay is STALLED rather than merely pending.
1120
1160
 
@@ -1131,14 +1171,27 @@ def _check_data_codex_replay(s: DoctorState) -> CheckResult:
1131
1171
  between the migration and the next sync, and it clears on its own. The WARN
1132
1172
  needs the durable `blocked` record, which only a completed-but-unsuccessful
1133
1173
  whole-tree walk writes.
1174
+
1175
+ The `deferred` record is the second stall shape (public #5). A BUDGETED tick
1176
+ cannot perform the replay at all, so the Codex hook declines it — and on a
1177
+ hook-only install no unbudgeted caller exists, which froze every Codex sync
1178
+ while `files_failed` and `files_deferred_torn` both stayed zero and no
1179
+ `blocked` record was ever written. The hook now hands the drain to a
1180
+ detached worker, so a RECENT deferral is the ordinary self-healing state and
1181
+ stays `ok`; one that has stood for over an hour means the hand-off is not
1182
+ landing and is the only signal that the install is frozen.
1134
1183
  """
1135
1184
  blocked = s.codex_replay_blocked or {}
1185
+ deferred = s.codex_replay_deferred or {}
1136
1186
  at = blocked.get("at")
1187
+ deferred_since = deferred.get("since")
1137
1188
  details = {
1138
1189
  "pending": bool(s.codex_replay_pending),
1139
1190
  "blocked_at": at,
1140
1191
  "files_failed": blocked.get("files_failed"),
1141
1192
  "files_deferred_torn": blocked.get("files_deferred_torn"),
1193
+ "deferred_since": deferred_since,
1194
+ "deferred_at": deferred.get("at"),
1142
1195
  }
1143
1196
  if s.codex_replay_pending and isinstance(at, str) and at:
1144
1197
  return CheckResult(
@@ -1150,15 +1203,105 @@ def _check_data_codex_replay(s: DoctorState) -> CheckResult:
1150
1203
  "is truncated), then `cctally cache-sync --source codex`"),
1151
1204
  details=details,
1152
1205
  )
1206
+ if s.codex_replay_pending and isinstance(deferred_since, str) and deferred_since:
1207
+ age_s = _age_seconds(deferred_since, s.now_utc)
1208
+ if age_s is not None and age_s > CODEX_REPLAY_DEFERRED_WARN_SECONDS:
1209
+ return CheckResult(
1210
+ id="data.codex_replay", title="Codex transcript replay",
1211
+ severity="warn",
1212
+ summary=(f"the Codex hook has deferred it since "
1213
+ f"{deferred_since} — a budgeted tick cannot run the "
1214
+ "replay, and all Codex ingest is frozen until an "
1215
+ "unbudgeted sync does"),
1216
+ remediation="Run `cctally cache-sync --source codex`",
1217
+ details=details,
1218
+ )
1219
+ return CheckResult(
1220
+ id="data.codex_replay", title="Codex transcript replay",
1221
+ severity="ok",
1222
+ summary=(f"pending; the Codex hook deferred it at "
1223
+ f"{deferred.get('at') or deferred_since} and handed the "
1224
+ "drain to a background worker"),
1225
+ remediation=None, details=details,
1226
+ )
1153
1227
  return CheckResult(
1154
1228
  id="data.codex_replay", title="Codex transcript replay",
1155
1229
  severity="ok",
1156
- summary="pending (clears on the next Codex sync)"
1230
+ # NOT "clears on the next Codex sync": a budgeted tick — the Codex
1231
+ # hook — declines the replay rather than running it, so on a hook-only
1232
+ # install the next sync is precisely what does not clear it.
1233
+ summary="pending (clears on the next unbudgeted Codex sync)"
1157
1234
  if s.codex_replay_pending else "none pending",
1158
1235
  remediation=None, details=details,
1159
1236
  )
1160
1237
 
1161
1238
 
1239
+ def _check_data_codex_quota_verification(s: DoctorState) -> CheckResult:
1240
+ """WARN when the detached Codex quota verification is not landing.
1241
+
1242
+ Public #5 moved every whole-history projection pass off the blocking hook
1243
+ path, so on a hook-only install ALL of it now depends on the
1244
+ `_codex-quota-verify` worker. Two of its routes make that dependency sharp
1245
+ rather than cosmetic: the catch-all (a rebuilt stats index, an
1246
+ interpretation bump, a reset ledger, a ledgerless cache) performs no
1247
+ projection work at all, which leaves the projection transiently MISSING —
1248
+ no blocks, no milestones, no alerts — until the worker converges it.
1249
+ Nothing else observes that. `data.codex_quota` is a different question
1250
+ entirely: it reports the freshness of the local rollout OBSERVATIONS, which
1251
+ stay perfectly fresh while the projection derived from them is absent.
1252
+
1253
+ The worker's own streams are `/dev/null` and its exit code is observed by
1254
+ nobody, so it writes its outcome to `hook-tick.log` for exactly this leg to
1255
+ read. The predicate is "failures with no success in 24h": one failed
1256
+ hand-off is ordinary (a contended spawn, a transient lock) and self-heals on
1257
+ the next throttle window, while a worker that has not landed once in a day
1258
+ is the persistent condition — and because the deadline only moves when a
1259
+ pass COMMITS, such a worker re-spawns forever with nothing to show for it.
1260
+
1261
+ Silence is OK, not a failure: an install with no Codex hooks never hands off
1262
+ at all, and every non-hook caller runs the pass inline.
1263
+ """
1264
+ activity = s.codex_quota_verify_activity or {}
1265
+ successes = int(activity.get("success_count_24h") or 0)
1266
+ errors = int(activity.get("error_count_24h") or 0)
1267
+ spawn_failures = int(activity.get("spawn_failure_count_24h") or 0)
1268
+ last_success = activity.get("last_success_at")
1269
+ details = {
1270
+ "success_count_24h": successes,
1271
+ "error_count_24h": errors,
1272
+ "spawn_failure_count_24h": spawn_failures,
1273
+ "last_success_at": (
1274
+ _iso_z(last_success) if isinstance(last_success, dt.datetime)
1275
+ else (last_success if isinstance(last_success, str) else None)
1276
+ ),
1277
+ }
1278
+ failures = errors + spawn_failures
1279
+ if failures and not successes:
1280
+ return CheckResult(
1281
+ id="data.codex_quota_verification",
1282
+ title="Codex quota verification",
1283
+ severity="warn",
1284
+ summary=(
1285
+ f"{failures} failed hand-off(s) and no completed pass in 24h — "
1286
+ "the Codex quota projection is not being verified"),
1287
+ remediation=(
1288
+ "Run `cctally cache-sync --source codex` (every non-hook caller "
1289
+ "runs the pass inline), then check hook-tick.log for "
1290
+ "`op=quota-verify`"),
1291
+ details=details,
1292
+ )
1293
+ return CheckResult(
1294
+ id="data.codex_quota_verification",
1295
+ title="Codex quota verification",
1296
+ severity="ok",
1297
+ summary=(
1298
+ f"{successes} completed, {failures} failed in 24h"
1299
+ if successes or failures else "no hand-off in the last 24h"),
1300
+ remediation=None,
1301
+ details=details,
1302
+ )
1303
+
1304
+
1162
1305
  def _check_data_codex_project_metadata(s: DoctorState) -> CheckResult:
1163
1306
  """Report the identity-safe all-history Codex metadata partition."""
1164
1307
  if s.codex_project_metadata_error is not None:
@@ -2621,6 +2764,61 @@ def _check_accounts_codex_identity(s: DoctorState) -> CheckResult:
2621
2764
  )
2622
2765
 
2623
2766
 
2767
+ #: A backlog younger than this is draining, not stuck. A heavy Codex burst
2768
+ #: legitimately leaves work for the next few ticks; only a backlog that has
2769
+ #: stayed non-zero CONTINUOUSLY past this is worth an operator's attention.
2770
+ CODEX_INGEST_BACKLOG_STUCK_SECONDS = 3600
2771
+
2772
+
2773
+ def _check_data_codex_ingest_backlog(s: DoctorState) -> CheckResult:
2774
+ """WARN when the hook's budgeted Codex ingest has been behind for over an hour.
2775
+
2776
+ The hook's ingest leg is bounded in wall clock (public #5 spec §4), so it
2777
+ can legitimately leave a backlog — that is the mechanism working, not a
2778
+ fault. What is worth reporting is a backlog that never drains: a store
2779
+ whose per-tick budget is smaller than its per-tick growth, or a walk that
2780
+ has stopped making progress.
2781
+
2782
+ Per the CLI contract a WARN alone does not change `doctor`'s exit code.
2783
+ """
2784
+ record = s.codex_ingest_backlog or {}
2785
+ try:
2786
+ files = int(record.get("files") or 0)
2787
+ except (TypeError, ValueError):
2788
+ files = 0
2789
+ try:
2790
+ pending_bytes = int(record.get("bytes") or 0)
2791
+ except (TypeError, ValueError):
2792
+ pending_bytes = 0
2793
+ since_text = record.get("since")
2794
+ age_s = _age_seconds(since_text, s.now_utc)
2795
+ details = {"files": files, "bytes": pending_bytes, "since": since_text,
2796
+ "age_s": age_s}
2797
+ if files <= 0:
2798
+ return CheckResult(
2799
+ id="data.codex_ingest_backlog", title="Codex ingest backlog",
2800
+ severity="ok", summary="no Codex ingest backlog",
2801
+ remediation=None, details=details,
2802
+ )
2803
+ if age_s is not None and age_s > CODEX_INGEST_BACKLOG_STUCK_SECONDS:
2804
+ return CheckResult(
2805
+ id="data.codex_ingest_backlog", title="Codex ingest backlog",
2806
+ severity="warn",
2807
+ summary=(f"{files} Codex rollout(s) / {pending_bytes} byte(s) still "
2808
+ f"unread after {age_s // 3600}h — the hook's budget is not "
2809
+ "draining them"),
2810
+ remediation="Run `cctally cache-sync --source codex`",
2811
+ details=details,
2812
+ )
2813
+ return CheckResult(
2814
+ id="data.codex_ingest_backlog", title="Codex ingest backlog",
2815
+ severity="ok",
2816
+ summary=(f"{files} Codex rollout(s) / {pending_bytes} byte(s) queued — "
2817
+ "draining"),
2818
+ remediation=None, details=details,
2819
+ )
2820
+
2821
+
2624
2822
  def _check_accounts_attribution(s: DoctorState) -> CheckResult:
2625
2823
  """WARN when Claude usage is flowing but landing in `unattributed` despite a
2626
2824
  resolved active account (the stamping pipeline is broken), or while the
@@ -2738,7 +2936,10 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
2738
2936
  # after the pair rather than between them.
2739
2937
  ("data.codex_project_metadata", "_check_data_codex_project_metadata"),
2740
2938
  ("data.codex_replay", "_check_data_codex_replay"),
2939
+ ("data.codex_ingest_backlog", "_check_data_codex_ingest_backlog"),
2741
2940
  ("data.codex_quota", "_check_data_codex_quota"),
2941
+ ("data.codex_quota_verification",
2942
+ "_check_data_codex_quota_verification"),
2742
2943
  ("data.parse_health", "_check_data_parse_health"),
2743
2944
  ("data.forked_buckets", "_check_data_forked_buckets"),
2744
2945
  ("data.post_credit_milestones", "_check_data_post_credit_milestones"),
package/bin/_lib_jsonl.py CHANGED
@@ -986,12 +986,21 @@ def _iter_codex_fused_records_with_offsets(
986
986
  initial_total_tokens: int = 0,
987
987
  source_root_key: str | None = None,
988
988
  state: _CodexIterState | None = None,
989
+ stop_before=None,
989
990
  ):
990
991
  """Yield typed facts for every complete, valid Codex JSONL object.
991
992
 
992
993
  Binary readers retain physical byte offsets and strictly reject invalid UTF-8,
993
994
  non-finite JSON constants, and non-object JSON. The mutable state carries
994
995
  the shipped accounting resume contract alongside the latest thread facts.
996
+
997
+ ``stop_before`` (public #5) is an optional predicate called with the byte
998
+ offset of each line BEFORE it is read; returning True ends the iteration
999
+ there, leaving ``fh`` positioned exactly at that offset so the caller can
1000
+ persist it as a resume point. It sits inside the reader rather than in the
1001
+ consuming loop because most lines yield nothing at all — a long run of
1002
+ non-emitting records would otherwise never hand control back, which is
1003
+ precisely the case a wall-clock budget has to survive.
995
1004
  """
996
1005
  if state is None:
997
1006
  state = _CodexIterState()
@@ -1003,6 +1012,9 @@ def _iter_codex_fused_records_with_offsets(
1003
1012
 
1004
1013
  while True:
1005
1014
  line_offset = fh.tell()
1015
+ if stop_before is not None and stop_before(line_offset):
1016
+ fh.seek(line_offset)
1017
+ return
1006
1018
  line = fh.readline()
1007
1019
  if not line:
1008
1020
  return
@@ -0,0 +1,188 @@
1
+ """Pure kernel deciding what an alert pass has to look at (public #5 §3).
2
+
3
+ Alert state is NOT a function of window dirtiness, so a projector bounded by the
4
+ change ledger would silently stop honouring four of the five things that can
5
+ make an alert eligible. Each axis below is a way the answer changes with no row
6
+ in ``quota_window_snapshots`` moving at all:
7
+
8
+ 1. **Physical-window dirtiness** — the ledger. The only axis the ledger sees.
9
+ 2. **Policy scope** — the resolved rule changed. ``quota_rule_fingerprint``
10
+ already hashes the resolved thresholds, gates, root and logical limit, and
11
+ is persisted per identity in ``quota_alert_arming``; comparing it against a
12
+ freshly resolved one detects the change. An exact-rule change is scoped to
13
+ its identities, a default/global change marks everything.
14
+ 3. **Delivery gate** — the global or quota switch flipped. DISABLING must
15
+ enumerate every Codex arming row, delete it and journal a disarm, and must do
16
+ so even with zero dirty windows and zero lifecycle-eligible roots; that is
17
+ why it needs no observation load and why the caller runs it before the
18
+ eligibility fast path. ENABLING is the opposite: it needs a semantic pass
19
+ over the affected identities regardless of physical dirtiness, because
20
+ activation has to write ``suppressed_backfill`` terminal rows for
21
+ already-satisfied thresholds instead of dispatching history.
22
+ 4. **Scheduled time** — an observation captured in the future is skipped today,
23
+ and becomes eligible when wall time passes it with no mutation to observe.
24
+ Persisting that boundary and treating ``now >= boundary`` as dirty is what
25
+ closes it. Unlike axes 2 and 3 this one fires on WALL CLOCK rather than on a
26
+ configuration change, which is why the hook path defers it
27
+ (``defer_scheduled``) instead of paying an unannounced whole-history pass on
28
+ a blocking tick.
29
+ 5. **Durable lifecycle state** — the existing arming rows and terminal events,
30
+ unchanged. Represented here only as the fingerprints axis 2 compares.
31
+
32
+ The scope this returns is deliberately a SUPERSET decision: widening a bounded
33
+ pass is always safe, missing an identity is not.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import datetime as dt
38
+ from dataclasses import dataclass
39
+ from typing import Iterable, Mapping
40
+
41
+
42
+ #: Ordered weakest to strongest; a later value absorbs an earlier one.
43
+ SCOPE_NONE = "none"
44
+ SCOPE_GROUPS = "groups"
45
+ SCOPE_ROOTS = "roots"
46
+ SCOPE_ALL = "all"
47
+
48
+ _ORDER = {SCOPE_NONE: 0, SCOPE_GROUPS: 1, SCOPE_ROOTS: 2, SCOPE_ALL: 3}
49
+
50
+ #: Recorded in place of ``"scheduled"`` when axis 4 came due but the caller
51
+ #: cannot honour it inline. A caller that sees it MUST carry the stored
52
+ #: boundary through untouched — the axis is postponed, not satisfied.
53
+ REASON_SCHEDULED_DEFERRED = "scheduled_deferred"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AlertDirtyScope:
58
+ """What the pass has to load, and whether it has to disarm first."""
59
+
60
+ disarm_all: bool
61
+ scope: str
62
+ roots: frozenset[str]
63
+ reasons: tuple[str, ...]
64
+
65
+ def widens(self, ledger_scope: str) -> bool:
66
+ """True when the alert axes demand more than the ledger already gives."""
67
+ return _ORDER[self.scope] > _ORDER[ledger_scope]
68
+
69
+
70
+ def _strongest(a: str, b: str) -> str:
71
+ return a if _ORDER[a] >= _ORDER[b] else b
72
+
73
+
74
+ def alert_dirty_scope(
75
+ *,
76
+ ledger_groups: Iterable[object],
77
+ stored_fingerprints: Mapping[tuple, str],
78
+ resolved_fingerprints: Mapping[tuple, str],
79
+ gate_before: "bool | None",
80
+ gate_after: bool,
81
+ now: dt.datetime,
82
+ next_evaluation_at: "dt.datetime | None",
83
+ defer_scheduled: bool = False,
84
+ ) -> AlertDirtyScope:
85
+ """Resolve the five axes into one decision.
86
+
87
+ ``stored_fingerprints`` / ``resolved_fingerprints`` are keyed by the arming
88
+ identity tuple ``(source, source_root_key, account_key, logical_limit_key,
89
+ observed_slot, window_minutes)``; the ROOT is element 1, which is what an
90
+ exact-rule change is scoped to.
91
+
92
+ ``defer_scheduled`` is the hook path's (``full_pass="defer"``). Axes 2 and 3
93
+ are driven by a configuration change the user just made, so widening for
94
+ them is bounded and expected; axis 4 is driven by WALL CLOCK, which makes it
95
+ the one route into a whole-history pass that can land on a blocking hook
96
+ tick with nothing to have predicted it. Under this flag it is recorded as
97
+ ``REASON_SCHEDULED_DEFERRED`` and does NOT strengthen the scope — and the
98
+ caller owes the stored boundary a carry-through, because a deferral that
99
+ lets the boundary be recomputed is a silent drop.
100
+ """
101
+ reasons: list[str] = []
102
+ if not gate_after:
103
+ # Nothing to evaluate: delivery is off, so the only work is making sure
104
+ # no arming boundary survives to turn disabled-period evidence into a
105
+ # later alert. It needs no observations at all.
106
+ return AlertDirtyScope(
107
+ disarm_all=True, scope=SCOPE_NONE, roots=frozenset(),
108
+ reasons=("gate_disabled",),
109
+ )
110
+
111
+ scope = SCOPE_NONE
112
+ roots: set[str] = set()
113
+
114
+ if any(True for _ in ledger_groups):
115
+ scope = _strongest(scope, SCOPE_GROUPS)
116
+ reasons.append("window_dirty")
117
+
118
+ if gate_before is not True:
119
+ # Enabling — or a first pass with no recorded gate state, which cannot
120
+ # be distinguished from one and must not be assumed to be a no-op.
121
+ scope = _strongest(scope, SCOPE_ALL)
122
+ reasons.append("gate_enabled")
123
+
124
+ changed_roots = {
125
+ identity[1]
126
+ for identity, fingerprint in resolved_fingerprints.items()
127
+ if stored_fingerprints.get(identity) != fingerprint
128
+ }
129
+ if changed_roots:
130
+ scope = _strongest(scope, SCOPE_ROOTS)
131
+ roots |= changed_roots
132
+ reasons.append("rule_changed")
133
+
134
+ if next_evaluation_at is not None and now >= next_evaluation_at:
135
+ # A future-clocked observation just became eligible. Which identity it
136
+ # belongs to is not recorded — only the instant — so the honest scope is
137
+ # everything, and on the hook path "everything" is precisely what may
138
+ # not run.
139
+ if defer_scheduled:
140
+ reasons.append(REASON_SCHEDULED_DEFERRED)
141
+ else:
142
+ scope = _strongest(scope, SCOPE_ALL)
143
+ reasons.append("scheduled")
144
+
145
+ return AlertDirtyScope(
146
+ disarm_all=False, scope=scope, roots=frozenset(roots),
147
+ reasons=tuple(reasons),
148
+ )
149
+
150
+
151
+ def next_evaluation_boundary(
152
+ *, capture_times: Iterable[dt.datetime], now: dt.datetime,
153
+ stored: "dt.datetime | None", retain_due: bool = False,
154
+ ) -> "dt.datetime | None":
155
+ """The earliest still-future capture the projector must come back for.
156
+
157
+ A bounded pass only sees the dirty windows, so the STORED boundary is
158
+ retained whenever it is still in the future: dropping it would forget a
159
+ future-clocked observation sitting in a window this pass never loaded. Once
160
+ wall time passes it the axis fires, the pass widens to everything, and the
161
+ boundary is recomputed from complete evidence — so a retained value can only
162
+ ever cost one extra pass, never a missed one.
163
+
164
+ ``retain_due`` keeps a boundary that is ALREADY due, which is the case where
165
+ "recomputed from complete evidence" is a lie: a reporting-only pass never
166
+ reaches a threshold decision at all, and a BOUNDED hook tick that declined
167
+ the widening deliberately did not look. Either would otherwise retire the
168
+ axis on behalf of an evaluation nobody performed.
169
+
170
+ A due value sorts before every future candidate, so it stays until a pass
171
+ that genuinely looked at everything retires it — in practice a hook tick
172
+ that widened to whole-history for axis 2 or 3, since carrying alert
173
+ eligibility is what separates such a pass from a reporting-only one and the
174
+ hook is the only production caller that carries it.
175
+
176
+ It does NOT stay "until a pass that can act on it does", and that gap is
177
+ open rather than closed: on a hook-only install with a steady enabled gate,
178
+ unchanged rules and a quiet ledger, no qualifying pass ever runs and the
179
+ instant is retained indefinitely. The cost is bounded — the tick stays
180
+ bounded and fast, and the window is re-evaluated as soon as it goes
181
+ ledger-dirty again, which for a live window is continuous — so the exposure
182
+ is a future-clocked capture in a window that then goes permanently quiet
183
+ never qualifying a threshold. Under-alerting, never a stall or a burst.
184
+ """
185
+ candidates = [value for value in capture_times if value > now]
186
+ if stored is not None and (retain_due or stored > now):
187
+ candidates.append(stored)
188
+ return min(candidates) if candidates else None