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.
@@ -196,6 +196,10 @@ class Migration:
196
196
 
197
197
  _STATS_MIGRATIONS: list[Migration] = []
198
198
  _CACHE_MIGRATIONS: list[Migration] = []
199
+ # conversations.db joins the framework (DB journal redesign spec §7.2). Shared
200
+ # list object — re-exported by ``bin/cctally`` + ``bin/_cctally_cache`` the same
201
+ # way as ``_CACHE_MIGRATIONS`` (mutate in place, never rebind).
202
+ _CONVERSATIONS_MIGRATIONS: list[Migration] = []
199
203
 
200
204
 
201
205
  class DowngradeDetected(Exception):
@@ -267,6 +271,15 @@ class StatsDbMaintenanceError(sqlite3.OperationalError):
267
271
  )
268
272
 
269
273
 
274
+ class StatsEpochMismatchError(sqlite3.DatabaseError):
275
+ """A stats.db at a version-mismatched index epoch that CANNOT be rebuilt
276
+ (DB journal redesign §7.1). Raised when ``user_version`` is neither legacy
277
+ (<= 13) nor the current ``STATS_INDEX_EPOCH`` AND no journal is present to
278
+ rebuild from — a hard error with guidance, never a silent rebuild-to-empty.
279
+ Subclasses ``sqlite3.DatabaseError`` so graceful-degrade sites treat it as a
280
+ DB failure; ``main()`` maps it to a staged exit 3."""
281
+
282
+
270
283
  _SQLITE_CORRUPTION_MESSAGES = (
271
284
  "database disk image is malformed",
272
285
  "file is not a database",
@@ -486,6 +499,22 @@ def cache_migration(name: str):
486
499
  return _make_migration_decorator(_CACHE_MIGRATIONS, "cache.db", name)
487
500
 
488
501
 
502
+ def conversations_migration(name: str):
503
+ """Register a conversations.db migration (DB journal redesign spec §7.2).
504
+ Use as @conversations_migration("NNN_descriptive_name").
505
+
506
+ HANDLER CONTRACT — idempotency is MANDATORY. See ``stats_migration`` above
507
+ for the full statement: the dispatcher's handler-commit → marker-stamp
508
+ two-transaction window means a crash (or a ``MigrationGateNotMet`` deferral)
509
+ re-invokes the handler on its own output, so every handler must be safe to
510
+ re-run (probe-then-return / ``INSERT OR IGNORE`` / not-yet-migrated guards).
511
+ conversations.db is re-derivable from provider JSONL, so ``open_conversations_db``
512
+ runs the dispatcher with ``recover_version_ahead=True`` (like cache.db).
513
+ """
514
+ return _make_migration_decorator(
515
+ _CONVERSATIONS_MIGRATIONS, "conversations.db", name)
516
+
517
+
489
518
  # Pre-framework migration markers were stored under unprefixed names;
490
519
  # the dispatcher's bootstrap rename rewrites them to NNN_ form on the
491
520
  # first open_db() that runs the framework. Raw-sqlite3 db commands
@@ -587,6 +616,253 @@ def _would_block_prod_migration(conn: sqlite3.Connection) -> bool:
587
616
  return False
588
617
 
589
618
 
619
+ def _would_block_prod_stats(path: pathlib.Path) -> bool:
620
+ """Path-based sibling of ``_would_block_prod_migration`` for the stats.db
621
+ classifier-gated auto-heal + ``db rebuild --db stats`` (spec §6.3, issue
622
+ #146): True iff a git-checkout binary would quarantine+rebuild a stats.db that
623
+ physically lives in the REAL prod data dir. Password-DB-resolved (HOME-faking
624
+ immune), suppressor-independent raw ``.git`` check. Escape:
625
+ ``CCTALLY_ALLOW_PROD_MIGRATION``."""
626
+ if _cctally_core._truthy_env("CCTALLY_ALLOW_PROD_MIGRATION"):
627
+ return False
628
+ if not (_cctally_core._repo_root() / ".git").exists():
629
+ return False
630
+ try:
631
+ return (
632
+ pathlib.Path(path).resolve().parent
633
+ == _cctally_core._real_prod_data_dir().resolve()
634
+ )
635
+ except OSError:
636
+ return False
637
+
638
+
639
+ # Known external cloud-sync / backup daemons that hold a DB file open and are the
640
+ # leading suspects for the region-shifting prod corruption (#336 defense 2). The
641
+ # forensics scan flags any running process whose name matches, so the auto-heal
642
+ # incident finally captures the culprit rather than only the damage.
643
+ _CORRUPTION_HOLDER_HINTS = (
644
+ "backupd", "bird", "cloudd", "Dropbox", "GoogleDrive", "OneDrive",
645
+ "FileProvider", "CloudDocs", "Box", "pCloud",
646
+ )
647
+
648
+
649
+ def _forensics_iso(epoch_or_now) -> str:
650
+ if isinstance(epoch_or_now, (int, float)):
651
+ d = dt.datetime.fromtimestamp(epoch_or_now, dt.timezone.utc)
652
+ else:
653
+ d = epoch_or_now
654
+ return d.isoformat(timespec="seconds").replace("+00:00", "Z")
655
+
656
+
657
+ def write_corruption_forensics(
658
+ db_path, *, db_label: str = "stats",
659
+ ) -> "pathlib.Path | None":
660
+ """Write the #336-defense-2 corruption forensics bundle FIRST — before any
661
+ quarantine/rebuild disturbs the evidence (spec §6.3). Captures the file family
662
+ sizes+mtimes, a best-effort ``PRAGMA integrity_check``, an ``lsof`` of the
663
+ family, and a scan of running processes for known cloud-sync/backup holders.
664
+ Every leg degrades to a captured error string rather than raising, so a heal
665
+ never fails because forensics could not fully run. Returns the bundle path (or
666
+ None if it could not be written). Shared by the auto-heal HEAL_HOOK and
667
+ ``db rebuild`` (and, later, doctor's incident leg)."""
668
+ db_path = pathlib.Path(db_path)
669
+ ts = _db_backup_timestamp()
670
+ try:
671
+ _cctally_core.LOG_DIR.mkdir(parents=True, exist_ok=True)
672
+ except OSError:
673
+ pass
674
+ out = _cctally_core.LOG_DIR / f"{db_path.name}-corruption-forensics-{ts}.json"
675
+ bundle: dict = {
676
+ "schemaVersion": 1,
677
+ "capturedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
678
+ "db": db_label,
679
+ "path": str(db_path),
680
+ "family": {},
681
+ "integrityCheck": None,
682
+ "lsof": None,
683
+ "holders": [],
684
+ }
685
+ for suffix in ("", "-wal", "-shm"):
686
+ p = pathlib.Path(str(db_path) + suffix)
687
+ try:
688
+ st = p.stat()
689
+ bundle["family"][p.name] = {
690
+ "bytes": st.st_size, "mtimeUtc": _forensics_iso(st.st_mtime),
691
+ }
692
+ except OSError:
693
+ bundle["family"][p.name] = None
694
+ try:
695
+ c = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
696
+ try:
697
+ c.execute("PRAGMA busy_timeout=2000")
698
+ bundle["integrityCheck"] = [
699
+ r[0] for r in c.execute("PRAGMA integrity_check").fetchall()
700
+ ]
701
+ finally:
702
+ c.close()
703
+ except Exception as exc:
704
+ bundle["integrityCheck"] = f"error: {exc}"
705
+ try:
706
+ cp = subprocess.run(
707
+ ["lsof", "--", str(db_path)],
708
+ capture_output=True, text=True, timeout=5,
709
+ )
710
+ bundle["lsof"] = cp.stdout or cp.stderr or ""
711
+ except Exception as exc:
712
+ bundle["lsof"] = f"error: {exc}"
713
+ try:
714
+ cp = subprocess.run(
715
+ ["ps", "-Axo", "pid=,comm="],
716
+ capture_output=True, text=True, timeout=5,
717
+ )
718
+ for line in (cp.stdout or "").splitlines():
719
+ low = line.lower()
720
+ if any(h.lower() in low for h in _CORRUPTION_HOLDER_HINTS):
721
+ bundle["holders"].append(line.strip())
722
+ except Exception as exc:
723
+ bundle["holders"] = [f"error: {exc}"]
724
+ try:
725
+ out.write_text(json.dumps(bundle, indent=2, sort_keys=True))
726
+ try:
727
+ os.chmod(out, 0o600)
728
+ except OSError:
729
+ pass
730
+ except OSError:
731
+ return None
732
+ return out
733
+
734
+
735
+ def quarantine_db_family(db_path, *, ts: "str | None" = None) -> pathlib.Path:
736
+ """Move a damaged DB + its ``-wal``/``-shm`` sidecars into a single
737
+ timestamped incident directory under ``quarantine/`` with a manifest (spec
738
+ §6.3). NEVER deletes evidence — three renames under the caller's exclusion
739
+ locks, not pretending to be one atomic op. ``0o700`` dir / ``0o600`` files.
740
+ Returns the incident directory (which may be empty if nothing was present)."""
741
+ db_path = pathlib.Path(db_path)
742
+ ts = ts or _db_backup_timestamp()
743
+ root = _cctally_core.APP_DIR / "quarantine"
744
+ incident = root / f"{db_path.name}-{ts}"
745
+ incident.mkdir(parents=True, exist_ok=True)
746
+ for d in (root, incident):
747
+ try:
748
+ os.chmod(d, 0o700)
749
+ except OSError:
750
+ pass
751
+ moved: list = []
752
+ for suffix in ("", "-wal", "-shm"):
753
+ src = pathlib.Path(str(db_path) + suffix)
754
+ if not src.exists():
755
+ continue
756
+ dst = incident / src.name
757
+ try:
758
+ os.replace(str(src), str(dst))
759
+ try:
760
+ os.chmod(dst, 0o600)
761
+ except OSError:
762
+ pass
763
+ moved.append(src.name)
764
+ except OSError as exc:
765
+ eprint(f"[quarantine] could not move {src}: {exc}")
766
+ manifest = {
767
+ "schemaVersion": 1,
768
+ "quarantinedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
769
+ "originalPath": str(db_path),
770
+ "movedFiles": moved,
771
+ }
772
+ try:
773
+ (incident / "manifest.json").write_text(
774
+ json.dumps(manifest, indent=2, sort_keys=True))
775
+ except OSError:
776
+ pass
777
+ return incident
778
+
779
+
780
+ def cmd_db_rebuild(args: argparse.Namespace) -> int:
781
+ """``db rebuild --db stats`` — explicit journal replay into a fresh index
782
+ (spec §9). Forensics-quarantines the current stats.db the SAME forensics-first
783
+ way auto-heal does (even when the DB is healthy — this is an operator's
784
+ deliberate reset), then rebuilds from the journal and reports counts +
785
+ duration. Held under the stats maintenance lock so it serializes with a
786
+ concurrent auto-heal. #146 prod guard: a dev/worktree binary refuses to
787
+ rebuild the real prod stats.db unless ``CCTALLY_ALLOW_PROD_MIGRATION=1``.
788
+ Exit 0 on success, 2 for the prod guard, 3 for a rebuild failure."""
789
+ from _lib_json_envelope import stamp_schema_version
790
+ import _cctally_journal
791
+
792
+ as_json = bool(getattr(args, "json", False))
793
+ path = _cctally_core.DB_PATH
794
+ if _would_block_prod_stats(path):
795
+ eprint(
796
+ "cctally: refusing to rebuild the prod stats.db "
797
+ "(~/.local/share/cctally) from a dev checkout. Run the installed "
798
+ "binary, or override with CCTALLY_ALLOW_PROD_MIGRATION=1."
799
+ )
800
+ return 2
801
+
802
+ _cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
803
+ maint_fd = os.open(
804
+ str(_cctally_core.STATS_LOCK_MAINTENANCE_PATH),
805
+ os.O_RDWR | os.O_CREAT, 0o600,
806
+ )
807
+ forensics = None
808
+ incident = None
809
+ try:
810
+ fcntl.flock(maint_fd, fcntl.LOCK_EX)
811
+ import _cctally_store
812
+ # Symmetry with the auto-heal path (Task-8 P3-3): take the bounded ingest
813
+ # lock so a concurrent ingest cycle can't write into the stats.db we are
814
+ # about to quarantine + rebuild. Journal-is-truth makes this
815
+ # correctness-neutral, but keeping the two rebuild paths identical avoids
816
+ # a latent write-into-quarantined-inode surprise.
817
+ ingest_fd = _cctally_store._heal_flock_bounded(
818
+ _cctally_core.JOURNAL_INGEST_LOCK_PATH, 5.0)
819
+ try:
820
+ if path.exists():
821
+ forensics = write_corruption_forensics(path, db_label="stats")
822
+ incident = quarantine_db_family(path)
823
+ result = _cctally_journal.rebuild_stats_index()
824
+ except Exception as exc:
825
+ eprint(f"cctally: stats.db rebuild failed: {exc}")
826
+ return 3
827
+ finally:
828
+ _cctally_store._heal_release_flock(ingest_fd)
829
+ finally:
830
+ try:
831
+ fcntl.flock(maint_fd, fcntl.LOCK_UN)
832
+ finally:
833
+ os.close(maint_fd)
834
+
835
+ total_rows = sum(result.rows_by_table.values())
836
+ if as_json:
837
+ payload = {
838
+ "db": "stats",
839
+ "segmentsRead": result.segments_read,
840
+ "linesFolded": result.lines_folded,
841
+ "malformed": result.malformed,
842
+ "durationSeconds": round(result.duration_s, 3),
843
+ "rowsByTable": result.rows_by_table,
844
+ "totalRows": total_rows,
845
+ "quarantineDir": str(incident) if incident else None,
846
+ "forensicsPath": str(forensics) if forensics else None,
847
+ }
848
+ print(json.dumps(stamp_schema_version(payload, version=1)))
849
+ else:
850
+ print(
851
+ f"cctally: rebuilt stats.db from {result.segments_read} journal "
852
+ f"segment(s) — {result.lines_folded} lines folded, "
853
+ f"{result.malformed} malformed, {total_rows} rows, "
854
+ f"{result.duration_s:.2f}s."
855
+ )
856
+ for tbl, n in result.rows_by_table.items():
857
+ if n:
858
+ print(f" {tbl}: {n}")
859
+ if incident is not None:
860
+ print(f" previous stats.db quarantined -> {incident}")
861
+ if forensics is not None:
862
+ print(f" forensics -> {forensics}")
863
+ return 0
864
+
865
+
590
866
  def _first_pending_migration_name(
591
867
  conn: sqlite3.Connection, registry: "list[Migration]", cur_version: int
592
868
  ) -> str:
@@ -934,6 +1210,15 @@ def _run_pending_migrations(
934
1210
  "session_entries", "conversation_messages", "codex_session_entries",
935
1211
  "codex_conversation_events",
936
1212
  ),
1213
+ # conversations.db (spec §7.2): a transcript-bearing store written
1214
+ # before the framework existed carries the marker but no
1215
+ # schema_migrations. Probing its data tables classifies it NON-fresh
1216
+ # so migration 001's handler runs (adopts the marker) instead of
1217
+ # being stamped without it — the difference between adopt and skip.
1218
+ "conversations.db": (
1219
+ "conversation_messages", "conversation_source_files",
1220
+ "codex_conversation_source_files",
1221
+ ),
937
1222
  }.get(db_label, ())
