cctally 1.91.0 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/_cctally_cache.py +863 -74
  3. package/bin/_cctally_config.py +57 -0
  4. package/bin/_cctally_core.py +39 -8
  5. package/bin/_cctally_dashboard.py +146 -5
  6. package/bin/_cctally_dashboard_conversation.py +164 -18
  7. package/bin/_cctally_dashboard_envelope.py +2 -0
  8. package/bin/_cctally_db.py +372 -10
  9. package/bin/_cctally_doctor.py +18 -1
  10. package/bin/_cctally_journal.py +535 -13
  11. package/bin/_cctally_journal_repair.py +6 -0
  12. package/bin/_cctally_parser.py +6 -0
  13. package/bin/_cctally_quota.py +171 -55
  14. package/bin/_cctally_record.py +13 -1
  15. package/bin/_cctally_rederive.py +4 -0
  16. package/bin/_cctally_store.py +311 -6
  17. package/bin/_cctally_transcript.py +32 -2
  18. package/bin/_lib_cache_report.py +8 -3
  19. package/bin/_lib_codex_conversation.py +851 -81
  20. package/bin/_lib_codex_conversation_query.py +2005 -95
  21. package/bin/_lib_codex_find_projection.py +370 -0
  22. package/bin/_lib_codex_harness_preamble.py +176 -0
  23. package/bin/_lib_codex_hooks.py +5 -3
  24. package/bin/_lib_codex_js_scan.py +254 -0
  25. package/bin/_lib_codex_landmarks.py +309 -0
  26. package/bin/_lib_codex_title_clean.py +116 -0
  27. package/bin/_lib_conversation_dispatch.py +153 -21
  28. package/bin/_lib_conversation_watch.py +4 -2
  29. package/bin/_lib_doctor.py +64 -0
  30. package/bin/_lib_quota_alert_axes.py +31 -34
  31. package/bin/_lib_stats_damage.py +523 -0
  32. package/bin/cctally +5 -0
  33. package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
  34. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  35. package/dashboard/static/dashboard.html +2 -2
  36. package/package.json +7 -1
  37. package/dashboard/static/assets/index-CILAoEja.js +0 -90
@@ -35,7 +35,7 @@ import signal
35
35
  import sqlite3
36
36
  import sys
37
37
  import time
38
- from dataclasses import dataclass, field
38
+ from dataclasses import dataclass, field, replace as _dc_replace
39
39
 
40
40
  import _cctally_core
41
41
  import _lib_accounts
@@ -1345,7 +1345,8 @@ def _report_file_account_conflicts(conflicts: int) -> None:
1345
1345
  # forever, for every provider, not just Codex.
1346
1346
  _QUOTA_SNAPSHOT_UPSERT_CLAUSE = (
1347
1347
  " ON CONFLICT(source, source_path, line_offset, logical_limit_key) "
1348
- "DO UPDATE SET account_key = excluded.account_key"
1348
+ "DO UPDATE SET account_key = excluded.account_key "
1349
+ "WHERE quota_window_snapshots.account_key IS NOT excluded.account_key"
1349
1350
  )
1350
1351
 
1351
1352
  _QUOTA_SNAPSHOT_UPSERT = _QUOTA_SNAPSHOT_INSERT + _QUOTA_SNAPSHOT_UPSERT_CLAUSE
@@ -1667,6 +1668,8 @@ def _cache_applier(decoded) -> int | None:
1667
1668
  `decoded[stop]`'s offset, retrying the remainder next cycle (the scalar
1668
1669
  cursor never advances past an unmaterialized record — spec §5.2 step 3).
1669
1670
  - Flock acquired + everything upserted → return None (full consumption).
