cctally 1.113.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -27,10 +27,13 @@ from pathlib import Path
27
27
  import _cctally_core
28
28
  import _cctally_journal as _journal
29
29
  import _cctally_record as _record
30
+ import _lib_account_protocol
30
31
  import _lib_journal
31
32
  import _lib_journal_router
32
33
  import _lib_json_envelope
33
34
  import _lib_rederive
35
+ import _cctally_store
36
+ from _cctally_store import StatsIndexNotCurrent
34
37
 
35
38
 
36
39
  class RederiveBusy(RuntimeError):
@@ -48,6 +51,14 @@ class RederiveApplyError(RuntimeError):
48
51
  self.cause = cause
49
52
 
50
53
 
54
+ class RederiveObligationNotDurable(RederiveApplyError):
55
+ """Fix round K12 (K11R-2): the corrected index IS published, but its
56
+ residual-derivation obligation could not be made durable. Raised only
57
+ after the apply's lock context has exited normally, so the locks are
58
+ released and the holder wake is posted; the command reports it with
59
+ message N2 and exit 3, never as a failed apply."""
60
+
61
+
51
62
  @dataclass(frozen=True)
52
63
  class RederivePreview:
53
64
  plan: _lib_rederive.RederivePlan
@@ -90,6 +101,13 @@ def _fingerprint(value) -> str:
90
101
  return "sha256:" + hashlib.sha256(_canonical_bytes(value)).hexdigest()
91
102
 
92
103
 
104
+ #: #874: the only tables the cache contract names. Inspecting any other table
105
+ #: is not the planner's business, and on a virtual table whose module this
106
+ #: SQLite build lacks (a legacy FTS5 index on a build without FTS5) even
107
+ #: `PRAGMA table_info` raises before the contract could type the gap.
108
+ _CONTRACT_TABLES = ("session_entries", "session_files", "codex_session_entries")
109
+
110
+
93
111
  def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
94
112
  names = {
95
113
  row[0] for row in cache_conn.execute(
@@ -99,12 +117,18 @@ def _cache_contract(cache_conn: sqlite3.Connection) -> dict[str, set[str]]:
99
117
  name: {
100
118
  row[1] for row in cache_conn.execute(f"PRAGMA table_info({name})")
101
119
  }
102
- for name in names
120
+ for name in _CONTRACT_TABLES
121
+ if name in names
103
122
  }
104
123
 
105
124
 
106
125
  def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -> str:
107
- """Hash every cost-bearing Claude cache row and its project metadata."""
126
+ """Hash every cost-bearing Claude cache row and its project metadata.
127
+
128
+ The caller must have validated the cache contract first, including
129
+ ``validate_codex_cache_contract`` when ``include_codex`` is set (#874):
130
+ a missing column here is a raw sqlite error, not a typed data gap.
131
+ """
108
132
  entry_columns = (
109
133
  "source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
110
134
  "output_tokens", "cache_create_tokens", "cache_read_tokens",
@@ -123,10 +147,6 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
123
147
  ]
124
148
  basis = {"sessionEntries": entries, "sessionFiles": files}
125
149
  if include_codex:
126
- if "codex_session_entries" not in _cache_contract(cache_conn):
127
- raise _lib_rederive.RederiveDataGap(
128
- "missing cache.db table codex_session_entries"
129
- )
130
150
  basis["codexEntries"] = [
131
151
  list(row) for row in cache_conn.execute(
132
152
  "SELECT source_path,line_offset,timestamp_utc,session_id,model,"
@@ -539,12 +559,19 @@ def _rederivable_raw_records(records: list[dict]) -> list[dict]:
539
559
  kind = (record.get("payload") or {}).get("kind")
540
560
  if kind in {"weekly_credit_floor", "account_observe",
541
561
  "account_label", "accounts_cutover", "sync_week",
542
- "claude_weekly_observation_decision"}:
562
+ *_REVIEWED_WEEKLY_KINDS}:
543
563
  out.append(record)
544
564
  return out
545
565
 
546
566
 
547
- _REVIEWED_WEEKLY_KIND = "claude_weekly_observation_decision"
567
+ #: #825/#832 spec 5.1: the previous release validates a reviewed weekly
568
+ #: decision by exact equality with its own `make_op`, which a stamped record can
569
+ #: never satisfy, so the current binary emits the versioned kind (an unknown op
570
+ #: to the previous release) and reads both.
571
+ _REVIEWED_WEEKLY_KIND = _lib_account_protocol.WEEKLY_OBSERVATION_DECISION_V2_KIND
572
+ _REVIEWED_WEEKLY_LEGACY_KIND = "claude_weekly_observation_decision"
573
+ _REVIEWED_WEEKLY_KINDS = frozenset((
574
+ _REVIEWED_WEEKLY_LEGACY_KIND, _REVIEWED_WEEKLY_KIND))
548
575
  _REVIEWED_MANIFEST_KEYS_V1 = frozenset({
549
576
  "schemaVersion", "journalHighWater", "journalPrefixHash",
550
577
  "reviewedAt", "reason", "decisions",
@@ -821,7 +848,8 @@ def _reviewed_weekly_state(records):
821
848
  ops = []
822
849
  for record in records:
823
850
  if (record.get("t") != "op" or
824
- (record.get("payload") or {}).get("kind") != _REVIEWED_WEEKLY_KIND):
851
+ (record.get("payload") or {}).get("kind")
852
+ not in _REVIEWED_WEEKLY_KINDS):
825
853
  continue
826
854
  payload = record["payload"]
827
855
  version = payload.get("schema_version")
@@ -901,11 +929,23 @@ def _reviewed_weekly_state(records):
901
929
  ), ops)
902
930
 
903
931
 
932
+ class _PinnedPrefixDigest:
933
+ """The one prefix digest a deferred reviewed-decision validation needs,
934
+ captured inline by the reading pass."""
935
+
936
+ def __init__(self, high_water, digest):
937
+ self._high_water = high_water
938
+ self._digest = digest
939
+
940
+ def digest_at(self, high_water):
941
+ return self._digest if high_water == self._high_water else None
942
+
943
+
904
944
  def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
905
945
  """Verify an operator decision against the bytes before its journal line."""
906
946
  payload = record.get("payload")
907
947
  if (not isinstance(payload, dict)
908
- or payload.get("kind") != _REVIEWED_WEEKLY_KIND):
948
+ or payload.get("kind") not in _REVIEWED_WEEKLY_KINDS):
909
949
  return
