cctally 1.79.2 → 1.80.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.
@@ -64,6 +64,8 @@ def _init_paths_from_env() -> None:
64
64
  global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CACHE_LOCK_MAINTENANCE_PATH
65
65
  global CONVERSATIONS_LOCK_PATH, CONVERSATIONS_LOCK_CODEX_PATH
66
66
  global CONVERSATIONS_LOCK_MAINTENANCE_PATH
67
+ global STATS_LOCK_MAINTENANCE_PATH
68
+ global JOURNAL_DIR, JOURNAL_LOCK_PATH, JOURNAL_INGEST_LOCK_PATH
67
69
  global CONFIG_LOCK_PATH
68
70
  global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
69
71
  global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
@@ -119,6 +121,26 @@ def _init_paths_from_env() -> None:
119
121
  CONVERSATIONS_LOCK_MAINTENANCE_PATH = (
120
122
  APP_DIR / "conversations.db.maintenance.lock"
121
123
  )
124
+ # Stats index maintenance flock (2026-07-22 DB journal redesign, spec §6.3 /
125
+ # §6.4). Held at the TOP of the lock-order law (maintenance → ingest →
126
+ # provider flocks → txn → journal.lock) by the classifier-gated corruption
127
+ # auto-heal and the `db rebuild --db stats` operator command, so a rebuild
128
+ # never races a concurrent healer/vacuum and only one process quarantines +
129
+ # recreates stats.db at a time. cache.db already has its own maintenance
130
+ # flock above; stats gets the symmetric one now that it, too, auto-heals.
131
+ STATS_LOCK_MAINTENANCE_PATH = APP_DIR / "stats.db.maintenance.lock"
132
+ # Append-only observation journal (2026-07-22 DB journal redesign, spec
133
+ # docs/superpowers/specs/2026-07-22-db-journal-redesign-design.md §4).
134
+ # JOURNAL_DIR holds the monthly observations-YYYY-MM.jsonl segments plus
135
+ # the one-time bootstrap-<ts>.jsonl export; the two lock files sit
136
+ # alongside the DBs in APP_DIR. journal.lock is the µs-scale, blocking,
137
+ # LEAF append lock (spec §4.3 — no other lock is ever taken while it is
138
+ # held); journal.ingest.lock admits at most one stats.db writer (spec
139
+ # §5.1). All three are APP_DIR-derived so dev/data-dir redirection carries
140
+ # them along, exactly like the cache/conversations locks above.
141
+ JOURNAL_DIR = APP_DIR / "journal"
142
+ JOURNAL_LOCK_PATH = APP_DIR / "journal.lock"
143
+ JOURNAL_INGEST_LOCK_PATH = APP_DIR / "journal.ingest.lock"
122
144
  CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
123
145
 
124
146
  CONFIG_PATH = APP_DIR / "config.json"
@@ -289,6 +311,22 @@ _init_paths_from_env()
289
311
  STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
290
312
 
291
313
 
314
+ # === stats.db epoch-rebuild versioning (DB journal redesign §7.1/§8) ==
315
+ #
316
+ # stats.db is no longer a versioned migration target — it is a DISPOSABLE index
317
+ # materialized from the append-only journal. A single ``STATS_INDEX_EPOCH``
318
+ # integer (stamped via ``PRAGMA user_version``, safely above the legacy 0–13
319
+ # range) versions the index; ANY mismatch resolves by journal rebuild, and
320
+ # ``DowngradeDetected``-bricking ceases for stats. ``LEGACY_STATS_HEAD`` is the
321
+ # frozen legacy migration head (``len(_STATS_MIGRATIONS)``): a stats.db whose
322
+ # ``user_version`` is still in the legacy range (<= 13) is a pre-journal install
323
+ # that ``open_db`` cuts over to the epoch on first open (spec §8). Schema change
324
+ # = bump ``STATS_INDEX_EPOCH`` (never a new stats migration — the registry is
325
+ # frozen).
326
+ STATS_INDEX_EPOCH = 1000
327
+ LEGACY_STATS_HEAD = 13
328
+
329
+
292
330
  # === Telemetry constants (non-path; see spec 2026-07-07) =============
293
331
  #
294
332
  # These are static (not APP_DIR-derived) so they live outside
@@ -493,6 +531,34 @@ def _command_as_of() -> dt.datetime:
493
531
  return dt.datetime.now(dt.timezone.utc)