1671
+ A quota-row change advances ``codex_physical_mutation_seq`` in the same
1672
+ transaction; an idempotent replay leaves the sequence unchanged.
1670
1673
  """
1671
1674
  quota_idx = [i for i, (rec, _s, _o) in enumerate(decoded)
1672
1675
  if _is_codex_quota_obs(rec)]
@@ -1706,7 +1709,14 @@ def _cache_applier(decoded) -> int | None:
1706
1709
  # must already govern the observations it covers.
1707
1710
  _, _file_conflicts = _apply_file_account_records(
1708
1711
  cache, [decoded[i][0] for i in file_idx])
1712
+ quota_changes_before = cache.total_changes
1709
1713
  _apply_quota_records(cache, [decoded[i][0] for i in quota_idx])
1714
+ if cache.total_changes != quota_changes_before:
1715
+ # #457: this path is independent of the fused rollout writer,
1716
+ # but its quota rows feed the same certificate and dashboard
1717
+ # signatures. Keep the token atomic with the materialization.
1718
+ import _cctally_cache
1719
+ _cctally_cache._bump_codex_physical_mutation_seq(cache)
1710
1720
  cache.commit()
1711
1721
  _report_file_account_conflicts(_file_conflicts)
1712
1722
  except sqlite3.Error as exc:
@@ -4027,7 +4037,12 @@ def _recover_completed_correction(
4027
4037
  "maintenance-correction-rebuild",
4028
4038
  ingest_lock=True,
4029
4039
  ):
4030
- rebuild_stats_index(high_water=signal.high_water)
4040
+ rebuild_stats_index(
4041
+ context=RebuildContext(
4042
+ trigger="correction-recovery-in-band"
4043
+ ),
4044
+ high_water=signal.high_water,
4045
+ )
4031
4046
  except BaseException as exc:
4032
4047
  _cleanup_new_correction_scratches(scratches_before)
4033
4048
  if isinstance(exc, (KeyboardInterrupt, SystemExit)):
@@ -4174,6 +4189,55 @@ _REBUILD_COUNT_TABLES = (
4174
4189
  )
4175
4190
 
4176
4191
 
4192
+ #: Every production path that reaches `rebuild_stats_index` against the live
4193
+ #: destination, plus one test-only identity. Closed by construction: a value
4194
+ #: outside this set is rejected by `RebuildContext.validate` (#496 S1 F3).
4195
+ #: `test-fixture` is for harnesses only, and
4196
+ #: `tests/test_stats_incident_identity.py` asserts no shipped call site emits it.
4197
+ REBUILD_TRIGGERS = frozenset({
4198
+ "corruption-heal",
4199
+ "interrupted-rebuild-recovery",
4200
+ "db-rebuild",
4201
+ "journal-repair-acknowledge",
4202
+ "journal-repair-recovery",
4203
+ "rederive-apply",
4204
+ "rederive-recovery",
4205
+ "correction-recovery-in-band",
4206
+ "epoch-transition",
4207
+ "test-fixture",
4208
+ })
4209
+
4210
+
4211
+ @dataclass(frozen=True)
4212
+ class RebuildContext:
4213
+ """Why this rebuild ran, and what evidence preceded it (#496 S1 F3).
4214
+
4215
+ A bare identifier would not be enough: `trigger_error` and `forensics_path`
4216
+ cannot be derived from it, and both are what tie a quarantine incident to
4217
+ the forensics bundle written moments earlier.
4218
+
4219
+ `record_path` is resolved by `rebuild_stats_index` itself, never by a
4220
+ caller, so preservation and the rebuild record name the same file.
4221
+ """
4222
+
4223
+ trigger: str
4224
+ trigger_error: "str | None" = None
4225
+ forensics_path: "str | None" = None
4226
+ record_path: "str | None" = None
4227
+
4228
+ def validate(self) -> "RebuildContext":
4229
+ if self.trigger not in REBUILD_TRIGGERS:
4230
+ raise ValueError(f"unknown rebuild trigger: {self.trigger!r}")
4231
+ if self.record_path is not None:
4232
+ # `rebuild_stats_index` overwrites this field unconditionally, so a
4233
+ # caller-supplied value would be silently discarded.
4234
+ raise ValueError(
4235
+ "record_path is resolved by rebuild_stats_index; callers must "
4236
+ "leave it unset"
4237
+ )
4238
+ return self
4239
+
4240
+
4177
4241
  @dataclass
4178
4242
  class RebuildResult:
4179
4243
  """Outcome of a `rebuild_stats_index` call (spec §5.4)."""
@@ -4299,7 +4363,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
4299
4363
  # omitted column, constraint, partial predicate, or index definition. An epoch
4300
4364
  # schema change must update this contract alongside STATS_INDEX_EPOCH.
4301
4365
  _REBUILD_SCHEMA_FINGERPRINT = (
4302
- "1e0a8cc22b3dc754cb8a6074ff9d2ef28df77b656dfc65347e4afbcb6edfdfae"
4366
+ "47bbbfde25fe4e4d40cb39671cf0c6c8fe2d8e9a7a5ce276927b410b916afd54"
4303
4367
  )
4304
4368
 
4305
4369
 
@@ -4532,8 +4596,15 @@ def stats_index_matches_journal_prefix(
4532
4596
  return False
4533
4597
 
4534
4598
 
4535
- def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
4536
- """Checkpoint a readable old index so removing its sidecars is kill-safe."""
4599
+ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> str:
4600
+ """Checkpoint a readable old index so removing its sidecars is kill-safe.
4601
+
4602
+ Returns what it actually did, so the incident manifest can say whether the
4603
+ explicit checkpoint ran (#496 S1 F8). Failure still RAISES rather than
4604
+ returning an outcome — the caller records `failed` and re-raises, because
4605
+ proceeding past an undrained WAL would pair stale sidecars with the
4606
+ replacement main file.
4607
+ """
4537
4608
  import _cctally_db
4538
4609
 
4539
4610
  try:
@@ -4551,11 +4622,281 @@ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
4551
4622
  # Auto-heal necessarily starts from an unreadable family. Preserve its
4552
4623
  # exact bytes below, then publish the already-validated replacement.
4553
4624
  if _cctally_db._is_sqlite_corruption_error(exc):
4554
- return
4625
+ return "skipped_corrupt"
4555
4626
  raise
4627
+ return "checkpointed"
4628
+
4629
+
4630
+ def _utc_iso_now() -> str:
4631
+ return dt.datetime.now(dt.timezone.utc).isoformat(
4632
+ timespec="seconds"
4633
+ ).replace("+00:00", "Z")
4634
+
4635
+
4636
+ def validate_published_stats_index(
4637
+ path, high_water: "tuple[str, int] | None"
4638
+ ) -> "str | None":
4639
+ """Validate an index on a FRESH read-only connection (#496 S1 F1).
4640
+
4641
+ Returns None on success, or a short failure reason. The building
4642
+ connection wrote the pages it then validated, so it is not an independent
4643
+ witness to what reached the disk; this reopens the file instead. The
4644
+ mechanism is already proven by `stats_index_matches_journal_prefix`, which
4645
+ runs the same check on the same kind of connection.
4646
+
4647
+ Public because `stats_open_guarded` runs exactly this check when it
4648
+ resolves a pending publication marker.
4649
+ """
4650
+ try:
4651
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
4652
+ try:
4653
+ _validate_rebuilt_stats_index(conn, high_water)
4654
+ finally:
4655
+ conn.close()
4656
+ except BaseException as exc:
4657
+ return f"{type(exc).__name__}: {exc}"[:500]
4658
+ return None
4659
+
4660
+
4661
+ def _publication_marker_path(destination) -> pathlib.Path:
4662
+ return pathlib.Path(str(destination) + ".publication")
4663
+
4664
+
4665
+ def _write_publication_marker(
4666
+ destination, record_path, *, started_at: str, scratch_path,
4667
+ status: str = "pending", error: "str | None" = None,
4668
+ prior: "dict | None" = None,
4669
+ ) -> None:
4670
+ """Publish the durable marker a later opener honours (#496 S1 F1).
4671
+
4672
+ Mirrors the existing `cache.db.repairing` marker idiom.
4673
+ `_atomic_write_private_json` writes at mode 0600 and fsyncs both the file
4674
+ and its parent directory.
4675
+
4676
+ `scratchPath` records the exact index this publication was about to install.
4677
+ Across processes `os.replace` is the only thing that consumes a scratch, so
4678
+ its presence or absence on disk is an exact answer to "did this run replace
4679
+ the destination?" — which is what interrupted-rebuild recovery needs in
4680
+ order to tell a marker it supersedes from one whose verdict is still owed.
4681
+ (Within one process the claim is weaker; see
4682
+ `_cctally_store._pending_stats_publication_never_replaced`.)
4683
+
4684
+ `priorFailure` carries a settled verdict this publication is about to
4685
+ overwrite, so a crash before `os.replace` cannot discard it — see
4686
+ `_settle_prior_publication_verdict`.
4687
+ """
4688
+ import _cctally_db
4689
+
4690
+ payload = {
4691
+ "schemaVersion": 1,
4692
+ "status": status,
4693
+ "recordPath": str(record_path),
4694
+ "startedAtUtc": started_at,
4695
+ "scratchPath": str(scratch_path),
4696
+ }
4697
+ if error is not None:
4698
+ payload["error"] = error
4699
+ if prior:
4700
+ payload["priorFailure"] = prior
4701
+ _cctally_db._atomic_write_private_json(
4702
+ _publication_marker_path(destination), payload
4703
+ )
4704
+ _fsync_dir(pathlib.Path(destination).parent)
4705
+
4706
+
4707
+ def _read_publication_marker(destination) -> "dict | None":
4708
+ """The marker's state as a MAPPING, or None when no marker exists.
4709
+
4710
+ Present-but-unusable bytes read as an empty mapping, for the reason given
4711
+ in `_cctally_store._read_stats_publication_marker`.
4712
+ """
4713
+ try:
4714
+ state = json.loads(_publication_marker_path(destination).read_text())
4715
+ except FileNotFoundError:
4716
+ return None
4717
+ except (OSError, ValueError):
4718
+ return {}
4719
+ return state if isinstance(state, dict) else {}
4720
+
4721
+
4722
+ def _settle_prior_publication_verdict(destination) -> "dict | None":
4723
+ """Settle the verdict a PREVIOUS publication still owes, before this one
4724
+ overwrites the single marker slot (#496 S1 F1).
4725
+
4726
+ `<db>.publication` is one file, so Phase 1 of a new publication destroys
4727
+ whatever the last one left there. `cmd_db_rebuild` takes maintenance
4728
+ EXCLUSIVE without opening the live database through `stats_open_guarded`,
4729
+ so a rebuild can legitimately begin while a pending marker still owes a
4730
+ verdict on an already-published, never-validated index. If this run then
4731
+ dies between its own marker write and its `os.replace`, the next opener
4732
+ sees a pending marker beside this run's own scratch, discards it as
4733
+ never-replaced, and accepts the earlier index having never validated it.
4734
+
4735
+ Resolving is preferred over refusing to overwrite, because a refusal would
4736
+ wedge the one operation that repairs a bad index behind the marker that
4737
+ reports it. Returns the verdict to CARRY into this run's marker, or None
4738
+ when nothing is owed:
4739
+
4740
+ - a `failed` marker is already settled and is carried verbatim;
4741
+ - a `pending` marker whose own scratch is still on disk never replaced
4742
+ anything, so it owes nothing about the live bytes of its own — but it may
4743
+ still be CARRYING an older run's verdict, which is passed through so a
4744
+ third consecutive crashed run cannot drop it;
4745
+ - a `pending` marker whose scratch is gone is settled HERE, by validating
4746
+ the destination against its record's pinned high-water — the same check
4747
+ the opener would have run. Success clears it; failure is written to both
4748
+ the marker and the record before this run touches the destination, and is
4749
+ then carried forward.
4750
+
4751
+ A marker that cannot be judged (no record, or no pinned high-water) is left
4752
+ to the opener's existing discard policy rather than wedging the rebuild.
4753
+ """
4754
+ import _cctally_db
4755
+
4756
+ state = _read_publication_marker(destination)
4757
+ if not state:
4758
+ return None
4759
+ status = str(state.get("status") or "")
4760
+ if status == "failed":
4761
+ return state
4762
+ if status != "pending":
4763
+ return None
4764
+ scratch = state.get("scratchPath")
4765
+ if isinstance(scratch, str) and scratch and pathlib.Path(scratch).exists():
4766
+ # This marker owes nothing itself, but dropping what it carries would
4767
+ # lose an older run's verdict once a third run crashes the same way.
4768
+ carried = state.get("priorFailure")
4769
+ return carried if isinstance(carried, dict) and carried else None
4770
+ record_path = state.get("recordPath")
4771
+ record = None
4772
+ if isinstance(record_path, str):
4773
+ try:
4774
+ record = json.loads(pathlib.Path(record_path).read_text())
4775
+ except (OSError, ValueError):
4776
+ record = None
4777
+ if not isinstance(record, dict) or "highWater" not in record:
4778
+ return None
4779
+ raw = record.get("highWater")
4780
+ high_water = (
4781
+ (str(raw[0]), int(raw[1]))
4782
+ if isinstance(raw, (list, tuple)) and len(raw) == 2
4783
+ else None
4784
+ )
4785
+ error = validate_published_stats_index(destination, high_water)
4786
+ if error is None:
4787
+ _remove_publication_marker(destination)
4788
+ return None
4789
+ settled = dict(state)
4790
+ settled.update({"status": "failed", "error": error})
4791
+ # Durable BEFORE this run touches the destination: a crash between here and
4792
+ # the marker this run is about to write must still leave the verdict.
4793
+ _cctally_db._atomic_write_private_json(
4794
+ _publication_marker_path(destination), settled
4795
+ )
4796
+ _fsync_dir(pathlib.Path(destination).parent)
4797
+ record.update({
4798
+ "status": "failed",
4799
+ "postPublicationValidation": {"ok": False, "error": error},
4800
+ })
4801
+ try:
4802
+ _write_rebuild_record(record_path, record)
4803
+ except OSError:
4804
+ pass
4805
+ print(
4806
+ "[stats] an earlier stats.db publication was interrupted before it "
4807
+ f"could validate what it published, and it FAILED that check: {error}. "
4808
+ f"Rebuild record: {record_path}.",
4809
+ file=sys.stderr,
4810
+ )
4811
+ return settled
4556
4812
 
4557
4813
 
4558
- def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4814
+ def _remove_publication_marker(destination) -> None:
4815
+ try:
4816
+ _publication_marker_path(destination).unlink()
4817
+ except FileNotFoundError:
4818
+ pass
4819
+ _fsync_dir(pathlib.Path(destination).parent)
4820
+
4821
+
4822
+ def _write_rebuild_record(path, payload: dict) -> None:
4823
+ import _cctally_db
4824
+
4825
+ path = pathlib.Path(path)
4826
+ path.parent.mkdir(parents=True, exist_ok=True)
4827
+ _cctally_db._atomic_write_private_json(path, payload)
4828
+
4829
+
4830
+ def _forensics_shape_token(forensics_path) -> "str | None":
4831
+ if not forensics_path:
4832
+ return None
4833
+ try:
4834
+ bundle = json.loads(pathlib.Path(forensics_path).read_text())
4835
+ except (OSError, ValueError):
4836
+ return None
4837
+ damage = bundle.get("damage")
4838
+ return damage.get("shapeToken") if isinstance(damage, dict) else None
4839
+
4840
+
4841
+ def _scan_stats_damage(path) -> dict:
4842
+ """Describe one stats family member by reading its bytes. Never raises."""
4843
+ try:
4844
+ import _lib_stats_damage
4845
+
4846
+ return _lib_stats_damage.describe_damage(integrity_rows=None, path=path)
4847
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
4848
+ return {
4849
+ "schemaVersion": 1,
4850
+ "method": "unavailable",
4851
+ "findings": [],
4852
+ "shapeToken": "none",
4853
+ "reason": f"{type(exc).__name__}: {exc}"[:200],
4854
+ }
4855
+
4856
+
4857
+ def _record_post_checkpoint_damage(
4858
+ incident: pathlib.Path, destination: pathlib.Path, outcome: str,
4859
+ ) -> "dict | None":
4860
+ """Add the post-checkpoint scan to an already-written incident manifest.
4861
+
4862
+ A second `_atomic_write_private_json` to the same path is safe: the write
4863
+ is atomic and nothing references the incident yet. It has to be a second
4864
+ write because preservation runs BEFORE the explicit checkpoint, so the
4865
+ outcome this records does not exist when the manifest is first written.
4866
+ """
4867
+ import _cctally_db
4868
+
4869
+ try:
4870
+ manifest_path = incident / "manifest.json"
4871
+ manifest = json.loads(manifest_path.read_text())
4872
+ damage = manifest.get("damage") or {}
4873
+ damage["postCheckpoint"] = _scan_stats_damage(destination)
4874
+ damage["checkpointOutcome"] = outcome
4875
+ manifest["damage"] = damage
4876
+ _cctally_db._atomic_write_private_json(manifest_path, manifest)
4877
+ return damage
4878
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
4879
+ print(
4880
+ f"[rebuild] post-checkpoint damage scan failed: {exc}",
4881
+ file=sys.stderr,
4882
+ )
4883
+ return None
4884
+
4885
+
4886
+ def _binary_version() -> "str | None":
4887
+ """The running binary's released version, or None when it cannot be read."""
4888
+ try:
4889
+ import _lib_changelog
4890
+
4891
+ value = _lib_changelog._read_latest_changelog_version()
4892
+ except Exception: # pragma: no cover — a missing CHANGELOG is not fatal
4893
+ return None
4894
+ return value[0] if value else None
4895
+
4896
+
4897
+ def _preserve_stats_family_for_cutover(
4898
+ path: pathlib.Path, *, context: RebuildContext,
4899
+ ) -> pathlib.Path:
4559
4900
  """Durably copy the old family into quarantine without removing the main."""
4560
4901
  import _cctally_db
4561
4902
 
@@ -4580,9 +4921,21 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4580
4921
  ]
4581
4922
  if not members:
4582
4923
  raise OSError(f"no database family exists to preserve at {path}")
4924
+ # Observed sizes are read BEFORE the copy, so the empty-WAL case in the
4925
+ # routine corruption heal is evidenced rather than assumed (#496 S1 F2).
4926
+ family_sizes = {}
4927
+ for name in members:
4928
+ try:
4929
+ family_sizes[name] = path.with_name(name).stat().st_size
4930
+ except OSError:
4931
+ family_sizes[name] = None
4932
+ # Read from the raw header rather than by opening the file: the file this
4933
+ # is asked about is typically one SQLite refuses to open, which is exactly
4934
+ # when the epoch it carried is worth recording (#496 S1).
4935
+ preserved_user_version = _cctally_db._read_user_version_header(path)
4583
4936
  _cctally_db._copy_db_family(path, destination)
4584
4937
  manifest = {
4585
- "schemaVersion": 1,
4938
+ "schemaVersion": 2,
4586
4939
  "quarantinedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(
4587
4940
  timespec="seconds"
4588
4941
  ).replace("+00:00", "Z"),
@@ -4590,6 +4943,25 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4590
4943
  "movedFiles": members,
4591
4944
  "complete": True,
4592
4945
  "cutoverProtocol": "preserve-then-atomic-replace-v1",
4946
+ # #496 S1 additive fields. Every key above keeps its v1 name and
4947
+ # meaning, so a v1 reader is unaffected by the bump.
4948
+ "trigger": context.trigger,
4949
+ "triggerError": context.trigger_error,
4950
+ "forensicsPath": context.forensics_path,
4951
+ "rebuildRecordPath": context.record_path,
4952
+ "binaryVersion": _binary_version(),
4953
+ "binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
4954
+ "preservedUserVersion": preserved_user_version,
4955
+ "familySizes": family_sizes,
4956
+ # The retained COPY is described, not the live file, because the copy
4957
+ # is the artifact that actually survives. `postCheckpoint` and
4958
+ # `checkpointOutcome` are filled in by the caller once the explicit
4959
+ # checkpoint has run (#496 S1 F8 section 6.3).
4960
+ "damage": {
4961
+ "preserved": _scan_stats_damage(destination),
4962
+ "postCheckpoint": None,
4963
+ "checkpointOutcome": None,
4964
+ },
4593
4965
  }
4594
4966
  _cctally_db._atomic_write_private_json(incident / "manifest.json", manifest)
4595
4967
  _fsync_dir(incident)
@@ -4602,16 +4974,35 @@ def _publish_rebuilt_stats_index(
4602
4974
  scratch: pathlib.Path,
4603
4975
  destination: pathlib.Path,
4604
4976
  preserve_existing: bool,
4977
+ context: RebuildContext,
4978
+ high_water: "tuple[str, int] | None",
4979
+ record: dict,
4605
4980
  before_swap=None,
4606
4981
  ) -> "pathlib.Path | None":
4607
- """Publish one validated, closed, sidecar-free scratch index atomically."""
4982
+ """Publish one validated, closed, sidecar-free scratch index atomically.
4983
+
4984
+ Publication is a two-phase durable transaction (#496 S1 F1). After
4985
+ `os.replace` no scratch pathname remains, so interrupted-rebuild recovery
4986
+ cannot activate, and the published file carries the current epoch so
4987
+ `open_db`'s zero-DDL fast path returns it with no validation. A
4988
+ post-publication failure that only RAISED would therefore leave a known-bad
4989
+ index accepted by every later command. The record and the marker are what
4990
+ make the verdict outlive this process.
4991
+ """
4608
4992
  import _cctally_store
4609
4993
 
4994
+ # A marker already beside the destination may still owe a verdict on bytes
4995
+ # that are live right now. Settle it BEFORE Phase 1 overwrites the only
4996
+ # marker slot, while the destination is still exactly what that publication
4997
+ # left there.
4998
+ prior = _settle_prior_publication_verdict(destination)
4999
+
4610
5000
  family_exists = any(
4611
5001
  pathlib.Path(str(destination) + suffix).exists()
4612
5002
  for suffix in ("", "-wal", "-shm")
4613
5003
  )
4614
5004
  incident = None
5005
+ damage_tokens = None
4615
5006
  if family_exists:
4616
5007
  blocked = _cctally_store._stats_family_drained(destination)
4617
5008
  if blocked is not None:
@@ -4620,20 +5011,102 @@ def _publish_rebuilt_stats_index(
4620
5011
  if preserve_existing:
4621
5012
  # Preserve the exact pre-cutover family, including a committed WAL
4622
5013
  # and SHM, before checkpointing mutates or removes those sidecars.
4623
- incident = _preserve_stats_family_for_cutover(destination)
5014
+ incident = _preserve_stats_family_for_cutover(
5015
+ destination, context=context
5016
+ )
5017
+ checkpoint_outcome = "skipped_absent"
4624
5018
  if destination.exists():
4625
- _prepare_existing_stats_for_cutover(destination)
5019
+ try:
5020
+ checkpoint_outcome = _prepare_existing_stats_for_cutover(
5021
+ destination
5022
+ )
5023
+ except BaseException:
5024
+ if incident is not None:
5025
+ _record_post_checkpoint_damage(
5026
+ incident, destination, "failed"
5027
+ )
5028
+ raise
5029
+ if incident is not None:
5030
+ # Scanned BEFORE the sidecars are removed, so this and the
5031
+ # preserved scan bracket the explicit checkpoint.
5032
+ damage = _record_post_checkpoint_damage(
5033
+ incident, destination, checkpoint_outcome
5034
+ )
5035
+ if damage:
5036
+ damage_tokens = {
5037
+ "forensics": _forensics_shape_token(context.forensics_path),
5038
+ "preserved": (damage.get("preserved") or {}).get(
5039
+ "shapeToken"
5040
+ ),
5041
+ "postCheckpoint": (damage.get("postCheckpoint") or {}).get(
5042
+ "shapeToken"
5043
+ ),
5044
+ "checkpointOutcome": damage.get("checkpointOutcome"),
5045
+ }
4626
5046
  # The old main stays present and, when it was readable, fully
4627
5047
  # checkpointed. A kill from here until os.replace therefore still
4628
5048
  # leaves a usable old destination while preventing stale sidecars from
4629
5049
  # being paired with the replacement main.
4630
5050
  _remove_db_sidecars_strict(destination)
5051
+ _cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
4631
5052
 
4632
5053
  if before_swap is not None:
4633
5054
  before_swap()
5055
+
5056
+ # Phase 1 of the publication transaction: the record and then the marker,
5057
+ # each fsynced, BEFORE the replacement becomes visible.
5058
+ started_at = _utc_iso_now()
5059
+ record = dict(record)
5060
+ record.update({
5061
+ "status": "pending",
5062
+ "startedAtUtc": started_at,
5063
+ "completedAtUtc": None,
5064
+ "incidentPath": str(incident) if incident is not None else None,
5065
+ "damageShapeTokens": damage_tokens,
5066
+ "postPublicationValidation": None,
5067
+ })
5068
+ record_path = pathlib.Path(context.record_path)
5069
+ _write_rebuild_record(record_path, record)
5070
+ _write_publication_marker(
5071
+ destination, record_path, started_at=started_at, scratch_path=scratch,
5072
+ prior=prior,
5073
+ )
5074
+
4634
5075
  _stats_rebuild_test_pause("rebuild_before_cutover")
4635
5076
  os.replace(str(scratch), str(destination))
4636
5077
  _fsync_dir(destination.parent)
5078
+ _stats_rebuild_test_pause("rebuild_after_publication_replace")
5079
+
5080
+ # Phase 2: validate the bytes that are now live, on a connection that never
5081
+ # saw them being written.
5082
+ post_error = validate_published_stats_index(destination, high_water)
5083
+ # The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
5084
+ # Remove them so the documented no-post-publication-stale-sidecar end state
5085
+ # still holds; an empty WAL is consistent with the freshly published main,
5086
+ # so a crash between validation and removal is harmless.
5087
+ _remove_db_sidecars_strict(destination)
5088
+
5089
+ record["postPublicationValidation"] = {
5090
+ "ok": post_error is None,
5091
+ "error": post_error,
5092
+ }
5093
+ record["completedAtUtc"] = _utc_iso_now()
5094
+ record["status"] = "ok" if post_error is None else "failed"
5095
+ _write_rebuild_record(record_path, record)
5096
+ if post_error is not None:
5097
+ # No rollback is possible: the old family is already quarantined, and
5098
+ # restoring it would republish a file known to be corrupt. Any carried
5099
+ # prior verdict is dropped here on purpose: `os.replace` succeeded, so
5100
+ # the bytes it judged are gone and THIS failure is the live one.
5101
+ _write_publication_marker(
5102
+ destination, record_path, started_at=started_at,
5103
+ scratch_path=scratch, status="failed", error=post_error,
5104
+ )
5105
+ raise JournalError(
5106
+ "published stats index failed post-publication validation: "
5107
+ f"{post_error}; rebuild record: {record_path}"
5108
+ )
5109
+ _remove_publication_marker(destination)
4637
5110
  return incident
4638
5111
 
4639
5112
 
@@ -4707,6 +5180,7 @@ def _rebuild_quota_cache_leg(records) -> None:
4707
5180
 
4708
5181
  def rebuild_stats_index(
4709
5182
  *,
5183
+ context: RebuildContext,
4710
5184
  target_path=None,
4711
5185
  high_water: "tuple[str, int] | None" = None,
4712
5186
  update_quota_cache: bool = True,
@@ -4721,6 +5195,10 @@ def rebuild_stats_index(
4721
5195
  no alerts, no `reconcile_config` (see the module note above). Post-rebuild the
4722
5196
  cursor equals the journal high-water.
4723
5197
 
5198
+ `context` states WHY this rebuild ran (#496 S1 F3). It is keyword-only with
5199
+ no default, so a future call site cannot silently produce an unattributed
5200
+ quarantine incident; omitting it raises `TypeError` at the call.
5201
+
4724
5202
  `target_path` selects the destination (default `DB_PATH`). `high_water`
4725
5203
  optionally pins the exact inclusive journal prefix; later bytes stay beyond
4726
5204
  the rebuilt cursor. `update_quota_cache=False` is the Task-C Claude-only
@@ -4731,6 +5209,17 @@ def rebuild_stats_index(
4731
5209
  publication but does not create a live-family quarantine incident.
4732
5210
  """
4733
5211
  start = time.monotonic()
5212
+ context = context.validate()
5213
+ # Resolve the rebuild record's path ONCE, here, because preservation runs
5214
+ # long before the record is written and both must name the same file
5215
+ # (#496 S1). Callers never supply it.
5216
+ record_stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S_%f")
5217
+ context = _dc_replace(
5218
+ context,
5219
+ record_path=str(
5220
+ _cctally_core.LOG_DIR / f"stats-rebuild-{record_stamp}.json"
5221
+ ),
5222
+ )
4734
5223
  dest = (pathlib.Path(target_path) if target_path is not None
4735
5224
  else pathlib.Path(_cctally_core.DB_PATH))
4736
5225
 
@@ -4907,11 +5396,44 @@ def rebuild_stats_index(
4907
5396
  with scratch.open("rb") as handle:
4908
5397
  os.fsync(handle.fileno())
4909
5398
  _fsync_dir(scratch.parent)
5399
+
5400
+ # First fresh-connection validation (#496 S1 F1). A failure here raises
5401
+ # BEFORE any preservation, so no incident is created and the old family
5402
+ # stays live — the existing contract is preserved exactly.
5403
+ pre_error = validate_published_stats_index(scratch, hw)
5404
+ if pre_error is not None:
5405
+ raise JournalError(
5406
+ f"rebuilt stats index failed pre-publication validation: {pre_error}"
5407
+ )
5408
+ # That read-only open recreated the scratch sidecars; publication requires
5409
+ # a sidecar-free scratch, and a leftover pair would also survive the
5410
+ # `os.replace` as a stray artifact.
5411
+ _remove_db_sidecars_strict(scratch)
5412
+
4910
5413
  incident = _publish_rebuilt_stats_index(
4911
5414
  scratch=scratch,
4912
5415
  destination=dest,
4913
5416
  preserve_existing=target_path is None,
4914
5417
  before_swap=before_swap,
5418
+ context=context,
5419
+ high_water=hw,
5420
+ record={
5421
+ "schemaVersion": 1,
5422
+ "trigger": context.trigger,
5423
+ "triggerError": context.trigger_error,
5424
+ "forensicsPath": context.forensics_path,
5425
+ "binaryVersion": _binary_version(),
5426
+ "binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
5427
+ "highWater": [hw[0], hw[1]] if hw is not None else None,
5428
+ "destination": str(dest),
5429
+ "targetPath": str(target_path) if target_path is not None else None,
5430
+ "segmentsRead": len(segments),
5431
+ "linesFolded": lines_folded,
5432
+ "malformed": malformed,
5433
+ "rowsByTable": rows_by_table,
5434
+ "buildSeconds": round(time.monotonic() - start, 3),
5435
+ "prePublicationValidation": {"ok": True, "error": None},
5436
+ },
4915
5437
  )
4916
5438
 
4917
5439
  return RebuildResult(
@@ -5386,5 +5908,5 @@ def run_epoch_transition(*, claude_json_path=None) -> str:
5386
5908
  preserves the old index only after the replacement is validated."""
5387
5909
  claude_key = _resolve_claude_cutover_identity(claude_json_path)
5388
5910
  recorded = append_accounts_cutover_op(claude_key)
5389
- rebuild_stats_index()
5911
+ rebuild_stats_index(context=RebuildContext(trigger="epoch-transition"))
5390
5912
  return recorded