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
@@ -60,6 +60,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
60
60
  from __future__ import annotations
61
61
 
62
62
  import argparse
63
+ import contextlib
63
64
  import contextvars
64
65
  import datetime as dt
65
66
  import enum
@@ -76,7 +77,7 @@ import tempfile
76
77
  import time
77
78
  import traceback
78
79
  from dataclasses import dataclass
79
- from typing import Any, Callable, NamedTuple
80
+ from typing import Any, Callable, Iterator, NamedTuple
80
81
 
81
82
 
82
83
  def _cctally():
@@ -800,7 +801,7 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
800
801
  test-suite's CCTALLY_DISABLE_DEV_AUTODETECT. Escape: CCTALLY_ALLOW_PROD_MIGRATION."""
801
802
  if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
802
803
  return False
803
- if not (_cctally_core._repo_root() / ".git").exists():
804
+ if not _cctally_core._has_git_checkout_marker():
804
805
  return False
805
806
  db_dir = _conn_db_dir(conn)
806
807
  if db_dir is None:
@@ -856,7 +857,7 @@ def _would_block_prod_stats(path: pathlib.Path) -> bool:
856
857
  ``CCTALLY_ALLOW_PROD_MIGRATION``."""
857
858
  if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
858
859
  return False
859
- if not (_cctally_core._repo_root() / ".git").exists():
860
+ if not _cctally_core._has_git_checkout_marker():
860
861
  return False
861
862
  try:
862
863
  return (
@@ -1699,6 +1700,10 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1699
1700
  )
1700
1701
  forensics = None
1701
1702
  incident = None
1703
+ import _cctally_completion
1704
+ # Fix round K11 (K9K10-R7): a rebuild that published but could not record
1705
+ # its residual obligation; reported once every lock is released.
1706
+ obligation_failure = None
1702
1707
  try:
1703
1708
  fcntl.flock(maint_fd, fcntl.LOCK_EX)
1704
1709
  # #386: record the hold so a nested live `open_db()` does not request
@@ -1750,9 +1755,12 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1750
1755
  forensics_path=(
1751
1756
  str(forensics) if forensics is not None else None
1752
1757
  ),
1753
- )
1758
+ ),
1759
+ complete_derivation=True,
1754
1760
  )
1755
1761
  incident = result.quarantine_dir
1762
+ except _cctally_completion.ResidualObligationNotDurable as exc:
1763
+ obligation_failure = exc
1756
1764
  except Exception as exc:
1757
1765
  eprint(f"cctally: stats.db rebuild failed: {exc}")
1758
1766
  return 3
@@ -1766,6 +1774,20 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1766
1774
  finally:
1767
1775
  os.close(maint_fd)
1768
1776
 
1777
+ if obligation_failure is not None:
1778
+ # The rebuilt index IS published (spec 4.7), so its completion wake
1779
+ # is posted as for any publication; only then is the obligation
1780
+ # failure reported. The inputs past the rebuilt prefix stay in the
1781
+ # journal for the next ingest.
1782
+ _cctally_completion.post_publication_wake()
1783
+ eprint(
1784
+ "cctally: stats.db was rebuilt, but the record that the inputs "
1785
+ "written during the rebuild still need deriving could not be "
1786
+ f"saved ({obligation_failure}). Those inputs stay in the journal "
1787
+ "and the next ingest derives them; check that the data directory "
1788
+ "is writable.")
1789
+ return 3
1790
+
1769
1791
  total_rows = sum(result.rows_by_table.values())
1770
1792
  if as_json:
1771
1793
  payload = {
@@ -1869,6 +1891,11 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1869
1891
  print(f" previous stats.db quarantined -> {incident}")
1870
1892
  if forensics is not None:
1871
1893
  print(f" forensics -> {forensics}")
1894
+ # #825/#832 spec 4.3 revision 2: a completed rebuild is a live index
1895
+ # publication; its completion wake (posted with every lock released)
1896
+ # dispatches the detached continuation, so this command never waits on
1897
+ # it. A file-only no-op when nothing is pending.
1898
+ _cctally_completion.post_publication_wake()
1872
1899
  return 0
1873
1900
 
1874
1901
 
@@ -2355,6 +2382,24 @@ def _run_pending_migrations(
2355
2382
  conn.commit()
2356
2383
 
2357
2384
 
2385
+ def _accounts_cutover_value_or_defer(what: str):
2386
+ """`find_accounts_cutover_op` for an account backfill migration.
2387
+
2388
+ #825/#832 fix round K11 (K9K10-R9): a journal the lookup cannot list,
2389
+ size or read — including the account-bound boundary lookup it applies,
2390
+ `AccountBoundCutoverUnavailable` — proves neither the op nor its
2391
+ absence. That is transient, so the migration defers
2392
+ (`MigrationGateNotMet`: no error log, no banner, retried on the next
2393
+ open) instead of failing hard and stopping the registry."""
2394
+ import _cctally_journal
2395
+ try:
2396
+ return _cctally_journal.find_accounts_cutover_op()
2397
+ except OSError as exc:
2398
+ raise MigrationGateNotMet(
2399
+ f"the journal could not be read to find the accounts cutover op "
2400
+ f"({exc}); deferring the {what}") from exc
2401
+
2402
+
2358
2403
  # === Region 3: 001 handler (was bin/cctally:11232-11344) ===
2359
2404
 
2360
2405
  @stats_migration("001_five_hour_block_models_backfill_v1")
@@ -3876,13 +3921,29 @@ def _codex_quota_ledger_ddl() -> tuple[str, ...]:
3876
3921
  #: because SQLite only uses an expression index when the query's expression
3877
3922
  #: matches the indexed one — indexing the bare ``COALESCE`` while the reader
3878
3923
  #: wraps it in ``unixepoch`` silently buys nothing.
3924
+ #:
3925
+ #: #901 Q11 (spec §5.3a, 901-PA-001 a; cache migration 048): the four order
3926
+ #: columns after the five equality members are the loader's ``ORDER BY
3927
+ #: source_root_key, captured_at_utc, resets_at_utc, source_path, line_offset``
3928
+ #: minus its constant first term, so each physical-group shard streams from the
3929
+ #: seek. Without them every shard re-sorted its group's whole history ("USE TEMP
3930
+ #: B-TREE FOR LAST 4 TERMS OF ORDER BY"; 3.3, 12.5 and 5.4 MB of temp in three
3931
+ #: measured calls), and one window group is a week of activity, not a bound.
3879
3932
  _QUOTA_GROUP_INDEX_DDL = (
3880
3933
  "CREATE INDEX IF NOT EXISTS idx_qws_physical_group "
3881
3934
  "ON quota_window_snapshots("
3882
3935
  " source_root_key, logical_limit_key, observed_slot, window_minutes,"
3883
- " unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc)))"
3936
+ " unixepoch(COALESCE(canonical_resets_at_utc, resets_at_utc)),"
3937
+ " captured_at_utc, resets_at_utc, source_path, line_offset)"
3884
3938
  " WHERE source='codex'"
3885
3939
  )
3940
+ #: The key columns ``PRAGMA index_xinfo`` reports for the current definition
3941
+ #: (``None`` is the expression member). An index of the same name with any
3942
+ #: other key list is a pre-048 definition and is replaced.
3943
+ _QUOTA_GROUP_INDEX_KEY_COLUMNS = (
3944
+ "source_root_key", "logical_limit_key", "observed_slot", "window_minutes",
3945
+ None, "captured_at_utc", "resets_at_utc", "source_path", "line_offset",
3946
+ )
3886
3947
 
3887
3948
 
3888
3949
  def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
@@ -3916,6 +3977,16 @@ def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
3916
3977
  Guarded on ``canonical_resets_at_utc`` for the same reason the ledger is: a
3917
3978
  legacy-shape cache whose schema apply took the FTS early-return before that
3918
3979
  column add would raise ``no such column`` here.
3980
+
3981
+ #901 Q11 (cache migration 048) extends the key list with the loader's order
3982
+ columns (see ``_QUOTA_GROUP_INDEX_DDL``). ``CREATE INDEX IF NOT EXISTS``
3983
+ never reshapes an existing index, so an index of this name with any other
3984
+ key list is dropped and recreated, and the two statements commit together
3985
+ (its own ``BEGIN IMMEDIATE``, or a savepoint inside a caller's
3986
+ transaction): the loader probes for the index by name and then pins it with
3987
+ ``INDEXED BY``, so a reader must never find it missing in between. A
3988
+ current index is left alone, so the helper is idempotent and costs one
3989
+ ``PRAGMA`` on a current store.
3919
3990
  """