494
532
 
495
533
 
534
+ def _as_of_or_command(as_of: "str | None") -> dt.datetime:
535
+ """Capture-time reference datetime for the derivation chokepoints (DB
536
+ journal redesign spec §5.2.3). When the ingester injects a record's ``at``
537
+ as ``as_of`` (ISO-8601 with ``Z`` / explicit offset), parse it to UTC so
538
+ derivation is replay-deterministic; otherwise fall back to the legacy
539
+ ``_command_as_of()`` wall clock (honoring the ``CCTALLY_AS_OF`` test hook).
540
+ Passing ``None`` keeps today's behavior bit-identical for legacy callers.
541
+ """
542
+ if as_of:
543
+ s = as_of.strip()
544
+ if s.endswith("Z"):
545
+ s = s[:-1] + "+00:00"
546
+ parsed = dt.datetime.fromisoformat(s)
547
+ # I3 gate pickup: reject a naive capture time. `.astimezone()` on a
548
+ # naive datetime silently assumes HOST-LOCAL, which would make
549
+ # capture-time-pure derivation non-deterministic across hosts (the very
550
+ # thing §5.2.3 injection exists to prevent). A journal `at` is always
551
+ # UTC ISO-Z / explicit-offset by construction, so a naive value here is
552
+ # a caller bug — fail loud instead of guessing the offset.
553
+ if parsed.tzinfo is None:
554
+ raise ValueError(
555
+ f"_as_of_or_command: naive capture time {as_of!r} "
556
+ "(expected ISO-8601 with 'Z' or an explicit offset)"
557
+ )
558
+ return parsed.astimezone(dt.timezone.utc)
559
+ return _command_as_of()
560
+
561
+
496
562
  def _now_utc() -> dt.datetime:
497
563
  """UTC now, with CCTALLY_AS_OF env override for fixture-stability.
498
564
 
@@ -1094,7 +1160,14 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1094
1160
  )
1095
1161
 
1096
1162
 
1097
- def open_db() -> sqlite3.Connection:
1163
+ def open_db(*, _target_path=None) -> sqlite3.Connection:
1164
+ # ``_target_path`` (internal, keyword-only) builds/opens the stats index at
1165
+ # an ALTERNATE path instead of the module-global ``DB_PATH`` — the seam
1166
+ # ``rebuild_stats_index`` uses to materialize a FRESH schema'd index at a
1167
+ # scratch/target path without rebinding the global (thread-safe: no other
1168
+ # caller's ``open_db()`` is affected). Every existing call passes nothing and
1169
+ # is byte-identical (spec §5.4 rebuild / §6.3 heal). ``None`` -> ``DB_PATH``.
1170
+ db_path = pathlib.Path(_target_path) if _target_path is not None else DB_PATH
1098
1171
  c = _cctally()
1099
1172
  # Spec §2.6 carve-out: open_db reaches the migration framework
1100
1173
  # (lives in _cctally_db + bin/cctally). Direct imports would
@@ -1111,14 +1184,27 @@ def open_db() -> sqlite3.Connection:
1111
1184
  _STATS_MIGRATIONS = c._STATS_MIGRATIONS
1112
1185
  _log_migration_error = c._log_migration_error
1113
1186
  _clear_migration_error_log_entries = c._clear_migration_error_log_entries
1114
-
1115
- repair_marker = DB_PATH.with_name("stats.db.repairing")
1187
+ # Unified opener policy (spec §6.1). Call-time import so the shared PRAGMA
1188
+ # policy applies without a module-load cycle (_cctally_store imports this
1189
+ # module). Routed through importlib.import_module rather than a bare
1190
+ # `import _cctally_store` statement so core stays a static IMPORT leaf
1191
+ # (tests/test_kernel_extraction_invariants.py::test_core_imports_no_siblings):
1192
+ # the leaf rule guards against module-LOAD cycles, and a call-time reach
1193
+ # inside open_db() creates none — but the guard's regex matches any
1194
+ # `import _cctally_*` statement, so the deliberate call-time reach uses the
1195
+ # importlib form (still a recognized runtime-loader edge for the package
1196
+ # closure test). stats keeps its own schema-apply until Task 9 flips it to
1197
+ # the epoch gate — this task only routes the PRAGMAs through the shared table.
1198
+ import importlib
1199
+ _cctally_store = importlib.import_module("_cctally_store")
1200
+
1201
+ repair_marker = db_path.with_name("stats.db.repairing")
1116
1202
  if repair_marker.exists():
1117
1203
  raise c.StatsDbMaintenanceError()
1118
1204
  ensure_dirs()
1119
1205
  if repair_marker.exists():
1120
1206
  raise c.StatsDbMaintenanceError()
1121
- conn = sqlite3.connect(DB_PATH)
1207
+ conn = sqlite3.connect(db_path)
1122
1208
  if repair_marker.exists():
1123
1209
  conn.close()
1124
1210
  raise c.StatsDbMaintenanceError()
@@ -1132,33 +1218,105 @@ def open_db() -> sqlite3.Connection:
1132
1218
  # (corruption that only surfaces mid-DDL stays a raw error; the probe
1133
1219
  # catches the common case).
1134
1220
  try:
1135
- conn.execute("PRAGMA journal_mode=WAL")
1136
- conn.execute("PRAGMA synchronous=NORMAL")
1137
- # Explicit for intent + symmetry with open_cache_db (bin/_cctally_cache.py).
1138
- # #297: raised 5000 -> 15000 so a writer waits out a slow-but-normal
1139
- # sync (>5 s) instead of instantly erroring with "database is locked".
1140
- # NOTE: busy_timeout does NOT absorb SQLITE_BUSY_SNAPSHOT (a WAL
1141
- # read-then-write transaction whose snapshot is invalidated by a
1142
- # competing commit raises "database is locked" instantly, bypassing the
1143
- # busy handler). The write paths defend against that by taking the write
1144
- # lock up front — BEGIN IMMEDIATE, or a write as the transaction's first
1145
- # DML. See cctally-dev#87.
1146
- conn.execute("PRAGMA busy_timeout=15000")
1147
- # #297: bound the persistent WAL file (symmetry with open_cache_db).
1148
- conn.execute(f"PRAGMA journal_size_limit={STATS_WAL_SIZE_LIMIT_BYTES}")
1221
+ # §6.1 PRAGMA policy via the shared table (stats: WAL / NORMAL /
1222
+ # busy_timeout 15000 / journal_size_limit 16 MiB / auto_vacuum unset).
1223
+ # #297: busy_timeout 15000 lets a writer wait out a slow-but-normal sync
1224
+ # (>5 s) instead of erroring "database is locked"; it does NOT absorb
1225
+ # SQLITE_BUSY_SNAPSHOT (defended by BEGIN IMMEDIATE / write-first at the
1226
+ # write sites, cctally-dev#87). journal_size_limit bounds the persistent
1227
+ # WAL. The corruption probe (SELECT 1) stays here inside the narrow
1228
+ # StatsDbCorruptError boundary.
1229
+ _cctally_store.apply_policy(conn, "stats")
1149
1230
  conn.execute("SELECT 1").fetchone()
1150
1231
  except sqlite3.DatabaseError as exc:
1151
1232
  try:
1152
1233
  conn.close()
1153
1234
  except Exception:
1154
1235
  pass
1155
- raise c.StatsDbCorruptError(
1156
- f"stats.db appears corrupt or unreadable ({exc}). path: {DB_PATH}. "
1157
- f"Not auto-recreatedit holds your recorded usage history. "
1158
- "Recovery: run `cctally db repair --db stats --yes`; it preserves "
1159
- "the corrupt original before replacing anything. Do not copy, "
1160
- "restore, or move the live DB by hand."
1161
- ) from exc
1236
+ # Classifier-gated corruption auto-heal (spec §6.3, Task 8). On a
1237
+ # POSITIVELY classified corruption of the REAL stats index (never a
1238
+ # ``_target_path`` rebuild build that path is disarmed to avoid
1239
+ # recursion), hand off to the store's HEAL_HOOK: it re-checks under the
1240
+ # maintenance lock, writes the forensics bundle FIRST, quarantines the
1241
+ # damaged family into an incident dir, rebuilds a fresh index from the
1242
+ # journal, and returns True. A single retry of the connect+probe then
1243
+ # runs against the fresh index; a second failure surfaces loudly. HEAL
1244
+ # returns False (declined) for a non-corruption ``DatabaseError`` (BUSY,
1245
+ # disk-full, permissions), the dev-checkout-on-prod guard, or when a
1246
+ # concurrent healer already fixed it — all of which fall through to the
1247
+ # original guided StatsDbCorruptError so the manual path still applies.
1248
+ heal = getattr(_cctally_store, "HEAL_HOOK", None)
1249
+ if _target_path is None and heal is not None and heal("stats", exc):
1250
+ conn = sqlite3.connect(db_path)
1251
+ conn.row_factory = sqlite3.Row
1252
+ try:
1253
+ _cctally_store.apply_policy(conn, "stats")
1254
+ conn.execute("SELECT 1").fetchone()
1255
+ except sqlite3.DatabaseError as exc2:
1256
+ try:
1257
+ conn.close()
1258
+ except Exception:
1259
+ pass
1260
+ raise c.StatsDbCorruptError(
1261
+ f"stats.db is still unreadable after an auto-heal rebuild "
1262
+ f"({exc2}). path: {db_path}. The damaged original was "
1263
+ "quarantined under quarantine/ with a forensics bundle in "
1264
+ "logs/. This is not expected — please report it."
1265
+ ) from exc2
1266
+ else:
1267
+ raise c.StatsDbCorruptError(
1268
+ f"stats.db appears corrupt or unreadable ({exc}). path: {db_path}. "
1269
+ f"Not auto-recreated — it holds your recorded usage history. "
1270
+ "Recovery: run `cctally db repair --db stats --yes`; it preserves "
1271
+ "the corrupt original before replacing anything. Do not copy, "
1272
+ "restore, or move the live DB by hand."
1273
+ ) from exc
1274
+
1275
+ # ── stats.db epoch gate / in-place cutover (DB journal redesign §7.1/§8) ──
1276
+ # The epoch machinery engages ONLY against the FROZEN production stats
1277
+ # registry (len(_STATS_MIGRATIONS) == LEGACY_STATS_HEAD); under
1278
+ # CCTALLY_MIGRATION_TEST_MODE the injected 14th stats migration lifts the
1279
+ # count and disables it, so the migration-framework harness keeps exercising
1280
+ # the legacy dispatcher path with no cutover.
1281
+ _epoch_engaged = _cctally_store.stats_epoch_enabled()
1282
+ if _epoch_engaged:
1283
+ _uv = conn.execute("PRAGMA user_version").fetchone()[0]
1284
+ if _uv == STATS_INDEX_EPOCH:
1285
+ # STEADY STATE — zero schema work (spec §6.2/§7.1). Applies to a
1286
+ # ``_target_path`` open too: a rebuilt/scratch index is stamped at the
1287
+ # epoch, so opening it must be a pure connect + PRAGMA + version read.
1288
+ return conn
1289
+ if _target_path is None:
1290
+ if _uv > LEGACY_STATS_HEAD:
1291
+ # Neither legacy (<=13) nor the current epoch — a future epoch or
1292
+ # a stray value. Resolve by journal REBUILD (spec §7.1), never by
1293
+ # the corruption heal path (disjoint); a mismatch with no journal
1294
+ # is a hard error, never a silent rebuild-to-empty.
1295
+ conn.close()
1296
+ return _cctally_store.resolve_stats_epoch_mismatch()
1297
+ # _uv <= LEGACY_STATS_HEAD → a pre-journal install: cut over below. A
1298
+ # dev/worktree binary must REFUSE to cut over a DB in the real prod
1299
+ # dir (mirrors #146 — the epoch stamp would brick the installed
1300
+ # release via DowngradeDetected). Leave the DB untouched; exit like
1301
+ # ProdMigrationRefused.
1302
+ if _cctally_store.would_block_prod_stats_cutover(db_path):
1303
+ conn.close()
1304
+ raise c.ProdMigrationRefused("stats.db", "cutover→epoch")
1305
+ # else: a ``_target_path`` build below LEGACY_STATS_HEAD (a fresh scratch
1306
+ # index) — fall through to the schema apply; the final block stamps the
1307
+ # epoch directly (no cutover export).
1308
+
1309
+ # §6.2 one-shot backfill gate (Task 8). Read the ``stats_open_fixups`` marker
1310
+ # ONCE, right after the corruption probe. When True (the marker is stamped at
1311
+ # the binary's current fixup version) the three self-extinguishing open-time
1312
+ # backfills below — five_hour_window_key backfill, quota-projection schema,
1313
+ # historical five_hour_blocks rollup — plus their probe SELECTs are skipped
1314
+ # entirely, so the steady-state open does ZERO backfill work. Fresh / pre-gate
1315
+ # DBs read False, run all three once, and re-stamp the marker at the end. The
1316
+ # surrounding schema apply (CREATE TABLE IF NOT EXISTS / add_column_if_missing
1317
+ # / dispatcher) still runs every open — Task 9 folds THAT under the
1318
+ # STATS_INDEX_EPOCH gate; this task only removes the recurring backfill cost.
1319
+ _fixups_current = _cctally_store.stats_open_fixups_current(conn)
1162
1320
  conn.execute(
1163
1321
  """
