cctally 1.116.0 → 1.117.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 (72) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +224 -7
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +34 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +389 -18
  15. package/bin/_cctally_forecast.py +30 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_oauth_samples.py +185 -7
  18. package/bin/_cctally_parser.py +38 -0
  19. package/bin/_cctally_project.py +34 -8
  20. package/bin/_cctally_quarantine.py +821 -343
  21. package/bin/_cctally_quota.py +69 -4
  22. package/bin/_cctally_quota_calibration.py +677 -3
  23. package/bin/_cctally_quota_model.py +927 -127
  24. package/bin/_cctally_record.py +922 -445
  25. package/bin/_cctally_rederive.py +119 -15
  26. package/bin/_cctally_statusline.py +1048 -176
  27. package/bin/_cctally_store.py +544 -110
  28. package/bin/_cctally_tui.py +93 -9
  29. package/bin/_cctally_update.py +77 -14
  30. package/bin/_lib_account_protocol.py +227 -19
  31. package/bin/_lib_claude_attribution.py +421 -0
  32. package/bin/_lib_clone_copy.py +30 -0
  33. package/bin/_lib_codex_conversation_query.py +45 -9
  34. package/bin/_lib_codex_conversation_watch.py +72 -13
  35. package/bin/_lib_codex_rollout_container.py +234 -19
  36. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  37. package/bin/_lib_credit.py +22 -13
  38. package/bin/_lib_dashboard_sources.py +163 -10
  39. package/bin/_lib_detector_access_registry.py +1335 -166
  40. package/bin/_lib_doctor.py +276 -92
  41. package/bin/_lib_ingest_frontier.py +269 -36
  42. package/bin/_lib_journal.py +273 -22
  43. package/bin/_lib_jsonl.py +12 -0
  44. package/bin/_lib_meter_rate_change.py +72 -0
  45. package/bin/_lib_outcome_quarantine.py +917 -554
  46. package/bin/_lib_provider_activity.py +24 -6
  47. package/bin/_lib_quota_calibration.py +57 -3
  48. package/bin/_lib_quota_copy.py +7 -2
  49. package/bin/_lib_quota_lineage.py +613 -0
  50. package/bin/_lib_quota_model.py +435 -22
  51. package/bin/_lib_rate_change_delivery.py +16 -0
  52. package/bin/_lib_reclaim_planner.py +6 -4
  53. package/bin/_lib_rederive.py +2046 -21
  54. package/bin/_lib_selector_state.py +131 -3
  55. package/bin/_lib_stale_proof.py +113 -4
  56. package/bin/_lib_statusline.py +72 -3
  57. package/bin/_lib_statusline_candidates.py +246 -42
  58. package/bin/_lib_write_io.py +418 -5
  59. package/bin/cctally +24 -11
  60. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-Dcfn8T6f.js} +1 -1
  61. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-BqmXbnhn.js} +1 -1
  62. package/dashboard/static/assets/{ModalRoot-HlxoagIa.js → ModalRoot-Ck3WJK2V.js} +1 -1
  63. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-B-HZkDnT.js} +1 -1
  64. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-QhJkHB7i.js} +1 -1
  65. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CkXEIQcK.js} +1 -1
  66. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  67. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-HKS1s56o.js} +3 -3
  68. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-BNGaNPCp.js} +1 -1
  69. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  70. package/dashboard/static/dashboard.html +3 -3
  71. package/package.json +5 -1
  72. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -34,6 +34,7 @@ Spec: docs/superpowers/specs/2026-08-19-620-s2-on-demand-diagnosis.md §3, §5
34
34
  """
35
35
  from __future__ import annotations
36
36
 
37
+ import contextlib
37
38
  import datetime as dt
38
39
  import hashlib
39
40
  import json
@@ -231,7 +232,17 @@ def open_read_only(kind: str) -> sqlite3.Connection:
231
232
  connection cannot write, so migration, legacy import, contract repair and
232
233
  replay cannot run behind our back and change the very bytes whose digest
233
234
  we are about to publish.
235
+
236
+ #1000 §5.10: `conversations` is not served here. A `mode=ro` open still
237
+ creates `-wal`/`-shm` beside the store, so its only opener is
238
+ `StoreBundle.connection`, under the diagnostic read admission held for
239
+ the connection's life.
234
240
  """
