cctally 1.92.3 → 1.93.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.
@@ -993,6 +993,7 @@ def write_corruption_forensics(
993
993
  bundle: dict = {
994
994
  "schemaVersion": 1,
995
995
  "capturedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
996
+ "sqliteRuntimeVersion": sqlite3.sqlite_version,
996
997
  "db": db_label,
997
998
  "path": str(db_path),
998
999
  "family": {},
@@ -1012,12 +1013,37 @@ def write_corruption_forensics(
1012
1013
  bundle["walEvidence"] = _capture_corruption_wal_evidence(
1013
1014
  db_path, ts, db_label=db_label, trigger_exception=trigger_exception,
1014
1015
  )
1016
+ try:
1017
+ import _lib_stats_wal
1018
+
1019
+ bundle["walIndexEvidence"] = _lib_stats_wal.inspect_wal_index_family(
1020
+ db_path
1021
+ )
1022
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a heal
1023
+ bundle["walIndexEvidence"] = {
1024
+ "schemaVersion": 1,
1025
+ "verdict": "unavailable",
1026
+ "captureStable": False,
1027
+ "reason": _bounded_forensics_text(
1028
+ exc, _FORENSICS_EXCEPTION_MESSAGE_MAX,
1029
+ ),
1030
+ "wal": None,
1031
+ "shm": None,
1032
+ "frameMapping": {
1033
+ "comparedCount": 0,
1034
+ "mismatchCount": 0,
1035
+ "mismatchSample": [],
1036
+ "truncated": False,
1037
+ },
1038
+ }
1015
1039
  for suffix in ("", "-wal", "-shm"):
1016
1040
  p = pathlib.Path(str(db_path) + suffix)
1017
1041
  try:
1018
1042
  st = p.stat()
1019
1043
  bundle["family"][p.name] = {
1020
- "bytes": st.st_size, "mtimeUtc": _forensics_iso(st.st_mtime),
1044
+ "bytes": st.st_size,
1045
+ "mtimeUtc": _forensics_iso(st.st_mtime),
1046
+ "inode": int(st.st_ino),
1021
1047
  }
1022
1048
  except OSError:
1023
1049
  bundle["family"][p.name] = None
@@ -1314,6 +1340,30 @@ def quarantine_db_family(
1314
1340
  _REBUILD_CONFLICT_SAMPLE = 10
1315
1341
 
1316
1342
 
1343
+ def _cache_recovery_payload(coverage) -> dict:
1344
+ """`db rebuild --json`'s `cacheRecovery` object (#496 S5b §4.7).
1345
+
1346
+ `complete` is NORMALIZED here, and that is the point of the helper. The
1347
+ coverage record starts life as `{"status": "skipped", "reason": None,
1348
+ "replayedObservations": 0}` with no `complete` key at all, and a rebuild run
1349
+ with `update_quota_cache=False` never adds one — so a raw `.get("complete")`
1350
+ emitted `null`, while `_write_quota_projection_state` reads that same
1351
+ absence as COMPLETE. The JSON and the durable flag disagreed about exactly
1352
+ one state, and `if not payload["cacheRecovery"]["complete"]` got the wrong
1353
+ answer for it. The two now agree by construction: an absent key is an absent
1354
+ duty, which is complete, and `phase` says `notRun` so a consumer can still
1355
+ tell "the leg did not run" from "the leg ran and had nothing to do".
1356
+ """
1357
+ coverage = coverage or {}
1358
+ ran = "complete" in coverage
1359
+ return {
1360
+ "phase": coverage.get("status") if ran else "notRun",
1361
+ "complete": bool(coverage.get("complete")) if ran else True,
1362
+ "coveredHighWater": coverage.get("coveredHighWater"),
1363
+ "remainder": coverage.get("remainder"),
1364
+ }
1365
+
1366
+
1317
1367
  def cmd_db_rebuild(args: argparse.Namespace) -> int:
1318
1368
  """``db rebuild --db stats`` — explicit journal replay into a fresh index
1319
1369
  (spec §9). Captures forensics first, builds and validates a fresh index while
@@ -1427,6 +1477,34 @@ def cmd_db_rebuild(args: argparse.Namespace) -> int:
1427
1477
  violation.to_dict()
1428
1478
  for violation in result.acknowledged_protocol_violations
1429
1479
  ],
1480
+ # #496 S5b — the durable selector prefix of the index this rebuild
1481
+ # replaced, when it was behind that index's own applied cursor.
1482
+ # `null` in the healthy case. Reported here and in the rebuild
1483
+ # record only: the live path's own recovery is silent by §6.3.
1484
+ "selectorDesynchronized": result.selector_desynchronized,
1485
+ # #496 S5b F11 — what the quota cache leg did. `covered` is the
1486
+ # intact path: the coverage certificate proved every cache-relevant
1487
+ # journal record in the pinned prefix was already materialized, so
1488
+ # the leg took no cache writer flock and replayed nothing.
1489
+ "quotaCacheCoverage": result.quota_cache_coverage or None,
1490
+ # #496 S5b §4.7 — publication success and cache-recovery
1491
+ # completeness are DISTINCT, and a consumer must not be able to read
1492
+ # one as the other. Reaching this point means the index was built,
1493
+ # validated and published; whether the cache the quota projection
1494
+ # was materialized from had reached its pinned target is a separate
1495
+ # question with a separate answer.
1496
+ "publication": {
1497
+ "ok": True,
1498
+ "statsQuotaProjectionIncomplete": bool(
1499
+ result.stats_quota_projection_incomplete),
1500
+ },
1501
+ "cacheRecovery": _cache_recovery_payload(
1502
+ result.quota_cache_coverage),
1503
+ # #496 S5b F12 — which journal segments the read pass skipped and,
1504
+ # per segment, why it refused the ones it read. Every refusal is a
1505
+ # silent full read by §6.3, so this and the rebuild record are the
1506
+ # only places that say which state it was.
1507
+ "segmentElision": (result.traversal or {}).get("elision"),
1430
1508
  }
1431
1509
  print(json.dumps(stamp_schema_version(payload, version=1)))
1432
1510
  else:
@@ -6464,6 +6542,16 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
6464
6542
  conn.execute(
6465
6543
  "DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
6466
6544
  )
6545
+ # #496 S5b §4.3, the identical hazard one layer over: this deletes
6546
+ # every Codex `quota_window_snapshots` row while the journal still
6547
+ # retains the observations behind them, and it does NOT bump
6548
+ # `codex_physical_mutation_seq`, so a stored coverage certificate
6549
+ # would stay stale-VALID and the next rebuild's fast path would skip
6550
+ # a replay over a cache holding zero Codex quota rows. Reachable
6551
+ # through `db skip 024` followed by `db unskip 024`. Same
6552
+ # transaction as the deletes.
6553
+ import _cctally_cache
6554
+ _cctally_cache._invalidate_codex_journal_coverage_certificate(conn)
6467
6555
  conn.commit()
6468
6556
  except Exception:
6469
6557
  conn.rollback()
@@ -1730,6 +1730,35 @@ def _doctor_gather_state_impl(
1730
1730
  journal_cursor_lag_bytes = lag
1731
1731
  except Exception:
1732
1732
  pass
1733
+ # #496 S5b: the durable incomplete-quota-projection flag carried inside the
1734
+ # published stats generation. Read-only, and independent of journal presence
1735
+ # because the flag describes the INDEX rather than the journal. None means
1736
+ # "no epoch-1009 index to ask" (absent file, missing table, unreadable DB),
1737
+ # which the pure kernel reports as not applicable rather than as a fault.
1738
+ #
1739
+ # This is a THIRD read-only stats open in this function, and folding it into
1740
+ # the `jc` open above was considered and rejected: that open sits inside
1741
+ # `if journal_present:`, so carrying this SELECT there would make the flag
1742
+ # unreadable on an install whose journal directory is absent — exactly the
1743
+ # independence the paragraph above states. One extra guarded open on the
1744
+ # doctor path is the cheaper of the two.
1745
+ stats_quota_projection_incomplete: "bool | None" = None
1746
+ try:
1747
+ if _cctally_core.DB_PATH.exists():
1748
+ qp = _stats_ro_guarded() # #386 opener protocol
1749
+ try:
1750
+ row = qp.execute(
1751
+ "SELECT incomplete FROM stats_quota_projection_state "
1752
+ "WHERE id = 1").fetchone()
1753
+ if row is not None:
1754
+ stats_quota_projection_incomplete = bool(int(row[0] or 0))
1755
+ except sqlite3.OperationalError:
1756
+ pass # pre-1009 index has no stats_quota_projection_state
1757
+ finally:
1758
+ qp.close()
1759
+ except Exception:
1760
+ stats_quota_projection_incomplete = None
1761
+
1733
1762
  # Auto-heal incident history — independent of journal presence (a corruption
1734
1763
  # incident can predate cutover). None only if BOTH dirs were unreadable.
1735
1764
  journal_heal_incidents = None
@@ -1856,6 +1885,8 @@ def _doctor_gather_state_impl(
1856
1885
  journal_protocol_violations=journal_protocol_violations,
1857
1886
  journal_protocol_acknowledged=journal_protocol_acknowledged,
1858
1887
  journal_protocol_error=journal_protocol_error,
1888
+ # #496 S5b: the published generation's incomplete-quota-projection flag.
1889
+ stats_quota_projection_incomplete=stats_quota_projection_incomplete,
1859
1890
  # #315: read-only cache free-page evidence for the reclaim hint.
1860
1891
  cache_db_page_count=cache_db_page_count,
1861
1892
  cache_db_freelist_count=cache_db_freelist_count,
@@ -2913,7 +2913,8 @@ def _print_project_section_or_note(rows, has_projects, window_resolved, args):
2913
2913
 
2914
2914
  def _append_project_share_rows(snap, rows, has_projects):
2915
2915
  """Append per-project ProjectCell rows to a budget ShareSnapshot so the
2916
- share-output anonymization chokepoint (``_lib_share._scrub``) rewrites the
2916
+ share-output anonymization chokepoint (the preparation pass
2917
+ ``_lib_share.render()`` runs over the raw snapshot) rewrites the
2917
2918
  basenames under default output and reveals them under ``--reveal-projects``
2918
2919
  (spec §7.5). No-op when projects are empty → existing share goldens stay
2919
2920
  byte-identical. Project names go through ``ProjectCell`` (the single
@@ -3029,7 +3030,8 @@ def _build_budget_snapshot(
3029
3030
 
3030
3031
  This builds the GLOBAL budget rows only; when per-project budgets are
3031
3032
  configured, `_append_project_share_rows` appends ProjectCell rows so
3032
- `--reveal-projects` reveals (or `_scrub` anonymizes) the per-project
3033
+ `--reveal-projects` reveals (or, by default, `render()`'s preparation
3034
+ pass anonymizes) the per-project
3033
3035
  basenames via the share chokepoint. No parallel renderer; the gate calls
3034
3036
  `_share_render_and_emit(snap, args)`.
3035
3037