1164
1322
  CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
@@ -1224,13 +1382,19 @@ def open_db() -> sqlite3.Connection:
1224
1382
  needs_5h_key_backfill = add_column_if_missing(
1225
1383
  conn, "weekly_usage_snapshots", "five_hour_window_key", "INTEGER"
1226
1384
  )
1227
- if not needs_5h_key_backfill and conn.execute(
1228
- "SELECT 1 FROM weekly_usage_snapshots "
1229
- "WHERE five_hour_resets_at IS NOT NULL "
1230
- " AND five_hour_window_key IS NULL "
1231
- "LIMIT 1"
1232
- ).fetchone() is not None:
1233
- needs_5h_key_backfill = True
1385
+ # §6.2 backfill gate (Task 8): the resumable-partial probe + the backfill
1386
+ # loop are open-time backfill work — skipped once the fixups marker is
1387
+ # stamped. (The `add_column_if_missing` above is schema apply, Task 9's.)
1388
+ if not _fixups_current:
1389
+ if not needs_5h_key_backfill and conn.execute(
1390
+ "SELECT 1 FROM weekly_usage_snapshots "
1391
+ "WHERE five_hour_resets_at IS NOT NULL "
1392
+ " AND five_hour_window_key IS NULL "
1393
+ "LIMIT 1"
1394
+ ).fetchone() is not None:
1395
+ needs_5h_key_backfill = True
1396
+ else:
1397
+ needs_5h_key_backfill = False
1234
1398
 