3920
3991
  cols = {
3921
3992
  str(row[1]) for row in conn.execute(
@@ -3923,8 +3994,34 @@ def _apply_codex_quota_group_index(conn: sqlite3.Connection) -> None:
3923
3994
  }
3924
3995
  if not cols or "canonical_resets_at_utc" not in cols:
3925
3996
  return
3926
- conn.execute(_QUOTA_GROUP_INDEX_DDL)
3927
- conn.execute("DROP INDEX IF EXISTS idx_qws_window_ident")
3997
+ current = tuple(
3998
+ row[2] for row in conn.execute(
3999
+ "PRAGMA index_xinfo(idx_qws_physical_group)") if row[5])
4000
+ retired = conn.execute(
4001
+ "SELECT 1 FROM sqlite_master WHERE type='index'"
4002
+ " AND name='idx_qws_window_ident'").fetchone() is not None
4003
+ if current == _QUOTA_GROUP_INDEX_KEY_COLUMNS and not retired:
4004
+ return
4005
+ own_transaction = not conn.in_transaction
4006
+ conn.execute(
4007
+ "BEGIN IMMEDIATE" if own_transaction
4008
+ else "SAVEPOINT codex_quota_group_index")
4009
+ try:
4010
+ if current and current != _QUOTA_GROUP_INDEX_KEY_COLUMNS:
4011
+ conn.execute("DROP INDEX IF EXISTS idx_qws_physical_group")
4012
+ conn.execute(_QUOTA_GROUP_INDEX_DDL)
4013
+ conn.execute("DROP INDEX IF EXISTS idx_qws_window_ident")
4014
+ except BaseException:
4015
+ if own_transaction:
4016
+ conn.rollback()
4017
+ else:
4018
+ conn.execute("ROLLBACK TO codex_quota_group_index")
4019
+ conn.execute("RELEASE codex_quota_group_index")
4020
+ raise
4021
+ if own_transaction:
4022
+ conn.commit()
4023
+ else:
4024
+ conn.execute("RELEASE codex_quota_group_index")
3928
4025
 
3929
4026
 
3930
4027
  #: Partial index over the rows the standing `observed_model` resolution can
@@ -4283,9 +4380,532 @@ def _apply_codex_accounting_change_ledger(conn: sqlite3.Connection) -> None:
4283
4380
  conn.execute(statement)
4284
4381
 
4285
4382
 
4383
+ # ── #901 W1: the maintained raw-partition maxima (spec §5.1, T1) ─────────────
4384
+ #
4385
+ # `load_codex_quota_observations(latest_per_identity=True)` computed
4386
+ # `MAX(unixepoch(captured_at_utc)) OVER (PARTITION BY <nine raw columns>)` across
4387
+ # every retained Codex quota row on every doctor refresh; on the measured store
4388
+ # that sorted 565K rows through ~1.1 GB of temp files per call (spec §1.2). The
4389
+ # table below holds exactly that window's per-partition maximum, maintained by
4390
+ # triggers in the same transaction as every mutation, so the latest read becomes
4391
+ # a scan of ~10^3 summary rows plus one index seek per partition for the rows
4392
+ # tied at its maximum second (tests/test_901_partition_latest_maintenance.py
4393
+ # holds it to the frozen window query after every writer class).
4394
+ #
4395
+ # Keys are `quote(<raw column>)`: NOT NULL, collision-free across NULL, '' and
4396
+ # storage classes, and equal exactly when the window's PARTITION BY puts two
4397
+ # rows in one partition (no collation on these columns; TEXT affinity stores
4398
+ # numbers as text and INTEGER affinity integral reals as integers, so value
4399
+ # equality and quote() equality coincide). The key columns are declared BLOB on
4400
+ # purpose: a TEXT key column gives `quote(q.col) = s.key` TEXT comparison
4401
+ # affinity, and SQLite then refuses the expression index (measured: the tie
4402
+ # fetch fell back to walking the whole `source` autoindex). The seek index leads
4403
+ # with `source` for the same measured reason.
4404
+ #
4405
+ # The triggers use built-in functions only (quote, trim, coalesce, unixepoch),
4406
+ # so an older binary that opens the store keeps them firing. They never upsert:
4407
+ # an outer statement's conflict policy propagates into trigger-body statements,
4408
+ # so every repair is a DELETE followed by an INSERT that cannot conflict. A
4409
+ # REPLACE against the snapshots table would delete rows without firing the
4410
+ # delete trigger (recursive_triggers is off); no writer issues one, and
4411
+ # test_no_writer_replaces_quota_rows fails if one appears.
4412
+ #
4413
+ # `unixepoch()` in an index expression throws on the literal 'now', so the seek
4414
+ # index refuses a Codex row whose capture is 'now' — the same property
4415
+ # idx_qws_physical_group already has for the reset. Ingest writes only
4416
+ # `_utc_iso` instants.
4417
+
4418
+ #: Must equal `_cctally_quota._CODEX_QUOTA_REQUIRED_TEXT` (#500: the loader and
4419
+ #: every population derived for it agree on one required-text set);
4420
+ #: tests/test_901_partition_latest_maintenance.py pins the equality.
4421
+ _CODEX_QUOTA_LATEST_REQUIRED_TEXT = (
4422
+ "source", "source_root_key", "source_path", "captured_at_utc",
4423
+ "observed_slot", "logical_limit_key", "resets_at_utc",
4424
+ )
4425
+
4426
+ #: (summary key column, raw column; None means the coalesced reset anchor), in
4427
+ #: the window query's PARTITION BY order.
4428
+ _CODEX_QUOTA_LATEST_KEYS = (
4429
+ ("k_source_root_key", "source_root_key"),
4430
+ ("k_logical_limit_key", "logical_limit_key"),
4431
+ ("k_observed_slot", "observed_slot"),
4432
+ ("k_window_minutes", "window_minutes"),
4433
+ ("k_limit_id", "limit_id"),
4434
+ ("k_limit_name", "limit_name"),
4435
+ ("k_observed_model", "observed_model"),
4436
+ ("k_account_key", "account_key"),
4437
+ ("k_reset_anchor", None),
4438
+ )
4439
+ _CODEX_QUOTA_LATEST_KEY_COLUMNS = tuple(
4440
+ key for key, _column in _CODEX_QUOTA_LATEST_KEYS)
4441
+
4442
+ #: Every column a partition key or a validity predicate reads.
4443
+ _CODEX_QUOTA_LATEST_UPDATE_OF = (
4444
+ "source", "source_root_key", "source_path", "captured_at_utc",
4445
+ "observed_slot", "logical_limit_key", "resets_at_utc",
4446
+ "canonical_resets_at_utc", "window_minutes", "limit_id", "limit_name",
4447
+ "observed_model", "account_key",
4448
+ )
4449
+
4450
+ #: The five objects whose joint presence means the summary has been maintained
4451
+ #: since it was bootstrapped. The reader uses it only when all five exist.
4452
+ _CODEX_QUOTA_LATEST_OBJECTS = (
4453
+ ("table", "codex_quota_partition_latest"),
4454
+ ("index", "idx_qws_partition_capture"),
4455
+ ("trigger", "trg_qws_latest_ins"),
4456
+ ("trigger", "trg_qws_latest_del"),
4457
+ ("trigger", "trg_qws_latest_upd"),
4458
+ )
4459
+
4460
+
4461
+ def _codex_quota_latest_raw(column, prefix: str) -> str:
4462
+ if column is None:
4463
+ return (f"COALESCE({prefix}canonical_resets_at_utc, "
4464
+ f"{prefix}resets_at_utc)")
4465
+ return f"{prefix}{column}"
4466
+
4467
+
4468
+ def _codex_quota_latest_validity_sql(prefix: str = "") -> str:
4469
+ """The loader's SQL-statable validity predicates, for one row image."""
4470
+ terms = [f"{prefix}source='codex'", f"{prefix}source_root_key IS NOT NULL"]
4471
+ terms += [f"trim(coalesce({prefix}{column}, '')) <> ''"
4472
+ for column in _CODEX_QUOTA_LATEST_REQUIRED_TEXT]
4473
+ terms += [f"unixepoch({prefix}captured_at_utc) IS NOT NULL",
4474
+ f"unixepoch({prefix}resets_at_utc) IS NOT NULL"]
4475
+ return " AND ".join(terms)
4476
+
4477
+
4478
+ def _codex_quota_latest_key_exprs(prefix: str = "") -> "tuple[str, ...]":
4479
+ return tuple(
4480
+ f"quote({_codex_quota_latest_raw(column, prefix)})"
4481
+ for _key, column in _CODEX_QUOTA_LATEST_KEYS)
4482
+
4483
+
4484
+ def _codex_quota_latest_summary_match(image: str) -> str:
4485
+ return " AND ".join(
4486
+ f"{key} = {expr}" for key, expr in zip(
4487
+ _CODEX_QUOTA_LATEST_KEY_COLUMNS,
4488
+ _codex_quota_latest_key_exprs(f"{image}.")))
4489
+
4490
+
4491
+ def _codex_quota_latest_base_match(image: str) -> str:
4492
+ return " AND ".join(
4493
+ f"{base} = {expr}" for base, expr in zip(
4494
+ _codex_quota_latest_key_exprs(""),
4495
+ _codex_quota_latest_key_exprs(f"{image}.")))
4496
+
4497
+
4498
+ def _codex_quota_latest_recompute_sql(image: str, *, only_if_absent: bool) -> str:
4499
+ """Re-derive one partition's maximum from the physical rows (one seek)."""
4500
+ absent_clause = (
4501
+ " AND NOT EXISTS (SELECT 1 FROM codex_quota_partition_latest"
4502
+ f" WHERE {_codex_quota_latest_summary_match(image)})"
4503
+ if only_if_absent else ""
4504
+ )
4505
+ return (
4506
+ "INSERT INTO codex_quota_partition_latest ("
4507
+ + ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
4508
+ + ", latest_capture_epoch)"
4509
+ f" SELECT {', '.join(_codex_quota_latest_key_exprs(image + '.'))},"
4510
+ " latest FROM (SELECT MAX(unixepoch(captured_at_utc)) AS latest"
4511
+ " FROM quota_window_snapshots"
4512
+ f" WHERE {_codex_quota_latest_validity_sql('')}"
4513
+ f" AND {_codex_quota_latest_base_match(image)})"
4514
+ f" WHERE latest IS NOT NULL{absent_clause}"
4515
+ )
4516
+
4517
+
4518
+ def _codex_quota_latest_bootstrap_select() -> str:
4519
+ """Every valid raw partition's maximum second, streamed off the seek index."""
4520
+ keys = ", ".join(_codex_quota_latest_key_exprs(""))
4521
+ return (
4522
+ f"SELECT {keys}, MAX(unixepoch(captured_at_utc))"
4523
+ " FROM quota_window_snapshots INDEXED BY idx_qws_partition_capture"
4524
+ f" WHERE {_codex_quota_latest_validity_sql('')}"
4525
+ f" GROUP BY source, {keys}"
4526
+ )
4527
+
4528
+
4529
+ def _codex_quota_latest_bootstrap_sql() -> str:
4530
+ return (
4531
+ "INSERT INTO codex_quota_partition_latest ("
4532
+ + ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
4533
+ + ", latest_capture_epoch) "
4534
+ + _codex_quota_latest_bootstrap_select()
4535
+ )
4536
+
4537
+
4538
+ def _codex_quota_latest_ddl() -> "tuple[str, ...]":
4539
+ """DDL for the summary, its seek index and its three triggers.
4540
+
4541
+ The triggers are dropped and re-created on every apply, like the change
4542
+ ledger's, so a definition change never leaves an older body behind; the
4543
+ table keeps IF NOT EXISTS and its rows survive.
4544
+ """
4545
+ columns = ",\n".join(
4546
+ f" {key} BLOB NOT NULL"
4547
+ for key in _CODEX_QUOTA_LATEST_KEY_COLUMNS)
4548
+ keys = ", ".join(_CODEX_QUOTA_LATEST_KEY_COLUMNS)
4549
+ index_columns = ", ".join((
4550
+ "source", *_codex_quota_latest_key_exprs(""),
4551
+ "unixepoch(captured_at_utc)"))
4552
+ insert_new = (
4553
+ f"INSERT INTO codex_quota_partition_latest ({keys}, latest_capture_epoch)"
4554
+ f" SELECT {', '.join(_codex_quota_latest_key_exprs('NEW.'))},"
4555
+ " unixepoch(NEW.captured_at_utc)"
4556
+ " WHERE NOT EXISTS (SELECT 1 FROM codex_quota_partition_latest"
4557
+ f" WHERE {_codex_quota_latest_summary_match('NEW')})"
4558
+ )
4559
+ return (
4560
+ "CREATE TABLE IF NOT EXISTS codex_quota_partition_latest (\n"
4561
+ f"{columns},\n"
4562
+ " latest_capture_epoch BLOB NOT NULL,\n"
4563
+ f" PRIMARY KEY ({keys})\n"
4564
+ " ) WITHOUT ROWID",
4565
+ "CREATE INDEX IF NOT EXISTS idx_qws_partition_capture "
4566
+ f"ON quota_window_snapshots({index_columns}) "
4567
+ f"WHERE {_codex_quota_latest_validity_sql('')}",
4568
+ "DROP TRIGGER IF EXISTS trg_qws_latest_ins",
4569
+ "CREATE TRIGGER trg_qws_latest_ins\n"
4570
+ " AFTER INSERT ON quota_window_snapshots\n"
4571
+ f" WHEN {_codex_quota_latest_validity_sql('NEW.')}\n"
4572
+ " BEGIN\n"
4573
+ " DELETE FROM codex_quota_partition_latest"
4574
+ f" WHERE {_codex_quota_latest_summary_match('NEW')}"
4575
+ " AND latest_capture_epoch < unixepoch(NEW.captured_at_utc);\n"
4576
+ f" {insert_new};\n"
4577
+ " END",
4578
+ "DROP TRIGGER IF EXISTS trg_qws_latest_del",
4579
+ "CREATE TRIGGER trg_qws_latest_del\n"
4580
+ " AFTER DELETE ON quota_window_snapshots\n"
4581
+ f" WHEN {_codex_quota_latest_validity_sql('OLD.')}\n"
4582
+ " BEGIN\n"
4583
+ " DELETE FROM codex_quota_partition_latest"
4584
+ f" WHERE {_codex_quota_latest_summary_match('OLD')}"
4585
+ " AND latest_capture_epoch = unixepoch(OLD.captured_at_utc);\n"
4586
+ " "
4587
+ f"{_codex_quota_latest_recompute_sql('OLD', only_if_absent=True)};\n"
4588
+ " END",
4589
+ "DROP TRIGGER IF EXISTS trg_qws_latest_upd",
4590
+ "CREATE TRIGGER trg_qws_latest_upd\n"
4591
+ f" AFTER UPDATE OF {', '.join(_CODEX_QUOTA_LATEST_UPDATE_OF)}"
4592
+ " ON quota_window_snapshots\n"
4593
+ " WHEN OLD.source = 'codex' OR NEW.source = 'codex'\n"
4594
+ " BEGIN\n"
4595
+ " DELETE FROM codex_quota_partition_latest"
4596
+ f" WHERE {_codex_quota_latest_summary_match('OLD')};\n"
4597
+ " "
4598
+ f"{_codex_quota_latest_recompute_sql('OLD', only_if_absent=False)};\n"
4599
+ " DELETE FROM codex_quota_partition_latest"
4600
+ f" WHERE {_codex_quota_latest_summary_match('NEW')};\n"
4601
+ " "
4602
+ f"{_codex_quota_latest_recompute_sql('NEW', only_if_absent=False)};\n"
4603
+ " END",
4604
+ )
4605
+
4606
+
4607
+ def _apply_codex_quota_latest_summary(
4608
+ conn: sqlite3.Connection, *, rebootstrap: bool = False,
4609
+ ) -> None:
4610
+ """Install the latest-capture summary and keep it equal to the physical rows.
4611
+
4612
+ Atomic: the table, its seek index, the three triggers and, when needed, the
4613
+ bootstrap commit together — as a savepoint inside the caller's open
4614
+ transaction, or as an IMMEDIATE transaction of their own. The bootstrap runs
4615
+ when any of the five objects was missing before this call (so a store that
4616
+ carries all five has been maintained by the triggers since they were
4617
+ created) or when ``rebootstrap`` asks for it, which migration 047 always
4618
+ does, so a re-upgrade after an older binary trimmed its marker re-derives
4619
+ the summary from the physical rows.
4620
+
4621
+ Guarded on the three columns the partition reads: a legacy-shape cache whose
4622
+ schema apply took the FTS early-return before they existed keeps the
4623
+ window-query fallback, which is correct, just not spill-free.
4624
+ """
4625
+ columns = {
4626
+ str(row[1]) for row in conn.execute(
4627
+ "PRAGMA table_info(quota_window_snapshots)")
4628
+ }
4629
+ if not {"canonical_resets_at_utc", "observed_model", "account_key"} <= columns:
4630
+ return
4631
+ own_transaction = not conn.in_transaction
4632
+ conn.execute(
4633
+ "BEGIN IMMEDIATE" if own_transaction
4634
+ else "SAVEPOINT codex_quota_latest_apply")
4635
+ try:
4636
+ present = {
4637
+ (str(row[0]), str(row[1])) for row in conn.execute(
4638
+ "SELECT type, name FROM sqlite_master "
4639
+ "WHERE type IN ('table', 'index', 'trigger')")
4640
+ }
4641
+ complete = all(item in present for item in _CODEX_QUOTA_LATEST_OBJECTS)
4642
+ for statement in _codex_quota_latest_ddl():
4643
+ conn.execute(statement)
4644
+ if rebootstrap or not complete:
4645
+ conn.execute("DELETE FROM codex_quota_partition_latest")
4646
+ conn.execute(_codex_quota_latest_bootstrap_sql())
4647
+ except BaseException:
4648
+ if own_transaction:
4649
+ conn.rollback()
4650
+ else:
4651
+ conn.execute("ROLLBACK TO codex_quota_latest_apply")
4652
+ conn.execute("RELEASE codex_quota_latest_apply")
4653
+ raise
4654
+ if own_transaction:
4655
+ conn.commit()
4656
+ else:
4657
+ conn.execute("RELEASE codex_quota_latest_apply")
4658
+
4659
+
4660
+ def _apply_codex_quota_load_order_index(conn: sqlite3.Connection) -> None:
4661
+ """The general loader's ORDER BY, served by an index (#901 §5.1, T1/T3).
4662
+
4663
+ Adoption (W3a, every Codex ingest including the hook), breakdown evidence,
4664
+ the projection's root loads and the CLI commands all read complete
4665
+ populations in ``source_root_key, captured_at_utc, resets_at_utc,
4666
+ source_path, line_offset`` order; without this index each one sorted its
4667
+ whole population through a temp b-tree. ``canonical_resets_at_utc`` rides
4668
+ at the end so the adoption pass's canonical-reset bound is evaluated on the
4669
+ index entry before any table row is fetched. Guarded on that column like
4670
+ idx_qws_physical_group.
4671
+ """
4672
+ columns = {
4673
+ str(row[1]) for row in conn.execute(
4674
+ "PRAGMA table_info(quota_window_snapshots)")
4675
+ }
4676
+ if "canonical_resets_at_utc" not in columns:
4677
+ return
4678
+ conn.execute(
4679
+ "CREATE INDEX IF NOT EXISTS idx_qws_codex_load_order "
4680
+ "ON quota_window_snapshots(source_root_key, captured_at_utc, "
4681
+ "resets_at_utc, source_path, line_offset, canonical_resets_at_utc) "
4682
+ "WHERE source='codex'"
4683
+ )
4684
+
4685
+
4686
+ def _apply_codex_metadata_lookup_indexes(conn: sqlite3.Connection) -> None:
4687
+ """The W2 correlated lookups' indexes (#901 §5.2, T2).
4688
+
4689
+ ``idx_codex_entries_root_path_time`` answers each file's and each thread's
4690
+ first accounting instant with one covering seek (the existing
4691
+ ``idx_codex_entries_root_path`` keeps serving the minimum ``id``, because
4692
+ the rowid trails its two columns). ``idx_codex_files_alias_recent`` and
4693
+ ``idx_codex_threads_recent`` hold the two queries' final orders so neither
4694
+ sorts. Each is guarded on its columns, because a legacy-shape cache can
4695
+ lack the ALTER-added ones.
4696
+ """
4697
+ def _columns(table: str) -> "set[str]":
4698
+ return {
4699
+ str(row[0]) for row in conn.execute(
4700
+ "SELECT name FROM pragma_table_info(?)", (table,))
4701
+ }
4702
+
4703
+ if {"source_root_key", "source_path", "timestamp_utc"} <= _columns(
4704
+ "codex_session_entries"):
4705
+ conn.execute(
4706
+ "CREATE INDEX IF NOT EXISTS idx_codex_entries_root_path_time "
4707
+ "ON codex_session_entries(source_root_key, source_path, "
4708
+ "timestamp_utc)"
4709
+ )
4710
+ if {"last_ingested_at", "path", "last_native_thread_id"} <= _columns(
4711
+ "codex_session_files"):
4712
+ conn.execute(
4713
+ "CREATE INDEX IF NOT EXISTS idx_codex_files_alias_recent "
4714
+ "ON codex_session_files(last_ingested_at, path) "
4715
+ "WHERE last_native_thread_id IS NOT NULL "
4716
+ "AND last_native_thread_id != ''"
4717
+ )
4718
+ if {"last_seen_utc", "conversation_key"} <= _columns(
4719
+ "codex_conversation_threads"):
4720
+ conn.execute(
4721
+ "CREATE INDEX IF NOT EXISTS idx_codex_threads_recent "
4722
+ "ON codex_conversation_threads(last_seen_utc, conversation_key)"
4723
+ )
4724
+
4725
+
4726
+ def _apply_pricing_model_indexes(conn: sqlite3.Connection) -> None:
4727
+ """Model-first covering indexes for ``_pricing_observed_models`` (#901 §5.3).
4728
+
4729
+ Both of its modes — doctor's trailing 30 days and pricing-check's whole
4730
+ history — group by model over one entries table. Covering the token columns
4731
+ lets SQLite stream the GROUP BY off the index with the timestamp filter
4732
+ evaluated on the entry; a narrower ``(model, timestamp_utc)`` index streams
4733
+ too but pays one table lookup per row, a latency regression the §6.3
4734
+ per-call-site gate would catch.
4735
+ """
4736
+ def _columns(table: str) -> "set[str]":
4737
+ return {
4738
+ str(row[0]) for row in conn.execute(
4739
+ "SELECT name FROM pragma_table_info(?)", (table,))
4740
+ }
4741
+
4742
+ if {"model", "timestamp_utc", "input_tokens", "output_tokens",
4743
+ "cache_create_tokens", "cache_read_tokens"} <= _columns(
4744
+ "session_entries"):
4745
+ conn.execute(
4746
+ "CREATE INDEX IF NOT EXISTS idx_entries_model_time "
4747
+ "ON session_entries(model, timestamp_utc, input_tokens, "
4748
+ "output_tokens, cache_create_tokens, cache_read_tokens)"
4749
+ )
4750
+ if {"model", "timestamp_utc", "total_tokens"} <= _columns(
4751
+ "codex_session_entries"):
4752
+ conn.execute(
4753
+ "CREATE INDEX IF NOT EXISTS idx_codex_entries_model_time "
4754
+ "ON codex_session_entries(model, timestamp_utc, total_tokens)"
4755
+ )
4756
+
4757
+
4758
+ def _apply_codex_window_attribution_read_index(conn: sqlite3.Connection) -> None:
4759
+ """The window-attribution read's order, served by an index (#901 I1/G1).
4760
+
4761
+ ``_cctally_cache.load_active_window_attributions`` (every Codex ingest's
4762
+ spend reconciliation, so the dashboard and the Codex hook, and the
4763
+ attribution overlay of every quota load) returns every asserted or
4764
+ retracted weekly attribution in ``asserted_at_utc, op_id`` order; without
4765
+ this index it sorted all of them through a temp b-tree, a population that
4766
+ grows by one assertion per attributed window. ``window_minutes`` leads
4767
+ because the read's one equality filter is on it; the root, retraction and
4768
+ account predicates are evaluated along the ordered scan. ``source_root_key``
4769
+ is deliberately NOT ahead of the ordering columns: several roots share one
4770
+ global assertion order (Amendment 1 item 1, ``c901-design-2``). Guarded on
4771
+ the table's columns, because the conversations store drops the table and a
4772
+ legacy-shape cache can predate it.
4773
+ """
4774
+ columns = {
4775
+ str(row[0]) for row in conn.execute(
4776
+ "SELECT name FROM pragma_table_info(?)",
4777
+ ("codex_window_attributions",))
4778
+ }
4779
+ if {"window_minutes", "asserted_at_utc", "op_id"} <= columns:
4780
+ conn.execute(
4781
+ "CREATE INDEX IF NOT EXISTS idx_codex_window_attributions_read_order "
4782
+ "ON codex_window_attributions(window_minutes ASC, asserted_at_utc ASC, "
4783
+ "op_id ASC)"
4784
+ )
4785
+
4786
+
4286
4787
  # === Region 7b2: Eager cache-migration trigger (V4 — same-invocation 008 apply) ===
4287
4788
 
4288
4789
 
4790
+ # #968 spec 5.3: container evidence beside each Codex cursor row. Every
4791
+ # persisted key stays the LOGICAL ``.jsonl`` path; these columns record which
4792
+ # physical container's content the cursor accepted. Defaults describe what an
4793
+ # existing row already is: a plain file whose content was accepted. No evidence
4794
+ # column is backfilled by statting a file now, for the reason migration 046
4795
+ # gives for device_id/inode.
4796
+ _CODEX_CONTAINER_CURSOR_COLUMNS: "tuple[tuple[str, str], ...]" = (
4797
+ ("container_kind", "TEXT NOT NULL DEFAULT 'plain'"),
4798
+ ("container_path", "TEXT"),
4799
+ ("container_size_bytes", "INTEGER"),
4800
+ ("decoded_size_bytes", "INTEGER"),
4801
+ ("content_anchor_version", "INTEGER"),
4802
+ ("content_anchor", "BLOB"),
4803
+ ("content_anchor_offset", "INTEGER"),
4804
+ ("container_state", "TEXT NOT NULL DEFAULT 'accepted'"),
4805
+ ("container_state_reason", "TEXT"),
4806
+ ("pending_container_fingerprint", "TEXT"),
4807
+ )
4808
+ _CODEX_CONTAINER_CACHE_ONLY_COLUMNS: "tuple[tuple[str, str], ...]" = (
4809
+ ("container_resume_strikes", "INTEGER NOT NULL DEFAULT 0"),
4810
+ )
4811
+ _CODEX_968_CACHE_TABLES = (
4812
+ "codex_prune_debt", "codex_barrier_membership", "codex_barrier_targets",
4813
+ "codex_barrier_population", "codex_barrier_certificates",
4814
+ "codex_barrier_repairs", "codex_barrier_entry_threads",
4815
+ )
4816
+ _CODEX_968_CONVERSATIONS_TABLES = (
4817
+ "codex_conversation_prune_debt", "codex_conversation_publications",
4818
+ "codex_conversation_published_threads",
4819
+ )
4820
+ #: The column list of `codex_conversation_threads`, shared by the two
4821
+ #: byte-preserving thread snapshots the publication barrier keeps: a thread
4822
+ #: row is copied with INSERT ... SELECT, never through JSON, so an undecodable
4823
+ #: cwd/git_json (#845) survives exactly as stored.
4824
+ _CODEX_THREAD_SNAPSHOT_COLUMNS_DDL = (
4825
+ " conversation_key TEXT NOT NULL PRIMARY KEY,"
4826
+ " source_root_key TEXT NOT NULL,"
4827
+ " native_thread_id TEXT NOT NULL,"
4828
+ " root_thread_id TEXT NOT NULL,"
4829
+ " parent_thread_id TEXT,"
4830
+ " source_path TEXT NOT NULL,"
4831
+ " cwd TEXT,"
4832
+ " git_json TEXT,"
4833
+ " source_kind TEXT,"
4834
+ " thread_source_json TEXT,"
4835
+ " model_provider TEXT,"
4836
+ " context_window INTEGER,"
4837
+ " first_seen_utc TEXT,"
4838
+ " last_seen_utc TEXT)"
4839
+ )
4840
+ _CODEX_968_CACHE_DDL = (
4841
+ "CREATE TABLE IF NOT EXISTS codex_prune_debt ("
4842
+ " source_path TEXT NOT NULL,"
4843
+ " source_root_key TEXT NOT NULL DEFAULT '',"
4844
+ " recorded_at_utc TEXT NOT NULL,"
4845
+ " PRIMARY KEY (source_path, source_root_key)) WITHOUT ROWID",
4846
+ "CREATE TABLE IF NOT EXISTS codex_barrier_membership ("
4847
+ " file_identity TEXT NOT NULL,"
4848
+ " generation_token TEXT NOT NULL,"
4849
+ " conversation_key TEXT NOT NULL,"
4850
+ " source_path TEXT NOT NULL,"
4851
+ " metadata_json TEXT,"
4852
+ " metadata_offset INTEGER,"
4853
+ " PRIMARY KEY (file_identity, generation_token, conversation_key))"
4854
+ " WITHOUT ROWID",
4855
+ "CREATE INDEX IF NOT EXISTS idx_codex_barrier_membership_conversation"
4856
+ " ON codex_barrier_membership(conversation_key)",
4857
+ "CREATE TABLE IF NOT EXISTS codex_barrier_targets ("
4858
+ " file_identity TEXT PRIMARY KEY,"
4859
+ " source_path TEXT NOT NULL,"
4860
+ " source_root_key TEXT,"
4861
+ " generation_token TEXT NOT NULL,"
4862
+ " scan_target INTEGER NOT NULL,"
4863
+ " complete_line_end INTEGER,"
4864
+ " state TEXT NOT NULL CHECK (state IN ('required','ready','suspended')),"
4865
+ " reason TEXT,"
4866
+ " accepted_generation_token TEXT,"
4867
+ " accepted_complete_line_end INTEGER)",
4868
+ "CREATE TABLE IF NOT EXISTS codex_barrier_population ("
4869
+ " conversation_key TEXT PRIMARY KEY,"
4870
+ " required_revision INTEGER NOT NULL DEFAULT 1,"
4871
+ " entered_at_utc TEXT NOT NULL)",
4872
+ "CREATE TABLE IF NOT EXISTS codex_barrier_entry_threads ("
4873
+ + _CODEX_THREAD_SNAPSHOT_COLUMNS_DDL,
4874
+ "CREATE INDEX IF NOT EXISTS idx_codex_barrier_entry_threads_parent"
4875
+ " ON codex_barrier_entry_threads"
4876
+ "(source_root_key, native_thread_id, conversation_key)",
4877
+ "CREATE TABLE IF NOT EXISTS codex_barrier_certificates ("
4878
+ " conversation_key TEXT NOT NULL,"
4879
+ " revision INTEGER NOT NULL,"
4880
+ " certificate_json TEXT NOT NULL,"
4881
+ " sealed_at_utc TEXT NOT NULL,"
4882
+ " PRIMARY KEY (conversation_key, revision)) WITHOUT ROWID",
4883
+ "CREATE TABLE IF NOT EXISTS codex_barrier_repairs ("
4884
+ " conversation_key TEXT PRIMARY KEY,"
4885
+ " revision INTEGER NOT NULL,"
4886
+ " reason TEXT NOT NULL)",
4887
+ )
4888
+ _CODEX_968_CONVERSATIONS_DDL = (
4889
+ "CREATE TABLE IF NOT EXISTS codex_conversation_prune_debt ("
4890
+ " source_path TEXT NOT NULL,"
4891
+ " source_root_key TEXT NOT NULL DEFAULT '',"
4892
+ " recorded_at_utc TEXT NOT NULL,"
4893
+ " PRIMARY KEY (source_path, source_root_key)) WITHOUT ROWID",
4894
+ "CREATE TABLE IF NOT EXISTS codex_conversation_publications ("
4895
+ " conversation_key TEXT PRIMARY KEY,"
4896
+ " required_revision INTEGER NOT NULL DEFAULT 0,"
4897
+ " ready_certificate_revision INTEGER,"
4898
+ " published_revision INTEGER NOT NULL DEFAULT 0,"
4899
+ " legacy_uncertified INTEGER NOT NULL DEFAULT 0,"
4900
+ " updated_at_utc TEXT NOT NULL)",
4901
+ "CREATE TABLE IF NOT EXISTS codex_conversation_published_threads ("
4902
+ + _CODEX_THREAD_SNAPSHOT_COLUMNS_DDL,
4903
+ "CREATE INDEX IF NOT EXISTS idx_codex_published_threads_parent"
4904
+ " ON codex_conversation_published_threads"
4905
+ "(source_root_key, native_thread_id, conversation_key)",
4906
+ )
4907
+
4908
+
4289
4909
  class SchemaDeliveryObject(NamedTuple):
4290
4910
  """One re-derivable schema object declared by a version-gated apply.
4291
4911
 
@@ -4451,7 +5071,7 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4451
5071
  "031_codex_file_account_map"),
4452
5072
  SchemaDeliveryObject(
4453
5073
  "index", "idx_qws_physical_group", "_apply_codex_quota_group_index",
4454
- "040_codex_quota_physical_group_index"),
5074
+ "048_codex_quota_physical_group_order"),
4455
5075
  SchemaDeliveryObject(
4456
5076
  "index", "idx_qws_unresolved_model",
4457
5077
  "_apply_codex_quota_unresolved_model_index",
@@ -4471,6 +5091,21 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4471
5091
  "column", "codex_session_files.inode",
4472
5092
  "_apply_codex_session_file_identity",
4473
5093
  "046_codex_source_file_identity"),
5094
+ *(SchemaDeliveryObject(
5095
+ "column", f"codex_session_files.{column}",
5096
+ "_apply_codex_container_cache_schema", "049_codex_rollout_containers")
5097
+ for column, _decl in (_CODEX_CONTAINER_CURSOR_COLUMNS
5098
+ + _CODEX_CONTAINER_CACHE_ONLY_COLUMNS)),
5099
+ *(SchemaDeliveryObject(
5100
+ "table", table, "_apply_codex_container_cache_schema",
5101
+ "049_codex_rollout_containers")
5102
+ for table in _CODEX_968_CACHE_TABLES),
5103
+ SchemaDeliveryObject(
5104
+ "index", "idx_codex_barrier_membership_conversation",
5105
+ "_apply_codex_container_cache_schema", "049_codex_rollout_containers"),
5106
+ SchemaDeliveryObject(
5107
+ "index", "idx_codex_barrier_entry_threads_parent",
5108
+ "_apply_codex_container_cache_schema", "049_codex_rollout_containers"),
4474
5109
  SchemaDeliveryObject(
4475
5110
  "index", "idx_codex_accounting_change_mutation",
4476
5111
  "_apply_codex_accounting_change_ledger",
@@ -4537,9 +5172,119 @@ CACHE_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
4537
5172
  "trigger", "trg_codex_accounting_upd",
4538
5173
  "_apply_codex_accounting_change_ledger",
4539
5174
  "044_codex_accounting_change_ledger"),
5175
+ SchemaDeliveryObject(
5176
+ "table", "codex_quota_partition_latest",
5177
+ "_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
5178
+ SchemaDeliveryObject(
5179
+ "index", "idx_qws_partition_capture",
5180
+ "_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
5181
+ SchemaDeliveryObject(
5182
+ "trigger", "trg_qws_latest_del",
5183
+ "_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
5184
+ SchemaDeliveryObject(
5185
+ "trigger", "trg_qws_latest_ins",
5186
+ "_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
5187
+ SchemaDeliveryObject(
5188
+ "trigger", "trg_qws_latest_upd",
5189
+ "_apply_codex_quota_latest_summary", "047_spill_free_read_paths"),
5190
+ SchemaDeliveryObject(
5191
+ "index", "idx_qws_codex_load_order",
5192
+ "_apply_codex_quota_load_order_index", "047_spill_free_read_paths"),
5193
+ SchemaDeliveryObject(
5194
+ "index", "idx_codex_entries_root_path_time",
5195
+ "_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
5196
+ SchemaDeliveryObject(
5197
+ "index", "idx_codex_files_alias_recent",
5198
+ "_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
5199
+ SchemaDeliveryObject(
5200
+ "index", "idx_codex_threads_recent",
5201
+ "_apply_codex_metadata_lookup_indexes", "047_spill_free_read_paths"),
5202
+ SchemaDeliveryObject(
5203
+ "index", "idx_entries_model_time",
5204
+ "_apply_pricing_model_indexes", "047_spill_free_read_paths"),
5205
+ SchemaDeliveryObject(
5206
+ "index", "idx_codex_entries_model_time",
5207
+ "_apply_pricing_model_indexes", "047_spill_free_read_paths"),
5208
+ SchemaDeliveryObject(
5209
+ "index", "idx_codex_window_attributions_read_order",
5210
+ "_apply_codex_window_attribution_read_index",
5211
+ "047_spill_free_read_paths"),
4540
5212
  )
4541
5213
 
4542
5214
 
5215
+ def _apply_codex_container_cache_schema(conn: sqlite3.Connection) -> None:
5216
+ """#968 spec 5.3: container columns and the cache-side maintenance state.
5217
+
5218
+ Shared by ``_apply_cache_schema`` and migration 049 so both shapes
5219
+ converge. Columns go through ``add_column_if_missing``; the cursor table is
5220
+ checked first because that helper raises on a missing table, and a table
5221
+ created later is created by the schema apply that calls this helper anyway.
5222
+ """
5223
+ # Literal column names keep the schema-delivery AST guard complete.
5224
+ if _table_exists(conn, "codex_session_files"):
5225
+ add_column_if_missing(
5226
+ conn, "codex_session_files", "container_kind",
5227
+ "TEXT NOT NULL DEFAULT 'plain'")
5228
+ add_column_if_missing(
5229
+ conn, "codex_session_files", "container_path", "TEXT")
5230
+ add_column_if_missing(
5231
+ conn, "codex_session_files", "container_size_bytes", "INTEGER")
5232
+ add_column_if_missing(
5233
+ conn, "codex_session_files", "decoded_size_bytes", "INTEGER")
5234
+ add_column_if_missing(
5235
+ conn, "codex_session_files", "content_anchor_version", "INTEGER")
5236
+ add_column_if_missing(
5237
+ conn, "codex_session_files", "content_anchor", "BLOB")
5238
+ add_column_if_missing(
5239
+ conn, "codex_session_files", "content_anchor_offset", "INTEGER")
5240
+ add_column_if_missing(
5241
+ conn, "codex_session_files", "container_state",
5242
+ "TEXT NOT NULL DEFAULT 'accepted'")
5243
+ add_column_if_missing(
5244
+ conn, "codex_session_files", "container_state_reason", "TEXT")
5245
+ add_column_if_missing(
5246
+ conn, "codex_session_files", "pending_container_fingerprint", "TEXT")
5247
+ add_column_if_missing(
5248
+ conn, "codex_session_files", "container_resume_strikes",
5249
+ "INTEGER NOT NULL DEFAULT 0")
5250
+ for statement in _CODEX_968_CACHE_DDL:
5251
+ conn.execute(statement)
5252
+
5253
+
5254
+ def _apply_codex_conversation_container_schema(conn: sqlite3.Connection) -> None:
5255
+ """#968 spec 5.3: the conversations twin of the container evidence.
5256
+
5257
+ Shared by ``_apply_conversations_schema`` (fast path and full path) and
5258
+ conversations migration 012.
5259
+ """
5260
+ # Keep these in the same order as _CODEX_CONTAINER_CURSOR_COLUMNS.
5261
+ if _table_exists(conn, "codex_conversation_source_files"):
5262
+ add_column_if_missing(
5263
+ conn, "codex_conversation_source_files", "container_kind",
5264
+ "TEXT NOT NULL DEFAULT 'plain'")
5265
+ add_column_if_missing(
5266
+ conn, "codex_conversation_source_files", "container_path", "TEXT")
5267
+ add_column_if_missing(
5268
+ conn, "codex_conversation_source_files", "container_size_bytes", "INTEGER")
5269
+ add_column_if_missing(
5270
+ conn, "codex_conversation_source_files", "decoded_size_bytes", "INTEGER")
5271
+ add_column_if_missing(
5272
+ conn, "codex_conversation_source_files", "content_anchor_version", "INTEGER")
5273
+ add_column_if_missing(
5274
+ conn, "codex_conversation_source_files", "content_anchor", "BLOB")
5275
+ add_column_if_missing(
5276
+ conn, "codex_conversation_source_files", "content_anchor_offset", "INTEGER")
5277
+ add_column_if_missing(
5278
+ conn, "codex_conversation_source_files", "container_state",
5279
+ "TEXT NOT NULL DEFAULT 'accepted'")
5280
+ add_column_if_missing(
5281
+ conn, "codex_conversation_source_files", "container_state_reason", "TEXT")
5282
+ add_column_if_missing(
5283
+ conn, "codex_conversation_source_files", "pending_container_fingerprint", "TEXT")
5284
+ for statement in _CODEX_968_CONVERSATIONS_DDL:
5285
+ conn.execute(statement)
5286
+
5287
+
4543
5288
  def _apply_codex_session_file_identity(conn: sqlite3.Connection) -> None:
4544
5289
  """Deliver the Codex accounting cursor's file identity (#769 S6, #716 A).
4545
5290
 
@@ -5111,6 +5856,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
5111
5856
  conn, "codex_session_files", "ingest_complete",
5112
5857
  "INTEGER NOT NULL DEFAULT 1")