241
+ if kind == "conversations":
242
+ raise EstablishmentFailure(
243
+ EstablishmentError.STORE_UNAVAILABLE.value,
244
+ "conversations store opens only under the read admission",
245
+ )
235
246
  path = _store_path(kind)
236
247
  if not path.exists():
237
248
  raise EstablishmentFailure(
@@ -732,14 +743,59 @@ class StoreBundle:
732
743
  # Memoized schema-gap answer. `_UNPROBED` rather than `None`, because
733
744
  # `None` is the answer "this store can serve every statement".
734
745
  self._conversations_gap: Any = _UNPROBED
746
+ # #1000 §5.10: the conversations read admission, held exactly as long
747
+ # as the conversations connection and released by `close()`.
748
+ self._admissions = contextlib.ExitStack()
735
749
 
736
750
  def connection(self, kind: str) -> sqlite3.Connection:
737
751
  conn = self._connections.get(kind)
738
752
  if conn is None:
739
- conn = open_read_only(kind)
753
+ if kind == "conversations":
754
+ conn = self._open_conversations()
755
+ else:
756
+ conn = open_read_only(kind)
740
757
  self._connections[kind] = conn
741
758
  return conn
742
759
 
760
+ def _open_conversations(self) -> sqlite3.Connection:
761
+ """Open `conversations.db` under the diagnostic read admission.
762
+
763
+ The admission (`_cctally_cache.conversation_ro_guarded`) holds the
764
+ conversation maintenance flock SHARED for the connection's life, so
765
+ the pre-rebuild probe's in-place read and recovery's quarantine
766
+ cannot run under this reader, and it declines rather than open the
767
+ family while they, or a repair, own it (#1000 §5.10).
768
+ """
769
+ import _cctally_cache
770
+
771
+ unavailable = EstablishmentError.STORE_UNAVAILABLE.value
772
+ admission = contextlib.ExitStack()
773
+ try:
774
+ conn = admission.enter_context(
775
+ _cctally_cache.conversation_ro_guarded(
776
+ timeout=READ_BUSY_TIMEOUT_MS / 1000))
777
+ except (sqlite3.Error, OSError) as exc:
778
+ # OSError: the admission's lock file cannot be opened (an
779
+ # unwritable data folder), which is unavailability, not a crash.
780
+ admission.close()
781
+ raise EstablishmentFailure(
782
+ unavailable,
783
+ f"conversations store could not be opened read-only: {exc}",
784
+ ) from exc
785
+ if conn is None:
786
+ admission.close()
787
+ if not os.path.lexists(_store_path("conversations")):
788
+ raise EstablishmentFailure(
789
+ unavailable, "conversations store is not present")
790
+ raise EstablishmentFailure(
791
+ unavailable,
792
+ "conversations store could not be opened read-only: "
793
+ + _cctally_cache.conversation_ro_decline_reason(),
794
+ )
795
+ self._admissions.enter_context(admission)
796
+ conn.row_factory = sqlite3.Row
797
+ return conn
798
+
743
799
  def conversations_schema_gap(self) -> str | None:
744
800
  """Which of our own tables this store cannot serve, memoized.
745
801
 
@@ -753,12 +809,15 @@ class StoreBundle:
753
809
  return self._conversations_gap
754
810
 
755
811
  def close(self) -> None:
756
- for conn in self._connections.values():
757
- try:
758
- conn.close()
759
- except sqlite3.Error:
760
- pass
761
- self._connections.clear()
812
+ try:
813
+ for conn in self._connections.values():
814
+ try:
815
+ conn.close()
816
+ except sqlite3.Error:
817
+ pass
818
+ self._connections.clear()
819
+ finally:
820
+ self._admissions.close()
762
821
 
763
822
  def __enter__(self) -> "StoreBundle":
764
823
  return self
@@ -4451,6 +4510,16 @@ def _finish_forked_provider(worker: tuple[int, int]):
4451
4510
  return payload[1]
4452
4511
 
4453
4512
 
4513
+ # The `_cctally_core` paths a spawned or forkserver worker takes from its
4514
+ # parent. The worker reloads cctally, which re-derives every other path from
4515
+ # the forkserver's environment, frozen when that server started. The
4516
+ # conversation maintenance lock must be the parent's own: the transcript read
4517
+ # admission excludes the pre-rebuild probe and recovery on that file and on
4518
+ # no other (#1000 §5.10).
4519
+ _WORKER_CORE_PATHS = ("CACHE_DB_PATH", "DB_PATH", "CONVERSATIONS_DB_PATH",
4520
+ "CONVERSATIONS_LOCK_MAINTENANCE_PATH", "CONFIG_PATH")
4521
+
4522
+
4454
4523
  def _spawn_worker_init(cctally_path: str,
4455
4524
  store_paths: Mapping[str, str]) -> None:
4456
4525
  """Initialize a safe spawned/forkserver dashboard worker."""
@@ -4590,9 +4659,7 @@ def build_diagnosis_bounded(scope: DiagnosisScope, *,
4590
4659
  """Build one dashboard diagnosis in a process retired at TIMEOUT_SECONDS."""
4591
4660
  c = _cctally()
4592
4661
  paths = {
4593
- name: str(getattr(_cctally_core, name))
4594
- for name in ("CACHE_DB_PATH", "DB_PATH",
4595
- "CONVERSATIONS_DB_PATH", "CONFIG_PATH")
4662
+ name: str(getattr(_cctally_core, name)) for name in _WORKER_CORE_PATHS
4596
4663
  }
4597
4664
  return _run_process_with_deadline(
4598
4665
  _build_diagnosis_process_task,
@@ -4610,9 +4677,7 @@ def _build_isolated_provider(args):
4610
4677
 
4611
4678
  c = _cctally()
4612
4679
  paths = {
4613
- name: str(getattr(_cctally_core, name))
4614
- for name in ("CACHE_DB_PATH", "DB_PATH",
4615
- "CONVERSATIONS_DB_PATH", "CONFIG_PATH")
4680
+ name: str(getattr(_cctally_core, name)) for name in _WORKER_CORE_PATHS
4616
4681
  }
4617
4682
  methods = multiprocessing.get_all_start_methods()
4618
4683
  method = "forkserver" if "forkserver" in methods else "spawn"
@@ -84,51 +84,18 @@ def _stats_ro_guarded():
84
84
  )
85
85
 
86
86
 
87
- @contextlib.contextmanager
88
87
  def _conversation_ro_guarded(*, timeout: float):
89
- """Bounded read-only transcript handle participating in #415 recovery."""
90
- path = pathlib.Path(_cctally_core.CONVERSATIONS_DB_PATH)
91
- if not path.exists():
92
- yield None
93
- return
94
- marker = path.with_name(f"{path.name}.repairing")
95
- pending = path.with_name(f"{path.name}.quarantine-pending.json")
96
- recovery = path.with_name(f"{path.name}.recovery.json")
97
- maintenance = pathlib.Path(
98
- _cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
99
- )
100
- lock_fh = conn = None
101
- try:
102
- maintenance.parent.mkdir(parents=True, exist_ok=True)
103
- lock_fh = open(maintenance, "a+")
104
- try:
105
- fcntl.flock(lock_fh, fcntl.LOCK_SH | fcntl.LOCK_NB)
106
- except BlockingIOError:
107
- yield None
108
- return
109
- if marker.exists() or pending.exists() or recovery.exists():
110
- yield None
111
- return
112
- conn = sqlite3.connect(
113
- path.resolve().as_uri() + "?mode=ro",
114
- uri=True,
115
- timeout=max(0.0, timeout),
116
- )
117
- if marker.exists() or pending.exists() or recovery.exists():
118
- conn.close()
119
- conn = None
120
- yield None
121
- return
122
- yield conn
123
- finally:
124
- if conn is not None:
125
- conn.close()
126
- if lock_fh is not None:
127
- try:
128
- fcntl.flock(lock_fh, fcntl.LOCK_UN)
129
- except OSError:
130
- pass
131
- lock_fh.close()
88
+ """Bounded read-only transcript handle participating in #415 recovery.
89
+
90
+ #1000 §5.10: the admission now lives in
91
+ ``_cctally_cache.conversation_ro_guarded``, shared with `db status` and
92
+ the `explain` reader. Doctor keeps this name for its call sites (and the
93
+ tests that patch it), and reaches the sibling call-time, as its other
94
+ sibling imports do (F1), because doctor loads before `_cctally_cache`.
95
+ """
96
+ import _cctally_cache
97
+
98
+ return _cctally_cache.conversation_ro_guarded(timeout=timeout)
132
99
 
133
100
 
134
101
  def _journal_heal_incident(kind: str, name: str, now_utc: dt.datetime) -> dict:
@@ -646,18 +613,16 @@ def _scan_cause(exc: BaseException) -> str:
646
613
 
647
614
 
648
615
  def _durable_inventory_marker(conn) -> bool:
649
- """Whether the validated index holds an unresolved spool marker left by
650
- an incomplete inventory (spec revision 5, section 8). Read inside the
651
- caller's validated snapshot; a missing quarantine table is no marker."""
616
+ """Whether the validated index shows the legacy spool inventory episode
617
+ open (#948 spec 7.3: the `journal.legacy_writer` addendum says detection
618
+ is paused only then). Read inside the caller's validated snapshot; a
619
+ missing quarantine table is no open episode."""
652
620
  import _cctally_quarantine
653
- import _lib_account_protocol
654
621
  try:
655
622
  state = _cctally_quarantine.load_state(conn)
656
623
  except Exception: # noqa: BLE001 — no readable marker claims no pause
657
624
  return False
658
- return any(marker.get("reason")
659
- == _lib_account_protocol.INVENTORY_INCOMPLETE_REASON
660
- for marker in state.spool_markers)
625
+ return state.open_episode() is not None
661
626
 
662
627
 
663
628
  def _legacy_spool_inventory_probe(paused: bool):
@@ -687,32 +652,26 @@ def _legacy_spool_inventory_probe(paused: bool):
687
652
  return None
688
653
 
689
654
 
690
- def _statusline_authority_commits() -> dict:
691
- """Each account partition's reconciled `authorityCommit` (#825/#832 spec
692
- 5.6 item 7, fix-round-1 Q6): control recovery is complete only when the
693
- account's statusline control transition records the commit of the
694
- observation witnessing its control certificate. Read-only; a missing or
695
- unreadable transition contributes nothing, so its account stays paused."""
696
- import _cctally_statusline
697
-
698
- now_epoch = int(dt.datetime.now(dt.timezone.utc).timestamp())
699
- commits = {}
700
- for account_key in _cctally_statusline._account_keys_on_disk():
701
- try:
702
- transition = _cctally_statusline._read_transition(
703
- account_key, now_epoch=now_epoch)
704
- except Exception: # noqa: BLE001 — unreadable state stays paused
705
- continue
706
- quarantine = None if transition is None else transition.quarantine
707
- commit = None if quarantine is None else quarantine.get(
708
- "authorityCommit")
709
- if commit is not None:
710
- commits[account_key] = commit
711
- return commits
655
+ def _quarantine_inventory_path() -> "str | None":
656
+ """The file a limit waiting for `inventory` names (#948 spec 7.3): the
657
+ path doctor's own legacy spool inventory fails on, else the first legacy
658
+ spool location (the inventory now completes and a writer has yet to
659
+ record that). Read-only; None when neither is known."""
660
+ import _cctally_journal
661
+ probe = _legacy_spool_inventory_probe(True)
662
+ if probe and probe.get("path"):
663
+ return str(probe["path"])
664
+ try:
665
+ for path in _cctally_journal._legacy_spool_paths():
666
+ return str(path)
667
+ except Exception: # noqa: BLE001 — a diagnostic read never crashes doctor
668
+ return None
669
+ return None
712
670
 
713
671
 
714
672
  def _gather_outcome_quarantine() -> dict:
715
- """The `accounting.outcome_quarantine` evidence (#825/#832 spec 8, 5.6).
673
+ """The `accounting.outcome_quarantine` evidence (#948 spec 7.1): the
674
+ per-account, per-limit state of the journal-derived recovery barrier.
716
675
 
717
676
  Read from ONE snapshot proven current (epoch and protocol); an absent,
718
677
  unreadable or unvalidated index is unavailable, never "no quarantine".
@@ -724,12 +683,10 @@ def _gather_outcome_quarantine() -> dict:
724
683
  try:
725
684
  if not _cctally_core.DB_PATH.exists():
726
685
  return out
727
- commits = _statusline_authority_commits()
728
686
  pc = _stats_ro_guarded()
729
687
  try:
730
688
  with _cctally_store.current_stats_snapshot(pc):
731
- report = _cctally_quarantine.report(
732
- pc, authority_commits=commits)
689
+ report = _cctally_quarantine.report(pc)
733
690
  labels = {}
734
691
  for row in pc.execute(
735
692
  "SELECT account_key, label, email FROM accounts"):
@@ -743,10 +700,13 @@ def _gather_outcome_quarantine() -> dict:
743
700
  accounts.append({**row, "accountLabel": labels.get(
744
701
  row["account"], row["account"])})
745
702
  out = {"available": True, "triggered": report["triggered"],
703
+ "generation": report.get("generation"),
704
+ "cause": report.get("cause"),
705
+ "inventoryEpisode": report.get("inventoryEpisode"),
746
706
  "accounts": accounts}
747
- # Spec revision 3: the unresolved spool markers, only while one exists.
748
- if report.get("unresolvedSpoolMarkers"):
749
- out["unresolvedSpoolMarkers"] = report["unresolvedSpoolMarkers"]
707
+ if any(doc.get("waitingFor") == "inventory"
708
+ for row in accounts for doc in (row.get("axes") or {}).values()):
709
+ out["inventoryPath"] = _quarantine_inventory_path()
750
710
  return out
751
711
 
752
712
 
@@ -1165,7 +1125,148 @@ def _journal_protocol_violation_rows(conn: sqlite3.Connection) -> list:
1165
1125
  return [json.loads(str(row[3])) for row in rows]
1166
1126
 
1167
1127
 
1168
- def _gather_accounts_state(now_utc: "dt.datetime") -> dict:
1128
+ _CLAUDE_COST_EXPORT_MIGRATION = "050_preserve_claude_cost_attribution"
1129
+
1130
+
1131
+ def _claude_cost_export_state(conn, doc) -> str:
1132
+ """Migration 050's export state (#944 spec 5.9): ``complete`` once
1133
+ applied, ``skipped`` when skipped, else ``stalled`` when its last attempt
1134
+ failed after its last completed chunk (or no chunk completed), else
1135
+ ``running``."""
1136
+ if conn.execute("SELECT 1 FROM schema_migrations WHERE name=?",
1137
+ (_CLAUDE_COST_EXPORT_MIGRATION,)).fetchone():
1138
+ return "complete"
1139
+ try:
1140
+ if conn.execute(
1141
+ "SELECT 1 FROM schema_migrations_skipped WHERE name=?",
1142
+ (_CLAUDE_COST_EXPORT_MIGRATION,)).fetchone():
1143
+ return "skipped"
1144
+ except sqlite3.OperationalError:
1145
+ pass
1146
+ doc = doc or {}
1147
+ error_at, chunk_at = doc.get("lastErrorAt"), doc.get("lastChunkAt")
1148
+ if error_at and (not chunk_at or str(error_at) > str(chunk_at)):
1149
+ return "stalled"
1150
+ return "running"
1151
+
1152
+
1153
+ def _gather_claude_cost_attribution(
1154
+ now_utc: "dt.datetime", *, cache_probe_allowed: bool = True) -> dict:
1155
+ """Read-only counts for ``accounts.claude_cost_attribution`` (#944 spec
1156
+ 5.9). The population is the ``timeline_agreement`` / ``fallback``
1157
+ decisions decided in the preceding 7 UTC days that a retained accounting
1158
+ row links to: a keyed decision through ``idx_entries_dedup`` with link
1159
+ equality, a physical or legacy one exactly on the link through
1160
+ ``idx_entries_attr_nonkeyed``. A decision no row links to (a replaced
1161
+ physical record, an orphan) is not counted. ``preservedRows`` counts the
1162
+ ``preserved_history`` decisions decided in the window without probing
1163
+ their links (#944 F4): for a week after an upgrade that is every
1164
+ existing row (~700K measured), and probing each one's link cost ~2.8 s of
1165
+ SQL on every doctor run, while every export links the row it records.
1166
+ Never raises; an absent or unreadable cache.db yields the empty shape."""
1167
+ import _lib_claude_attribution as K
1168
+ end_us = K.us_from_iso(
1169
+ now_utc.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"))
1170
+ start_us = end_us - 7 * 86_400 * 1_000_000
1171
+ out = {
1172
+ "windowStart": K.iso_from_us(start_us),
1173
+ "windowEnd": K.iso_from_us(end_us),
1174
+ "population": "deduplicatedClaudeCostRows",
1175
+ "totalRows": 0, "fallbackRows": 0, "fallbackRealAccountRows": 0,
1176
+ "fallbackUnattributedRows": 0, "byReason": {},
1177
+ "firstFallbackAt": None, "lastFallbackAt": None,
1178
+ "preservedRows": 0, "unlinkedRows": 0,
1179
+ "preservation": {"state": None, "saved": None, "total": None,
1180
+ "lastError": None, "lastErrorAt": None},
1181
+ "decisionConflicts": 0, "alignedRows": 0, "lastDeferral": None,
1182
+ }
1183
+ path = _cctally_core.CACHE_DB_PATH
1184
+ # A live cache repair owner bars every cache probe (#344).
1185
+ if not cache_probe_allowed or not path.exists():
1186
+ return out
1187
+ try:
1188
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=0.0)
1189
+ except sqlite3.Error:
1190
+ return out
1191
+ try:
1192
+ import _cctally_claude_attribution as G
1193
+ if not G.schema_present(conn):
1194
+ return out
1195
+ # #944 F10: compare in decided_at's own format. ``decided_at_utc``
1196
+ # is written by ``now_utc_iso`` (seconds precision, ``Z``), and a
1197
+ # microsecond bound sorts after every decision of its own second
1198
+ # ('.' < 'Z'), dropping a decision made in the second that ends the
1199
+ # window. The bounds are the window's instants at that precision.
1200
+ window = tuple(
1201
+ _cctally_core.now_utc_iso(
1202
+ dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc)
1203
+ + dt.timedelta(microseconds=us))
1204
+ for us in (start_us, end_us))
1205
+ # The population is the evidence and fallback decisions only: the
1206
+ # preserved ones are excluded here, before any link is probed.
1207
+ population = (K.BASIS_AGREEMENT, K.BASIS_FALLBACK)
1208
+ rows = conn.execute(
1209
+ "SELECT d.basis, d.account_key, d.reason, d.decided_at_utc"
1210
+ " FROM claude_cost_decisions d"
1211
+ " WHERE d.decided_at_utc >= ? AND d.decided_at_utc <= ?"
1212
+ " AND d.basis IN (?, ?)"
1213
+ " AND d.form = ? AND EXISTS (SELECT 1 FROM session_entries e"
1214
+ " WHERE e.msg_id = d.msg_id AND e.req_id = d.req_id"
1215
+ " AND e.attribution_decision_key = d.decision_key)"
1216
+ " UNION ALL"
1217
+ " SELECT d.basis, d.account_key, d.reason, d.decided_at_utc"
1218
+ " FROM claude_cost_decisions d"
1219
+ " WHERE d.decided_at_utc >= ? AND d.decided_at_utc <= ?"
1220
+ " AND d.basis IN (?, ?)"
1221
+ " AND d.form <> ? AND EXISTS (SELECT 1 FROM session_entries e"
1222
+ " WHERE e.attribution_decision_key = d.decision_key"
1223
+ " AND (e.msg_id IS NULL OR e.req_id IS NULL))",
1224
+ (*window, *population, K.FORM_KEYED,
1225
+ *window, *population, K.FORM_KEYED)).fetchall()
1226
+ out["preservedRows"] = conn.execute(
1227
+ "SELECT COUNT(*) FROM claude_cost_decisions"
1228
+ " WHERE decided_at_utc >= ? AND decided_at_utc <= ?"
1229
+ " AND basis = ?", (*window, K.BASIS_PRESERVED)).fetchone()[0]
1230
+ fallback_at = []
1231
+ for basis, account, reason, decided_at in rows:
1232
+ out["totalRows"] += 1
1233
+ if basis != K.BASIS_FALLBACK:
1234
+ continue
1235
+ out["fallbackRows"] += 1
1236
+ if account and account != K.UNATTRIBUTED:
1237
+ out["fallbackRealAccountRows"] += 1
1238
+ else:
1239
+ out["fallbackUnattributedRows"] += 1
1240
+ key = reason or "unknown"
1241
+ out["byReason"][key] = out["byReason"].get(key, 0) + 1
1242
+ fallback_at.append(decided_at)
1243
+ if fallback_at:
1244
+ out["firstFallbackAt"] = min(fallback_at)
1245
+ out["lastFallbackAt"] = max(fallback_at)
1246
+ out["byReason"] = dict(sorted(out["byReason"].items()))
1247
+ out["unlinkedRows"] = conn.execute(
1248
+ "SELECT COUNT(*) FROM session_entries"
1249
+ " WHERE attribution_decision_key IS NULL").fetchone()[0]
1250
+ doc = G.read_preservation(conn)
1251
+ out["preservation"] = {
1252
+ "state": _claude_cost_export_state(conn, doc),
1253
+ "saved": (doc or {}).get("saved"),
1254
+ "total": (doc or {}).get("total"),
1255
+ "lastError": (doc or {}).get("lastError"),
1256
+ "lastErrorAt": (doc or {}).get("lastErrorAt"),
1257
+ }
1258
+ out["decisionConflicts"] = G.decision_conflicts(conn)
1259
+ out["alignedRows"] = G.aligned_rows(conn)
1260
+ out["lastDeferral"] = G.last_deferral(conn)
1261
+ except sqlite3.Error:
1262
+ return out
1263
+ finally:
1264
+ conn.close()
1265
+ return out
1266
+
1267
+
1268
+ def _gather_accounts_state(now_utc: "dt.datetime", *,
1269
+ cache_probe_allowed: bool = True) -> dict:
1169
1270
  """Best-effort account-attribution state for the doctor `accounts.*` legs
1170
1271
  (#341). Never raises: identity + registry reads are read-only and each guard
1171
1272
  swallows OperationalError so a legacy / pre-account stats.db degrades to the
@@ -1182,6 +1283,10 @@ def _gather_accounts_state(now_utc: "dt.datetime") -> dict:
1182
1283
  state["claude_email"] = ident.get("email")
1183
1284
  except Exception:
1184
1285
  pass
1286
+ # #944 spec 5.9: gathered from cache.db, so before the stats.db early
1287
+ # return; the check is always present.
1288
+ state["claude_cost_attribution"] = _gather_claude_cost_attribution(
1289
+ now_utc, cache_probe_allowed=cache_probe_allowed)
1185
1290
  if not _cctally_core.DB_PATH.exists():
1186
1291
  return state
1187
1292
  try:
@@ -1465,13 +1570,20 @@ def _gather_quota_rate_change(c, rejection=None) -> "dict | None":
1465
1570
  """
1466
1571
  if rejection == "calibration-absent":
1467
1572
  return dict(_QUOTA_NOT_ASSESSED)
1468
- mrc = c._load_sibling("_lib_meter_rate_change")
1469
1573
  glue = c._load_sibling("_cctally_quota_model")
1574
+ calibration = c._load_sibling("_cctally_quota_calibration")
1470
1575
  loaded = glue.read_stored_state_readonly()
1471
1576
  if loaded is None:
1472
1577
  return None
1473
1578
  regimes = glue.stored_regimes(loaded, None)
1474
- active = mrc.active_rate_change(regimes)
1579
+ # #1015 spec 5.6: over the CLEAN regimes, with a superseded transition
1580
+ # suppressed. Correction facts that cannot be read (the fail-fast reader
1581
+ # never recovers or waits) leave the change unverified: not assessed,
1582
+ # never asserted.
1583
+ state, active = calibration.assess_active_rate_change(
1584
+ None, regimes=regimes)
1585
+ if state == calibration.MARKER_UNAVAILABLE:
1586
+ return dict(_QUOTA_NOT_ASSESSED)
1475
1587
  if active is None:
1476
1588
  return dict(_QUOTA_NO_CHANGE)
1477
1589
  return dict(active, assessed=True)
@@ -1659,8 +1771,10 @@ def _gather_codex_container_state(c, *, now_utc, cache_probe_allowed):
1659
1771
 
1660
1772
  try:
1661
1773
  decoder = rc.decoder_capability()
1774
+ # #970 spec 5.9: the census reads the same locations discovery does,
1775
+ # each native home's flat archive directory included.
1662
1776
  selected = [item for item in cache._iter_codex_logical_containers(
1663
- c._codex_session_roots()) if item.kind == rc.ZSTD]
1777
+ cache._codex_home_locations()) if item.kind == rc.ZSTD]
1664
1778
  census = dict(compressedOnly=len(selected), retainedComplete=0,
1665
1779
  retainedIncomplete=0, neverIngested=0, unverified=0)