910
950
  bound = payload.get("journal_high_water")
911
951
  version = payload.get("schema_version")
@@ -924,6 +964,7 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
924
964
  or not payload["reason"].strip()
925
965
  or record != _lib_journal.make_op(
926
966
  at=record.get("at"), src=record.get("src"), payload=payload,
967
+ writer_generation=record.get("wpg"),
927
968
  )):
928
969
  raise _lib_rederive.RederiveConflict(
929
970
  "malformed retained reviewed weekly decision op"
@@ -1006,6 +1047,47 @@ def _derive_desired_events_into(
1006
1047
  + ", ".join(sorted(unknown_ids))
1007
1048
  )
1008
1049
  conn = _cctally_core.open_db(_target_path=str(scratch_path))
1050
+ # #825/#832 spec 5.6 item 8: the scratch replay re-evaluates the same
1051
+ # triggers and certificates over the corrected history in journal order,
1052
+ # so it can never plan an outcome ordinary processing withholds.
1053
+ import _cctally_quarantine
1054
+ fold = _cctally_quarantine.QuarantineFold.empty(held_ids=held_ids)
1055
+ try:
1056
+ return replay_scratch_records(
1057
+ conn, records, cache_conn, fold=fold, held_ids=held_ids,
1058
+ block_sources=block_sources)
1059
+ finally:
1060
+ conn.close()
1061
+
1062
+
1063
+ def replay_scratch_records(
1064
+ conn, records, cache_conn, *, fold, held_ids=frozenset(),
1065
+ block_sources: "dict | None" = None, coordinates=None,
1066
+ skip_indexes=frozenset(), on_input=None, read_adapters=None,
1067
+ retention=None,
1068
+ ) -> list[dict]:
1069
+ """THE scratch derivation: run every rederivable raw record of
1070
+ ``records`` through the live pipeline hooks and harvest, in journal
1071
+ order, on the private scratch connection ``conn``, capturing every derived
1072
+ candidate instead of journaling it (`db rederive` and the #825/#832
1073
+ derivation-completion step share it, so there is one detector
1074
+ implementation).
1075
+
1076
+ ``fold`` is the outcome-quarantine fold, advanced over every slot —
1077
+ excluded placeholders included — so each input's gate is evaluated at its
1078
+ original position. ``coordinates`` (parallel to ``records``, each
1079
+ `(segment, offset)` or None) feeds the fold the physical positions a live
1080
+ ingest gives it. An index in ``skip_indexes`` still advances the fold but
1081
+ its hooks do not run. ``on_input(index, events)`` receives each raw input's
1082
+ own derived candidates. ``read_adapters`` replaces the stable cost/config
1083
+ read adapters over ``cache_conn`` (a context manager), for a caller with no
1084
+ cache to read. ``retention`` (derivation completion only, a
1085
+ `_cctally_journal.ScratchRetention`) keeps the scratch projection on the
1086
+ retained effective decisions: the emitters consult it, and every
1087
+ ``evt``/``correction`` line of ``records`` is offered to it at its own
1088
+ position. Returns every candidate in derivation order."""
1089
+ raw_ids = {id(record) for record in _rederivable_raw_records(
1090
+ [record for record in records if isinstance(record, dict)])}
1009
1091
  events: list[dict] = []
1010
1092
  reviewed_weekly_basis_by_account = {}
1011
1093
  hooks = (
@@ -1014,32 +1096,70 @@ def _derive_desired_events_into(
1014
1096
  _record._pipeline_record_credit,
1015
1097
  _record._pipeline_sync_week,
1016
1098
  )
1017
- try:
1018
- with _scratch_read_adapters(cache_conn):
1019
- for record in raw_records:
1020
- ctx = _journal.IngestContext(
1021
- conn=conn,
1022
- batch=[record],
1023
- config={},
1024
- event_sink=events,
1025
- projection_writes=False,
1026
- held_weekly_observation_ids=held_ids,
1027
- reviewed_weekly_basis_by_account=(
1028
- reviewed_weekly_basis_by_account),
1029
- block_source_evidence=block_sources,
1030
- )
1099
+ import _lib_account_protocol as _protocol
1100
+ adapters = (_scratch_read_adapters(cache_conn) if read_adapters is None
1101
+ else read_adapters)
1102
+ with adapters:
1103
+ for index, record in enumerate(records):
1104
+ if not isinstance(record, dict):
1105
+ continue
1106
+ if record.get("t") == _protocol.EXCLUDED_PLACEHOLDER_TYPE:
1107
+ carried = record.get("quarantine") or {}
1108
+ fold.on_excluded(
1109
+ carried.get("record") or {},
1110
+ segment=carried.get("segment"),
1111
+ offset=carried.get("offset"))
1112
+ continue
1113
+ if (retention is not None
1114
+ and record.get("t") in ("evt", "correction")):
1031
1115
  conn.execute("BEGIN IMMEDIATE")
1032
1116
  try:
1033
- for hook in hooks:
1034
- hook(ctx, record)
1035
- _journal._harvest(ctx)
1117
+ retention.on_journaled(conn, record)
1036
1118
  conn.commit()
1037
1119
  except BaseException:
1038
1120
  conn.rollback()
1039
1121
  raise
1040
- return events
1041
- finally:
1042
- conn.close()
1122
+ coordinate = (None if coordinates is None
1123
+ else coordinates[index])
1124
+ if coordinate is None:
1125
+ gate = fold.on_record(record)
1126
+ else:
1127
+ gate = fold.on_record(
1128
+ record, segment=coordinate[0], offset=coordinate[1])
1129
+ if id(record) not in raw_ids or index in skip_indexes:
1130
+ continue
1131
+ before = len(events)
1132
+ ctx = _journal.IngestContext(
1133
+ conn=conn,
1134
+ batch=[record],
1135
+ config={},
1136
+ event_sink=events,
1137
+ projection_writes=False,
1138
+ held_weekly_observation_ids=held_ids,
1139
+ reviewed_weekly_basis_by_account=(
1140
+ reviewed_weekly_basis_by_account),
1141
+ block_source_evidence=block_sources,
1142
+ quarantine=fold,
1143
+ outcome_gate=gate,
1144
+ scratch_retention=retention,
1145
+ )
1146
+ if retention is not None:
1147
+ retention.current_input = index
1148
+ conn.execute("BEGIN IMMEDIATE")
1149
+ try:
1150
+ for hook in hooks:
1151
+ hook(ctx, record)
1152
+ _journal._harvest(ctx)
1153
+ conn.commit()
1154
+ except BaseException:
1155
+ conn.rollback()
1156
+ raise
1157
+ finally:
1158
+ if retention is not None:
1159
+ retention.current_input = None
1160
+ if on_input is not None:
1161
+ on_input(index, events[before:])
1162
+ return events
1043
1163
 
1044
1164
 
1045
1165
  def _planner_op_kinds() -> "set[str]":
@@ -1088,15 +1208,20 @@ def plan_claude_usage(
1088
1208
  + ",".join(report.unclassified_evt_kinds)
1089
1209
  + " op=" + ",".join(report.unclassified_op_kinds)
1090
1210
  )
1091
- _lib_rederive.validate_claude_cache_contract(_cache_contract(cache_conn))
1092
- raw_records = _rederivable_raw_records(records)
1093
- _validate_cache_rows(cache_conn, raw_records)
1211
+ tables = _cache_contract(cache_conn)
1212
+ _lib_rederive.validate_claude_cache_contract(tables)
1094
1213
  codex_pricing_records = any(
1095
1214
  (record.get("payload") or {}).get("vendor") == "codex"
1096
1215
  and (record.get("payload") or {}).get("kind") == "budget"
1097
1216
  and (record.get("payload") or {}).get("_pricing")
1098
1217
  for record in records
1099
1218
  )
1219
+ if codex_pricing_records:
1220
+ # #874: the Codex fingerprint and re-pricing read the Codex cache only
1221
+ # here, so its contract is required only here, and before any reader.
1222
+ _lib_rederive.validate_codex_cache_contract(tables)
1223
+ raw_records = _rederivable_raw_records(records)
1224
+ _validate_cache_rows(cache_conn, raw_records)
1100
1225
  cache_fingerprint = _cache_fingerprint(
1101
1226
  cache_conn, include_codex=codex_pricing_records,
1102
1227
  )
@@ -1172,6 +1297,7 @@ def plan_claude_usage(
1172
1297
  )
1173
1298
  return _lib_rederive.build_claude_usage_plan(
1174
1299
  selection=selection,
1300
+ physical_revisions=_excluded_physical_revisions(records),
1175
1301
  desired_events=desired,
1176
1302
  journal_high_water=journal_high_water,
1177
1303
  cache_fingerprint=cache_fingerprint,
@@ -1188,6 +1314,26 @@ def plan_claude_usage(
1188
1314
  )
1189
1315
 
1190
1316
 
1317
+ def _excluded_physical_revisions(records) -> dict:
1318
+ """The highest revision per id among the excluded events and correction
1319
+ actions the placeholders of ``records`` carry (#825/#832 fix round A,
1320
+ Codex fix-round-1 Q2)."""
1321
+ import _lib_account_protocol as _protocol
1322
+ out: dict = {}
1323
+ for record in records:
1324
+ if (not isinstance(record, dict)
1325
+ or record.get("t") != _protocol.EXCLUDED_PLACEHOLDER_TYPE):
1326
+ continue
1327
+ carried = (record.get("quarantine") or {}).get("record") or {}
1328
+ event_id = carried.get("id")
1329
+ rev = carried.get("rev", 0)
1330
+ if (carried.get("t") in ("evt", "correction")
1331
+ and isinstance(event_id, str) and type(rev) is int
1332
+ and rev >= 0):
1333
+ out[event_id] = max(rev, out.get(event_id, rev))
1334
+ return out
1335
+
1336
+
1191
1337
  def owned_conflicted_event_ids(selection) -> frozenset:
1192
1338
  """The quarantined same-revision groups (#374) this family owns.
1193
1339
 
@@ -1240,9 +1386,17 @@ def read_rederive_journal_prefix(
1240
1386
  records: list[dict] = []
1241
1387
  record_ends: list[tuple[str, int]] = []
1242
1388
  evidence: list = []
1389
+ evidence_owners: list = []
1243
1390
  malformed = 0
1244
1391
  prior_high_water = None
1245
1392
  hasher = _lib_journal_router.PrefixHashAccumulator()
1393
+ # #825/#832 (spec 5.3): admission before interpretation, resolved within
1394
+ # this one pass. A reviewed decision's validation needs the prefix digest
1395
+ # at its own position, so the digest is taken inline and the validation
1396
+ # runs after the pass, for admitted decisions only.
1397
+ pass_boundary = _journal._PassBoundary()
1398
+ coordinates: list = []
1399
+ pending_reviewed: list = []
1246
1400
  for segment, offset, raw in _journal._iter_range_with_segments(
1247
1401
  None,
1248
1402
  high_water,
@@ -1256,19 +1410,57 @@ def read_rederive_journal_prefix(
1256
1410
  malformed += 1
1257
1411
  prior_high_water = record_end
1258
1412
  continue
1413
+ pass_boundary.observe(record)
1259
1414
  if record.get("t") == "op":
1260
- _validate_retained_reviewed_op(
1261
- record, prior_high_water, hasher,
1262
- )
1415
+ if (record.get("payload") or {}).get("kind") in _REVIEWED_WEEKLY_KINDS:
1416
+ pending_reviewed.append((
1417
+ len(records), record, prior_high_water,
1418
+ None if prior_high_water is None
1419
+ else hasher.digest_at(prior_high_water),
1420
+ ))
1263
1421
  _journal._capture_protocol_prefix_evidence(
1264
1422
  record,
1265
1423
  prior_high_water,
1266
1424
  evidence,
1267
1425
  hasher=hasher,
1268
1426
  )
1427
+ if len(evidence) > len(evidence_owners):
1428
+ evidence_owners.append(len(records))
1269
1429
  records.append(record)
1270
1430
  record_ends.append(record_end)
1431
+ coordinates.append((segment, offset))
1271
1432
  prior_high_water = record_end
1433
+ # An inert placeholder keeps every position and sequence number; an
1434
+ # excluded correction, reviewed decision or Claude record is never planning
1435
+ # input (case F7).
1436
+ originals = list(records)
1437
+ placeholder = _lib_account_protocol.excluded_placeholder()
1438
+ _journal._apply_pass_admission(
1439
+ records, coordinates, pass_boundary.resolve(high_water),
1440
+ placeholder=placeholder)
1441
+ # #825/#832 fix round C (CD2-005): an excluded acknowledgement takes its
1442
+ # raw-prefix evidence with it, so selection reads it as absent.
1443
+ evidence = _journal._admitted_prefix_evidence(
1444
+ evidence, evidence_owners, records)
1445
+ # #825/#832 spec 5.6: each excluded slot keeps its original record and
1446
+ # coordinate for the outcome-quarantine fold of the scratch replay (a
1447
+ # trigger whose readings widen the every-account envelope). Nothing else
1448
+ # reads a placeholder's fields.
1449
+ for index, slot in enumerate(records):
1450
+ if slot is placeholder:
1451
+ records[index] = {
1452
+ **placeholder,
1453
+ "quarantine": {
1454
+ "record": originals[index],
1455
+ "segment": coordinates[index][0],
1456
+ "offset": coordinates[index][1],
1457
+ },
1458
+ }
1459
+ for index, record, prior, digest in pending_reviewed:
1460
+ if records[index] is record:
1461
+ _validate_retained_reviewed_op(
1462
+ record, prior, _PinnedPrefixDigest(prior, digest),
1463
+ )
1272
1464
  # Released before the raise below, so a malformed prefix does not pin the
1273
1465
  # accumulator's buffered segment — 410 MB on the maintainer's journal — on
1274
1466
  # the traceback while the exception unwinds. On the success path the frame
@@ -1441,7 +1633,7 @@ def _preview_from_snapshot(
1441
1633
  index for index, record in enumerate(records)
1442
1634
  if record.get("t") == "op"
1443
1635
  and (record.get("payload") or {}).get("kind")
1444
- == _REVIEWED_WEEKLY_KIND
1636
+ in _REVIEWED_WEEKLY_KINDS
1445
1637
  ]
1446
1638
  if decision_indexes:
1447
1639
  index = decision_indexes[-1]
@@ -1615,6 +1807,18 @@ def _rederive_locks(*, apply: bool, timeout: float):
1615
1807
  finally:
1616
1808
  _cctally_core.note_stats_maintenance_released()
1617
1809
  release_cache_writer_flocks(held)
1810
+ # #825/#832 spec 4.3 revision 2: a current holder posts a wake once its
1811
+ # locks are released (the fast path; the dependency waiter covers every
1812
+ # other exit) — after a rederive's live index publication or none.
1813
+ _post_statusline_wake("holder:rederive")
1814
+
1815
+
1816
+ def _post_statusline_wake(reason: str) -> None:
1817
+ try:
1818
+ import _cctally_statusline
1819
+ _cctally_statusline.post_wake(reason)
1820
+ except Exception:
1821
+ pass
1618
1822
 
1619
1823
 
1620
1824
  # --------------------------------------------------------------------------
@@ -1752,6 +1956,10 @@ def codex_attribution_apply_locks(*, timeout: float = _REDERIVE_LOCK_TIMEOUT_SEC
1752
1956
  finally:
1753
1957
  _cctally_core.note_stats_maintenance_released()
1754
1958
  owner.release_all()
1959
+ # #825/#832 spec 4.3 revision 2: the outer lock owner of a
1960
+ # `run_stats_ingest(locks_held=True)` cycle posts its wake once the whole
1961
+ # set is released.
1962
+ _post_statusline_wake("holder:attribution")
1755
1963
 
1756
1964
 
1757
1965
  def preview_db_rederive(
@@ -1775,14 +1983,16 @@ def _stats_has_batch(batch_id: str) -> bool:
1775
1983
  with _open_sqlite_snapshot(
1776
1984
  path,
1777
1985
  prefix="cctally-rederive-stats-",
1778
- ) as conn:
1986
+ ) as conn, _cctally_store.current_stats_snapshot(conn):
1987
+ # #825/#832 spec 6: convergence evidence only from a validated
1988
+ # current index; anything else fails closed (rebuild).
1779
1989
  row = conn.execute(
1780
1990
  "SELECT 1 FROM journal_effective_events "
1781
1991
  "WHERE batch_id = ? LIMIT 1",
1782
1992
  (batch_id,),
1783
1993
  ).fetchone()
1784
1994
  return row is not None
1785
- except (OSError, sqlite3.Error):
1995
+ except (OSError, sqlite3.Error, StatsIndexNotCurrent):
1786
1996
  return False
1787
1997
 
1788
1998
 
@@ -1819,7 +2029,8 @@ def _reviewed_op_needs_materialization(preview: RederivePreview) -> bool:
1819
2029
  last_index = None
1820
2030
  for index, record in enumerate(preview.records):
1821
2031
  if (record.get("t") == "op" and
1822
- (record.get("payload") or {}).get("kind") == _REVIEWED_WEEKLY_KIND):
2032
+ (record.get("payload") or {}).get("kind")
2033
+ in _REVIEWED_WEEKLY_KINDS):
1823
2034
  last_index = index
1824
2035
  if last_index is None:
1825
2036
  return False
@@ -1919,7 +2130,11 @@ def apply_db_rederive(
1919
2130
  reviewed_op: "dict | None" = None,
1920
2131
  expected_plan_hashes: "tuple[str, str] | None" = None,
1921
2132
  ) -> RederiveCommandResult:
1922
- with _rederive_locks(apply=True, timeout=lock_timeout):
2133
+ import _cctally_completion
2134
+
2135
+ lost: list = []
2136
+ with _rederive_locks(apply=True, timeout=lock_timeout), \
2137
+ _held_past_unlock(lost):
1923
2138
  appended_reviewed_op = False
1924
2139
  if reviewed_op is not None:
1925
2140
  durable_hashes = _reviewed_op_expected_hashes(
@@ -2035,11 +2250,16 @@ def apply_db_rederive(
2035
2250
  context=_journal.RebuildContext(trigger="rederive-apply"),
2036
2251
  high_water=batch_high_water,
2037
2252
  update_quota_cache=False,
2253
+ complete_derivation=True,
2038
2254
  before_swap=lambda: _call_crash_hook(
2039
2255
  "before-rebuild-swap"
2040
2256
  ),
2041
2257
  )
2042
2258
  _call_crash_hook("after-rebuild")
2259
+ except _cctally_completion.ResidualObligationNotDurable as exc:
2260
+ raise _obligation_lost(
2261
+ "stats rebuild", preview, preview.batch_id, exc
2262
+ ) from exc
2043
2263
  except Exception as exc:
2044
2264
  raise RederiveApplyError(
2045
2265
  "stats rebuild", preview, preview.batch_id, exc
@@ -2069,11 +2289,16 @@ def apply_db_rederive(
2069
2289
  ),
2070
2290
  high_water=preview.latest_completed_high_water,
2071
2291
  update_quota_cache=False,
2292
+ complete_derivation=True,
2072
2293
  before_swap=lambda: _call_crash_hook(
2073
2294
  "before-rebuild-swap"
2074
2295
  ),
2075
2296
  )
2076
2297
  _call_crash_hook("after-rebuild")
2298
+ except _cctally_completion.ResidualObligationNotDurable as exc:
2299
+ raise _obligation_lost(
2300
+ "stats recovery", preview, latest, exc
2301
+ ) from exc
2077
2302
  except Exception as exc:
2078
2303
  raise RederiveApplyError(
2079
2304
  "stats recovery", preview, latest, exc
@@ -2090,7 +2315,13 @@ def apply_db_rederive(
2090
2315
  context=_journal.RebuildContext(trigger="rederive-recovery"),
2091
2316
  high_water=preview.journal_high_water,
2092
2317
  update_quota_cache=False,
2318
+ complete_derivation=True,
2093
2319
  )
2320
+ except _cctally_completion.ResidualObligationNotDurable as exc:
2321
+ raise _obligation_lost(
2322
+ "reviewed decision materialization", report_preview,
2323
+ latest, exc,
2324
+ ) from exc
2094
2325
  except Exception as exc:
2095
2326
  raise RederiveApplyError(
2096
2327
  "reviewed decision materialization", report_preview,
@@ -2108,6 +2339,23 @@ def apply_db_rederive(
2108
2339
  batch_id=latest,
2109
2340
  rebuild=None,
2110
2341
  )
2342
+ raise lost[0]
2343
+
2344
+
2345
+ @contextlib.contextmanager
2346
+ def _held_past_unlock(lost: list):
2347
+ """Fix round K12 (K11R-2): hold a lost-obligation outcome until the
2348
+ enclosing lock context has exited normally (locks released, holder wake
2349
+ posted); the caller re-raises it from ``lost`` afterwards."""
2350
+ try:
2351
+ yield
2352
+ except RederiveObligationNotDurable as exc:
2353
+ lost.append(exc)
2354
+
2355
+
2356
+ def _obligation_lost(stage, preview, batch_id, exc):
2357
+ """The rebuild published; only its residual obligation was lost."""
2358
+ return RederiveObligationNotDurable(stage, preview, batch_id, exc)
2111
2359
 
2112
2360
 
2113
2361
  def _high_water_dict(high_water):
@@ -2316,6 +2564,15 @@ def cmd_db_rederive(args) -> int:
2316
2564
  else:
2317
2565
  print(f"cctally: db rederive: {message}", file=sys.stderr)
2318
2566
  return 2
2567
+ if apply and _journal.account_bound_cutover_enforced():
2568
+ # #825/#832 spec 5.5: a prefix-bound administrative operation
2569
+ # establishes the account-bound cutover before mutable planning, so
2570
+ # the plan's prefix already contains the boundary. A reviewed manifest
2571
+ # cut from a prefix without it then fails its existing drift check.
2572
+ try:
2573
+ _journal.ensure_account_bound_cutover()
2574
+ except _journal.AccountBoundCutoverDeferred:
2575
+ pass
2319
2576
  try:
2320
2577
  reviewed_path = getattr(args, "reviewed_weekly_decisions", None)
2321
2578
  reviewed_op = (
@@ -2400,6 +2657,28 @@ def cmd_db_rederive(args) -> int:
2400
2657
  else:
2401
2658
  print(f"cctally: db rederive conflict: {exc}", file=sys.stderr)
2402
2659
  return 2
2660
+ except RederiveObligationNotDurable as exc:
2661
+ # Fix round K12 (K11R-2): the corrected index IS published (and its
2662
+ # holder wake posted); only the residual obligation was lost.
2663
+ payload = _command_payload(
2664
+ status="failed",
2665
+ preview=exc.preview,
2666
+ batch_id=exc.batch_id,
2667
+ errors=(str(exc),),
2668
+ )
2669
+ payload["indexPublished"] = True
2670
+ if as_json:
2671
+ _emit_command_payload(payload, as_json=True)
2672
+ else:
2673
+ print(
2674
+ "cctally: the corrected stats.db was published, but the "
2675
+ "record that the inputs written during the rebuild still need "
2676
+ f"deriving could not be saved ({exc.cause}). Those inputs stay "
2677
+ "in the journal and the next ingest derives them; check that "
2678
+ "the data directory is writable.",
2679
+ file=sys.stderr,
2680
+ )
2681
+ return 3
2403
2682
  except RederiveApplyError as exc:
2404
2683
  payload = _command_payload(
2405
2684
  status="failed",