5113
5858
  _apply_codex_session_file_identity(conn)
5859
+ _apply_codex_container_cache_schema(conn)
5114
5860
  add_column_if_missing(conn, "quota_window_snapshots", "account_key", "TEXT")
5115
5861
  # #195: the cache-write TTL split. NULLable with NO DEFAULT — NULL is the
5116
5862
  # "split unknown" sentinel a pre-#195 row produces for free, and a real
@@ -5249,6 +5995,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
5249
5995
  _apply_codex_quota_unresolved_model_index(conn)
5250
5996
  _apply_codex_quota_change_ledger(conn)
5251
5997
  _apply_codex_accounting_change_ledger(conn)
5998
+ # #901 (spec §5.1-§5.3): the latest-capture summary, the general loader's
5999
+ # ordering index, the W2 lookup indexes, the pricing model indexes and the
6000
+ # window-attribution read order (Amendment 1 item 1). BEFORE the
6001
+ # legacy-FTS early-return, so an old-shape cache.db gains them.
6002
+ _apply_codex_quota_latest_summary(conn)
6003
+ _apply_codex_quota_load_order_index(conn)
6004
+ _apply_codex_metadata_lookup_indexes(conn)
6005
+ _apply_pricing_model_indexes(conn)
6006
+ _apply_codex_window_attribution_read_index(conn)
5252
6007
  conn.execute(
5253
6008
  "CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
5254
6009
  "ON session_files(session_id)"
@@ -5394,6 +6149,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
5394
6149
  _apply_codex_find_projection_schema(conn)
5395
6150
  _apply_conversation_generation_schema(conn)
5396
6151
  _apply_codex_conversation_source_identity(conn)
6152
+ _apply_conversation_latest_meta_indexes(conn)
6153
+ _apply_codex_conversation_container_schema(conn)
5397
6154
  return
5398
6155
 
5399
6156
  _apply_cache_schema(conn)
@@ -5410,6 +6167,14 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
5410
6167
  -- it), so this only clears the now-orphan ledger and keeps
