cctally 1.91.0 → 1.92.1

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 (42) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -2
  3. package/bin/_cctally_cache.py +903 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +94 -14
  6. package/bin/_cctally_dashboard.py +217 -19
  7. package/bin/_cctally_dashboard_conversation.py +170 -20
  8. package/bin/_cctally_dashboard_envelope.py +2 -0
  9. package/bin/_cctally_db.py +481 -19
  10. package/bin/_cctally_doctor.py +18 -1
  11. package/bin/_cctally_journal.py +1156 -21
  12. package/bin/_cctally_journal_repair.py +6 -0
  13. package/bin/_cctally_parser.py +26 -0
  14. package/bin/_cctally_quota.py +171 -55
  15. package/bin/_cctally_record.py +13 -1
  16. package/bin/_cctally_rederive.py +4 -0
  17. package/bin/_cctally_statusline.py +6 -6
  18. package/bin/_cctally_store.py +1061 -40
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_cctally_tui.py +54 -6
  21. package/bin/_lib_cache_report.py +8 -3
  22. package/bin/_lib_codex_conversation.py +851 -81
  23. package/bin/_lib_codex_conversation_query.py +2031 -96
  24. package/bin/_lib_codex_find_projection.py +517 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_title_clean.py +116 -0
  30. package/bin/_lib_conversation_dispatch.py +168 -22
  31. package/bin/_lib_conversation_query.py +62 -2
  32. package/bin/_lib_conversation_watch.py +4 -2
  33. package/bin/_lib_doctor.py +64 -0
  34. package/bin/_lib_quota_alert_axes.py +31 -34
  35. package/bin/_lib_stats_damage.py +523 -0
  36. package/bin/_lib_stats_publish.py +243 -0
  37. package/bin/cctally +17 -3
  38. package/dashboard/static/assets/index-Dat-mza6.js +97 -0
  39. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  40. package/dashboard/static/dashboard.html +2 -2
  41. package/package.json +8 -1
  42. 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)."""
@@ -4251,6 +4315,7 @@ _REBUILD_REQUIRED_TABLES = frozenset(
4251
4315
  "schema_migrations",
4252
4316
  "schema_migrations_skipped",
4253
4317
  "stats_open_fixups",
4318
+ "stats_publication_stamp",
4254
4319
  "week_reset_events",
4255
4320
  "weekly_cost_snapshots",
4256
4321
  "weekly_credit_floors",
@@ -4299,7 +4364,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
4299
4364
  # omitted column, constraint, partial predicate, or index definition. An epoch
4300
4365
  # schema change must update this contract alongside STATS_INDEX_EPOCH.
4301
4366
  _REBUILD_SCHEMA_FINGERPRINT = (
4302
- "1e0a8cc22b3dc754cb8a6074ff9d2ef28df77b656dfc65347e4afbcb6edfdfae"
4367
+ "7dde5a7995f441558d08b0204136824d6ff7208b221e576c79a76854b76aa178"
4303
4368
  )
4304
4369
 
4305
4370
 
@@ -4532,8 +4597,15 @@ def stats_index_matches_journal_prefix(
4532
4597
  return False
4533
4598
 
4534
4599
 
4535
- def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
4536
- """Checkpoint a readable old index so removing its sidecars is kill-safe."""
4600
+ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> str:
4601
+ """Checkpoint a readable old index so removing its sidecars is kill-safe.
4602
+
4603
+ Returns what it actually did, so the incident manifest can say whether the
4604
+ explicit checkpoint ran (#496 S1 F8). Failure still RAISES rather than
4605
+ returning an outcome — the caller records `failed` and re-raises, because
4606
+ proceeding past an undrained WAL would pair stale sidecars with the
4607
+ replacement main file.
4608
+ """
4537
4609
  import _cctally_db
4538
4610
 
4539
4611
  try:
@@ -4551,11 +4623,424 @@ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
4551
4623
  # Auto-heal necessarily starts from an unreadable family. Preserve its
4552
4624
  # exact bytes below, then publish the already-validated replacement.
4553
4625
  if _cctally_db._is_sqlite_corruption_error(exc):
4554
- return
4626
+ return "skipped_corrupt"
4555
4627
  raise
4628
+ return "checkpointed"
4629
+
4630
+
4631
+ def _utc_iso_now() -> str:
4632
+ return dt.datetime.now(dt.timezone.utc).isoformat(
4633
+ timespec="seconds"
4634
+ ).replace("+00:00", "Z")
4635
+
4636
+
4637
+ def read_publication_stamp(path):
4638
+ """Read `stats_publication_stamp` from ``path`` on a fresh read-only conn.
4639
+
4640
+ Never raises. Returns the input `_lib_stats_publish.resolve_stamp` expects:
4641
+
4642
+ - the exception that prevented the read, which resolves INDETERMINATE;
4643
+ - `None` when the read succeeded and named no publication;
4644
+ - the list of row mappings the table held.
4645
+
4646
+ **A destination whose `user_version` is not this binary's
4647
+ `STATS_INDEX_EPOCH` returns `None`, and that is a proof rather than a
4648
+ convenience.** Every scratch eligible for publication has already been
4649
+ validated at `STATS_INDEX_EPOCH`, and the publication transaction stamps
4650
+ that epoch onto the destination in the same commit as the stamp row, so a
4651
+ committed publication always leaves the destination at this epoch. A
4652
+ destination at any other epoch therefore proves this publication did not
4653
+ commit — which is exactly what makes an interrupted upgrade rebuild
4654
+ recoverable: the epoch-1007 index it was publishing into has no stamp
4655
+ table at all, and reading that absence as INDETERMINATE would condemn a
4656
+ perfectly healthy index instead of discarding a marker that never became
4657
+ live. When the epochs differ in the other direction, a newer binary reading
4658
+ an older destination, the same conclusion holds and the epoch gate refuses
4659
+ the destination anyway.
4660
+ """
4661
+ try:
4662
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
4663
+ try:
4664
+ epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
4665
+ if epoch != _cctally_core.STATS_INDEX_EPOCH:
4666
+ return None
4667
+ rows = conn.execute(
4668
+ "SELECT record_path FROM stats_publication_stamp"
4669
+ ).fetchall()
4670
+ finally:
4671
+ conn.close()
4672
+ except BaseException as exc:
4673
+ return exc
4674
+ if not rows:
4675
+ return None
4676
+ return [{"record_path": row[0]} for row in rows]
4677
+
4678
+
4679
+ def in_place_publication_proven_predecessor(destination, state) -> bool:
4680
+ """Whether a PENDING in-place marker's publication provably never committed.
4681
+
4682
+ True only on `PROVEN_PREDECESSOR`: the stamp was read and does not name
4683
+ this marker's record, so the live bytes are the untouched predecessor and
4684
+ the marker may be discarded. `MATCH` means the verdict is still owed, and
4685
+ `INDETERMINATE` fails closed — it must never collapse into either of the
4686
+ other two states, because discarding on an unreadable stamp is exactly the
4687
+ silent-acceptance class the publication transaction exists to close.
4688
+
4689
+ Public because both discriminator sites consume it: the opener's
4690
+ `_cctally_store._pending_stats_publication_never_replaced` and this
4691
+ module's `_settle_prior_publication_verdict`.
4692
+ """
4693
+ import _lib_stats_publish as sp
4694
+
4695
+ record_path = state.get("recordPath")
4696
+ verdict = sp.resolve_stamp(
4697
+ read_publication_stamp(destination),
4698
+ record_path if isinstance(record_path, str) else None,
4699
+ )
4700
+ return verdict == sp.STAMP_PROVEN_PREDECESSOR
4701
+
4702
+
4703
+ def _stamp_identity_error(path, expected_record_path: str) -> "str | None":
4704
+ import _lib_stats_publish as sp
4705
+
4706
+ verdict = sp.resolve_stamp(
4707
+ read_publication_stamp(path), expected_record_path
4708
+ )
4709
+ if verdict == sp.STAMP_MATCH:
4710
+ return None
4711
+ return (
4712
+ "published stats index does not carry this publication's stamp "
4713
+ f"({verdict}); expected {expected_record_path}"
4714
+ )
4715
+
4716
+
4717
+ def validate_published_stats_index(
4718
+ path, high_water: "tuple[str, int] | None", *,
4719
+ expected_record_path: "str | None" = None,
4720
+ ) -> "str | None":
4721
+ """Validate an index on a FRESH read-only connection (#496 S1 F1).
4722
+
4723
+ Returns None on success, or a short failure reason. The building
4724
+ connection wrote the pages it then validated, so it is not an independent
4725
+ witness to what reached the disk; this reopens the file instead. The
4726
+ mechanism is already proven by `stats_index_matches_journal_prefix`, which
4727
+ runs the same check on the same kind of connection.
4728
+
4729
+ ``expected_record_path`` names the publication whose bytes these are meant
4730
+ to be, and the stamp row is verified against it (#496 S3 §5): the
4731
+ high-water alone answers "is this index a correct materialization of the
4732
+ journal prefix", not "is this index the generation THIS publication just
4733
+ installed". The in-place publisher passes it because it has just written
4734
+ that identity inside the publication transaction. The opener deliberately
4735
+ does NOT: it has already resolved the stamp as a three-state question, and
4736
+ folding that into a boolean validation error would turn an INDETERMINATE
4737
+ read into a settled `failed` verdict.
4738
+
4739
+ Public because `stats_open_guarded` runs exactly this check when it
4740
+ resolves a pending publication marker.
4741
+ """
4742
+ try:
4743
+ conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
4744
+ try:
4745
+ _validate_rebuilt_stats_index(conn, high_water)
4746
+ finally:
4747
+ conn.close()
4748
+ except BaseException as exc:
4749
+ return f"{type(exc).__name__}: {exc}"[:500]
4750
+ if expected_record_path is not None:
4751
+ return _stamp_identity_error(path, expected_record_path)
4752
+ return None
4753
+
4754
+
4755
+ def _publication_marker_path(destination) -> pathlib.Path:
4756
+ return pathlib.Path(str(destination) + ".publication")
4757
+
4758
+
4759
+ def _write_publication_marker(
4760
+ destination, record_path, *, started_at: str, scratch_path,
4761
+ status: str = "pending", error: "str | None" = None,
4762
+ prior: "dict | None" = None, mechanism: str = "replace",
4763
+ ) -> None:
4764
+ """Publish the durable marker a later opener honours (#496 S1 F1).
4765
+
4766
+ Mirrors the existing `cache.db.repairing` marker idiom.
4767
+ `_atomic_write_private_json` writes at mode 0600 and fsyncs both the file
4768
+ and its parent directory.
4769
+
4770
+ `scratchPath` records the exact index this publication was about to install.
4771
+ Across processes `os.replace` is the only thing that consumes a scratch, so
4772
+ its presence or absence on disk is an exact answer to "did this run replace
4773
+ the destination?" — which is what interrupted-rebuild recovery needs in
4774
+ order to tell a marker it supersedes from one whose verdict is still owed.
4775
+ (Within one process the claim is weaker; see
4776
+ `_cctally_store._pending_stats_publication_never_replaced`.)
4777
+
4778
+ `mechanism` states which publication protocol this marker belongs to, so
4779
+ the opener SELECTS its discriminator instead of inferring one (#496 S3 §5).
4780
+ `replace` keeps the `scratchPath` proxy above, which remains exactly
4781
+ correct there. `in_place` attaches the scratch read-only and leaves it on
4782
+ disk whether the transaction committed or rolled back, so the proxy
4783
+ inverts and the publication's own `stats_publication_stamp` row answers
4784
+ instead.
4785
+
4786
+ `priorFailure` carries a settled verdict this publication is about to
4787
+ overwrite, so a crash before `os.replace` cannot discard it — see
4788
+ `_settle_prior_publication_verdict`.
4789
+ """
4790
+ import _cctally_db
4791
+
4792
+ payload = {
4793
+ "schemaVersion": 1,
4794
+ "status": status,
4795
+ "recordPath": str(record_path),
4796
+ "startedAtUtc": started_at,
4797
+ "scratchPath": str(scratch_path),
4798
+ "mechanism": mechanism,
4799
+ }
4800
+ if error is not None:
4801
+ payload["error"] = error
4802
+ if prior:
4803
+ payload["priorFailure"] = prior
4804
+ _cctally_db._atomic_write_private_json(
4805
+ _publication_marker_path(destination), payload
4806
+ )
4807
+ _fsync_dir(pathlib.Path(destination).parent)
4808
+
4809
+
4810
+ def _read_publication_marker(destination) -> "dict | None":
4811
+ """The marker's state as a MAPPING, or None when no marker exists.
4812
+
4813
+ Present-but-unusable bytes read as an empty mapping, for the reason given
4814
+ in `_cctally_store._read_stats_publication_marker`.
4815
+ """
4816
+ try:
4817
+ state = json.loads(_publication_marker_path(destination).read_text())
4818
+ except FileNotFoundError:
4819
+ return None
4820
+ except (OSError, ValueError):
4821
+ return {}
4822
+ return state if isinstance(state, dict) else {}
4823
+
4824
+
4825
+ def _pending_publication_owes_nothing(destination, state) -> bool:
4826
+ """Whether a PENDING marker's own publication never reached the live bytes.
4827
+
4828
+ The marker STATES its mechanism, so the discriminator is selected rather
4829
+ than inferred (#496 S3 §5). `replace` keeps the `scratchPath` proxy, which
4830
+ is exactly correct there because across processes `os.replace` is the only
4831
+ thing that consumes a scratch. `in_place` attaches its scratch read-only
4832
+ and leaves it on disk whether the transaction committed or rolled back, so
4833
+ that proxy INVERTS and the publication's own stamp answers instead. Neither
4834
+ is generalized over the other.
4835
+
4836
+ A marker written before the mechanism field existed reads as `replace`,
4837
+ which is what those binaries did.
4838
+ """
4839
+ if str(state.get("mechanism") or "replace") == "in_place":
4840
+ return in_place_publication_proven_predecessor(destination, state)
4841
+ scratch = state.get("scratchPath")
4842
+ return (
4843
+ isinstance(scratch, str)
4844
+ and bool(scratch)
4845
+ and pathlib.Path(scratch).exists()
4846
+ )
4847
+
4848
+
4849
+ def _settle_prior_publication_verdict(destination) -> "dict | None":
4850
+ """Settle the verdict a PREVIOUS publication still owes, before this one
4851
+ overwrites the single marker slot (#496 S1 F1).
4852
+
4853
+ `<db>.publication` is one file, so Phase 1 of a new publication destroys
4854
+ whatever the last one left there. `cmd_db_rebuild` takes maintenance
4855
+ EXCLUSIVE without opening the live database through `stats_open_guarded`,
4856
+ so a rebuild can legitimately begin while a pending marker still owes a
4857
+ verdict on an already-published, never-validated index. If this run then
4858
+ dies between its own marker write and its `os.replace`, the next opener
4859
+ sees a pending marker beside this run's own scratch, discards it as
4860
+ never-replaced, and accepts the earlier index having never validated it.
4861
+
4862
+ Resolving is preferred over refusing to overwrite, because a refusal would
4863
+ wedge the one operation that repairs a bad index behind the marker that
4864
+ reports it. Returns the verdict to CARRY into this run's marker, or None
4865
+ when nothing is owed:
4866
+
4867
+ - a `failed` marker is already settled and is carried verbatim;
4868
+ - a `pending` marker whose own publication provably never became live owes
4869
+ nothing about the live bytes of its own — but it may still be CARRYING an
4870
+ older run's verdict, which is passed through so a third consecutive
4871
+ crashed run cannot drop it;
4872
+ - any other `pending` marker is settled HERE, by validating the destination
4873
+ against its record's pinned high-water — the same check the opener would
4874
+ have run. Success clears it; failure is written to both the marker and
4875
+ the record before this run touches the destination, and is then carried
4876
+ forward.
4877
+
4878
+ A marker that cannot be judged (no record, or no pinned high-water) is left
4879
+ to the opener's existing discard policy rather than wedging the rebuild.
4880
+ """
4881
+ import _cctally_db
4882
+
4883
+ state = _read_publication_marker(destination)
4884
+ if not state:
4885
+ return None
4886
+ status = str(state.get("status") or "")
4887
+ if status == "failed":
4888
+ return state
4889
+ if status != "pending":
4890
+ return None
4891
+ if _pending_publication_owes_nothing(destination, state):
4892
+ # This marker owes nothing itself, but dropping what it carries would
4893
+ # lose an older run's verdict once a third run crashes the same way.
4894
+ carried = state.get("priorFailure")
4895
+ return carried if isinstance(carried, dict) and carried else None
4896
+ record_path = state.get("recordPath")
4897
+ record = None
4898
+ if isinstance(record_path, str):
4899
+ try:
4900
+ record = json.loads(pathlib.Path(record_path).read_text())
4901
+ except (OSError, ValueError):
4902
+ record = None
4903
+ if not isinstance(record, dict) or "highWater" not in record:
4904
+ return None
4905
+ raw = record.get("highWater")
4906
+ high_water = (
4907
+ (str(raw[0]), int(raw[1]))
4908
+ if isinstance(raw, (list, tuple)) and len(raw) == 2
4909
+ else None
4910
+ )
4911
+ error = validate_published_stats_index(destination, high_water)
4912
+ if error is None:
4913
+ _remove_publication_marker(destination)
4914
+ return None
4915
+ settled = dict(state)
4916
+ settled.update({"status": "failed", "error": error})
4917
+ # Durable BEFORE this run touches the destination: a crash between here and
4918
+ # the marker this run is about to write must still leave the verdict.
4919
+ _cctally_db._atomic_write_private_json(
4920
+ _publication_marker_path(destination), settled
4921
+ )
4922
+ _fsync_dir(pathlib.Path(destination).parent)
4923
+ record.update({
4924
+ "status": "failed",
4925
+ "postPublicationValidation": {"ok": False, "error": error},
4926
+ })
4927
+ try:
4928
+ _write_rebuild_record(record_path, record)
4929
+ except OSError:
4930
+ pass
4931
+ # #496 exists partly because corruption gets MISATTRIBUTED, so the wording
4932
+ # must not accuse an earlier publication of failing a check that never ran.
4933
+ # A destination that cannot be read fails this validation for a reason that
4934
+ # is not evidence about the publication: the stamp read is INDETERMINATE
4935
+ # and the high-water check then trips on the unrelated damage.
4936
+ unreadable = _cctally_db._is_sqlite_corruption_error(error) or error.startswith(
4937
+ ("DatabaseError:", "OperationalError:", "InterfaceError:", "sqlite3.")
4938
+ )
4939
+ if unreadable:
4940
+ print(
4941
+ "[stats] an earlier stats.db publication was interrupted before it "
4942
+ "could validate what it published, and that check could not be "
4943
+ f"completed because the index could not be read: {error}. The read "
4944
+ "failure is not evidence about that publication. Rebuild record: "
4945
+ f"{record_path}.",
4946
+ file=sys.stderr,
4947
+ )
4948
+ else:
4949
+ print(
4950
+ "[stats] an earlier stats.db publication was interrupted before it "
4951
+ f"could validate what it published, and it FAILED that check: "
4952
+ f"{error}. Rebuild record: {record_path}.",
4953
+ file=sys.stderr,
4954
+ )
4955
+ return settled
4956
+
4957
+
4958
+ def _remove_publication_marker(destination) -> None:
4959
+ try:
4960
+ _publication_marker_path(destination).unlink()
4961
+ except FileNotFoundError:
4962
+ pass
4963
+ _fsync_dir(pathlib.Path(destination).parent)
4964
+
4965
+
4966
+ def _write_rebuild_record(path, payload: dict) -> None:
4967
+ import _cctally_db
4968
+
4969
+ path = pathlib.Path(path)
4970
+ path.parent.mkdir(parents=True, exist_ok=True)
4971
+ _cctally_db._atomic_write_private_json(path, payload)
4972
+
4973
+
4974
+ def _forensics_shape_token(forensics_path) -> "str | None":
4975
+ if not forensics_path:
4976
+ return None
4977
+ try:
4978
+ bundle = json.loads(pathlib.Path(forensics_path).read_text())
4979
+ except (OSError, ValueError):
4980
+ return None
4981
+ damage = bundle.get("damage")
4982
+ return damage.get("shapeToken") if isinstance(damage, dict) else None
4983
+
4984
+
4985
+ def _scan_stats_damage(path) -> dict:
4986
+ """Describe one stats family member by reading its bytes. Never raises."""
4987
+ try:
4988
+ import _lib_stats_damage
4989
+
4990
+ return _lib_stats_damage.describe_damage(integrity_rows=None, path=path)
4991
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
4992
+ return {
4993
+ "schemaVersion": 1,
4994
+ "method": "unavailable",
4995
+ "findings": [],
4996
+ "shapeToken": "none",
4997
+ "reason": f"{type(exc).__name__}: {exc}"[:200],
4998
+ }
4999
+
4556
5000
 
5001
+ def _record_post_checkpoint_damage(
5002
+ incident: pathlib.Path, destination: pathlib.Path, outcome: str,
5003
+ ) -> "dict | None":
5004
+ """Add the post-checkpoint scan to an already-written incident manifest.
4557
5005
 
4558
- def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
5006
+ A second `_atomic_write_private_json` to the same path is safe: the write
5007
+ is atomic and nothing references the incident yet. It has to be a second
5008
+ write because preservation runs BEFORE the explicit checkpoint, so the
5009
+ outcome this records does not exist when the manifest is first written.
5010
+ """
5011
+ import _cctally_db
5012
+
5013
+ try:
5014
+ manifest_path = incident / "manifest.json"
5015
+ manifest = json.loads(manifest_path.read_text())
5016
+ damage = manifest.get("damage") or {}
5017
+ damage["postCheckpoint"] = _scan_stats_damage(destination)
5018
+ damage["checkpointOutcome"] = outcome
5019
+ manifest["damage"] = damage
5020
+ _cctally_db._atomic_write_private_json(manifest_path, manifest)
5021
+ return damage
5022
+ except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
5023
+ print(
5024
+ f"[rebuild] post-checkpoint damage scan failed: {exc}",
5025
+ file=sys.stderr,
5026
+ )
5027
+ return None
5028
+
5029
+
5030
+ def _binary_version() -> "str | None":
5031
+ """The running binary's released version, or None when it cannot be read."""
5032
+ try:
5033
+ import _lib_changelog
5034
+
5035
+ value = _lib_changelog._read_latest_changelog_version()
5036
+ except Exception: # pragma: no cover — a missing CHANGELOG is not fatal
5037
+ return None
5038
+ return value[0] if value else None
5039
+
5040
+
5041
+ def _preserve_stats_family_for_cutover(
5042
+ path: pathlib.Path, *, context: RebuildContext,
5043
+ ) -> pathlib.Path:
4559
5044
  """Durably copy the old family into quarantine without removing the main."""
4560
5045
  import _cctally_db
4561
5046
 
@@ -4580,9 +5065,21 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4580
5065
  ]
4581
5066
  if not members:
4582
5067
  raise OSError(f"no database family exists to preserve at {path}")
5068
+ # Observed sizes are read BEFORE the copy, so the empty-WAL case in the
5069
+ # routine corruption heal is evidenced rather than assumed (#496 S1 F2).
5070
+ family_sizes = {}
5071
+ for name in members:
5072
+ try:
5073
+ family_sizes[name] = path.with_name(name).stat().st_size
5074
+ except OSError:
5075
+ family_sizes[name] = None
5076
+ # Read from the raw header rather than by opening the file: the file this
5077
+ # is asked about is typically one SQLite refuses to open, which is exactly
5078
+ # when the epoch it carried is worth recording (#496 S1).
5079
+ preserved_user_version = _cctally_db._read_user_version_header(path)
4583
5080
  _cctally_db._copy_db_family(path, destination)
4584
5081
  manifest = {
4585
- "schemaVersion": 1,
5082
+ "schemaVersion": 2,
4586
5083
  "quarantinedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(
4587
5084
  timespec="seconds"
4588
5085
  ).replace("+00:00", "Z"),
@@ -4590,6 +5087,25 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4590
5087
  "movedFiles": members,
4591
5088
  "complete": True,
4592
5089
  "cutoverProtocol": "preserve-then-atomic-replace-v1",
5090
+ # #496 S1 additive fields. Every key above keeps its v1 name and
5091
+ # meaning, so a v1 reader is unaffected by the bump.
5092
+ "trigger": context.trigger,
5093
+ "triggerError": context.trigger_error,
5094
+ "forensicsPath": context.forensics_path,
5095
+ "rebuildRecordPath": context.record_path,
5096
+ "binaryVersion": _binary_version(),
5097
+ "binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
5098
+ "preservedUserVersion": preserved_user_version,
5099
+ "familySizes": family_sizes,
5100
+ # The retained COPY is described, not the live file, because the copy
5101
+ # is the artifact that actually survives. `postCheckpoint` and
5102
+ # `checkpointOutcome` are filled in by the caller once the explicit
5103
+ # checkpoint has run (#496 S1 F8 section 6.3).
5104
+ "damage": {
5105
+ "preserved": _scan_stats_damage(destination),
5106
+ "postCheckpoint": None,
5107
+ "checkpointOutcome": None,
5108
+ },
4593
5109
  }
4594
5110
  _cctally_db._atomic_write_private_json(incident / "manifest.json", manifest)
4595
5111
  _fsync_dir(incident)
@@ -4597,21 +5113,499 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
4597
5113
  return incident
4598
5114
 
4599
5115
 
5116
+ #: Set on the exception a failed in-place publication raises, so the caller can
5117
+ #: read the phase it had reached. "The transaction raised" is not a safe
5118
+ #: discriminator: a failure before the commit can roll back while a failure
5119
+ #: after it cannot, and a commit-time I/O error leaves the outcome unknown.
5120
+ _PUBLICATION_PHASE_ATTR = "_cctally_publication_phase"
5121
+
5122
+
5123
+ def publication_phase_of(exc) -> "str | None":
5124
+ """The publication phase ``exc`` was raised in, or None when unrecorded."""
5125
+ return getattr(exc, _PUBLICATION_PHASE_ATTR, None)
5126
+
5127
+
5128
+ def _open_publication_connection(destination) -> sqlite3.Connection:
5129
+ """Open the LIVE destination for an in-place publish.
5130
+
5131
+ `stats_open_guarded` skips its own flock when the caller already holds
5132
+ stats maintenance, which every production trigger does. Interrupted-rebuild
5133
+ recovery is suppressed because this run's own `.rebuilding-*` scratch is on
5134
+ disk right now and is not an interruption to recover from.
5135
+
5136
+ `stats_open_guarded` does NOT apply connection policy — `open_db` does that
5137
+ separately — so the busy timeout, journal mode and WAL size limit are
5138
+ applied here rather than assumed.
5139
+
5140
+ The connection is opened with `uri=True` because the publisher ATTACHes the
5141
+ scratch through a `file:...?mode=ro` URI. SQLite honours a URI filename in
5142
+ `ATTACH` only when the main connection carries `SQLITE_OPEN_URI`, or when
5143
+ the library happens to be built with `SQLITE_USE_URI`. Relying on the
5144
+ latter would make the read-only attach ambient rather than guaranteed.
5145
+ """
5146
+ import _cctally_store
5147
+
5148
+ conn = _cctally_store.stats_open_guarded(
5149
+ pathlib.Path(destination),
5150
+ connect=lambda path: sqlite3.connect(
5151
+ pathlib.Path(path).resolve().as_uri(), uri=True
5152
+ ),
5153
+ recover_interruptions=False,
5154
+ )
5155
+ try:
5156
+ _cctally_store.apply_policy(conn, "stats")
5157
+ except BaseException:
5158
+ try:
5159
+ conn.close()
5160
+ except Exception:
5161
+ pass
5162
+ raise
5163
+ return conn
5164
+
5165
+
5166
+ def _carry_sqlite_sequence(conn: sqlite3.Connection) -> None:
5167
+ """Install the scratch's AUTOINCREMENT watermarks, not the copy's.
5168
+
5169
+ A table-by-table row copy sets each counter to `max(rowid)`, whereas
5170
+ `os.replace` publishes the scratch's `sqlite_sequence` verbatim. Measured on
5171
+ SQLite 3.53.4: a scratch with `max(id)=6` and a counter of 10 — the shape
5172
+ produced whenever the fold inserts rows and later deletes them, as the
5173
+ `five_hour_block_close` fold's exact-child DELETE/INSERT does — published a
5174
+ counter of 6, and the next insert took id 7, an id a deleted row had
5175
+ already used.
5176
+
5177
+ Delete-then-insert rather than update, because `DROP TABLE` removes the
5178
+ table's `sqlite_sequence` row and `CREATE TABLE` does not put one back, so
5179
+ an UPDATE has nothing of its own to act on. Measured on SQLite 3.53.4, a
5180
+ zero-row `INSERT ... SELECT` does create the row with seq 0 — so on that
5181
+ version an UPDATE would in fact land — but that is an undocumented
5182
+ implementation detail rather than a contract, and silently losing an
5183
+ AUTOINCREMENT watermark hands out an id a deleted row already used.
5184
+ """
5185
+ present = conn.execute(
5186
+ "SELECT (SELECT 1 FROM src.sqlite_schema WHERE type = 'table' "
5187
+ "AND name = 'sqlite_sequence'), "
5188
+ "(SELECT 1 FROM main.sqlite_schema WHERE type = 'table' "
5189
+ "AND name = 'sqlite_sequence')"
5190
+ ).fetchone()
5191
+ if present is None or present[0] is None or present[1] is None:
5192
+ return
5193
+ rows = conn.execute("SELECT name, seq FROM src.sqlite_sequence").fetchall()
5194
+ for name, seq in rows:
5195
+ conn.execute("DELETE FROM main.sqlite_sequence WHERE name = ?", (name,))
5196
+ conn.execute(
5197
+ "INSERT INTO main.sqlite_sequence (name, seq) VALUES (?, ?)",
5198
+ (name, seq),
5199
+ )
5200
+
5201
+
5202
+ def _publish_generation_in_place(
5203
+ conn: sqlite3.Connection, scratch, *, record_path, started_at: str
5204
+ ) -> str:
5205
+ """Install the validated scratch's generation into the LIVE database.
5206
+
5207
+ Returns the terminating phase. The whole swap is ONE `BEGIN IMMEDIATE`, so
5208
+ a reader inside a transaction keeps the generation it opened on, an
5209
+ abandoned attempt leaves the prior generation live and sound, and
5210
+ `PRAGMA user_version` flips atomically at the commit.
5211
+ """
5212
+ import _lib_stats_publish as sp
5213
+
5214
+ if int(conn.execute("PRAGMA foreign_keys").fetchone()[0]) != 0:
5215
+ # `apply_policy` never enables foreign keys and the schema documents
5216
+ # them as enforcement-off, so the derived-FK seam for
5217
+ # `five_hour_milestones.block_id` is a fold ordering contract rather
5218
+ # than an enforced constraint. Assert that rather than depend on it
5219
+ # silently, so a future change that turns them on fails loudly here.
5220
+ raise JournalError(
5221
+ "stats publication requires foreign_keys=0; the schema's derived-FK "
5222
+ "seam is a fold ordering contract, not an enforced constraint"
5223
+ )
5224
+ resolved = pathlib.Path(scratch).resolve()
5225
+ conn.execute("ATTACH DATABASE ? AS src", (resolved.as_uri() + "?mode=ro",))
5226
+ attached = conn.execute(
5227
+ "SELECT file FROM pragma_database_list WHERE name = 'src'"
5228
+ ).fetchone()
5229
+ if attached is None or pathlib.Path(str(attached[0])) != resolved:
5230
+ # A connection without SQLITE_OPEN_URI treats the URI as a literal
5231
+ # filename and silently attaches a new, EMPTY database under that name.
5232
+ # Publishing from it would install an empty generation, so the identity
5233
+ # of what was attached is checked rather than assumed.
5234
+ try:
5235
+ conn.execute("DETACH DATABASE src")
5236
+ except Exception:
5237
+ pass
5238
+ raise JournalError(
5239
+ "stats publication attached the wrong file as its scratch: "
5240
+ f"expected {resolved}, got {attached[0] if attached else '<none>'}"
5241
+ )
5242
+ phase = sp.PRE_COMMIT
5243
+ try:
5244
+ try:
5245
+ conn.execute("BEGIN IMMEDIATE")
5246
+ # Both schemas are read INSIDE the transaction, so the drop list
5247
+ # describes the generation actually being retired.
5248
+ src_objects = conn.execute(
5249
+ "SELECT type, name, sql FROM src.sqlite_schema"
5250
+ ).fetchall()
5251
+ dest_objects = conn.execute(
5252
+ "SELECT type, name, sql FROM main.sqlite_schema"
5253
+ ).fetchall()
5254
+ plan = sp.plan_generation_swap(dest_objects, src_objects)
5255
+ if plan.rejected:
5256
+ raise JournalError(
5257
+ "stats publication cannot copy unsupported object(s): "
5258
+ + ", ".join(plan.rejected)
5259
+ )
5260
+ for statement in plan.drop_statements:
5261
+ conn.execute(statement)
5262
+ for statement in plan.create_table_statements:
5263
+ conn.execute(statement)
5264
+ for name in plan.copy_tables:
5265
+ conn.execute(
5266
+ f'INSERT INTO main."{name}" SELECT * FROM src."{name}"'
5267
+ )
5268
+ for statement in plan.create_index_statements:
5269
+ conn.execute(statement)
5270
+ _carry_sqlite_sequence(conn)
5271
+ epoch = int(conn.execute("PRAGMA src.user_version").fetchone()[0])
5272
+ if epoch != _cctally_core.STATS_INDEX_EPOCH:
5273
+ # `read_publication_stamp`'s entire short-circuit rests on the
5274
+ # claim that a committed publication always leaves the
5275
+ # destination at THIS binary's epoch. Upstream validation
5276
+ # already guarantees the scratch carries it; asserting it here
5277
+ # costs nothing and turns the argument into an invariant.
5278
+ raise JournalError(
5279
+ "stats publication refuses to stamp a scratch at index "
5280
+ f"epoch {epoch}; this binary builds "
5281
+ f"{_cctally_core.STATS_INDEX_EPOCH}"
5282
+ )
5283
+ conn.execute(f"PRAGMA main.user_version={epoch:d}")
5284
+ # The publication's own identity, committed atomically with the
5285
+ # content and the epoch it describes (#496 S3 §5).
5286
+ conn.execute("DELETE FROM main.stats_publication_stamp")
5287
+ conn.execute(
5288
+ "INSERT INTO main.stats_publication_stamp "
5289
+ "(record_path, started_at_utc, stamped_at_utc) VALUES (?, ?, ?)",
5290
+ (str(record_path), started_at, _utc_iso_now()),
5291
+ )
5292
+ phase = sp.COMMIT_UNKNOWN
5293
+ conn.commit()
5294
+ phase = sp.COMMITTED
5295
+ _stats_rebuild_test_pause("publication_after_commit_before_detach")
5296
+ except BaseException as exc:
5297
+ if phase == sp.PRE_COMMIT:
5298
+ try:
5299
+ conn.rollback()
5300
+ except Exception:
5301
+ pass
5302
+ try:
5303
+ setattr(exc, _PUBLICATION_PHASE_ATTR, phase)
5304
+ except Exception: # pragma: no cover — some exceptions are frozen
5305
+ pass
5306
+ raise
5307
+ finally:
5308
+ # DETACH cannot run inside a transaction, so this is best-effort: a
5309
+ # failure that left one open is already being raised.
5310
+ try:
5311
+ conn.execute("DETACH DATABASE src")
5312
+ except Exception:
5313
+ pass
5314
+ return phase
5315
+
5316
+
5317
+ def _checkpoint_after_publication(conn: sqlite3.Connection) -> str:
5318
+ """Drain the WAL after a committed in-place publish — BEST EFFORT.
5319
+
5320
+ `wal_checkpoint(TRUNCATE)` returns a busy ROW rather than raising, and this
5321
+ repository has measured it taking about 16 seconds against a 15-second
5322
+ `busy_timeout` under a pinned reader. Its result is recorded and never
5323
+ interpreted as a transaction failure, and it is never a reason to fall back
5324
+ after a commit.
5325
+ """
5326
+ try:
5327
+ row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
5328
+ except sqlite3.Error as exc:
5329
+ return f"error:{type(exc).__name__}"
5330
+ if row is None:
5331
+ return "unknown"
5332
+ return "checkpointed" if int(row[0]) == 0 else "busy"
5333
+
5334
+
5335
+ def _remove_empty_db_sidecars(path) -> None:
5336
+ """Remove the sidecars a read-only validation open leaves behind.
5337
+
5338
+ Unlike the physical path, an in-place publish leaves a LEGITIMATE WAL: it
5339
+ belongs to the live database, and deleting a non-empty one would discard
5340
+ committed frames. Only a zero-length WAL is removed — the state a
5341
+ successful TRUNCATE checkpoint and a clean last close leave — so the
5342
+ documented sidecar-free end state still holds without ever risking data.
5343
+ """
5344
+ path = pathlib.Path(path)
5345
+ wal = pathlib.Path(str(path) + "-wal")
5346
+ try:
5347
+ if wal.stat().st_size:
5348
+ return
5349
+ except OSError:
5350
+ pass
5351
+ for suffix in ("-wal", "-shm"):
5352
+ try:
5353
+ pathlib.Path(str(path) + suffix).unlink()
5354
+ except FileNotFoundError:
5355
+ pass
5356
+ except OSError:
5357
+ return
5358
+ _fsync_dir(path.parent)
5359
+
5360
+
5361
+ #: Probe 2 measured the live WAL peaking at 1.01x the main file across five
5362
+ #: successive in-place publishes, so the projection is that plus a margin.
5363
+ _PUBLICATION_WAL_PROJECTION = 1.05
5364
+ #: Headroom for the rollback journal and freelist churn of an abandoned attempt.
5365
+ _PUBLICATION_ROLLBACK_MARGIN = 0.25
5366
+
5367
+
5368
+ def _free_disk_bytes(directory) -> int:
5369
+ """Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
5370
+ import _cctally_db
5371
+
5372
+ return _cctally_db._free_disk_bytes(directory)
5373
+
5374
+
5375
+ def _db_family_bytes(path) -> int:
5376
+ total = 0
5377
+ for suffix in ("", "-wal", "-shm"):
5378
+ try:
5379
+ total += pathlib.Path(str(path) + suffix).stat().st_size
5380
+ except OSError:
5381
+ pass
5382
+ return total
5383
+
5384
+
5385
+ def _publication_required_free_bytes(scratch, destination) -> int:
5386
+ """Conservative free-space floor for publishing ``scratch``.
5387
+
5388
+ The live attempt adds a database-sized WAL while the scratch still exists,
5389
+ and a physical fallback would then add a full quarantine copy of the old
5390
+ family on top of that. All three are counted, because the run cannot know
5391
+ at this point which mechanism it will end up using.
5392
+ """
5393
+ scratch_bytes = _db_family_bytes(scratch)
5394
+ projected = _PUBLICATION_WAL_PROJECTION + _PUBLICATION_ROLLBACK_MARGIN
5395
+ return int(scratch_bytes * projected) + _db_family_bytes(destination)
5396
+
5397
+
5398
+ def _preflight_publication_space(scratch, destination) -> None:
5399
+ """Abort before any live mutation when the disk cannot hold the publish.
5400
+
5401
+ Aborting is deliberately NOT a reason to fall back to replacement: a full
5402
+ disk leaves a perfectly good generation live, and physically replacing it
5403
+ is exactly the outcome §12 refuses.
5404
+ """
5405
+ parent = pathlib.Path(destination).parent
5406
+ needed = _publication_required_free_bytes(scratch, destination)
5407
+ try:
5408
+ free = _free_disk_bytes(parent)
5409
+ except OSError as exc: # pragma: no cover — statvfs failing is exotic
5410
+ raise JournalError(
5411
+ f"could not determine free space on {parent} before publishing "
5412
+ f"the rebuilt stats index: {exc}"
5413
+ ) from exc
5414
+ if free < needed:
5415
+ raise JournalError(
5416
+ "publishing the rebuilt stats index needs about "
5417
+ f"{needed / (1024 * 1024):.1f} MB free on {parent}, but only "
5418
+ f"{free / (1024 * 1024):.1f} MB is available. The existing index "
5419
+ "is untouched; free space and retry."
5420
+ )
5421
+
5422
+
5423
+ #: Returned by the in-place publisher when the destination cannot be operated
5424
+ #: on structurally and physical replacement is the sanctioned fallback.
5425
+ _FALL_BACK = object()
5426
+
5427
+
5428
+ def _publish_stats_index_in_place(
5429
+ *, scratch, destination, context, high_water, record, fire_before_swap,
5430
+ prior,
5431
+ ):
5432
+ """Publish transactionally into the live file (#496 S3 §4).
5433
+
5434
+ Returns `None` on success (an in-place publish never preserves, so there is
5435
+ no incident directory), or `_FALL_BACK` when physical replacement is the
5436
+ sanctioned response.
5437
+ """
5438
+ import _lib_stats_publish as sp
5439
+
5440
+ try:
5441
+ conn = _open_publication_connection(destination)
5442
+ except BaseException as exc:
5443
+ if sp.may_fall_back_to_replacement(exc):
5444
+ print(
5445
+ "[rebuild] the live stats index cannot be opened "
5446
+ f"({exc}); publishing by replacement instead",
5447
+ file=sys.stderr,
5448
+ )
5449
+ return _FALL_BACK
5450
+ raise
5451
+
5452
+ started_at = _utc_iso_now()
5453
+ record_path = pathlib.Path(context.record_path)
5454
+ live = dict(record)
5455
+ live.update({
5456
+ "status": "pending",
5457
+ "startedAtUtc": started_at,
5458
+ "completedAtUtc": None,
5459
+ # An in-place publish never preserves. Preservation is a consequence of
5460
+ # destroying a file; `db backup --db stats` is the supported snapshot.
5461
+ "incidentPath": None,
5462
+ "damageShapeTokens": None,
5463
+ "postPublicationValidation": None,
5464
+ "publicationMechanism": "in_place",
5465
+ })
5466
+ try:
5467
+ fire_before_swap()
5468
+ # Phase 1 of the publication transaction: the record and then the
5469
+ # marker, each fsynced, BEFORE any live byte changes.
5470
+ _write_rebuild_record(record_path, live)
5471
+ _stats_rebuild_test_pause("publication_before_marker")
5472
+ _write_publication_marker(
5473
+ destination, record_path, started_at=started_at,
5474
+ scratch_path=scratch, prior=prior, mechanism="in_place",
5475
+ )
5476
+ _stats_rebuild_test_pause("rebuild_before_cutover")
5477
+ _publish_generation_in_place(
5478
+ conn, scratch, record_path=record_path, started_at=started_at,
5479
+ )
5480
+ except BaseException as exc:
5481
+ # Rollback, detach and CLOSE are all mandatory before any fallback: the
5482
+ # drain gate is a whole-system handle scan, and this connection would
5483
+ # either fail it or hollow out the invariant it exists to enforce.
5484
+ try:
5485
+ conn.close()
5486
+ except Exception:
5487
+ pass
5488
+ phase = publication_phase_of(exc)
5489
+ if phase == sp.PRE_COMMIT and sp.may_fall_back_to_replacement(exc):
5490
+ print(
5491
+ "[rebuild] the in-place stats publication rolled back "
5492
+ f"({exc}); publishing by replacement instead",
5493
+ file=sys.stderr,
5494
+ )
5495
+ record["inPlaceAttempt"] = {
5496
+ "phase": phase, "error": f"{type(exc).__name__}: {exc}"[:500],
5497
+ }
5498
+ return _FALL_BACK
5499
+ raise
5500
+
5501
+ checkpoint_outcome = _checkpoint_after_publication(conn)
5502
+ try:
5503
+ conn.close()
5504
+ except Exception:
5505
+ pass
5506
+ _stats_rebuild_test_pause("rebuild_after_publication_replace")
5507
+
5508
+ # Phase 2: validate the bytes that are now live, on a connection that never
5509
+ # saw them being written. The expected publication identity goes with it:
5510
+ # the high-water alone cannot distinguish this generation from an equally
5511
+ # journal-consistent one some other run installed.
5512
+ post_error = validate_published_stats_index(
5513
+ destination, high_water, expected_record_path=str(record_path),
5514
+ )
5515
+ _remove_empty_db_sidecars(destination)
5516
+
5517
+ live["publicationCheckpoint"] = checkpoint_outcome
5518
+ live["postPublicationValidation"] = {
5519
+ "ok": post_error is None, "error": post_error,
5520
+ }
5521
+ live["completedAtUtc"] = _utc_iso_now()
5522
+ live["status"] = "ok" if post_error is None else "failed"
5523
+ _write_rebuild_record(record_path, live)
5524
+ if post_error is not None:
5525
+ # The scratch is deliberately NOT removed here: it is the last
5526
+ # independently validated copy of this generation, and the live bytes
5527
+ # just failed.
5528
+ _write_publication_marker(
5529
+ destination, record_path, started_at=started_at,
5530
+ scratch_path=scratch, status="failed", error=post_error,
5531
+ mechanism="in_place",
5532
+ )
5533
+ raise JournalError(
5534
+ "published stats index failed post-publication validation: "
5535
+ f"{post_error}; rebuild record: {record_path}"
5536
+ )
5537
+ _stats_rebuild_test_pause("publication_after_verdict_before_marker_removal")
5538
+ _remove_publication_marker(destination)
5539
+ # Removal follows verdict settlement. A surviving `.rebuilding-*` family is
5540
+ # classified FIRST by the next opener and would route this healthy index
5541
+ # through interrupted-rebuild recovery.
5542
+ _stats_rebuild_test_pause("publication_before_scratch_removal")
5543
+ _remove_db_family(scratch)
5544
+ _fsync_dir(pathlib.Path(destination).parent)
5545
+ return None
5546
+
5547
+
4600
5548
  def _publish_rebuilt_stats_index(
4601
5549
  *,
4602
5550
  scratch: pathlib.Path,
4603
5551
  destination: pathlib.Path,
4604
5552
  preserve_existing: bool,
5553
+ context: RebuildContext,
5554
+ high_water: "tuple[str, int] | None",
5555
+ record: dict,
4605
5556
  before_swap=None,
4606
5557
  ) -> "pathlib.Path | None":
4607
- """Publish one validated, closed, sidecar-free scratch index atomically."""
5558
+ """Publish one validated, closed, sidecar-free scratch index.
5559
+
5560
+ In-place transactional publication is the mechanism (#496 S3). Physical
5561
+ replacement is the fallback, taken when the destination cannot be operated
5562
+ on structurally. The mechanism is chosen against the destination in front
5563
+ of this run, not by the trigger that reached it: corruption is not uniform,
5564
+ and a readable-but-damaged destination publishes in place like any other.
5565
+
5566
+ Publication is a two-phase durable transaction (#496 S1 F1) under either
5567
+ mechanism. A published file carries the current epoch, so `open_db`'s
5568
+ zero-DDL fast path returns it with no validation and a post-publication
5569
+ failure that only RAISED would leave a known-bad index accepted by every
5570
+ later command. The record and the marker are what make the verdict outlive
5571
+ this process.
5572
+ """
4608
5573
  import _cctally_store
4609
5574
 
5575
+ # Read-only, and first: a short disk must abort while the destination is
5576
+ # still untouched, not part-way through a publication.
5577
+ _preflight_publication_space(scratch, destination)
5578
+
5579
+ # A marker already beside the destination may still owe a verdict on bytes
5580
+ # that are live right now. Settle it BEFORE Phase 1 overwrites the only
5581
+ # marker slot, while the destination is still exactly what that publication
5582
+ # left there.
5583
+ prior = _settle_prior_publication_verdict(destination)
5584
+
5585
+ # The `before_swap` seam fires ONCE, for either mechanism: it is the
5586
+ # `db rederive` crash seam, and a fallback must not re-enter it.
5587
+ fired = []
5588
+
5589
+ def fire_before_swap() -> None:
5590
+ if before_swap is not None and not fired:
5591
+ fired.append(True)
5592
+ before_swap()
5593
+
5594
+ if pathlib.Path(destination).exists():
5595
+ published = _publish_stats_index_in_place(
5596
+ scratch=scratch, destination=destination, context=context,
5597
+ high_water=high_water, record=record,
5598
+ fire_before_swap=fire_before_swap, prior=prior,
5599
+ )
5600
+ if published is not _FALL_BACK:
5601
+ return published
5602
+
4610
5603
  family_exists = any(
4611
5604
  pathlib.Path(str(destination) + suffix).exists()
4612
5605
  for suffix in ("", "-wal", "-shm")
4613
5606
  )
4614
5607
  incident = None
5608
+ damage_tokens = None
4615
5609
  if family_exists:
4616
5610
  blocked = _cctally_store._stats_family_drained(destination)
4617
5611
  if blocked is not None:
@@ -4620,20 +5614,103 @@ def _publish_rebuilt_stats_index(
4620
5614
  if preserve_existing:
4621
5615
  # Preserve the exact pre-cutover family, including a committed WAL
4622
5616
  # and SHM, before checkpointing mutates or removes those sidecars.
4623
- incident = _preserve_stats_family_for_cutover(destination)
5617
+ incident = _preserve_stats_family_for_cutover(
5618
+ destination, context=context
5619
+ )
5620
+ checkpoint_outcome = "skipped_absent"
4624
5621
  if destination.exists():
4625
- _prepare_existing_stats_for_cutover(destination)
5622
+ try:
5623
+ checkpoint_outcome = _prepare_existing_stats_for_cutover(
5624
+ destination
5625
+ )
5626
+ except BaseException:
5627
+ if incident is not None:
5628
+ _record_post_checkpoint_damage(
5629
+ incident, destination, "failed"
5630
+ )
5631
+ raise
5632
+ if incident is not None:
5633
+ # Scanned BEFORE the sidecars are removed, so this and the
5634
+ # preserved scan bracket the explicit checkpoint.
5635
+ damage = _record_post_checkpoint_damage(
5636
+ incident, destination, checkpoint_outcome
5637
+ )
5638
+ if damage:
5639
+ damage_tokens = {
5640
+ "forensics": _forensics_shape_token(context.forensics_path),
5641
+ "preserved": (damage.get("preserved") or {}).get(
5642
+ "shapeToken"
5643
+ ),
5644
+ "postCheckpoint": (damage.get("postCheckpoint") or {}).get(
5645
+ "shapeToken"
5646
+ ),
5647
+ "checkpointOutcome": damage.get("checkpointOutcome"),
5648
+ }
4626
5649
  # The old main stays present and, when it was readable, fully
4627
5650
  # checkpointed. A kill from here until os.replace therefore still
4628
5651
  # leaves a usable old destination while preventing stale sidecars from
4629
5652
  # being paired with the replacement main.
4630
5653
  _remove_db_sidecars_strict(destination)
5654
+ _cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
5655
+
5656
+ fire_before_swap()
5657
+
5658
+ # Phase 1 of the publication transaction: the record and then the marker,
5659
+ # each fsynced, BEFORE the replacement becomes visible.
5660
+ started_at = _utc_iso_now()
5661
+ record = dict(record)
5662
+ record.update({
5663
+ "status": "pending",
5664
+ "startedAtUtc": started_at,
5665
+ "completedAtUtc": None,
5666
+ "incidentPath": str(incident) if incident is not None else None,
5667
+ "damageShapeTokens": damage_tokens,
5668
+ "postPublicationValidation": None,
5669
+ "publicationMechanism": "replace",
5670
+ })
5671
+ record_path = pathlib.Path(context.record_path)
5672
+ _write_rebuild_record(record_path, record)
5673
+ _write_publication_marker(
5674
+ destination, record_path, started_at=started_at, scratch_path=scratch,
5675
+ prior=prior, mechanism="replace",
5676
+ )
4631
5677
 
4632
- if before_swap is not None:
4633
- before_swap()
4634
5678
  _stats_rebuild_test_pause("rebuild_before_cutover")
4635
5679
  os.replace(str(scratch), str(destination))
4636
5680
  _fsync_dir(destination.parent)
5681
+ _stats_rebuild_test_pause("rebuild_after_publication_replace")
5682
+
5683
+ # Phase 2: validate the bytes that are now live, on a connection that never
5684
+ # saw them being written.
5685
+ post_error = validate_published_stats_index(destination, high_water)
5686
+ # The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
5687
+ # Remove them so the documented no-post-publication-stale-sidecar end state
5688
+ # still holds; an empty WAL is consistent with the freshly published main,
5689
+ # so a crash between validation and removal is harmless.
5690
+ _remove_db_sidecars_strict(destination)
5691
+
5692
+ record["postPublicationValidation"] = {
5693
+ "ok": post_error is None,
5694
+ "error": post_error,
5695
+ }
5696
+ record["completedAtUtc"] = _utc_iso_now()
5697
+ record["status"] = "ok" if post_error is None else "failed"
5698
+ _write_rebuild_record(record_path, record)
5699
+ if post_error is not None:
5700
+ # No rollback is possible: the old family is already quarantined, and
5701
+ # restoring it would republish a file known to be corrupt. Any carried
5702
+ # prior verdict is dropped here on purpose: `os.replace` succeeded, so
5703
+ # the bytes it judged are gone and THIS failure is the live one.
5704
+ _write_publication_marker(
5705
+ destination, record_path, started_at=started_at,
5706
+ scratch_path=scratch, status="failed", error=post_error,
5707
+ mechanism="replace",
5708
+ )
5709
+ raise JournalError(
5710
+ "published stats index failed post-publication validation: "
5711
+ f"{post_error}; rebuild record: {record_path}"
5712
+ )
5713
+ _remove_publication_marker(destination)
4637
5714
  return incident
4638
5715
 
4639
5716
 
@@ -4707,6 +5784,7 @@ def _rebuild_quota_cache_leg(records) -> None:
4707
5784
 
4708
5785
  def rebuild_stats_index(
4709
5786
  *,
5787
+ context: RebuildContext,
4710
5788
  target_path=None,
4711
5789
  high_water: "tuple[str, int] | None" = None,
4712
5790
  update_quota_cache: bool = True,
@@ -4721,6 +5799,10 @@ def rebuild_stats_index(
4721
5799
  no alerts, no `reconcile_config` (see the module note above). Post-rebuild the
4722
5800
  cursor equals the journal high-water.
4723
5801
 
5802
+ `context` states WHY this rebuild ran (#496 S1 F3). It is keyword-only with
5803
+ no default, so a future call site cannot silently produce an unattributed
5804
+ quarantine incident; omitting it raises `TypeError` at the call.
5805
+
4724
5806
  `target_path` selects the destination (default `DB_PATH`). `high_water`
4725
5807
  optionally pins the exact inclusive journal prefix; later bytes stay beyond
4726
5808
  the rebuilt cursor. `update_quota_cache=False` is the Task-C Claude-only
@@ -4731,6 +5813,17 @@ def rebuild_stats_index(
4731
5813
  publication but does not create a live-family quarantine incident.
4732
5814
  """
4733
5815
  start = time.monotonic()
5816
+ context = context.validate()
5817
+ # Resolve the rebuild record's path ONCE, here, because preservation runs
5818
+ # long before the record is written and both must name the same file
5819
+ # (#496 S1). Callers never supply it.
5820
+ record_stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S_%f")
5821
+ context = _dc_replace(
5822
+ context,
5823
+ record_path=str(
5824
+ _cctally_core.LOG_DIR / f"stats-rebuild-{record_stamp}.json"
5825
+ ),
5826
+ )
4734
5827
  dest = (pathlib.Path(target_path) if target_path is not None
4735
5828
  else pathlib.Path(_cctally_core.DB_PATH))
4736
5829
 
@@ -4907,21 +6000,63 @@ def rebuild_stats_index(
4907
6000
  with scratch.open("rb") as handle:
4908
6001
  os.fsync(handle.fileno())
4909
6002
  _fsync_dir(scratch.parent)
6003
+
6004
+ # Extract the compact result data and RELEASE the replay structures before
6005
+ # publication begins (#496 S3 §4.2). The in-place attempt adds a
6006
+ # database-sized WAL while the scratch still exists, so the measured
6007
+ # multi-gigabyte replay peak must not still be resident on top of it.
6008
+ segments_read = len(segments)
6009
+ conflicts = effective.conflicts
6010
+ protocol_violations = effective.protocol_violations
6011
+ acknowledged = effective.acknowledged_protocol_violations
6012
+ decoded = effective = stream = structural = tail = None
6013
+ segments = protocol_evidence = None
6014
+
6015
+ # First fresh-connection validation (#496 S1 F1). A failure here raises
6016
+ # BEFORE any preservation, so no incident is created and the old family
6017
+ # stays live — the existing contract is preserved exactly.
6018
+ pre_error = validate_published_stats_index(scratch, hw)
6019
+ if pre_error is not None:
6020
+ raise JournalError(
6021
+ f"rebuilt stats index failed pre-publication validation: {pre_error}"
6022
+ )
6023
+ # That read-only open recreated the scratch sidecars; publication requires
6024
+ # a sidecar-free scratch, and a leftover pair would also survive the
6025
+ # `os.replace` as a stray artifact.
6026
+ _remove_db_sidecars_strict(scratch)
6027
+
4910
6028
  incident = _publish_rebuilt_stats_index(
4911
6029
  scratch=scratch,
4912
6030
  destination=dest,
4913
6031
  preserve_existing=target_path is None,
4914
6032
  before_swap=before_swap,
6033
+ context=context,
6034
+ high_water=hw,
6035
+ record={
6036
+ "schemaVersion": 1,
6037
+ "trigger": context.trigger,
6038
+ "triggerError": context.trigger_error,
6039
+ "forensicsPath": context.forensics_path,
6040
+ "binaryVersion": _binary_version(),
6041
+ "binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
6042
+ "highWater": [hw[0], hw[1]] if hw is not None else None,
6043
+ "destination": str(dest),
6044
+ "targetPath": str(target_path) if target_path is not None else None,
6045
+ "segmentsRead": segments_read,
6046
+ "linesFolded": lines_folded,
6047
+ "malformed": malformed,
6048
+ "rowsByTable": rows_by_table,
6049
+ "buildSeconds": round(time.monotonic() - start, 3),
6050
+ "prePublicationValidation": {"ok": True, "error": None},
6051
+ },
4915
6052
  )
4916
6053
 
4917
6054
  return RebuildResult(
4918
6055
  rows_by_table=rows_by_table, malformed=malformed,
4919
- duration_s=time.monotonic() - start, segments_read=len(segments),
4920
- lines_folded=lines_folded, conflicts=effective.conflicts,
4921
- protocol_violations=effective.protocol_violations,
4922
- acknowledged_protocol_violations=(
4923
- effective.acknowledged_protocol_violations
4924
- ),
6056
+ duration_s=time.monotonic() - start, segments_read=segments_read,
6057
+ lines_folded=lines_folded, conflicts=conflicts,
6058
+ protocol_violations=protocol_violations,
6059
+ acknowledged_protocol_violations=acknowledged,
4925
6060
  quarantine_dir=incident,
4926
6061
  )
4927
6062
 
@@ -5386,5 +6521,5 @@ def run_epoch_transition(*, claude_json_path=None) -> str:
5386
6521
  preserves the old index only after the replacement is validated."""
5387
6522
  claude_key = _resolve_claude_cutover_identity(claude_json_path)
5388
6523
  recorded = append_accounts_cutover_op(claude_key)
5389
- rebuild_stats_index()
6524
+ rebuild_stats_index(context=RebuildContext(trigger="epoch-transition"))
5390
6525
  return recorded