938
1223
  for probe_table in probe_tables:
939
1224
  # _probe_table_nonempty centralizes the "is there data here?"
@@ -3106,6 +3391,28 @@ def _set_cache_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
3106
3391
  "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
3107
3392
 
3108
3393
 
3394
+ @conversations_migration("001_adopt_schema_version_marker")
3395
+ def _conv_001_adopt_schema_version_marker(conn: sqlite3.Connection) -> None:
3396
+ """Bring conversations.db under the migration framework (spec §7.2).
3397
+
3398
+ Adoption, not migration: ``_apply_conversations_schema`` (run before the
3399
+ dispatcher in ``open_conversations_db``) already owns the physical schema and
3400
+ the ``cache_meta['conversation_schema_version']`` marker. On an EXISTING
3401
+ populated DB the store's version gate keeps ``_apply_conversations_schema``
3402
+ from re-running the schema (its own marker short-circuit), and the
3403
+ dispatcher's data-emptiness probe (``conversation_messages`` /
3404
+ ``conversation_source_files``) classifies the DB as NON-fresh so this handler
3405
+ runs and the dispatcher stamps the marker + advances ``user_version`` to 1
3406
+ WITHOUT touching a single transcript row. On a FRESH DB the dispatcher's
3407
+ fresh-install path stamps the marker without invoking this handler at all.
3408
+
3409
+ The handler is a self-contained, idempotent no-op-equivalent: it re-asserts
3410
+ the ``conversation_schema_version`` marker (an ``INSERT … ON CONFLICT`` upsert)
3411
+ so it is safe to re-run on its own output (handler-idempotency contract)."""
3412
+ _set_cache_meta(conn, "conversation_schema_version", "1")
3413
+ conn.commit()
3414
+
3415
+
3109
3416
  # #177 S6: the consolidated multi-column external-content FTS5 table that