1235
1399
  if needs_5h_key_backfill:
1236
1400
  backfill_rows = conn.execute(
@@ -1667,7 +1831,13 @@ def open_db() -> sqlite3.Connection:
1667
1831
  # schema in the fresh-install path before the dispatcher, exactly like the
1668
1832
  # existing live CREATE tables; its handler calls this same idempotent helper
1669
1833
  # for an older stats.db and the dispatcher central-stamps on clean return.
1670
- _apply_quota_projection_schema(conn)
1834
+ # §6.2 backfill gate (Task 8): the quota-projection schema apply is one of
1835
+ # the three open-time backfills — skipped once the fixups marker is stamped
1836
+ # (the marker is set only AFTER this ran, so marker-present ⇒ tables present).
1837
+ # Fresh installs read False and apply it here (the dispatcher fast-stamps 013
1838
+ # without running its handler, so this open-time call is the sole creator).
1839
+ if not _fixups_current:
1840
+ _apply_quota_projection_schema(conn)
1671
1841
 
1672
1842
  # Migration framework dispatcher. Replaces the prior inline gate stack
1673
1843
  # (has_blocks + _migration_done) with the framework's _run_pending_-
@@ -1692,63 +1862,147 @@ def open_db() -> sqlite3.Connection:
1692
1862
  # Runs AFTER the dispatcher so `schema_migrations` exists for the
1693
1863
  # marker INSERTs inside the backfill body, and so any fresh-install
1694
1864
  # stamp-only path the dispatcher took above is already committed.
1695
- existing = conn.execute(
1696
- "SELECT 1 FROM five_hour_blocks LIMIT 1"
1697
- ).fetchone()
1698
- has_snapshots = conn.execute(
1699
- "SELECT 1 FROM weekly_usage_snapshots "
1700
- "WHERE five_hour_window_key IS NOT NULL "
1701
- " AND five_hour_percent IS NOT NULL "
1702
- "LIMIT 1"
1703
- ).fetchone()
1704
- if not existing and has_snapshots:
1705
- inserted = _backfill_five_hour_blocks(conn)
1706
- # Re-run the 5h dedup migration AFTER backfill creates parents.
1707
- # The dispatcher above ran while five_hour_blocks was empty, so
1708
- # the dedup handler no-op'd and stamped its marker. Snapshot
1709
- # keys can carry jitter beyond the 600s canonical floor (the
1710
- # 003_* migration handles up to 1800s grouping), so the
1711
- # backfill's `DISTINCT five_hour_window_key` over those keys
1712
- # can produce duplicate parent rows for one physical 5h
1713
- # window. Without this re-invocation those duplicates persist
1714
- # forever — the marker says it ran. Handler owns its own
1715
- # BEGIN/COMMIT and is idempotent (no groups no-op).
1716
- #
1717
- # Honor `db skip` here as well: if the operator marked 003 as
1718
- # skipped (e.g., poison pill on their machine), we must NOT
1719
- # back-door run the handler. Duplicates introduced by the
1720
- # backfill will persist until they `db unskip` which is the
1721
- # explicit choice the skip records. Failure path mirrors the
1722
- # dispatcher's contract: route through _log_migration_error so
1723
- # the next interactive command renders the banner, and clear
1724
- # the log entry on success so the banner auto-dismisses.
1725
- if inserted > 0:
1726
- target_name = "003_merge_5h_block_duplicates_v1"
1727
- try:
1728
- skipped = {
1729
- row[0] for row in conn.execute(
1730
- "SELECT name FROM schema_migrations_skipped"
1731
- ).fetchall()
1732
- }
1733
- except sqlite3.OperationalError:
1734
- skipped = set()
1735
- if target_name not in skipped:
1736
- for _m in _STATS_MIGRATIONS:
1737
- if _m.name == target_name:
1738
- qualified = f"stats.db:{target_name}"
1739
- try:
1740
- _m.handler(conn)
1741
- _clear_migration_error_log_entries(qualified)
1742
- except Exception as exc:
1743
- _log_migration_error(
1744
- name=qualified,
1745
- exc=exc,
1746
- tb=traceback.format_exc(),
1747
- )
1748
- eprint(f"[migration {qualified}] failed: {exc}")
1749
- break
1865
+ # §6.2 backfill gate (Task 8): the two probe SELECTs + the backfill + its
1866
+ # migration-003 re-invocation are open-time backfill work — skipped once the
1867
+ # fixups marker is stamped. Dead in the journal world (the ingest cycle writes
1868
+ # blocks with their snapshots), so it fires only on a pre-journal upgrade DB's
1869
+ # first open; after that the marker gates it out permanently.
1870
+ if not _fixups_current:
1871
+ existing = conn.execute(
1872
+ "SELECT 1 FROM five_hour_blocks LIMIT 1"
1873
+ ).fetchone()
1874
+ has_snapshots = conn.execute(
1875
+ "SELECT 1 FROM weekly_usage_snapshots "
1876
+ "WHERE five_hour_window_key IS NOT NULL "
1877
+ " AND five_hour_percent IS NOT NULL "
1878
+ "LIMIT 1"
1879
+ ).fetchone()
1880
+ if not existing and has_snapshots:
1881
+ inserted = _backfill_five_hour_blocks(conn)
1882
+ # Re-run the 5h dedup migration AFTER backfill creates parents.
1883
+ # The dispatcher above ran while five_hour_blocks was empty, so
1884
+ # the dedup handler no-op'd and stamped its marker. Snapshot
1885
+ # keys can carry jitter beyond the 600s canonical floor (the
1886
+ # 003_* migration handles up to 1800s grouping), so the
1887
+ # backfill's `DISTINCT five_hour_window_key` over those keys
1888
+ # can produce duplicate parent rows for one physical 5h
1889
+ # window. Without this re-invocation those duplicates persist
1890
+ # forever the marker says it ran. Handler owns its own
1891
+ # BEGIN/COMMIT and is idempotent (no groups no-op).
1892
+ #
1893
+ # Honor `db skip` here as well: if the operator marked 003 as
1894
+ # skipped (e.g., poison pill on their machine), we must NOT
1895
+ # back-door run the handler. Duplicates introduced by the
1896
+ # backfill will persist until they `db unskip` — which is the
1897
+ # explicit choice the skip records. Failure path mirrors the
1898
+ # dispatcher's contract: route through _log_migration_error so
1899
+ # the next interactive command renders the banner, and clear
1900
+ # the log entry on success so the banner auto-dismisses.
1901
+ if inserted > 0:
1902
+ target_name = "003_merge_5h_block_duplicates_v1"
1903
+ try:
1904
+ skipped = {
1905
+ row[0] for row in conn.execute(
1906
+ "SELECT name FROM schema_migrations_skipped"
1907
+ ).fetchall()
1908
+ }
1909
+ except sqlite3.OperationalError:
1910
+ skipped = set()
1911
+ if target_name not in skipped:
1912
+ for _m in _STATS_MIGRATIONS:
1913
+ if _m.name == target_name:
1914
+ qualified = f"stats.db:{target_name}"
1915
+ try:
1916
+ _m.handler(conn)
1917
+ _clear_migration_error_log_entries(qualified)
1918
+ except Exception as exc:
1919
+ _log_migration_error(
1920
+ name=qualified,
1921
+ exc=exc,
1922
+ tb=traceback.format_exc(),
1923
+ )
1924
+ eprint(f"[migration {qualified}] failed: {exc}")
1925
+ break
1926
+
1927
+ # ── Append-only journal replay-identity columns + ingest cursor ──
1928
+ # (2026-07-22 DB journal redesign, spec §4.2 / §5.2). Every row a
1929
+ # journal fold materializes carries the originating line's stable `id`
1930
+ # in `journal_id`, with a partial UNIQUE index so the ingester's
1931
+ # INSERT OR IGNORE fold is idempotent under replay/re-ingest. Runs AFTER
1932
+ # the migration dispatcher so the columns land on the final (migrated)
1933
+ # table shape — migrations 005/006 recreate percent/5h milestone tables
1934
+ # and must not drop the column. All additive (add_column_if_missing /
1935
+ # CREATE ... IF NOT EXISTS), framework-untracked — same posture as
1936
+ # weekly_credit_floors / project_budget_milestones. Task 9 folds this
1937
+ # under the STATS_INDEX_EPOCH version gate; until then it is idempotent
1938
+ # per open (add_column_if_missing / IF NOT EXISTS no-op once present).
1939
+ for _jtable in (
1940
+ "weekly_usage_snapshots", "weekly_cost_snapshots", "week_reset_events",
1941
+ "five_hour_reset_events", "five_hour_blocks", "weekly_credit_floors",
1942
+ "percent_milestones", "five_hour_milestones", "budget_milestones",
1943
+ "projected_milestones", "project_budget_milestones",
1944
+ ):
1945
+ add_column_if_missing(conn, _jtable, "journal_id", "TEXT")
1946
+ conn.execute(
1947
+ f"CREATE UNIQUE INDEX IF NOT EXISTS idx_{_jtable}_journal_id "
1948
+ f"ON {_jtable}(journal_id) WHERE journal_id IS NOT NULL"
1949
+ )
1950
+ # Companion partial index (spec §5.3 harvest / Task 6 gate P2): the ingest
1951
+ # cycle's harvest scans every natural-keyed family for rows the pipeline
1952
+ # inserted this cycle (`WHERE journal_id IS NULL`). A partial index over
1953
+ # exactly those un-stamped rows keeps that scan O(this-cycle inserts), not
1954
+ # O(table) — at the 10x envelope the stamped rows are ~all of the table, so
1955
+ # a full scan would be pathological. Only the 8 HARVEST families need it
1956
+ # (the Model-A / op-fold tables — weekly_usage_snapshots, weekly_cost_
1957
+ # snapshots, weekly_credit_floors — are never harvest-scanned).
1958
+ for _htable in (
1959
+ "week_reset_events", "five_hour_reset_events", "five_hour_blocks",
1960
+ "percent_milestones", "five_hour_milestones", "budget_milestones",
1961
+ "projected_milestones", "project_budget_milestones",
1962
+ ):
1963
+ conn.execute(
1964
+ f"CREATE INDEX IF NOT EXISTS idx_{_htable}_journal_id_null "
1965
+ f"ON {_htable}(id) WHERE journal_id IS NULL"
1966
+ )
1967
+ # Single-row segment+offset consumption watermark (spec §5.2). The cursor
1968
+ # never advances past a byte range the ingest cycle did not read.
1969
+ conn.execute(
1970
+ "CREATE TABLE IF NOT EXISTS journal_cursor ("
1971
+ "id INTEGER PRIMARY KEY CHECK (id = 1), "
1972
+ "segment TEXT NOT NULL, "
1973
+ "offset INTEGER NOT NULL)"
1974
+ )
1975
+
1976
+ # §6.2 backfill gate (Task 8): stamp the one-shot marker AFTER the three
1977
+ # open-time backfills ran, so the next open skips them (and their probes)
1978
+ # entirely. The marker table DDL runs ONLY on this "fixups ran" path, never
1979
+ # on the steady-state open. A crash before this commit leaves the marker
1980
+ # unset and everything re-runs idempotently next open (invariant).
1981
+ if not _fixups_current:
1982
+ _cctally_store.mark_stats_open_fixups_done(conn)
1750
1983
 
1751
1984
  conn.commit()
1985
+
1986
+ # ── Epoch stamp / in-place cutover (DB journal redesign §7.1/§8) ──
1987
+ # The full schema is now applied (head 13 + journal_id columns + cursor). A
1988
+ # ``_target_path`` build (rebuild scratch) stamps the epoch DIRECTLY (no
1989
+ # export — the rebuild folds the journal itself). A real legacy install runs
1990
+ # the cutover: export history to a bootstrap segment, stamp ``journal_id`` on
1991
+ # every row, advance the cursor, and stamp the epoch — all atomic (spec §8).
1992
+ # Under test mode (epoch disabled) neither runs, so the DB stays at
1993
+ # len(registry) for the migration-framework harness.
1994
+ if _epoch_engaged:
1995
+ if _target_path is not None:
1996
+ conn.execute(f"PRAGMA user_version = {STATS_INDEX_EPOCH}")
1997
+ conn.commit()
1998
+ elif conn.execute("PRAGMA user_version").fetchone()[0] == LEGACY_STATS_HEAD:
1999
+ # Cut over ONLY once the legacy dispatcher reached the export baseline
2000
+ # (head 13). A DEFERRED migration (MigrationGateNotMet — e.g. the
2001
+ # 008/009/010 recompute gate) leaves user_version < 13; skip the
2002
+ # cutover so the next open retries the dispatcher first, then cuts over
2003
+ # (spec §8 step 1: "run any pending legacy stats migrations, reaching
2004
+ # the export baseline"). Never journal a pre-recompute stats shape.
2005
+ importlib.import_module("_cctally_journal").run_cutover(conn)
1752
2006
  return conn
1753
2007
 
1754
2008
 
@@ -5534,12 +5534,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5534
5534
  # forward-only, exactly like the CLI `config set budget.period` path
5535
5535
  # (`_cctally_config.py`) — else the next record-usage tick would
5536
5536
  # instant-popup retroactive alerts under the new period window.
5537
+ # 6f writer reroute: the DYNAMIC per-leaf axis union is UNCHANGED,
5538
+ # but the reconciles now run THROUGH the ingest cycle (opportunistic
5539
+ # + exception-wrapped) so their latched crossings are journaled and
5540
+ # rebuild-replayable — the last budget-config write site off the
5541
+ # bespoke-stats.db-connection path. `touched_projects=None` (the
5542
+ # dashboard write is wholesale, never project-scoped).
5543
+ _reconcile_axes: set = set()
5537
5544
  if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds", "period"}:
5538
- _cctally()._reconcile_budget_on_config_write(validated_budget)
5545
+ _reconcile_axes.add("budget")
5539
5546
  if touched & {"project_alerts_enabled", "alert_thresholds"}:
5540
- _cctally()._reconcile_project_budget_milestones_on_write(
5541
- validated_budget
5542
- )
5547
+ _reconcile_axes.add("project_budget")
5543
5548
  # Codex actual-spend reconcile (#134). Key it on the ``alerts_enabled``
5544
5549
  # SUB-leaf specifically — NOT ``"codex" in touched``. The helper
5545
5550
  # (`_reconcile_codex_budget_on_config_write`) is itself gated on
@@ -5552,9 +5557,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5552
5557
  # live-pace, Q4).
5553
5558
  codex_in = budget_in.get("codex")
5554
5559
  if isinstance(codex_in, dict) and "alerts_enabled" in codex_in:
5555
- _cctally()._reconcile_codex_budget_on_config_write(
5556
- validated_budget
5557
- )
5560
+ _reconcile_axes.add("codex_budget")
5561
+ if _reconcile_axes:
5562
+ import _cctally_journal as _jr
5563
+ _jr.reconcile_budget_config(validated_budget, axes=_reconcile_axes)
5558
5564
  if (
5559
5565
  update_check_validated is not None
5560
5566
  or update_channel_validated is not None