cctally 1.91.0 → 1.92.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 (37) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/_cctally_cache.py +863 -74
  3. package/bin/_cctally_config.py +57 -0
  4. package/bin/_cctally_core.py +39 -8
  5. package/bin/_cctally_dashboard.py +146 -5
  6. package/bin/_cctally_dashboard_conversation.py +164 -18
  7. package/bin/_cctally_dashboard_envelope.py +2 -0
  8. package/bin/_cctally_db.py +372 -10
  9. package/bin/_cctally_doctor.py +18 -1
  10. package/bin/_cctally_journal.py +535 -13
  11. package/bin/_cctally_journal_repair.py +6 -0
  12. package/bin/_cctally_parser.py +6 -0
  13. package/bin/_cctally_quota.py +171 -55
  14. package/bin/_cctally_record.py +13 -1
  15. package/bin/_cctally_rederive.py +4 -0
  16. package/bin/_cctally_store.py +311 -6
  17. package/bin/_cctally_transcript.py +32 -2
  18. package/bin/_lib_cache_report.py +8 -3
  19. package/bin/_lib_codex_conversation.py +851 -81
  20. package/bin/_lib_codex_conversation_query.py +2005 -95
  21. package/bin/_lib_codex_find_projection.py +370 -0
  22. package/bin/_lib_codex_harness_preamble.py +176 -0
  23. package/bin/_lib_codex_hooks.py +5 -3
  24. package/bin/_lib_codex_js_scan.py +254 -0
  25. package/bin/_lib_codex_landmarks.py +309 -0
  26. package/bin/_lib_codex_title_clean.py +116 -0
  27. package/bin/_lib_conversation_dispatch.py +153 -21
  28. package/bin/_lib_conversation_watch.py +4 -2
  29. package/bin/_lib_doctor.py +64 -0
  30. package/bin/_lib_quota_alert_axes.py +31 -34
  31. package/bin/_lib_stats_damage.py +523 -0
  32. package/bin/cctally +5 -0
  33. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  34. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  35. package/dashboard/static/dashboard.html +2 -2
  36. package/package.json +7 -1
  37. package/dashboard/static/assets/index-CILAoEja.js +0 -90
@@ -275,6 +275,16 @@ class StatsDbCorruptError(sqlite3.DatabaseError):
275
275
  """
276
276
 
277
277
 
278
+ class StatsPublicationFailedError(StatsDbCorruptError):
279
+ """A replacement stats index was published and then FAILED validation.
280
+
281
+ Distinct from ``StatsDbCorruptError``'s ordinary case because replacement
282
+ already occurred, so the inherited "Not auto-recreated" wording would be
283
+ false. Subclasses it deliberately: every graceful-degrade site and the CLI
284
+ boundary's staged exit 3 keep applying unchanged (#496 S1 F1).
285
+ """
286
+
287
+
278
288
  class StatsDbMaintenanceError(sqlite3.OperationalError):
279
289
  """A guided repair owns stats.db; new cctally opens must stay out.
280
290
 
@@ -778,6 +788,114 @@ def _corruption_trigger_record(
778
788
  }
779
789
 
780
790
 