3110
3417
  # replaces the old conversation_fts(text) + conversation_fts_aux(search_aux)
3111
3418
  # pair. The three column names MUST match the conversation_messages columns BY
@@ -5911,6 +6218,22 @@ def _migration_codex_quota_projection_state(conn: sqlite3.Connection) -> None:
5911
6218
  _cctally_core._apply_quota_projection_schema(conn)
5912
6219
 
5913
6220
 
6221
+ # ── Stats registry FROZEN at the legacy head (DB journal redesign §7.1) ──
6222
+ # stats.db moved to epoch-rebuild versioning (STATS_INDEX_EPOCH); NO stats
6223
+ # migration is ever written again — a schema change bumps the epoch instead. This
6224
+ # module-load assertion fires if a 14th @stats_migration lands, mirroring the
6225
+ # import-time contiguity check in `_make_migration_decorator`. It runs BEFORE the
6226
+ # test-injection block below (which registers a 14th ONLY under
6227
+ # CCTALLY_MIGRATION_TEST_MODE), so the production shape is asserted at exactly 13.
6228
+ STATS_REGISTRY_FROZEN_HEAD = 13
6229
+ assert len(_STATS_MIGRATIONS) == STATS_REGISTRY_FROZEN_HEAD, (
6230
+ f"stats registry is frozen at {STATS_REGISTRY_FROZEN_HEAD} (DB journal "
6231
+ f"redesign §7.1) but has {len(_STATS_MIGRATIONS)} migrations — a new "
6232
+ f"@stats_migration was added. Schema changes now bump STATS_INDEX_EPOCH; the "
6233
+ f"stats registry is frozen and must not grow."
6234
+ )
6235
+
6236
+
5914
6237
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
5915
6238
 