5411
6168
  -- conversations.db transcripts-only.
5412
6169
  DROP TABLE IF EXISTS quota_window_change_log;
6170
+ -- #968: only the conversation twins belong in this store.
6171
+ DROP TABLE IF EXISTS codex_prune_debt;
6172
+ DROP TABLE IF EXISTS codex_barrier_membership;
6173
+ DROP TABLE IF EXISTS codex_barrier_targets;
6174
+ DROP TABLE IF EXISTS codex_barrier_population;
6175
+ DROP TABLE IF EXISTS codex_barrier_certificates;
6176
+ DROP TABLE IF EXISTS codex_barrier_repairs;
6177
+ DROP TABLE IF EXISTS codex_barrier_entry_threads;
5413
6178
  DROP TABLE IF EXISTS codex_accounting_change_log;
5414
6179
  DROP TABLE IF EXISTS codex_conversation_threads;
5415
6180
  DROP TABLE IF EXISTS codex_source_roots;
@@ -5425,6 +6190,10 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
5425
6190
  -- tests/test_conversations_transcripts_only.py, which derives its
5426
6191
  -- expectation from COVERAGE_CACHE_FAMILIES rather than restating a list.
5427
6192
  DROP TABLE IF EXISTS codex_window_attributions;
6193
+ -- #901: the latest-capture summary is a cache.db read model over
6194
+ -- quota_window_snapshots, which was dropped above together with its
6195
+ -- triggers and seek index. Drop the table as well.
6196
+ DROP TABLE IF EXISTS codex_quota_partition_latest;
5428
6197
 