791
+ def _capture_corruption_wal_evidence(
792
+ db_path: pathlib.Path, ts: str, *, db_label: str, trigger_exception,
793
+ ) -> dict:
794
+ """Copy the WAL/SHM bytes present at forensics time (#496 S1 F2).
795
+
796
+ Scoped to stats.db. Retention for `logs/stats.db-corruption-forensics-<ts>/`
797
+ is handed to S6 alongside the other new log families; nobody owns retention
798
+ for a cache.db or conversations.db evidence directory, and those WALs are
799
+ not bounded by stats.db's 16 MiB `journal_size_limit` — `cache.db-wal` has
800
+ been observed above 256 MiB under a multi-agent hook storm (#297).
801
+ Generalizing this stays available to a later session that brings its own
802
+ retention story.
803
+
804
+ This is a FORENSICS-TIME capture, not an exact detection-time one, and the
805
+ difference is real: the connection whose failure classified the corruption
806
+ is closed inside ``open_db`` before the heal hook runs, and the heal's
807
+ locked re-check probe runs before this, so a clean close of that read-write
808
+ connection could already have checkpointed the WAL if it was the last
809
+ handle. Closing that residual gap means capturing at the ``open_db``
810
+ corruption boundary, which is not this session's surface.
811
+
812
+ Field evidence bounds the cost: 41 of the 74 retained production bundles
813
+ recorded a non-empty WAL at exactly this point.
814
+
815
+ Both the heal's re-check and the bundle's own integrity probe open
816
+ read-only, and a read-only connection cannot checkpoint, so nothing between
817
+ this capture and those probes can empty the WAL.
818
+ """
819
+ empty = {"disposition": None, "path": None, "bytes": {}, "reason": None}
820
+ if db_label != "stats":
821
+ # Recorded rather than omitted, so a cache bundle still says WHY it
822
+ # carries no evidence.
823
+ return {**empty, "disposition": "skipped_not_stats"}
824
+ if trigger_exception is None or not _is_sqlite_corruption_error(
825
+ trigger_exception
826
+ ):
827
+ # A deliberate `db rebuild` on a healthy index must not accumulate
828
+ # evidence directories.
829
+ return {**empty, "disposition": "skipped_not_corruption"}
830
+ wal = pathlib.Path(str(db_path) + "-wal")
831
+ try:
832
+ wal_bytes = wal.stat().st_size
833
+ except OSError:
834
+ wal_bytes = 0
835
+ if wal_bytes <= 0:
836
+ return {**empty, "disposition": "skipped_empty"}
837
+ try:
838
+ evidence = (
839
+ _cctally_core.LOG_DIR
840
+ / f"{db_path.name}-corruption-forensics-{ts}"
841
+ )
842
+ evidence.mkdir(mode=0o700, parents=True, exist_ok=True)
843
+ # The main file is deliberately NOT copied: it is tens of megabytes,
844
+ # and the cutover's preservation retains it anyway.
845
+ _copy_db_family(
846
+ db_path, evidence / db_path.name, suffixes=("-wal", "-shm"),
847
+ )
848
+ copied: dict = {}
849
+ for suffix in ("-wal", "-shm"):
850
+ member = evidence / f"{db_path.name}{suffix}"
851
+ try:
852
+ copied[member.name] = member.stat().st_size
853
+ except OSError:
854
+ copied[member.name] = None
855
+ return {
856
+ "disposition": "captured",
857
+ "path": str(evidence),
858
+ "bytes": copied,
859
+ "reason": None,
860
+ }
861
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a heal
862
+ # Deliberately broader than OSError. An escaping exception propagates
863
+ # out of `write_corruption_forensics`, and in the cache path that
864
+ # caller's own `except Exception` then DECLINES destructive recovery —
865
+ # so a diagnostic copy failure would change what the heal does.
866
+ return {
867
+ **empty,
868
+ "disposition": "failed",
869
+ "reason": _bounded_forensics_text(
870
+ exc, _FORENSICS_EXCEPTION_MESSAGE_MAX,
871
+ ),
872
+ }
873
+
874
+
875
+ def _describe_corruption_damage(integrity_rows, probe_db_path) -> dict:
876
+ """Structured damage description, or a recorded reason it is unavailable.
877
+
878
+ A forensics bundle must never fail to write because characterization
879
+ failed, so every exception is captured rather than propagated (#496 S1 F8).
880
+ """
881
+ try:
882
+ import _lib_stats_damage
883
+
884
+ return _lib_stats_damage.describe_damage(
885
+ integrity_rows=integrity_rows, path=probe_db_path,
886
+ )
887
+ except Exception as exc:
888
+ return {
889
+ "schemaVersion": 1,
890
+ "method": "unavailable",
891
+ "findings": [],
892
+ "shapeToken": "none",
893
+ "reason": _bounded_forensics_text(
894
+ exc, _FORENSICS_EXCEPTION_MESSAGE_MAX,
895
+ ),
896
+ }
897
+
898
+
781
899
  def write_corruption_forensics(
782
900
  db_path,
783
901
  *,
@@ -834,6 +952,12 @@ def write_corruption_forensics(
834
952
  bundle["trigger"] = _corruption_trigger_record(
835
953
  trigger_origin, trigger_exception,
836
954
  )
955
+ # Capture the WAL before the integrity probe, and before anything else can
956
+ # disturb it (#496 S1 F2). Shares the bundle's timestamp stem so the pair
957
+ # is obvious.
958
+ bundle["walEvidence"] = _capture_corruption_wal_evidence(
959
+ db_path, ts, db_label=db_label, trigger_exception=trigger_exception,
960
+ )
837
961
  for suffix in ("", "-wal", "-shm"):
838
962
  p = pathlib.Path(str(db_path) + suffix)
839
963
  try:
@@ -876,6 +1000,11 @@ def write_corruption_forensics(
876
1000
  reason = "integrity_check_unavailable"
877
1001
  bundle["probeDisposition"] = disposition.value
878
1002
  bundle["probeReason"] = reason
1003
+ # Scanned against `probe_db_path`, the same artifact `integrityCheck`
1004
+ # describes, so a `both` verdict never mixes two files.
1005
+ bundle["damage"] = _describe_corruption_damage(
1006
+ bundle["integrityCheck"], probe_db_path,
1007
+ )
879
1008
  try:
880
1009
  cp = subprocess.run(
881
1010
  ["lsof", "--", str(db_path)],
@@ -1196,7 +1325,15 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1196
1325
  # authorizer-armed `open_db(_target_path=...)` connection, and we
1197
1326
  # hold maintenance exclusive, which is what spec §3.1 sanctions.
1198
1327
  with _cctally_store.stats_write_scope("maintenance-rebuild"):
1199
- result = _cctally_journal.rebuild_stats_index()
1328
+ result = _cctally_journal.rebuild_stats_index(
1329
+ context=_cctally_journal.RebuildContext(
1330
+ trigger="db-rebuild",
1331
+ trigger_error=None,
1332
+ forensics_path=(
1333
+ str(forensics) if forensics is not None else None
1334
+ ),
1335
+ )
1336
+ )
1200
1337
  incident = result.quarantine_dir
1201
1338
  except Exception as exc:
1202
1339
  eprint(f"cctally: stats.db rebuild failed: {exc}")
@@ -3781,7 +3918,8 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
3781
3918
  ON codex_conversation_rollups(last_activity_utc DESC, conversation_key DESC);
3782
3919
 
3783
3920
  -- codex_conversation_file_touches: write-class axis for the `files`
3784
- -- search kind + outline file stats (§3.3). source_path gives explicit
3921
+ -- search kind (§3.3). The richer outline file stats stay derived from
3922
+ -- retained event payloads at read time. source_path gives explicit
3785
3923
  -- lineage so the per-file delete/truncate/prune paths scope deletions
3786
3924
  -- exactly as they do for the other Codex families.
3787
3925
  CREATE TABLE IF NOT EXISTS codex_conversation_file_touches (
@@ -4145,7 +4283,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
4145
4283
  ).fetchone()
4146
4284
  except sqlite3.OperationalError:
4147
4285
  current = None
4148
- if current is not None and current[0] == "1":
4286
+ if current is not None and current[0] == "2":
4287
+ _apply_codex_find_projection_schema(conn)
4149
4288
  return
4150
4289
 
4151
4290
  _apply_cache_schema(conn)
@@ -4195,11 +4334,81 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
4195
4334
  );
4196
4335
  """
4197
4336
  )
4337
+ # #347: transcript attribution belongs to the physical rows. These are
4338
+ # pure column additions; migration 005 owns only the one-time legacy
4339
+ # backfill policy. NULL is the stored spelling of the `unattributed`
4340
+ # sentinel, matching the accounting cache contract from #341.
4341
+ add_column_if_missing(conn, "conversation_messages", "account_key", "TEXT")
4342
+ add_column_if_missing(conn, "codex_conversation_events", "account_key", "TEXT")
4343
+ add_column_if_missing(conn, "codex_conversation_messages", "account_key", "TEXT")
4344
+ conn.execute(
4345
+ "CREATE INDEX IF NOT EXISTS idx_conv_messages_account_session "
4346
+ "ON conversation_messages(account_key, session_id, timestamp_utc, id)"
4347
+ )
4348
+ conn.execute(
4349
+ "CREATE INDEX IF NOT EXISTS idx_codex_conv_messages_account_conversation "
4350
+ "ON codex_conversation_messages(account_key, conversation_key, timestamp_utc, id)"
4351
+ )
4352
+ conn.execute(
4353
+ "CREATE INDEX IF NOT EXISTS idx_codex_events_account_conversation "
4354
+ "ON codex_conversation_events(account_key, conversation_key, line_offset)"
4355
+ )
4198
4356
  conn.execute(
4199
4357
  "INSERT INTO cache_meta(key,value) VALUES "
4200
- "('conversation_schema_version','1') "
4358
+ "('conversation_schema_version','2') "
4201
4359
  "ON CONFLICT(key) DO UPDATE SET value=excluded.value"
4202
4360
  )
4361
+ _apply_codex_find_projection_schema(conn)
4362
+
4363
+
4364
+ def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
4365
+ """Create #482's disposable visible-text projection tables.
4366
+
4367
+ This helper is called even when the conversations base-schema marker is
4368
+ current: migration 004 must be able to add the derivation without bumping
4369
+ or replaying the much larger historical transcript schema.
4370
+ """
4371
+ conn.executescript(
4372
+ """
4373
+ CREATE TABLE IF NOT EXISTS codex_find_projection (
4374
+ message_id INTEGER NOT NULL,
4375
+ conversation_key TEXT NOT NULL,
4376
+ item_key TEXT NOT NULL,
4377
+ block_key TEXT NOT NULL,
4378
+ container_block_key TEXT NOT NULL,
4379
+ surface TEXT NOT NULL
4380
+ CHECK(surface IN ('body','call','output','completion')),
4381
+ render_order INTEGER NOT NULL,
4382
+ projected_text TEXT NOT NULL,
4383
+ leaves_json TEXT NOT NULL,
4384
+ disclosure_json TEXT NOT NULL,
4385
+ projection_version INTEGER NOT NULL,
4386
+ PRIMARY KEY(message_id, surface),
4387
+ FOREIGN KEY(message_id) REFERENCES codex_conversation_messages(id)
4388
+ ON DELETE CASCADE
4389
+ );
4390
+ CREATE INDEX IF NOT EXISTS idx_codex_find_projection_conversation_order
4391
+ ON codex_find_projection(
4392
+ conversation_key, render_order, message_id, surface
4393
+ );
4394
+ CREATE TRIGGER IF NOT EXISTS codex_find_projection_message_ad
4395
+ AFTER DELETE ON codex_conversation_messages BEGIN
4396
+ DELETE FROM codex_find_projection WHERE message_id=old.id;
4397
+ END;
4398
+ """
4399
+ )
4400
+ # Fresh stores are complete without a backfill. Populated upgrades are
4401
+ # armed by migration 004 under the provider flock.
4402
+ has_state = conn.execute(
4403
+ "SELECT 1 FROM cache_meta WHERE key IN "
4404
+ "('codex_find_projection_complete_version',"
4405
+ " 'codex_find_projection_backfill_pending') LIMIT 1"
4406
+ ).fetchone()
4407
+ if has_state is None and conn.execute(
4408
+ "SELECT 1 FROM codex_conversation_messages LIMIT 1"
4409
+ ).fetchone() is None:
4410
+ _set_cache_meta(conn, "codex_find_projection_complete_version", "1")
4411
+ _set_cache_meta(conn, "codex_find_projection_generation", "0")
4203
4412
 
4204
4413
 
4205
4414
  def _fts5_available(conn: sqlite3.Connection) -> bool:
@@ -4336,6 +4545,139 @@ def _conv_003_background_mcp_result_replay(conn: sqlite3.Connection) -> None:
4336
4545
  _release_cache_db_writer_flocks(held)
4337
4546
 
4338
4547
 
4548
+ @conversations_migration("004_codex_find_projection")
4549
+ def _conv_004_codex_find_projection(conn: sqlite3.Connection) -> None:
4550
+ """Arm #482's bounded projection backfill for populated Codex stores.
4551
+
4552
+ The projection is fully derivable from retained normalized messages and
4553
+ event payloads, so the migration never replays JSONL. The normal Codex
4554
+ conversation synchronizer consumes the marker under the same provider
4555
+ flock, in bounded batches. Fresh empty stores were certified by the schema
4556
+ helper and this handler becomes an idempotent no-op.
4557
+ """
4558
+ held = _acquire_conversations_db_codex_provider_flock(
4559
+ conn, migration="conversations 004 Codex find projection")
4560
+ try:
4561
+ _apply_codex_find_projection_schema(conn)
4562
+ complete = conn.execute(
4563
+ "SELECT 1 FROM cache_meta "
4564
+ "WHERE key='codex_find_projection_complete_version' AND value='1'"
4565
+ ).fetchone()
4566
+ if complete is None:
4567
+ _set_cache_meta(conn, "codex_find_projection_backfill_pending", "1")
4568
+ conn.execute(
4569
+ "INSERT OR IGNORE INTO cache_meta(key,value) VALUES"
4570
+ "('codex_find_projection_backfill_cursor','0')"
4571
+ )
4572
+ _set_cache_meta(conn, "codex_find_projection_generation", "0")
4573
+ conn.commit()
4574
+ finally:
4575
+ _release_cache_db_writer_flocks(held)
4576
+
4577
+
4578
+ @conversations_migration("005_conversation_account_dimension")
4579
+ def _conv_005_conversation_account_dimension(conn: sqlite3.Connection) -> None:
4580
+ """Backfill the #347 transcript account dimension.
4581
+
4582
+ Decision R4 from #341 is the compatibility rule: retained Claude history
4583
+ belongs to the journaled cutover account, while retained Codex history is
4584
+ genuinely unrecoverable and remains NULL/``unattributed``. The physical
4585
+ columns are added by the base-schema helper before dispatch; this handler
4586
+ owns only the data-shape transition and is idempotent.
4587
+ """
4588
+ import _cctally_journal
4589
+ import _lib_accounts
4590
+
4591
+ held = _acquire_conversations_db_claude_provider_flock(
4592
+ conn, migration="conversations 005 account dimension"
4593
+ )
4594
+ try:
4595
+ held += _acquire_conversations_db_codex_provider_flock(
4596
+ conn, migration="conversations 005 account dimension"
4597
+ )
4598
+ claude_key = _cctally_journal.find_accounts_cutover_op()
4599
+ if claude_key is None:
4600
+ pending = conn.execute(
4601
+ "SELECT 1 FROM conversation_messages "
4602
+ "WHERE account_key IS NULL LIMIT 1"
4603
+ ).fetchone()
4604
+ if pending is not None:
4605
+ raise MigrationGateNotMet(
4606
+ "accounts cutover op not yet appended; deferring Claude "
4607
+ "conversation backfill until the epoch transition records it"
4608
+ )
4609
+ claude_key = _lib_accounts.UNATTRIBUTED
4610
+ stored_key = (
4611
+ None if claude_key == _lib_accounts.UNATTRIBUTED else claude_key
4612
+ )
4613
+ conn.execute(
4614
+ "UPDATE conversation_messages SET account_key=? "
4615
+ "WHERE account_key IS NULL",
4616
+ (stored_key,),
4617
+ )
4618
+ # Explicitly preserve the pre-feature Codex decision. This UPDATE makes
4619
+ # the idempotent intent visible in the migration golden without guessing
4620
+ # from whichever auth.json is active during upgrade.
4621
+ conn.execute(
4622
+ "UPDATE codex_conversation_events SET account_key=NULL "
4623
+ "WHERE account_key IS NULL"
4624
+ )
4625
+ conn.execute(
4626
+ "UPDATE codex_conversation_messages SET account_key=NULL "
4627
+ "WHERE account_key IS NULL"
4628
+ )
4629
+ _set_cache_meta(conn, "conversation_account_dimension", "1")
4630
+ conn.commit()
4631
+ finally:
4632
+ _release_cache_db_writer_flocks(held)
4633
+
4634
+
4635
+ @conversations_migration("006_backfill_codex_file_touches")
4636
+ def _conv_006_backfill_codex_file_touches(conn: sqlite3.Connection) -> None:
4637
+ """Repair the file-search projection for retained dict-shaped patches.
4638
+
4639
+ The normalized writer historically recognized only list-shaped ``changes``;
4640
+ real Codex patch completions use an object keyed by file path. The physical
4641
+ event payloads are retained unbounded, so rebuild the derived touch rows from
4642
+ those authoritative bytes and link only events that still have a normalized
4643
+ message. ``INSERT OR IGNORE`` preserves valid legacy-list rows and makes a
4644
+ markerless retry idempotent.
4645
+ """
4646
+ import _lib_codex_conversation as conversation
4647
+
4648
+ held = _acquire_conversations_db_codex_provider_flock(
4649
+ conn, migration="conversations 006 Codex file touches")
4650
+ try:
4651
+ pending: list[tuple[int, str, str, str, str]] = []
4652
+ for message_id, conversation_key, source_path, payload_json in conn.execute(
4653
+ "SELECT m.id,m.conversation_key,m.source_path,e.payload_json "
4654
+ "FROM codex_conversation_events e "
4655
+ "JOIN codex_conversation_messages m "
4656
+ "ON m.source_path=e.source_path AND m.line_offset=e.line_offset "
4657
+ "WHERE e.event_type='patch_apply_end'"
4658
+ ).fetchall():
4659
+ try:
4660
+ decoded = json.loads(payload_json or "{}")
4661
+ except (json.JSONDecodeError, TypeError):
4662
+ continue
4663
+ payload = decoded.get("payload") if isinstance(decoded, dict) else None
4664
+ for file_path in conversation.codex_patch_file_paths(payload):
4665
+ pending.append((
4666
+ message_id, conversation_key, source_path,
4667
+ file_path, "apply_patch",
4668
+ ))
4669
+ if pending:
4670
+ conn.executemany(
4671
+ "INSERT OR IGNORE INTO codex_conversation_file_touches "
4672
+ "(message_id,conversation_key,source_path,file_path,tool) "
4673
+ "VALUES(?,?,?,?,?)",
4674
+ pending,
4675
+ )
4676
+ conn.commit()
4677
+ finally:
4678
+ _release_cache_db_writer_flocks(held)
4679
+
4680
+
4339
4681
  # #177 S6: the consolidated multi-column external-content FTS5 table that
4340
4682
  # replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
4341
4683
  # pair. The three column names MUST match the conversation_messages columns BY
@@ -4760,6 +5102,7 @@ def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
4760
5102
  "INSERT INTO codex_conversation_fts(codex_conversation_fts) VALUES('delete-all')")
4761
5103
  _create_codex_conversation_fts_triggers(conn)
4762
5104
  for stmt in (
5105
+ "DELETE FROM codex_find_projection",
4763
5106
  "DELETE FROM codex_conversation_file_touches",
4764
5107
  "DELETE FROM codex_conversation_rollups",
4765
5108
  ):
@@ -6198,8 +6541,7 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
6198
6541
  "SELECT 1 FROM sqlite_master "
6199
6542
  "WHERE type='table' AND name='codex_conversation_events'"
6200
6543
  ).fetchone() is not None:
6201
- conn.execute(
6202
- "UPDATE quota_window_snapshots AS q SET observed_model=("
6544
+ model_lookup = (
6203
6545
  " SELECT json_extract(e.payload_json, '$.payload.model')"
6204
6546
  " FROM codex_conversation_events AS e"
6205
6547
  " WHERE e.source_path=q.source_path"
@@ -6207,9 +6549,21 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
6207
6549
  " AND e.record_type IN ('turn_context','session_meta')"
6208
6550
  " AND json_valid(e.payload_json)"
6209
6551
  " AND json_type(e.payload_json, '$.payload.model')='text'"
6210
- " ORDER BY e.line_offset DESC LIMIT 1)"
6552
+ " ORDER BY e.line_offset DESC LIMIT 1"
6553
+ )
6554
+ changed = conn.execute(
6555
+ "UPDATE quota_window_snapshots AS q SET observed_model=("
6556
+ + model_lookup + ")"
6211
6557
  " WHERE q.source='codex' AND q.observed_model IS NULL"
6558
+ " AND (" + model_lookup + ") IS NOT NULL"
6212
6559
  )
6560
+ if changed.rowcount:
6561
+ # #457: migration DML changes the same physical quota
6562
+ # inputs as ordinary ingest, so invalidate every consumer
6563
+ # in this transaction. The IS NOT NULL guard keeps a
6564
+ # markerless retry byte-idempotent, including this token.
6565
+ import _cctally_cache
6566
+ _cctally_cache._bump_codex_physical_mutation_seq(conn)
6213
6567
  for drop in (
6214
6568
  "DROP TRIGGER IF EXISTS conv_fts_ai",
6215
6569
  "DROP TRIGGER IF EXISTS conv_fts_ad",
@@ -6854,8 +7208,10 @@ def _039_codex_quota_observed_model_backfill(conn: sqlite3.Connection) -> None:
6854
7208
  AND whose lookup actually resolves are considered, so a re-run over its own
6855
7209
  output writes nothing at all — not even a NULL-to-NULL update, which would
6856
7210
  fire the ledger trigger and dirty a window that never changed. Its real DML
6857
- IS ledgered, which is the mechanism working as designed: a migration that
6858
- rewrites this column no longer has to remember to announce it.
7211
+ IS ledgered, which is that mechanism working as designed: a migration that
7212
+ rewrites this column no longer has to remember a ledger-specific
7213
+ announcement. The independent physical mutation token still advances for
7214
+ the dashboard/certificate consumers that consult it before reconciliation.
6859
7215
 
6860
7216
  The migration is the ONE-TIME leg. It is not the whole guarantee: `db skip`
6861
7217
  and a fresh journal-repopulated cache both bypass it, so ``sync_codex_cache``
@@ -6863,7 +7219,13 @@ def _039_codex_quota_observed_model_backfill(conn: sqlite3.Connection) -> None:
6863
7219
 
6864
7220
  NO self-stamp — the dispatcher central-stamps on a clean return (#140).
6865
7221
  """
6866
- backfill_codex_quota_observed_model(conn)
7222
+ changed = backfill_codex_quota_observed_model(conn)
7223
+ if changed:
7224
+ # #457: the ledger invalidates the incremental projector, while this
7225
+ # shared token independently invalidates certificate/coherence and
7226
+ # dashboard/TUI snapshot consumers. Both move in this transaction.
7227
+ import _cctally_cache
7228
+ _cctally_cache._bump_codex_physical_mutation_seq(conn)
6867
7229
  conn.commit()
6868
7230
 
6869
7231
 
@@ -971,6 +971,7 @@ def _doctor_gather_state_impl(
971
971
  conv_rollup_sync_in_progress = False
972
972
  conversations_db_page_count = None
973
973
  conversations_db_freelist_count = None
974
+ codex_prune_refusals: list[dict] = []
974
975
  try:
975
976
  if _cctally_core.CONVERSATIONS_DB_PATH.exists():
976
977
  # This gather also runs inside dashboard snapshot precompute. A
@@ -1009,6 +1010,18 @@ def _doctor_gather_state_impl(
1009
1010
  conv_messages_distinct_sessions = int(row[0])
1010
1011
  except sqlite3.OperationalError:
1011
1012
  pass
1013
+ try:
1014
+ import _cctally_cache as _cc_sib
1015
+ row = conn.execute(
1016
+ "SELECT value FROM cache_meta WHERE key=?",
1017
+ (_cc_sib.CODEX_ORPHAN_PRUNE_REFUSED_KEY,),
1018
+ ).fetchone()
1019
+ if row and row[0]:
1020
+ record = json.loads(row[0])
1021
+ if isinstance(record, dict):
1022
+ codex_prune_refusals.append(record)
1023
+ except (sqlite3.OperationalError, ValueError, TypeError):
1024
+ pass
1012
1025
  # Pending reingest/split/backfill flags ⇒ a full sync hasn't yet
1013
1026
  # reconciled the rollup. Read the canonical flag set from
1014
1027
  # _cctally_cache so it stays in lockstep with the sync consumers.
@@ -1219,7 +1232,8 @@ def _doctor_gather_state_impl(
1219
1232
  try:
1220
1233
  for _key in ("parse_health_claude", "parse_health_codex",
1221
1234
  "codex_torn_auth_deferred", _blocked_key,
1222
- _deferred_key, "codex_ingest_backlog"):
1235
+ _deferred_key, "codex_ingest_backlog",
1236
+ "codex_orphan_prune_refused"):
1223
1237
  try:
1224
1238
  row = conn.execute(
1225
1239
  "SELECT value FROM cache_meta WHERE key = ?",
@@ -1238,6 +1252,8 @@ def _doctor_gather_state_impl(
1238
1252
  codex_replay_deferred = _parsed
1239
1253
  elif _key == "codex_ingest_backlog":
1240
1254
  codex_ingest_backlog = _parsed
1255
+ elif _key == "codex_orphan_prune_refused":
1256
+ codex_prune_refusals.append(_parsed)
1241
1257
  else:
1242
1258
  codex_torn_deferred = _parsed
1243
1259
  except (sqlite3.OperationalError, ValueError):
@@ -1828,6 +1844,7 @@ def _doctor_gather_state_impl(
1828
1844
  codex_replay_pending=codex_replay_pending,
1829
1845
  codex_replay_blocked=codex_replay_blocked,
1830
1846
  codex_replay_deferred=codex_replay_deferred,
1847
+ codex_prune_refusals=codex_prune_refusals or None,
1831
1848
  stats_db_quick_check=stats_db_quick_check,
1832
1849
  cache_db_quick_check=cache_db_quick_check,
1833
1850
  conversations_db_quick_check=conversations_db_quick_check,