1666
1780
  status = None
@@ -2354,13 +2468,23 @@ def _doctor_gather_state_impl(
2354
2468
  # hardcoded ~/.codex/sessions), matching the multi-root ingestion path
2355
2469
  # from #108. _codex_session_roots() already applies the sessions/-subdir
2356
2470
  # rule and filters to existing dirs, so a bare glob per root suffices.
2471
+ # #970 spec 5.9: a native home's archived_sessions/ directory (globbed
2472
+ # flat, as discovery does) also holds Codex history.
2357
2473
  codex_jsonl_present = False
2358
2474
  try:
2359
- for codex_dir in c._codex_session_roots():
2475
+ import _cctally_cache as _cache_for_codex_files
2476
+ for location in _cache_for_codex_files._codex_home_locations():
2477
+ codex_dir = location.walk_root
2360
2478
  if (next(codex_dir.glob("**/*.jsonl"), None) is not None
2361
2479
  or next(codex_dir.glob("**/*.jsonl.zst"), None) is not None):
2362
2480
  codex_jsonl_present = True
2363
2481
  break
2482
+ archive = location.archive_root
2483
+ if archive is not None and (
2484
+ next(archive.glob("*.jsonl"), None) is not None
2485
+ or next(archive.glob("*.jsonl.zst"), None) is not None):
2486
+ codex_jsonl_present = True
2487
+ break
2364
2488
  except Exception:
2365
2489
  pass
2366
2490
 
@@ -3219,7 +3343,8 @@ def _doctor_gather_state_impl(
3219
3343
  cctally_version = (
3220
3344
  cctally_version_tuple[0] if cctally_version_tuple else "unknown"
3221
3345
  )
3222
- accounts_state = _gather_accounts_state(now_utc)
3346
+ accounts_state = _gather_accounts_state(
3347
+ now_utc, cache_probe_allowed=_cache_probe_allowed)
3223
3348
  accounts_state["codex_null_reset_anchors"] = codex_null_reset_anchors
3224
3349
  accounts_state["codex_window_attribution"] = (
3225
3350
  _gather_codex_window_attribution_state()