5429
6198
  CREATE TABLE IF NOT EXISTS conversation_source_files (
5430
6199
  path TEXT PRIMARY KEY,
@@ -5478,6 +6247,8 @@ def _apply_conversations_schema(conn: sqlite3.Connection) -> None:
5478
6247
  _apply_codex_find_projection_schema(conn)
5479
6248
  _apply_conversation_generation_schema(conn)
5480
6249
  _apply_codex_conversation_source_identity(conn)
6250
+ _apply_conversation_latest_meta_indexes(conn)
6251
+ _apply_codex_conversation_container_schema(conn)
5481
6252
 
5482
6253
 
5483
6254
  def _ensure_codex_session_meta_provenance_index(
@@ -5654,6 +6425,36 @@ def _apply_codex_conversation_source_identity(conn: sqlite3.Connection) -> None:
5654
6425
  conn, "codex_conversation_source_files", "inode", "INTEGER")
5655
6426
 
5656
6427
 
6428
+ def _apply_conversation_latest_meta_indexes(conn: sqlite3.Connection) -> None:
6429
+ """The latest-metadata lookups' partial indexes (#901 Q11, 901-PA-001 b).
6430
+
6431
+ ``_lib_conversation_query._session_latest_meta_map`` resolves a session's
6432
+ latest non-NULL ``cwd`` and latest non-NULL ``git_branch`` with two
6433
+ ``LIMIT 1`` lookups ordered ``timestamp_utc DESC, id DESC``. Each partial
6434
+ index holds exactly the rows its lookup may return, in that order, so the
6435
+ lookup is one seek however long the session is and however sparse its
6436
+ metadata. The window query they replace sorted every touched session's
6437
+ whole history through temp b-trees on each conversation-sync pass (9.56 MB
6438
+ of temp in one measured call over a long session).
6439
+
6440
+ Called by the fresh-install schema apply and by conversations migration
6441
+ 011, so both shapes converge. Skipped when ``conversation_messages`` is
6442
+ absent (a reachable state, not a corrupt one).
6443
+ """
6444
+ if not _table_exists(conn, "conversation_messages"):
6445
+ return
6446
+ conn.execute(
6447
+ "CREATE INDEX IF NOT EXISTS idx_conv_session_latest_cwd "
6448
+ "ON conversation_messages(session_id, timestamp_utc, id) "
6449
+ "WHERE cwd IS NOT NULL"
6450
+ )
6451
+ conn.execute(
6452
+ "CREATE INDEX IF NOT EXISTS idx_conv_session_latest_git_branch "
6453
+ "ON conversation_messages(session_id, timestamp_utc, id) "
6454
+ "WHERE git_branch IS NOT NULL"
6455
+ )
6456
+
6457
+
5657
6458
  def _apply_codex_find_projection_schema(conn: sqlite3.Connection) -> None:
5658
6459
  """Create #482's disposable visible-text projection tables.
5659
6460
 
@@ -5817,6 +6618,27 @@ CONVERSATIONS_REDERIVABLE_OBJECTS: "tuple[SchemaDeliveryObject, ...]" = (
5817
6618
  "column", "codex_conversation_source_files.inode",
5818
6619
  "_apply_codex_conversation_source_identity",
5819
6620
  "010_codex_conversation_source_file_identity"),
6621
+ SchemaDeliveryObject(
6622
+ "index", "idx_conv_session_latest_cwd",
6623
+ "_apply_conversation_latest_meta_indexes",
6624
+ "011_conversation_latest_meta_indexes"),
6625
+ SchemaDeliveryObject(
6626
+ "index", "idx_conv_session_latest_git_branch",
6627
+ "_apply_conversation_latest_meta_indexes",
6628
+ "011_conversation_latest_meta_indexes"),
6629
+ *(SchemaDeliveryObject(
6630
+ "column", f"codex_conversation_source_files.{column}",
6631
+ "_apply_codex_conversation_container_schema",
6632
+ "012_codex_conversation_containers")
6633
+ for column, _decl in _CODEX_CONTAINER_CURSOR_COLUMNS),
6634
+ *(SchemaDeliveryObject(
6635
+ "table", table, "_apply_codex_conversation_container_schema",
6636
+ "012_codex_conversation_containers")
6637
+ for table in _CODEX_968_CONVERSATIONS_TABLES),
6638
+ SchemaDeliveryObject(
6639
+ "index", "idx_codex_published_threads_parent",
6640
+ "_apply_codex_conversation_container_schema",
6641
+ "012_codex_conversation_containers"),
5820
6642
  SchemaDeliveryObject(
5821
6643
  "table", "claude_conversation_account_stamp_gaps",
5822
6644
  "_apply_conversation_generation_schema",
@@ -6026,7 +6848,8 @@ def _conv_005_conversation_account_dimension(conn: sqlite3.Connection) -> None:
6026
6848
  held += _acquire_conversations_db_codex_provider_flock(
6027
6849
  conn, migration="conversations 005 account dimension"
6028
6850
  )
6029
- claude_key = _cctally_journal.find_accounts_cutover_op()
6851
+ claude_key = _accounts_cutover_value_or_defer(
6852
+ "Claude conversation backfill")
6030
6853
  if claude_key is None:
6031
6854
  pending = conn.execute(
6032
6855
  "SELECT 1 FROM conversation_messages "
@@ -6453,6 +7276,50 @@ def _conv_010_codex_conversation_source_file_identity(
6453
7276
  conn.commit()
6454
7277
 
6455
7278
 
7279
+ @conversations_migration("011_conversation_latest_meta_indexes")
7280
+ def _conv_011_conversation_latest_meta_indexes(
7281
+ conn: sqlite3.Connection,
7282
+ ) -> None:
7283
+ """Deliver the latest-metadata partial indexes (#901 Q11, 901-PA-001 b).
7284
+
7285
+ ``_session_latest_meta_map`` now resolves each touched session's latest
7286
+ ``cwd`` and ``git_branch`` with two index-served ``LIMIT 1`` lookups; the
7287
+ indexes and the reasoning live on ``_apply_conversation_latest_meta_indexes``,
7288
+ the helper the fresh-install apply also calls. The schema apply that would
7289
+ create them is version-gated, so this registration is what reaches an
7290
+ already-current store.
7291
+
7292
+ Takes the Claude provider flock and defers cleanly on contention, like 009:
7293
+ building the indexes holds SQLite's write lock for a sort of the Claude
7294
+ transcript rows (one-time setup work), and a concurrent Claude transcript
7295
+ writer then reports contention instead of exhausting its busy timeout.
7296
+
7297
+ Re-running is a no-op (``IF NOT EXISTS``). NO self-stamp — the dispatcher
7298
+ central-stamps on a clean return (#140).
7299
+ """
7300
+ held = _acquire_conversations_db_claude_provider_flock(
7301
+ conn, migration="conversations 011 latest metadata indexes")
7302
+ try:
7303
+ _apply_conversation_latest_meta_indexes(conn)
7304
+ conn.commit()
7305
+ finally:
7306
+ _release_cache_db_writer_flocks(held)
7307
+
7308
+
7309
+ @conversations_migration("012_codex_conversation_containers")
7310
+ def _conv_012_codex_conversation_containers(conn: sqlite3.Connection) -> None:
7311
+ """#968 spec 5.3: the conversations twin of cache migration 049.
7312
+
7313
+ Delivers the transcript cursor's container columns, the conversation-side
7314
+ prune debt and the last-good publication records to an already-current
7315
+ store. Same version-gate reason and the same no-backfill rule as 049; no
7316
+ provider flock for the same reason as 010. Re-running is a no-op. NO
7317
+ self-stamp — the dispatcher central-stamps on a clean return (#140).
7318
+ """
7319
+ _apply_codex_conversation_container_schema(conn)
7320
+ conn.commit()
7321
+
7322
+
6456
7323
  # #177 S6: the consolidated multi-column external-content FTS5 table that
6457
7324
  # replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
6458
7325
  # pair. The three column names MUST match the conversation_messages columns BY
@@ -8435,7 +9302,7 @@ def _029_backfill_claude_account(conn: sqlite3.Connection) -> None:
8435
9302
  import _cctally_journal as _jr
8436
9303
  import _lib_accounts as _acc
8437
9304
 
8438
- op_value = _jr.find_accounts_cutover_op()
9305
+ op_value = _accounts_cutover_value_or_defer("Claude account backfill")
8439
9306
  if op_value is None:
8440
9307
  # Cutover hasn't appended its op yet. Defer only if there is legacy Claude
8441
9308
  # history to attribute; a fresh install (no such rows) proceeds as a no-op.
@@ -9277,6 +10144,89 @@ def _046_codex_source_file_identity(conn: sqlite3.Connection) -> None:
9277
10144
  conn.commit()
9278
10145
 
9279
10146
 
10147
+ @cache_migration("047_spill_free_read_paths")
10148
+ def _047_spill_free_read_paths(conn: sqlite3.Connection) -> None:
10149
+ """#901 (spec §5.1-§5.3): deliver the spill-free read paths to existing stores.
10150
+
10151
+ ``_apply_cache_schema`` creates every object below for a new store, but that
10152
+ pass is VERSION-GATED: ``open_cache_db`` runs it only when ``user_version``
10153
+ differs from ``len(_CACHE_MIGRATIONS)``. Registering here bumps the head,
10154
+ the mechanism migrations 029, 031, 044, 045 and 046 rely on.
10155
+
10156
+ The handler ALWAYS re-derives the latest-capture summary from the physical
10157
+ rows (``rebootstrap=True``), so a re-upgrade after an older binary trimmed
10158
+ this marker converges on the rows actually stored whatever happened to the
10159
+ summary meanwhile. On a first upgrade the schema apply has already
10160
+ bootstrapped it once; the second derivation is one ordered scan of the new
10161
+ seek index, paid once.
10162
+
10163
+ It also delivers ``idx_codex_window_attributions_read_order`` (Amendment 1
10164
+ item 1, ``c901-design-2``), the window-attribution read's assertion order.
10165
+
10166
+ Takes the Codex provider flock like handlers 024-027, 034 and 043, because
10167
+ the summary is Codex-derived, and DEFERS (``MigrationGateNotMet``) before
10168
+ any DDL when a Codex sync holds it. Idempotent: every object uses IF NOT
10169
+ EXISTS or drop-then-create, and a re-derivation over its own output writes
10170
+ the same rows. NO self-stamp — the dispatcher central-stamps on a clean
10171
+ return (#140).
10172
+ """
10173
+ held = _acquire_cache_db_codex_provider_flock(
10174
+ conn, migration="047 spill-free read paths")
10175
+ try:
10176
+ _apply_codex_quota_latest_summary(conn, rebootstrap=True)
10177
+ _apply_codex_quota_load_order_index(conn)
10178
+ _apply_codex_metadata_lookup_indexes(conn)
10179
+ _apply_pricing_model_indexes(conn)
10180
+ _apply_codex_window_attribution_read_index(conn)
10181
+ conn.commit()
10182
+ finally:
10183
+ _release_cache_db_writer_flocks(held)
10184
+
10185
+
10186
+ @cache_migration("048_codex_quota_physical_group_order")
10187
+ def _048_codex_quota_physical_group_order(conn: sqlite3.Connection) -> None:
10188
+ """#901 Q11 (spec §5.3a, 901-PA-001 a): stream every physical-group shard.
10189
+
10190
+ Replaces ``idx_qws_physical_group``'s five-member definition with the same
10191
+ name, partial predicate and five equality members followed by the loader's
10192
+ order columns ``captured_at_utc, resets_at_utc, source_path,
10193
+ line_offset``. The shard SQL, its population and its order are unchanged;
10194
+ only the sorter goes. The reasoning lives on
10195
+ ``_apply_codex_quota_group_index``, which this delegates to so the
10196
+ migration and the schema apply cannot drift, and which replaces the index
10197
+ atomically.
10198
+
10199
+ Same version-gate reason as 040: the schema apply that converges the index
10200
+ is skipped by a steady-state open, so registering here is what makes an
10201
+ already-current install pick it up. The rebuild is a one-time sort of the
10202
+ Codex quota rows (setup work, not a recurring path).
10203
+
10204
+ Re-running is a no-op: a current index is left alone. NO self-stamp — the
10205
+ dispatcher central-stamps on a clean return (#140).
10206
+ """
10207
+ _apply_codex_quota_group_index(conn)
10208
+ conn.commit()
10209
+
10210
+
10211
+ @cache_migration("049_codex_rollout_containers")
10212
+ def _049_codex_rollout_containers(conn: sqlite3.Connection) -> None:
10213
+ """#968 spec 5.3: deliver container evidence and maintenance state.
10214
+
10215
+ The schema apply that declares these objects is version-gated, so only a
10216
+ registered migration reaches an already-current store (the reason 046
10217
+ gives). Existing cursor rows read as plain and accepted, which is exactly
10218
+ what they are; no container evidence is backfilled from a stat taken now.
10219
+ The seven tables start empty: prune debt, verification state and the
10220
+ publication barrier are written only by the runtime paths that own them.
10221
+
10222
+ Takes no provider flock: ADD COLUMN with a constant default and CREATE of
10223
+ an empty table rewrite no rows. Re-running is a no-op. NO self-stamp — the
10224
+ dispatcher central-stamps on a clean return (#140).
10225
+ """
10226
+ _apply_codex_container_cache_schema(conn)
10227
+ conn.commit()
10228
+
10229
+
9280
10230
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
9281
10231
 
9282
10232
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -10867,6 +11817,13 @@ def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
10867
11817
  return path.with_name(f"{path.name}.repairing")
10868
11818
 
10869
11819
 
11820
+ def _keeper_yield_request_path(path: pathlib.Path) -> pathlib.Path:
11821
+ """#901 W9 (Q15): the store-scoped request that makes every process's
11822
+ idle checkpoint keeper (`_lib_wal_checkpoint`) close. Its owner record is
11823
+ the repair marker's, written, read and checked for liveness the same way."""
11824
+ return path.with_name(f"{path.name}.keeper-yield")
11825
+
11826
+
10870
11827
  _REPAIR_OWNER_VERSION = 1
10871
11828
  _REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS = 30 * 60
10872
11829
 
@@ -11019,10 +11976,30 @@ def _claim_repair_marker(
11019
11976
  path: pathlib.Path,
11020
11977
  ) -> "tuple[RepairMarkerClaim | None, str]":
11021
11978
  """Atomically publish a complete owner record; reclaim stale ownership."""
11022
- marker = _repair_marker_path(path)
11979
+ return _claim_owner_record(
11980
+ _repair_marker_path(path),
11981
+ lambda: _remove_stale_repair_marker(path),
11982
+ owner="repair-owner",
11983
+ record="repair marker",
11984
+ )
11985
+
11986
+
11987
+ def _claim_owner_record(
11988
+ marker: pathlib.Path,
11989
+ remove_stale: "Callable[[], tuple[bool, str]]",
11990
+ *,
11991
+ owner: str,
11992
+ record: str,
11993
+ ) -> "tuple[RepairMarkerClaim | None, str]":
11994
+ """Publish ``marker`` with a complete owner record by exclusive create.
11995
+
11996
+ The repair marker's discipline, shared with #901's keeper-yield request:
11997
+ the record is written and fsynced under a private name and linked into
11998
+ place, so it is complete or absent; an existing record is replaced only
11999
+ when ``remove_stale`` proves its owner dead or its pid reused."""
11023
12000
  process_start = _process_start_identity(os.getpid())
11024
12001
  if process_start is None:
11025
- raise OSError("could not establish repair-owner process identity")
12002
+ raise OSError(f"could not establish {owner} process identity")
11026
12003
  for _attempt in range(3):
11027
12004
  claim_id = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
11028
12005
  temp = marker.with_name(f".{marker.name}.{claim_id}.tmp")
@@ -11041,7 +12018,7 @@ def _claim_repair_marker(
11041
12018
  try:
11042
12019
  os.link(temp, marker)
11043
12020
  except FileExistsError:
11044
- removed, reason = _remove_stale_repair_marker(path)
12021
+ removed, reason = remove_stale()
11045
12022
  if not removed:
11046
12023
  return None, reason
11047
12024
  continue
@@ -11052,7 +12029,7 @@ def _claim_repair_marker(
11052
12029
  temp.unlink()
11053
12030
  except FileNotFoundError:
11054
12031
  pass
11055
- return None, f"could not claim repair marker {marker}"
12032
+ return None, f"could not claim {record} {marker}"
11056
12033
 
11057
12034
 
11058
12035
  def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None:
@@ -11067,8 +12044,135 @@ def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None
11067
12044
  _fsync_directory(marker.parent)
11068
12045
 
11069
12046
 
12047
+ #: #901 W9 (Q15): the one monotonic deadline a process that needs a
12048
+ #: cache.db or conversations.db family exclusive or drained waits for the
12049
+ #: other processes' idle checkpoint keepers to close, per store: twice the
12050
+ #: keepers' 10 s timer interval. Never renewed per keeper, scan or retry.
12051
+ _KEEPER_YIELD_DEADLINE_SECONDS = 20.0
12052
+ #: The pause between two handle checks, or two exclusive acquisitions, while
12053
+ #: a requester waits under that deadline.
12054
+ _KEEPER_YIELD_RETRY_SECONDS = 0.25
12055
+
12056
+
12057
+ def _remove_stale_keeper_yield_request(
12058
+ path: pathlib.Path,
12059
+ ) -> "tuple[bool, str]":
12060
+ """Remove a provably stale keeper-yield request (dead or reused-pid
12061
+ owner), as `_remove_stale_repair_marker` does for the repair marker."""
12062
+ request = _keeper_yield_request_path(path)
12063
+ if not request.exists():
12064
+ return True, ""
12065
+ live, reason = _repair_marker_is_live(request)
12066
+ if live:
12067
+ return False, f"another {path.name} keeper-yield request owns {request} ({reason})"
12068
+ try:
12069
+ request.unlink()
12070
+ except FileNotFoundError:
12071
+ return True, ""
12072
+ except OSError as exc:
12073
+ return False, f"could not remove stale keeper-yield request {request}: {exc}"
12074
+ _fsync_directory(request.parent)
12075
+ return True, reason
12076
+
12077
+
12078
+ def _claim_keeper_yield_request(
12079
+ path: pathlib.Path,
12080
+ ) -> "tuple[RepairMarkerClaim | None, str]":
12081
+ """Publish ``<store>.keeper-yield`` with this process as its owner."""
12082
+ return _claim_owner_record(
12083
+ _keeper_yield_request_path(path),
12084
+ lambda: _remove_stale_keeper_yield_request(path),
12085
+ owner="keeper-yield-owner",
12086
+ record="keeper-yield request",
12087
+ )
12088
+
12089
+
12090
+ def _release_keeper_yield_request(
12091
+ path: pathlib.Path, claim: RepairMarkerClaim,
12092
+ ) -> None:
12093
+ """Remove the request; only its owner does, and a record someone else now
12094
+ owns is left alone. Best effort: a request left behind fences keepers
12095
+ only while this process lives, and it is stale once the process exits."""
12096
+ request = _keeper_yield_request_path(path)
12097
+ owner = _read_repair_owner(request)
12098
+ if owner is not None and owner.get("claim_id") != claim.claim_id:
12099
+ return
12100
+ try:
12101
+ request.unlink()
12102
+ except OSError:
12103
+ return
12104
+ try:
12105
+ _fsync_directory(request.parent)
12106
+ except OSError:
12107
+ pass
12108
+
12109
+
12110
+ @contextlib.contextmanager
12111
+ def _keeper_yield_request(path) -> "Iterator[float]":
12112
+ """Ask every process's idle checkpoint keeper on the store to close (#901
12113
+ W9, Q15) and yield the one monotonic deadline to wait for them.
12114
+
12115
+ The caller already holds its maintenance and provider locks, taken as
12116
+ before and failing as promptly as before; the request grants no authority
12117
+ of its own. It is published with the repair marker's owner record, this
12118
+ process's own keeper is closed at once, and the request is removed when
12119
+ the caller finishes or fails. The other processes observe it at their next
12120
+ `arm`, timer round or exit and close their keepers without a checkpoint.
12121
+ When it cannot be published the caller still waits, because a sync or a
12122
+ reader may close in time; it then refuses at the deadline as before."""
12123
+ path = pathlib.Path(path)
12124
+ try:
12125
+ claim, _reason = _claim_keeper_yield_request(path)
12126
+ except OSError:
12127
+ claim = None
12128
+ try:
12129
+ try:
12130
+ import _lib_wal_checkpoint
12131
+
12132
+ _lib_wal_checkpoint.release(path)
12133
+ except Exception: # noqa: BLE001 — the drain check below decides
12134
+ pass
12135
+ yield time.monotonic() + _KEEPER_YIELD_DEADLINE_SECONDS
12136
+ finally:
12137
+ if claim is not None:
12138
+ _release_keeper_yield_request(path, claim)
12139
+
12140
+
12141
+ def _await_family_drained(
12142
+ path: pathlib.Path, deadline: float,
12143
+ ) -> "set[int] | None":
12144
+ """Repeat the family's handle check until it finds no open handle or the
12145
+ deadline passes (#901 W9, Q15); return the last answer.
12146
+
12147
+ ``None`` (unknown) returns at once, as a refusal. Nothing is killed,
12148
+ ignored or exempted: at the deadline the caller refuses with today's
12149
+ message for whatever handle remains — a keeper that did not yield, a sync
12150
+ in progress, a reader."""
12151
+ while True:
12152
+ open_pids = _db_family_open_pids(path)
12153
+ if not open_pids:
12154
+ return open_pids
12155
+ remaining = deadline - time.monotonic()
12156
+ if remaining <= 0:
12157
+ return open_pids
12158
+ time.sleep(min(_KEEPER_YIELD_RETRY_SECONDS, remaining))
12159
+
12160
+
11070
12161
  def _db_family_open_pids(path: pathlib.Path) -> "set[int] | None":
11071
- """Return processes with any SQLite family member open; None if unknown."""
12162
+ """Return processes with any SQLite family member open; None if unknown.
12163
+
12164
+ #901 W9 (Q14): this process's idle keeper for the store is released first,
12165
+ without a checkpoint. It exists only to defer checkpoints, and every
12166
+ caller asks this question before replacing or quarantining the family, so
12167
+ the keeper must never be the handle that refuses this process's own store
12168
+ recovery; the next sync's arm reopens it on whatever file is then there.
12169
+ """
12170
+ try:
12171
+ import _lib_wal_checkpoint
12172
+
12173
+ _lib_wal_checkpoint.release(path)
12174
+ except Exception: # noqa: BLE001 — the drain check below still decides
12175
+ pass
11072
12176
  family = [
11073
12177
  pathlib.Path(str(path) + suffix)
11074
12178
  for suffix in ("", "-journal", "-wal", "-shm")
@@ -11094,6 +12198,8 @@ def _db_family_open_pids(path: pathlib.Path) -> "set[int] | None":
11094
12198
  )
11095
12199
  except OSError:
11096
12200
  return None
12201
+ if result.returncode not in (0, 1):
12202
+ return None
11097
12203
  return {
11098
12204
  int(line[1:])
11099
12205
  for line in result.stdout.splitlines()
@@ -12065,13 +13171,51 @@ def _vacuum_required_free_bytes(path) -> int:
12065
13171
  return 2 * db_bytes + wal_bytes
12066
13172
 
12067
13173
 
12068
- def _run_vacuum_exclusive(path, label: str) -> int:
13174
+ def _acquire_vacuum_exclusion(
13175
+ conn: sqlite3.Connection, deadline: "float | None",
13176
+ ) -> None:
13177
+ """Take a WAL store's EXCLUSIVE lock and keep it (#901 W9, Q15).
13178
+
13179
+ Under ``locking_mode=EXCLUSIVE`` a write transaction takes the database
13180
+ file's EXCLUSIVE lock and the connection keeps it after the transaction
13181
+ ends, so ``BEGIN IMMEDIATE`` then ``ROLLBACK`` acquires the exclusion
13182
+ without writing anything, and the checkpoint and VACUUM that follow run
13183
+ under one continuous hold (#386). Only this acquisition is retried — each
13184
+ attempt under the existing busy timeout, a short pause between attempts —
13185
+ until ``deadline``, the keepers' drain grace; the VACUUM is never retried.
13186
+ ``deadline=None`` makes one attempt. The last busy error is raised at the
13187
+ deadline, so the caller's refusal is today's."""
13188
+ while True:
13189
+ try:
13190
+ conn.execute("BEGIN IMMEDIATE")
13191
+ except sqlite3.OperationalError as exc:
13192
+ text = str(exc).lower()
13193
+ if (
13194
+ deadline is None
13195
+ or ("lock" not in text and "busy" not in text)
13196
+ or time.monotonic() >= deadline
13197
+ ):
13198
+ raise
13199
+ time.sleep(max(0.0, min(_KEEPER_YIELD_RETRY_SECONDS,
13200
+ deadline - time.monotonic())))
13201
+ continue
13202
+ conn.execute("ROLLBACK")
13203
+ return
13204
+
13205
+
13206
+ def _run_vacuum_exclusive(
13207
+ path, label: str, *, deadline: "float | None" = None,
13208
+ ) -> int:
12069
13209
  """VACUUM ``path`` under EXCLUSIVE; checkpoint WAL-backed stores first.
12070
13210
 
12071
13211
  ``locking_mode=EXCLUSIVE`` + a short ``busy_timeout`` make a concurrent
12072
- reader/writer FAIL PROMPTLY (no TOCTOU gap — the exclusion is the DB's own
12073
- lock, which the advisory flocks do not provide against dashboard readers).
12074
- Exit 0 on success, 3 when the DB is in use."""
13212
+ reader/writer fail rather than hang (no TOCTOU gap — the exclusion is the
13213
+ DB's own lock, which the advisory flocks do not provide against dashboard
13214
+ readers). On a WAL store (#901 W9, Q15) the exclusive acquisition comes
13215
+ first and is retried until ``deadline`` while other processes' idle
13216
+ checkpoint keepers yield to the caller's request; the checkpoint and the
13217
+ VACUUM then run under that one hold. Exit 0 on success, 3 when the DB is
13218
+ in use."""
12075
13219
  conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
12076
13220
  try:
12077
13221
  conn.execute(f"PRAGMA busy_timeout={_VACUUM_BUSY_TIMEOUT_MS}")
@@ -12080,6 +13224,7 @@ def _run_vacuum_exclusive(path, label: str) -> int:
12080
13224
  try:
12081
13225
  conn.execute("PRAGMA locking_mode=EXCLUSIVE")
12082
13226
  if label != "stats.db":
13227
+ _acquire_vacuum_exclusion(conn, deadline)
12083
13228
  conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
12084
13229
  conn.execute("VACUUM")
12085
13230
  except sqlite3.OperationalError as exc:
@@ -12189,7 +13334,13 @@ def _vacuum_one_db(path, label: str, provider_locked: bool) -> int:
12189
13334
  "before retrying VACUUM."
12190
13335
  )
12191
13336
  return 3
12192
- return _run_vacuum_exclusive(path, label)
13337
+ # stats.db has no checkpoint keeper: one attempt, as before.
13338
+ return _run_vacuum_exclusive(path, label)
13339
+ # #901 W9 (Q15): with both locks held, ask the other processes'
13340
+ # idle checkpoint keepers to close, close this process's own, and
13341
+ # retry only the exclusive acquisition within the drain grace.
13342
+ with _keeper_yield_request(path) as deadline:
13343
+ return _run_vacuum_exclusive(path, label, deadline=deadline)
12193
13344
  finally:
12194
13345
  for fh in held:
12195
13346
  try:
@@ -12212,9 +13363,12 @@ def cmd_db_vacuum(args: argparse.Namespace) -> int:
12212
13363
 
12213
13364
  NEVER automatic. Holds the maintenance flock + (for cache.db) the provider
12214
13365
  flocks, then runs a checkpoint + VACUUM under a real SQLite EXCLUSIVE lock so
12215
- a concurrent dashboard reader fails promptly instead of racing. Refuses when
12216
- free disk is below ~2x the file + WAL. Exit 0 on success, 3 when a target is
12217
- in use or disk is short."""
13366
+ a concurrent dashboard reader makes it refuse instead of racing. For
13367
+ cache.db and conversations.db it first asks other processes' idle
13368
+ checkpoint keepers to close (#901 W9, Q15) and retries the exclusive
13369
+ acquisition for at most 20 s; a handle still open then gets the same
13370
+ refusal. Refuses when free disk is below ~2x the file + WAL. Exit 0 on
13371
+ success, 3 when a target is in use or disk is short."""
12218
13372
  which = getattr(args, "db", "cache")
12219
13373
  targets = []
12220
13374
  if which in ("cache", "all"):