5916
6239
  # ──────────────────────────────────────────────────────────────────────
@@ -5966,12 +6289,16 @@ def cmd_db_status(args: argparse.Namespace) -> int:
5966
6289
  "databases": {
5967
6290
  "stats.db": _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"),
5968
6291
  "cache.db": _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"),
6292
+ "conversations.db": _db_status_for(
6293
+ _cctally_core.CONVERSATIONS_DB_PATH, _CONVERSATIONS_MIGRATIONS,
6294
+ "conversations.db",
6295
+ ),
5969
6296
  },
5970
6297
  }
5971
6298
  if getattr(args, "json", False):
5972
6299
  print(json.dumps(payload, indent=2, sort_keys=True))
5973
6300
  return 0
5974
- for db_label in ("stats.db", "cache.db"):
6301
+ for db_label in ("stats.db", "cache.db", "conversations.db"):
5975
6302
  info = payload["databases"][db_label]
5976
6303
  skipped_count = sum(1 for m in info["migrations"] if m["status"] == "skipped")
5977
6304
  suffix = f" ({skipped_count} skipped)" if skipped_count else ""
@@ -6143,14 +6470,22 @@ def _db_resolve_migration_name(name_arg: str) -> tuple[str, str, list[Migration]
6143
6470
  if any(m.name == unq for m in _CACHE_MIGRATIONS):
6144
6471
  return "cache.db", unq, _CACHE_MIGRATIONS
6145
6472
  raise LookupError(name_arg)
6473
+ if name_arg.startswith("conversations.db:"):
6474
+ unq = name_arg[len("conversations.db:"):]
6475
+ if any(m.name == unq for m in _CONVERSATIONS_MIGRATIONS):
6476
+ return "conversations.db", unq, _CONVERSATIONS_MIGRATIONS
6477
+ raise LookupError(name_arg)
6146
6478
  in_stats = any(m.name == name_arg for m in _STATS_MIGRATIONS)
6147
6479
  in_cache = any(m.name == name_arg for m in _CACHE_MIGRATIONS)
6148
- if in_stats and in_cache:
6480
+ in_conv = any(m.name == name_arg for m in _CONVERSATIONS_MIGRATIONS)
6481
+ if (in_stats + in_cache + in_conv) > 1:
6149
6482
  raise RuntimeError(name_arg)
6150
6483
  if in_stats:
6151
6484
  return "stats.db", name_arg, _STATS_MIGRATIONS
6152
6485
  if in_cache:
6153
6486
  return "cache.db", name_arg, _CACHE_MIGRATIONS
6487
+ if in_conv:
6488
+ return "conversations.db", name_arg, _CONVERSATIONS_MIGRATIONS
6154
6489
  raise LookupError(name_arg)
6155
6490
 
6156
6491
 
@@ -6159,6 +6494,8 @@ def _db_path_for_label(db_label: str) -> pathlib.Path:
6159
6494
  return _cctally_core.DB_PATH
6160
6495
  if db_label == "cache.db":
6161
6496
  return _cctally_core.CACHE_DB_PATH
6497
+ if db_label == "conversations.db":
6498
+ return _cctally_core.CONVERSATIONS_DB_PATH
6162
6499
  raise ValueError(f"unknown db_label: {db_label}")
6163
6500
 
6164
6501
 
@@ -6175,7 +6512,7 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
6175
6512
  except RuntimeError:
6176
6513
  eprint(
6177
6514
  f"cctally: ambiguous migration name '{name_arg}'; "
6178
- f"qualify as 'stats.db:{name_arg}' or 'cache.db:{name_arg}'"
6515
+ f"qualify as 'stats.db:{name_arg}', 'cache.db:{name_arg}', or 'conversations.db:{name_arg}'"
6179
6516
  )
6180
6517
  return 2
6181
6518
  except LookupError:
@@ -6256,7 +6593,7 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
6256
6593
  except RuntimeError:
6257
6594
  eprint(
6258
6595
  f"cctally: ambiguous migration name '{name_arg}'; "
6259
- f"qualify as 'stats.db:{name_arg}' or 'cache.db:{name_arg}'"
6596
+ f"qualify as 'stats.db:{name_arg}', 'cache.db:{name_arg}', or 'conversations.db:{name_arg}'"
6260
6597
  )
6261
6598
  return 2
6262
6599
  except LookupError:
@@ -6306,10 +6643,21 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
6306
6643
  dispatcher. Idempotent: a no-op when the DB is not ahead.
6307
6644
  """
6308
6645
  which = args.db # "cache" | "stats"
6309
- if which == "cache":
6310
- path, registry, label = _cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db"
6311
- else:
6312
- path, registry, label = _cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db"
6646
+ if which == "stats":
6647
+ # RETIRED (DB journal redesign §7.1). stats.db is now a disposable
6648
+ # journal index — a version mismatch resolves by rebuild, not by
6649
+ # trim-and-revert. The old trim path would mangle an epoch-stamped DB
6650
+ # (user_version 1000 > 13 looks "version-ahead" to the legacy registry).
6651
+ # Point the operator at the rebuild surface instead.
6652
+ eprint(
6653
+ "cctally: `db recover --db stats` is retired — stats.db is now a "
6654
+ "disposable index materialized from the append-only journal, so a "
6655
+ "version mismatch self-heals by rebuild rather than trim-and-revert. "
6656
+ "Run `cctally db rebuild --db stats` to rebuild it from the journal."
6657
+ )
6658
+ return 2
6659
+ path, registry, label = (
6660
+ _cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
6313
6661
 
6314
6662
  # Absent file → nothing to recover; do NOT connect (sqlite3.connect would
6315
6663
  # create an empty DB file — mirrors cmd_db_unskip).
@@ -6327,34 +6675,11 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
6327
6675
  f"(≤ known {head}); nothing to recover."
6328
6676
  )
6329
6677
  return 0
6330
- # Prod guard (issue #146): a dev/worktree binary must not trim the unknown
6331
- # migration markers + revert user_version on the installed release's
6332
- # NON-re-derivable prod stats.db the destructive cousin of the #142
6333
- # forward-migration guard (trimmed markers can't be re-derived). Reuses
6334
- # the same connection-scoped predicate (git checkout AND the DB physically
6335
- # in the real prod dir, password-DB-resolved, honoring
6336
- # CCTALLY_ALLOW_PROD_MIGRATION). cache.db is re-derivable and intentionally
6337
- # exempt — it mirrors the dispatcher's opt-in auto-heal.
6338
- if which == "stats" and _would_block_prod_migration(conn):
6339
- eprint(
6340
- "cctally: refusing to recover stats.db in the prod data dir "
6341
- "(~/.local/share/cctally) from a dev checkout — trimming the "
6342
- "unknown migration markers and reverting user_version on the "
6343
- "installed release's non-re-derivable stats.db could corrupt it. "
6344
- "Run the installed binary, or override with "
6345
- "CCTALLY_ALLOW_PROD_MIGRATION=1."
6346
- )
6347
- return 2
6348
- if which == "stats" and not getattr(args, "yes", False):
6349
- eprint(
6350
- f"cctally: {label} is at version {cur_version} but this cctally "
6351
- f"only knows up to {head}. Recovering stats.db trims the unknown "
6352
- f"migration markers and reverts user_version, but any schema the "
6353
- f"unknown migration created is left in place and a re-record/"
6354
- f"re-sync may be needed. Re-run with --yes to proceed, or restore "
6355
- f"{label} from a backup."
6356
- )
6357
- return 2
6678
+ # `--db stats` returned early above (retired, §7.1), so control only
6679
+ # reaches here for cache.db: re-derivable, healed in place with no --yes
6680
+ # and no #146 prod guard (the dispatcher's opt-in auto-heal already covers
6681
+ # it). The former `which == "stats"` prod-guard + --yes gates were dead
6682
+ # after the retirement and have been removed.
6358
6683
  info = _recover_version_ahead(conn, registry, label)
6359
6684
  print(
6360
6685
  f"cctally: reverted {label} v{info['reverted_from']} → "