cctally 1.94.2 → 1.95.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.
@@ -40,9 +40,10 @@ provider flocks (Claude → Codex) → SQLite transactions → ``journal.lock``
40
40
  flock; no SQLite write transaction ever spans a flock acquisition.
41
41
 
42
42
  **Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
43
- ``db checkpoint``'s ``mode=rw`` connect and ``db vacuum``'s exclusive connect
44
- deliberately bypass ``open_index``/``open_db`` so they carry no schema-apply /
45
- migration side effects on maintenance paths.
43
+ the cache checkpoint's ``mode=rw`` connect and ``db vacuum``'s exclusive
44
+ connect deliberately bypass ``open_index``/``open_db`` so they carry no
45
+ schema-apply / migration side effects on maintenance paths. The retired stats
46
+ checkpoint returns before any raw connect.
46
47
 
47
48
  **#386 narrowed that carve-out for stats.** Skipping the *schema apply* is not
48
49
  the same as skipping the *opener protocol*: spec §3.1's third clause requires
@@ -79,11 +80,12 @@ import _cctally_db
79
80
  # §6.1 policy table
80
81
  # --------------------------------------------------------------------------
81
82
 
82
- # WAL size caps (spec §6.1). Mirror the long-standing per-DB constants
83
- # (_cctally_core.STATS_WAL_SIZE_LIMIT_BYTES, _cctally_cache.CACHE_WAL_SIZE_LIMIT_BYTES);
84
- # duplicated here so the store module does not import _cctally_cache (which
83
+ # Journal size limits (spec §6.1). The stats setting applies to rollback
84
+ # journaling (DELETE removes the transient journal after a clean transaction);
85
+ # the cache value remains a persistent WAL cap.
86
+ # Duplicated here so the store module does not import _cctally_cache (which
85
87
  # loads this module — that would be a cycle).
86
- _STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16 MiB
88
+ _STATS_JOURNAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16 MiB
87
89
  _CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
88
90
 
89
91
 
@@ -91,8 +93,8 @@ _CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
91
93
  class StorePolicy:
92
94
  """The §6.1 connection policy for one store."""
93
95
 
94
- journal_mode: str # "WAL"
95
- synchronous: str # "NORMAL"
96
+ journal_mode: str # "DELETE" | "WAL"
97
+ synchronous: str # "FULL" | "NORMAL"
96
98
  busy_timeout: int # milliseconds
97
99
  journal_size_limit: int # bytes
98
100
  auto_vacuum: str | None # "INCREMENTAL", or None to leave unset
@@ -105,8 +107,8 @@ STORE_POLICY: dict[str, StorePolicy] = {
105
107
  # from the first epoch rebuild — a populated DB needs a full VACUUM to
106
108
  # change modes, so it is deliberately NOT applied at in-place cutover.
107
109
  "stats": StorePolicy(
108
- journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
109
- journal_size_limit=_STATS_WAL_SIZE_LIMIT_BYTES, auto_vacuum=None,
110
+ journal_mode="DELETE", synchronous="FULL", busy_timeout=15000,
111
+ journal_size_limit=_STATS_JOURNAL_SIZE_LIMIT_BYTES, auto_vacuum=None,
110
112
  row_factory="row", uri=False,
111
113
  ),
112
114
  "cache": StorePolicy(
@@ -170,7 +172,7 @@ def apply_policy(conn: sqlite3.Connection, store: str) -> None:
170
172
  """Apply the §6.1 PRAGMA policy for ``store`` to an open connection.
171
173
 
172
174
  ``auto_vacuum`` (when set) is emitted first: it only takes effect before the
173
- first page is written, so it must precede ``journal_mode=WAL`` / any DDL.
175
+ first page is written, so it must precede ``journal_mode`` / any DDL.
174
176
  """
175
177
  policy = STORE_POLICY[store]
176
178
  if policy.auto_vacuum is not None:
@@ -199,7 +201,7 @@ STATS_FAMILY_MODE = 0o600
199
201
 
200
202
 
201
203
  def _harden_stats_family(path) -> None:
202
- """Best-effort `0600` on a stats database and its `-wal`/`-shm` sidecars.
204
+ """Best-effort `0600` on a stats database and SQLite sidecars.
203
205
 
204
206
  **This closes a defense-in-depth inconsistency, not a live exposure**
205
207
  (§9.1). `ensure_dirs()` chmods the data directory to `0700`, so no other
@@ -214,8 +216,8 @@ def _harden_stats_family(path) -> None:
214
216
  installs a new inode behind a memoized path. The comparison is what avoids
215
217
  the repeated `chmod`: an `lstat` is cheap, and a `chmod` on an
216
218
  already-correct file is the cost being avoided. The statusline opens the
217
- index at three sites, so the steady state is three `lstat` triples per
218
- render and zero `chmod` calls.
219
+ index at three sites, so the steady state is twelve family-member `lstat`
220
+ calls per render and zero `chmod` calls.
219
221
 
220
222
  The `lstat` does not follow symlinks, and a member that IS a symlink is
221
223
  left alone rather than chmod'd through. This function never unlinks and
@@ -226,7 +228,7 @@ def _harden_stats_family(path) -> None:
226
228
  `ENOENT`.
227
229
  """
228
230
  base = str(path)
229
- for member in (base, base + "-wal", base + "-shm"):
231
+ for member in (base, base + "-journal", base + "-wal", base + "-shm"):
230
232
  try:
231
233
  info = os.lstat(member)
232
234
  except OSError:
@@ -1142,7 +1144,7 @@ def _resume_pending_quarantine(db_path: pathlib.Path) -> None:
1142
1144
 
1143
1145
  _STATS_REBUILD_ARTIFACT_RE = re.compile(
1144
1146
  r"^(?P<base>stats\.db\.rebuilding-\d{8}T\d{6}_\d{6})"
1145
- r"(?P<sidecar>-wal|-shm)?$"
1147
+ r"(?P<sidecar>-journal|-wal|-shm)?$"
1146
1148
  )
1147
1149
  _STATS_QUARANTINE_INCIDENT_RE = re.compile(
1148
1150
  r"^stats\.db-(?:\d{8}T\d{6}Z|\d{8}T\d{6}_\d{6})$"
@@ -1271,7 +1273,7 @@ def _remove_stale_stats_rebuild_artifacts(
1271
1273
  ) -> None:
1272
1274
  """Remove only exact scratch families, failing loudly on incomplete cleanup."""
1273
1275
  for artifact in artifacts:
1274
- for suffix in ("", "-wal", "-shm"):
1276
+ for suffix in ("", "-journal", "-wal", "-shm"):
1275
1277
  candidate = pathlib.Path(f"{artifact}{suffix}")
1276
1278
  try:
1277
1279
  candidate.unlink()
@@ -1282,7 +1284,7 @@ def _remove_stale_stats_rebuild_artifacts(
1282
1284
  leftovers = [
1283
1285
  str(pathlib.Path(f"{artifact}{suffix}"))
1284
1286
  for artifact in artifacts
1285
- for suffix in ("", "-wal", "-shm")
1287
+ for suffix in ("", "-journal", "-wal", "-shm")
1286
1288
  if pathlib.Path(f"{artifact}{suffix}").exists()
1287
1289
  ]
1288
1290
  if leftovers:
@@ -1539,8 +1541,8 @@ def stats_open_guarded(
1539
1541
  raise _cctally_db.StatsDbMaintenanceError()
1540
1542
  # #386 enforcement: EVERY stats connection this module hands out
1541
1543
  # carries the authorizer. Arming HERE and nowhere else is what
1542
- # keeps raw `sqlite3.connect` escape hatches (the storm suite's
1543
- # `_grow_wal`, `db checkpoint`'s `mode=rw`) unaffected — a
1544
+ # keeps raw `sqlite3.connect` escape hatches (such as the storm
1545
+ # suite's legacy `_grow_wal`) unaffected — a
1544
1546
  # broader arming point would make their writes unsanctioned and
1545
1547
  # the correct fix would then be to narrow the arming, never to
1546
1548
  # weaken the guard.
@@ -1572,9 +1574,8 @@ def _acquire_stats_maintenance_reentrant(path) -> "int | None":
1572
1574
  indefinitely. ``run_stats_ingest`` holds maintenance SHARED across its entire
1573
1575
  cycle, and this helper's caller — the epoch resolver — is reachable from a
1574
1576
  nested ``open_db()`` inside that cycle. Without this check that nested open
1575
- is an unconditional self-deadlock. The corruption heal applies the same
1576
- ownership-first rule through ``_acquire_stats_maintenance_for_heal``, which
1577
- additionally BOUNDS the acquire.
1577
+ is an unconditional self-deadlock. The corruption detector no longer
1578
+ acquires maintenance; its detached worker starts in a fresh process.
1578
1579
 
1579
1580
  Proceeding on a shared hold is a deliberate, narrow weakening: the caller
1580
1581
  still runs ``_stats_family_drained`` before any physical replacement, which
@@ -1624,54 +1625,6 @@ def _heal_release_maintenance_flock(fd: int) -> None:
1624
1625
  _heal_release_flock(fd)
1625
1626
 
1626
1627
 
1627
- _HEAL_MAINTENANCE_WAIT_S = 5.0
1628
-
1629
-
1630
- def _acquire_stats_maintenance_for_heal(
1631
- timeout_s: float = _HEAL_MAINTENANCE_WAIT_S,
1632
- ) -> "tuple[int | None, bool]":
1633
- """Ownership-first, mode-aware maintenance for the corruption heal (§6).
1634
-
1635
- Returns ``(fd, True)`` when the heal may proceed — ``fd`` is ``None`` when
1636
- an existing hold was REUSED and nothing was acquired — and ``(None,
1637
- False)`` when the bounded acquire expired.
1638
-
1639
- **Ownership-first, not mode-first.** ``flock`` conflicts are per open-file-
1640
- description and apply WITHIN a process, so requesting the lock a second
1641
- time on a second descriptor blocks this process against itself whenever the
1642
- hold it already owns is EXCLUSIVE (`_cctally_core` documents that at the
1643
- maintenance tracker, and ``run_stats_ingest`` can hold exclusive when it
1644
- calls ``open_db()``). The tracker is a depth counter that records THAT a
1645
- hold exists and never which mode, and it does not need to: the rule reuses
1646
- any hold whatever its mode, so the two cases never have to be told apart.
1647
- Upgrading a shared hold to exclusive is the one operation that would need
1648
- the mode, and it is exactly the second acquire that deadlocks.
1649
-
1650
- **Bounded, never blocking, when nothing is held.** The heal runs inside an
1651
- ordinary open, and the detached worker owns maintenance EXCLUSIVE for the
1652
- whole of its rebuild. An unbounded acquire here would make every statusline
1653
- and dashboard open that meets corruption wait out that rebuild — the
1654
- blocking this architecture exists to remove. A timeout means some OTHER
1655
- holder owns it, and failing soft is correct: decline, and let a later open
1656
- retry.
1657
- """
1658
- if _cctally_core.holds_stats_maintenance():
1659
- return (None, True)
1660
- fd = _heal_flock_bounded(
1661
- _cctally_core.STATS_LOCK_MAINTENANCE_PATH, timeout_s
1662
- )
1663
- if fd is None:
1664
- return (None, False)
1665
- _cctally_core.note_stats_maintenance_acquired()
1666
- return (fd, True)
1667
-
1668
-
1669
- def _release_stats_maintenance_for_heal(fd: "int | None") -> None:
1670
- """Release what ``_acquire_stats_maintenance_for_heal`` took, if anything."""
1671
- if fd is not None:
1672
- _heal_release_maintenance_flock(fd)
1673
-
1674
-
1675
1628
  def _heal_flock_bounded(path, timeout_s: float) -> "int | None":
1676
1629
  """Bounded EX flock. Returns the HELD fd, or ``None`` on timeout.
1677
1630
 
@@ -1752,6 +1705,51 @@ def _stats_family_drained(path) -> "str | None":
1752
1705
  return None
1753
1706
 
1754
1707
 
1708
+ def _stats_header_declares_wal(path) -> bool:
1709
+ """Whether the on-disk SQLite header declares WAL read/write versions.
1710
+
1711
+ This deliberately reads raw bytes instead of opening SQLite. Opening a
1712
+ previous-epoch WAL family is already enough to materialize SHM, and the
1713
+ #538 transition must prove every old handle is gone before it asks SQLite
1714
+ to convert that family to rollback journaling.
1715
+ """
1716
+ try:
1717
+ with pathlib.Path(path).open("rb") as handle:
1718
+ header = handle.read(20)
1719
+ except OSError:
1720
+ return False
1721
+ return (
1722
+ len(header) == 20
1723
+ and header[:16] == b"SQLite format 3\x00"
1724
+ and (header[18] == 2 or header[19] == 2)
1725
+ )
1726
+
1727
+
1728
+ def _stats_legacy_wal_family_present(path) -> bool:
1729
+ """Whether `path` still carries any part of the retired stats WAL mode."""
1730
+ path = pathlib.Path(path)
1731
+ return (
1732
+ _stats_header_declares_wal(path)
1733
+ or pathlib.Path(f"{path}-wal").exists()
1734
+ or pathlib.Path(f"{path}-shm").exists()
1735
+ )
1736
+
1737
+
1738
+ def _require_stats_legacy_wal_cold(path) -> None:
1739
+ """Fail before opening/converting a previous-epoch stats WAL family."""
1740
+ if not _stats_legacy_wal_family_present(path):
1741
+ return
1742
+ blocked = _stats_family_drained(path)
1743
+ if blocked is None:
1744
+ return
1745
+ raise _cctally_db.StatsEpochMismatchError(
1746
+ "stats.db still uses the retired WAL format and cannot cross the "
1747
+ f"rollback-journal epoch while its old family is live ({blocked}). "
1748
+ "Stop and restart the dashboard and every older cctally process, then "
1749
+ "retry; the existing stats.db/WAL/SHM bytes were left untouched."
1750
+ )
1751
+
1752
+
1755
1753
  def _heal_release_flock(fd: int) -> None:
1756
1754
  try:
1757
1755
  fcntl.flock(fd, fcntl.LOCK_UN)
@@ -1806,12 +1804,14 @@ def _stats_heal_hook(
1806
1804
  *,
1807
1805
  post_query: bool = False,
1808
1806
  ) -> bool:
1809
- """Classifier-gated corruption auto-heal for stats.db (spec §6.3). Returns
1810
- True when it healed (quarantined + rebuilt) OR a sibling already healed under
1811
- the maintenance lock; False when it DECLINES a non-corruption
1812
- ``DatabaseError`` (BUSY / disk-full / permission), the dev-checkout-on-prod
1813
- guard, or re-entrancy. A False return leaves ``open_db`` to raise its guided
1814
- ``StatsDbCorruptError``.
1807
+ """Admit classifier-gated stats corruption recovery without maintenance.
1808
+
1809
+ False means the trigger is ineligible before admission: a non-corruption
1810
+ ``DatabaseError`` (BUSY / disk-full / permission), the dev-on-prod guard,
1811
+ re-entrancy, or no journal. An eligible trigger durably elects or coalesces
1812
+ onto one detached worker and raises ``StatsHealDeferred`` immediately. The
1813
+ worker owns the maintenance wait, authoritative probe, forensics and
1814
+ classifier confirmation before any rebuild.
1815
1815
 
1816
1816
  It can also RAISE ``_cctally_db.StatsPublicationFailedError`` (#496 S1 F1),
1817
1817
  and both callers depend on that: ``_cctally_tui._tui_heal_post_query_stats``
@@ -1845,136 +1845,61 @@ def _stats_heal_hook(
1845
1845
  return False
1846
1846
  _HEAL_ACTIVE = True
1847
1847
  try:
1848
- # Ownership-first and BOUNDED (#496 S3 §6). A hold this context already
1849
- # owns is reused whatever its mode; otherwise exclusive is acquired
1850
- # within a bound, because the detached worker owns maintenance for the
1851
- # whole of its rebuild and an ordinary open must never wait that out.
1852
- maint_fd, acquired = _acquire_stats_maintenance_for_heal()
1853
- if not acquired:
1854
- probe = _probe_stats_integrity_ok if post_query else _probe_stats_ok
1855
- if probe(path):
1856
- # Some other maintenance owner already republished a readable
1857
- # index while we waited — retry the open rather than decline.
1858
- return True
1848
+ # #530: admission is the FIRST contended operation. The detector never
1849
+ # asks for maintenance even non-blockingly because a rotating set of
1850
+ # shared hook owners can otherwise make every caller lose before a
1851
+ # durable request exists. The elected fresh worker owns the authoritative
1852
+ # maintenance wait, classifier probe, forensics and rebuild.
1853
+ request = _build_stats_heal_request(
1854
+ exc, None, post_query=post_query, high_water=hw,
1855
+ )
1856
+ admission_event = {
1857
+ **build_stats_heal_event(request, "pending-classification"),
1858
+ "outcome": "admitted",
1859
+ "coalescedDetections": 0,
1860
+ }
1861
+ reservation, elected_request = _reserve_stats_corruption_heal(
1862
+ request, admission_event=admission_event,
1863
+ )
1864
+ if reservation == "reserved":
1865
+ active_request = elected_request or request
1866
+ elif reservation == "pending":
1867
+ # The elected request and coalesced count were read and updated
1868
+ # while admission was still held. Never reconstruct identity from
1869
+ # a marker that the worker may not have written yet or may already
1870
+ # have removed.
1871
+ active_request = elected_request or request
1872
+ else:
1873
+ active_request = request
1874
+
1875
+ # Spawn under no lock. A worker is launched only for the elected
1876
+ # request; pending callers coalesce and failed admission stays retryable.
1877
+ outcome = complete_stats_corruption_heal(reservation)
1878
+ # F15 (#496 S3 §7). Detachment supplies the timing for free: report at
1879
+ # admission, naming the elected heal id. Forensics now belongs to the
1880
+ # detached owner so it can capture evidence only after maintenance has
1881
+ # drained and before any publication mutation.
1882
+ bundle = active_request.get("forensicsPath")
1883
+ if outcome == "failed":
1859
1884
  print(
1860
- "[heal] stats.db auto-heal declined: another maintenance "
1861
- "owner holds stats.db.maintenance.lock; a later open will "
1862
- "retry.",
1885
+ f"[heal] stats.db is corrupt ({exc}); nothing was replaced by "
1886
+ "this command. Recovery could not be durably admitted; a "
1887
+ "later open will retry.",
1863
1888
  file=sys.stderr,
1864
1889
  )
1865
- return False
1866
- import _cctally_retention
1867
-
1868
- # #496 S6 §5.3, the parent's phase. SHARED from before the forensics
1869
- # bundle until either the request marker naming it is fsynced or the
1870
- # decision is terminal. A decline writes no marker and needs no bridge,
1871
- # because nothing will act on the bundle later.
1872
- retention = contextlib.ExitStack()
1873
- try:
1874
- probe = _probe_stats_integrity_ok if post_query else _probe_stats_ok
1875
- if probe(path):
1876
- return True # a sibling process already healed it — retry the open
1877
- retention.enter_context(
1878
- _cctally_retention.retention_shared(label="stats auto-heal")
1879
- )
1880
- # Forensics FIRST — before anything disturbs the evidence. The
1881
- # trigger pair is what arms the #496 S1 forensics-time WAL capture
1882
- # and what lets the quarantine incident name the bundle that
1883
- # preceded it.
1884
- #
1885
- forensics = _cctally_db.write_corruption_forensics(
1886
- path,
1887
- db_label="stats",
1888
- trigger_origin="corruption-heal",
1889
- trigger_exception=exc,
1890
- return_result=True,
1891
- )
1892
- request = _build_stats_heal_request(
1893
- exc, forensics, post_query=post_query, high_water=hw,
1890
+ else:
1891
+ evidence = (
1892
+ f"Forensics: {bundle}."
1893
+ if bundle
1894
+ else "Forensics will be captured by that owner before recovery."
1894
1895
  )
1895
- # F4, first point (#496 S3 §7). Classifier gating stays a
1896
- # PRECONDITION; this narrows within classified triggers exactly as
1897
- # the cache path does at `_cctally_cache.py`. A disposition other
1898
- # than CONFIRMED declines: no deferral, no worker, no replacement,
1899
- # and a printed reason naming the bundle.
1900
- confirmed = (
1901
- forensics is not None
1902
- and forensics.disposition
1903
- is _cctally_db.CorruptionProbeDisposition.CONFIRMED
1904
- and forensics.path is not None
1896
+ print(
1897
+ f"[heal] stats.db is corrupt ({exc}); nothing was replaced by "
1898
+ f"this command. Recovery was admitted to run in the background "
1899
+ f"as heal {active_request.get('healId') or request['healId']}. "
1900
+ f"{evidence}",
1901
+ file=sys.stderr,
1905
1902
  )
1906
- if not confirmed:
1907
- bundle = (
1908
- str(forensics.path)
1909
- if forensics is not None and forensics.path is not None
1910
- else "unavailable"
1911
- )
1912
- reason = (
1913
- forensics.reason if forensics is not None else "unavailable"
1914
- )
1915
- append_stats_heal_event({
1916
- **build_stats_heal_event(request, "unconfirmed"),
1917
- "outcome": "declined-unconfirmed",
1918
- "declineReason": reason,
1919
- })
1920
- print(
1921
- "[heal] stats.db auto-heal declined for classified "
1922
- f"trigger: corruption was not confirmed ({reason}; "
1923
- f"forensics: {bundle}); leaving the stats.db file family "
1924
- "untouched.",
1925
- file=sys.stderr,
1926
- )
1927
- return False
1928
- append_stats_heal_event(build_stats_heal_event(request, "confirmed"))
1929
- # Phase 1: reserve and persist, UNDER maintenance and the shared
1930
- # retention hold. The marker is durable when this returns
1931
- # `reserved`, which is what closes the bundle-to-request gap.
1932
- reservation = reserve_stats_corruption_heal(request)
1933
- if reservation != "reserved":
1934
- # #496 S6 §5.3, the SECOND terminal decision. The shared hold
1935
- # is about to be released over a forensics bundle the durable
1936
- # request marker does not name — it names the earlier
1937
- # detection's bundle. That is legitimate for the same reason a
1938
- # decline is: nothing will act on THIS bundle later. Rewriting
1939
- # the marker to name it instead would be worse, because a
1940
- # worker that has already read the marker would attribute its
1941
- # incident to a bundle it never captured, and the record would
1942
- # mix two detections. §3.3 then governs the stranded bundle as
1943
- # an unreferenced one, which self-classifies from its own
1944
- # `trigger.origin` and is reclaimable under the ordinary
1945
- # bounds. Terminalizing it here, inside the hold, is what makes
1946
- # the decision durable before the hold drops, and stops the
1947
- # ring entry sitting at `detected` forever.
1948
- update_stats_heal_event(
1949
- request["healId"],
1950
- outcome=(
1951
- "coalesced" if reservation == "pending"
1952
- else "admission-failed"
1953
- ),
1954
- )
1955
- finally:
1956
- # Retention is released first: it sits BELOW maintenance in the
1957
- # lock order, so it must not outlive the hold it was taken under.
1958
- retention.close()
1959
- # Maintenance released BEFORE spawning: the worker takes it
1960
- # EXCLUSIVE as a fresh process holding nothing, and a caller still
1961
- # holding it here would make that acquire wait for a request it is
1962
- # itself in the middle of filing.
1963
- _release_stats_maintenance_for_heal(maint_fd)
1964
- # Phase 3: spawn, under no lock at all.
1965
- outcome = complete_stats_corruption_heal(reservation)
1966
- # F15 (#496 S3 §7). Detachment supplies the timing for free: report at
1967
- # DETECTION, naming the absolute forensics path and the heal id. It
1968
- # cannot name an incident path, because the quarantine directory is
1969
- # allocated only during preservation, after the worker has chosen
1970
- # physical fallback and begun it; the worker adds that to the ring.
1971
- bundle = request.get("forensicsPath") or "unavailable"
1972
- print(
1973
- f"[heal] stats.db is corrupt ({exc}); nothing was replaced by this "
1974
- f"command. A rebuild from the journal was scheduled to run in the "
1975
- f"background as heal {request['healId']}. Forensics: {bundle}.",
1976
- file=sys.stderr,
1977
- )
1978
1903
  # Escalation is REPORT-ONLY: no halt, and no throttle beyond the
1979
1904
  # admission marker's existing retry interval. Halting auto-heal after
1980
1905
  # N occurrences was considered and rejected (§3 Q4).
@@ -1995,8 +1920,8 @@ def _stats_heal_hook(
1995
1920
  # partial report.
1996
1921
  raise _cctally_db.StatsHealDeferred(
1997
1922
  outcome,
1998
- heal_id=request["healId"],
1999
- forensics_path=request.get("forensicsPath"),
1923
+ heal_id=str(active_request.get("healId") or request["healId"]),
1924
+ forensics_path=active_request.get("forensicsPath"),
2000
1925
  )
2001
1926
  except Exception as heal_exc:
2002
1927
  print(f"[heal] stats.db auto-heal failed: {heal_exc}", file=sys.stderr)
@@ -2021,8 +1946,9 @@ HEAL_HOOK = _stats_heal_hook
2021
1946
  # #496 S3 §6 — the detached corruption heal
2022
1947
  # --------------------------------------------------------------------------
2023
1948
  #
2024
- # The hook writes forensics and files a REQUEST; a detached worker does the
2025
- # rebuild. Admission copies the three layers of `defer_stats_epoch_rebuild` — a
1949
+ # The hook files a preliminary REQUEST; a detached worker captures forensics
1950
+ # and does the rebuild. Admission copies the three layers of
1951
+ # `defer_stats_epoch_rebuild` — a
2026
1952
  # non-blocking admission flock whose loser returns immediately, a pending
2027
1953
  # marker with a retry window, and a worker-active probe that refreshes the
2028
1954
  # marker instead of spawning a duplicate — over its OWN files, so the two
@@ -2031,8 +1957,9 @@ HEAL_HOOK = _stats_heal_hook
2031
1957
  # The epoch path's marker is an empty touched file. This one is a durable JSON
2032
1958
  # document, because the worker runs later and in another process and needs
2033
1959
  # facts the hook established at detection: the heal id that correlates the
2034
- # durable event record, the trigger evidence, the forensics bundle, the
2035
- # journal information to revalidate, and — load-bearing — WHICH PROBE to run.
1960
+ # durable event record, the trigger evidence, the journal information to
1961
+ # revalidate, and — load-bearing — WHICH PROBE to run. The elected worker
1962
+ # enriches that same marker with its forensics bundle before publication.
2036
1963
  # A `post_query` detection was established by a failed `quick_check` against a
2037
1964
  # file SQLite opens happily, so a worker that always used the cheap readability
2038
1965
  # probe would exit on exactly the readable-but-corrupt population this
@@ -2041,6 +1968,8 @@ HEAL_HOOK = _stats_heal_hook
2041
1968
  STATS_CORRUPTION_HEAL_COMMAND = "_stats-corruption-heal"
2042
1969
  _STATS_HEAL_RETRY_SECONDS = 60.0
2043
1970
  _STATS_HEAL_WORKER_MAINTENANCE_WAIT_S = 120.0
1971
+ _STATS_HEAL_ADMISSION_WAIT_S = 0.75
1972
+ _STATS_HEAL_ADMISSION_RING_WAIT_S = 0.25
2044
1973
  _STATS_HEAL_PROBE_INTEGRITY = "integrity"
2045
1974
  _STATS_HEAL_PROBE_READABILITY = "readability"
2046
1975
 
@@ -2096,6 +2025,8 @@ def _build_stats_heal_request(
2096
2025
  exc, _cctally_db._FORENSICS_EXCEPTION_MESSAGE_MAX
2097
2026
  ),
2098
2027
  "triggerType": type(exc).__name__,
2028
+ "triggerSqliteErrorCode": getattr(exc, "sqlite_errorcode", None),
2029
+ "triggerSqliteErrorName": getattr(exc, "sqlite_errorname", None),
2099
2030
  "forensicsPath": (
2100
2031
  str(forensics.path)
2101
2032
  if forensics is not None and forensics.path is not None
@@ -2112,6 +2043,62 @@ def _build_stats_heal_request(
2112
2043
  }
2113
2044
 
2114
2045
 
2046
+ def _stats_heal_trigger_exception(request: dict) -> BaseException:
2047
+ """Reconstitute the classified SQLite trigger for worker-side forensics."""
2048
+ exc_type = getattr(
2049
+ sqlite3, str(request.get("triggerType") or "DatabaseError"),
2050
+ sqlite3.DatabaseError,
2051
+ )
2052
+ if not isinstance(exc_type, type) or not issubclass(
2053
+ exc_type, sqlite3.DatabaseError
2054
+ ):
2055
+ exc_type = sqlite3.DatabaseError
2056
+ exc = exc_type(str(request.get("triggerError") or "database is malformed"))
2057
+ for attr, key in (
2058
+ ("sqlite_errorcode", "triggerSqliteErrorCode"),
2059
+ ("sqlite_errorname", "triggerSqliteErrorName"),
2060
+ ):
2061
+ value = request.get(key)
2062
+ if value is not None:
2063
+ try:
2064
+ setattr(exc, attr, value)
2065
+ except (AttributeError, TypeError):
2066
+ pass
2067
+ return exc
2068
+
2069
+
2070
+ def _persist_stats_heal_request(request: dict) -> bool:
2071
+ """Durably enrich the elected request without changing its identity.
2072
+
2073
+ The worker calls this while holding maintenance, ingest and retention
2074
+ SHARED, after forensics and before any publication mutation. A crash after
2075
+ the write therefore leaves the durable marker naming the evidence bundle;
2076
+ a failed write declines recovery rather than stranding an unreferenced
2077
+ bundle and proceeding destructively.
2078
+ """
2079
+ fd = _heal_flock_bounded(_stats_heal_admission_path(), 10.0)
2080
+ if fd is None:
2081
+ return False
2082
+ try:
2083
+ current = _read_stats_heal_request()
2084
+ if not current or current.get("healId") != request.get("healId"):
2085
+ return False
2086
+ # Coalescers update the preliminary marker under admission. Preserve
2087
+ # their newest count when the worker enriches its older in-memory copy.
2088
+ request["coalescedDetections"] = int(
2089
+ current.get("coalescedDetections") or 0
2090
+ )
2091
+ try:
2092
+ _cctally_db._atomic_write_private_json(
2093
+ _stats_heal_marker_path(), request
2094
+ )
2095
+ except OSError:
2096
+ return False
2097
+ return True
2098
+ finally:
2099
+ _heal_release_flock(fd)
2100
+
2101
+
2115
2102
  def _read_stats_heal_request() -> "dict | None":
2116
2103
  try:
2117
2104
  payload = json.loads(_stats_heal_marker_path().read_text())
@@ -2189,68 +2176,126 @@ def _log_stats_heal(
2189
2176
  pass
2190
2177
 
2191
2178
 
2192
- def reserve_stats_corruption_heal(request: dict) -> str:
2193
- """Phase 1 of the deferral: decide admission and make the marker durable.
2194
-
2195
- #496 S6 §5.3 splits `defer_stats_corruption_heal` in two so the documented
2196
- lock order is satisfiable at all. Reservation — the admission flock, the
2197
- marker-age check, the worker-active probe and the fsynced marker write —
2198
- must happen while the caller still holds stats maintenance and the SHARED
2199
- retention lock, because the marker is what bridges the parent-to-worker
2200
- process boundary and tells reclamation that the forensics bundle it names
2201
- is still live. The spawn must happen AFTER maintenance is released,
2202
- because the worker takes maintenance exclusive as a fresh process.
2179
+ def _reserve_stats_corruption_heal(
2180
+ request: dict, *, admission_event: "dict | None" = None,
2181
+ ) -> "tuple[str, dict | None]":
2182
+ """Elect one recovery owner before any maintenance acquisition.
2203
2183
 
2204
- The heal ring cannot serve as that bridge: `_mutate_stats_heal_ring`
2205
- returns False on a mkdir failure, a bounded-lock timeout and an `OSError`
2206
- during the write, and both hook call sites discard that result so on a
2207
- legitimate failure path the ring entry does not exist and the bundle would
2208
- be unprotected.
2184
+ The preliminary marker bridges detector to worker and contains no
2185
+ forensics path yet. When supplied, ``admission_event`` is appended before
2186
+ the admission flock is released, so a coalescer cannot observe the marker
2187
+ before the one occurrence event it increments exists. The worker later
2188
+ captures forensics under maintenance/ingest/retention and durably enriches
2189
+ the same marker before any publication mutation.
2209
2190
 
2210
- Returns `reserved` (the marker is durable and the caller MUST spawn),
2211
- `pending` (coalesced onto an existing request) or `failed`.
2191
+ Returns ``(status, elected_request)``. The elected identity is read and any
2192
+ coalesced count is persisted while this function still owns admission; a
2193
+ caller never has to race a later marker read. Admission is bounded well
2194
+ below the old five-second maintenance wait. It succeeds only when both the
2195
+ marker and its required occurrence event are durable.
2212
2196
  """
2213
2197
  try:
2214
2198
  pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
2215
- admission_fd = os.open(
2216
- _stats_heal_admission_path(), os.O_WRONLY | os.O_CREAT, 0o600
2199
+ admission_fd = _heal_flock_bounded(
2200
+ _stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S
2217
2201
  )
2218
2202
  except OSError:
2219
- return "failed"
2203
+ return ("failed", None)
2204
+ if admission_fd is None:
2205
+ return ("failed", None)
2220
2206
  try:
2221
- try:
2222
- fcntl.flock(admission_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
2223
- except OSError:
2224
- return "pending"
2225
2207
  marker = _stats_heal_marker_path()
2226
2208
  try:
2227
2209
  age = time.time() - marker.stat().st_mtime
2228
2210
  except FileNotFoundError:
2229
2211
  age = None
2230
2212
  except OSError:
2231
- return "failed"
2213
+ return ("failed", None)
2232
2214
  if age is not None and age < _STATS_HEAL_RETRY_SECONDS:
2233
- return "pending"
2215
+ elected = _read_stats_heal_request()
2216
+ if not elected:
2217
+ return ("failed", None)
2218
+ elected = dict(elected)
2219
+ elected["coalescedDetections"] = int(
2220
+ elected.get("coalescedDetections") or 0
2221
+ ) + 1
2222
+ try:
2223
+ _cctally_db._atomic_write_private_json(marker, elected)
2224
+ except OSError:
2225
+ return ("failed", None)
2226
+ # The marker is the eventual source of truth for this count. Keep
2227
+ # the in-flight event current when its short prompt-safe ring bound
2228
+ # is available; the worker reconciles it before marker removal.
2229
+ update_stats_heal_event(
2230
+ str(elected.get("healId") or ""),
2231
+ wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
2232
+ coalescedDetections=elected["coalescedDetections"],
2233
+ )
2234
+ return ("pending", elected)
2234
2235
  if _stats_heal_worker_active():
2235
2236
  # A real rebuild outlives the marker retry interval. Refresh the
2236
2237
  # admission stamp instead of launching a process that can only lose
2237
2238
  # the worker flock and exit.
2239
+ elected = _read_stats_heal_request()
2240
+ if not elected:
2241
+ return ("failed", None)
2242
+ elected = dict(elected)
2243
+ elected["coalescedDetections"] = int(
2244
+ elected.get("coalescedDetections") or 0
2245
+ ) + 1
2238
2246
  try:
2239
- os.utime(marker, None)
2247
+ _cctally_db._atomic_write_private_json(marker, elected)
2240
2248
  except OSError:
2241
- pass
2242
- return "pending"
2249
+ return ("failed", None)
2250
+ update_stats_heal_event(
2251
+ str(elected.get("healId") or ""),
2252
+ wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
2253
+ coalescedDetections=elected["coalescedDetections"],
2254
+ )
2255
+ return ("pending", elected)
2256
+ stale_request = _read_stats_heal_request() if age is not None else None
2257
+ if stale_request:
2258
+ if not update_stats_heal_event(
2259
+ str(stale_request.get("healId") or ""),
2260
+ wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
2261
+ outcome="stale-retried",
2262
+ coalescedDetections=int(
2263
+ stale_request.get("coalescedDetections") or 0
2264
+ ),
2265
+ ):
2266
+ return ("failed", None)
2267
+ admitted = dict(request)
2268
+ admitted["coalescedDetections"] = 0
2243
2269
  try:
2244
- _cctally_db._atomic_write_private_json(marker, request)
2270
+ _cctally_db._atomic_write_private_json(marker, admitted)
2245
2271
  except OSError:
2246
- return "failed"
2247
- return "reserved"
2272
+ return ("failed", None)
2273
+ # Keep the marker and its one occurrence event inside the same
2274
+ # admission critical section. A coalescing detector cannot observe the
2275
+ # marker before the event it must increment exists.
2276
+ event = admission_event or {
2277
+ **build_stats_heal_event(admitted, "pending-classification"),
2278
+ "outcome": "admitted",
2279
+ "coalescedDetections": 0,
2280
+ }
2281
+ if not append_stats_heal_event(
2282
+ event, wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S
2283
+ ):
2284
+ _unlink_stats_heal_marker()
2285
+ return ("failed", None)
2286
+ return ("reserved", admitted)
2248
2287
  finally:
2249
- try:
2250
- fcntl.flock(admission_fd, fcntl.LOCK_UN)
2251
- except OSError:
2252
- pass
2253
- os.close(admission_fd)
2288
+ _heal_release_flock(admission_fd)
2289
+
2290
+
2291
+ def reserve_stats_corruption_heal(
2292
+ request: dict, *, admission_event: "dict | None" = None,
2293
+ ) -> str:
2294
+ """Compatibility wrapper returning only the admission status."""
2295
+ status, _elected = _reserve_stats_corruption_heal(
2296
+ request, admission_event=admission_event,
2297
+ )
2298
+ return status
2254
2299
 
2255
2300
 
2256
2301
  def complete_stats_corruption_heal(reservation: str) -> str:
@@ -2275,33 +2320,68 @@ def complete_stats_corruption_heal(reservation: str) -> str:
2275
2320
  return "failed"
2276
2321
 
2277
2322
 
2278
- def _unlink_stats_heal_marker_under_admission() -> None:
2323
+ def _unlink_stats_heal_marker_under_admission() -> bool:
2324
+ """Settle a spawn failure with the final count before allowing retry."""
2325
+ admission_fd = _heal_flock_bounded(
2326
+ _stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S
2327
+ )
2328
+ if admission_fd is None:
2329
+ return False
2279
2330
  try:
2280
- admission_fd = os.open(
2281
- _stats_heal_admission_path(), os.O_WRONLY | os.O_CREAT, 0o600
2282
- )
2283
- except OSError:
2284
- return
2331
+ current = _read_stats_heal_request()
2332
+ if current:
2333
+ heal_id = str(current.get("healId") or "")
2334
+ if not update_stats_heal_event(
2335
+ heal_id,
2336
+ wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
2337
+ outcome="spawn-failed",
2338
+ coalescedDetections=int(
2339
+ current.get("coalescedDetections") or 0
2340
+ ),
2341
+ ):
2342
+ return False
2343
+ _unlink_stats_heal_marker()
2344
+ return True
2345
+ finally:
2346
+ _heal_release_flock(admission_fd)
2347
+
2348
+
2349
+ def _finalize_stats_heal_request(heal_id: str) -> bool:
2350
+ """Reconcile the elected count and remove its marker atomically.
2351
+
2352
+ A coalescer may update the marker after the worker's in-memory request was
2353
+ read. The worker therefore takes admission once more, copies the final
2354
+ count into the already-durable occurrence event, and only then unlinks the
2355
+ marker. If the event cannot be updated, the marker stays retryable.
2356
+ """
2357
+ fd = _heal_flock_bounded(_stats_heal_admission_path(), 10.0)
2358
+ if fd is None:
2359
+ return False
2285
2360
  try:
2286
- try:
2287
- fcntl.flock(admission_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
2288
- except OSError:
2289
- return
2361
+ current = _read_stats_heal_request()
2362
+ if not current:
2363
+ return True
2364
+ if str(current.get("healId") or "") != heal_id:
2365
+ return False
2366
+ if not update_stats_heal_event(
2367
+ heal_id,
2368
+ coalescedDetections=int(
2369
+ current.get("coalescedDetections") or 0
2370
+ ),
2371
+ ):
2372
+ return False
2290
2373
  _unlink_stats_heal_marker()
2374
+ return True
2291
2375
  finally:
2292
- try:
2293
- fcntl.flock(admission_fd, fcntl.LOCK_UN)
2294
- except OSError:
2295
- pass
2296
- os.close(admission_fd)
2376
+ _heal_release_flock(fd)
2297
2377
 
2298
2378
 
2299
2379
  def defer_stats_corruption_heal(request: dict) -> str:
2300
2380
  """Schedule one retryable detached corruption heal without blocking.
2301
2381
 
2302
- The composed form of the two phases above, kept for callers that hold no
2303
- maintenance lock and therefore have no handoff to bridge. The auto-heal
2304
- hook calls the two phases separately (§5.3).
2382
+ The composed form of the two phases above, kept for direct callers. The
2383
+ auto-heal hook calls the phases separately so it can attach the admission
2384
+ event and report the elected occurrence.
2305
2385
  """
2306
2386
  return complete_stats_corruption_heal(
2307
2387
  reserve_stats_corruption_heal(request)
@@ -2358,22 +2438,73 @@ def _run_stats_corruption_heal(request: dict) -> str:
2358
2438
  )
2359
2439
  if ingest_fd is None:
2360
2440
  return "ingest-busy"
2361
- # #496 S6 §5.3, the worker's phase: SHARED after maintenance and
2362
- # ingest, before publication, released after the final rebuild record.
2363
- # The request marker is cleared last, by the caller, once this returns.
2441
+ # #530 moves the complete evidence/classification phase into the elected
2442
+ # worker. It owns maintenance -> ingest -> retention before capturing
2443
+ # forensics, then durably enriches the request marker before any
2444
+ # publication mutation. The marker is cleared last by the caller.
2364
2445
  import _cctally_retention
2365
2446
 
2366
2447
  try:
2367
- with _cctally_retention.retention_shared(label="stats heal worker"), \
2368
- stats_write_scope("maintenance-heal"):
2369
- result = _cctally_journal.rebuild_stats_index(
2370
- context=_cctally_journal.RebuildContext(
2371
- trigger="corruption-heal",
2372
- trigger_error=str(request.get("triggerError") or ""),
2373
- forensics_path=request.get("forensicsPath"),
2448
+ with _cctally_retention.retention_shared(label="stats heal worker"):
2449
+ trigger_exc = _stats_heal_trigger_exception(request)
2450
+ forensics = _cctally_db.write_corruption_forensics(
2451
+ path,
2452
+ db_label="stats",
2453
+ trigger_origin="corruption-heal",
2454
+ trigger_exception=trigger_exc,
2455
+ return_result=True,
2456
+ )
2457
+ enriched = dict(request)
2458
+ enriched["forensicsPath"] = (
2459
+ str(forensics.path)
2460
+ if forensics is not None and forensics.path is not None
2461
+ else None
2462
+ )
2463
+ enriched["forensicsDisposition"] = (
2464
+ forensics.disposition.value
2465
+ if forensics is not None else None
2466
+ )
2467
+ confirmed = (
2468
+ forensics is not None
2469
+ and forensics.disposition
2470
+ is _cctally_db.CorruptionProbeDisposition.CONFIRMED
2471
+ and forensics.path is not None
2472
+ )
2473
+ disposition = "confirmed" if confirmed else "unconfirmed"
2474
+ reason = (
2475
+ forensics.reason if forensics is not None else "unavailable"
2476
+ )
2477
+ if not _persist_stats_heal_request(enriched):
2478
+ update_stats_heal_event(
2479
+ heal_id,
2480
+ disposition=disposition,
2481
+ forensicsPath=enriched.get("forensicsPath"),
2482
+ outcome="request-persist-failed",
2483
+ declineReason="request marker could not be enriched",
2484
+ )
2485
+ return "request-persist-failed"
2486
+ request = enriched
2487
+ update_stats_heal_event(
2488
+ heal_id,
2489
+ disposition=disposition,
2490
+ forensicsPath=request.get("forensicsPath"),
2491
+ coalescedDetections=int(
2492
+ request.get("coalescedDetections") or 0
2374
2493
  ),
2375
- high_water=high_water,
2494
+ outcome=("confirmed" if confirmed else "declined-unconfirmed"),
2495
+ declineReason=(None if confirmed else reason),
2376
2496
  )
2497
+ if not confirmed:
2498
+ return "declined-unconfirmed"
2499
+ with stats_write_scope("maintenance-heal"):
2500
+ result = _cctally_journal.rebuild_stats_index(
2501
+ context=_cctally_journal.RebuildContext(
2502
+ trigger="corruption-heal",
2503
+ trigger_error=str(request.get("triggerError") or ""),
2504
+ forensics_path=request.get("forensicsPath"),
2505
+ ),
2506
+ high_water=high_water,
2507
+ )
2377
2508
  finally:
2378
2509
  if ingest_fd is not None:
2379
2510
  _heal_release_flock(ingest_fd)
@@ -2418,7 +2549,9 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2418
2549
  return 0
2419
2550
  if outcome != "success":
2420
2551
  _record_stats_heal_outcome(heal_id, outcome)
2421
- _unlink_stats_heal_marker()
2552
+ if not _finalize_stats_heal_request(heal_id):
2553
+ _log_stats_heal("finalization-pending", heal_id=heal_id)
2554
+ return 0
2422
2555
  _log_stats_heal(outcome, heal_id=heal_id)
2423
2556
  return 0
2424
2557
  finally:
@@ -2545,14 +2678,16 @@ def _write_stats_heal_ring(events: list) -> None:
2545
2678
  )
2546
2679
 
2547
2680
 
2548
- def _mutate_stats_heal_ring(mutate) -> bool:
2681
+ def _mutate_stats_heal_ring(
2682
+ mutate, *, wait_s: float = _STATS_HEAL_RING_WAIT_S,
2683
+ ) -> bool:
2549
2684
  """Read-modify-write the ring under a BOUNDED wait for its lock."""
2550
2685
  try:
2551
2686
  pathlib.Path(_cctally_core.LOG_DIR).mkdir(parents=True, exist_ok=True)
2552
2687
  except OSError:
2553
2688
  return False
2554
2689
  fd = _heal_flock_bounded(
2555
- _stats_heal_ring_lock_path(), _STATS_HEAL_RING_WAIT_S
2690
+ _stats_heal_ring_lock_path(), wait_s
2556
2691
  )
2557
2692
  if fd is None:
2558
2693
  # Loud, never silent: the ring is the accountability guarantee, so a
@@ -2577,16 +2712,20 @@ def _mutate_stats_heal_ring(mutate) -> bool:
2577
2712
  _heal_release_flock(fd)
2578
2713
 
2579
2714
 
2580
- def append_stats_heal_event(entry: dict) -> bool:
2715
+ def append_stats_heal_event(
2716
+ entry: dict, *, wait_s: float = _STATS_HEAL_RING_WAIT_S,
2717
+ ) -> bool:
2581
2718
  """Append one detection entry. Bounded by count, oldest dropped first."""
2582
2719
  def mutate(events):
2583
2720
  events.append(entry)
2584
2721
  return events
2585
2722
 
2586
- return _mutate_stats_heal_ring(mutate)
2723
+ return _mutate_stats_heal_ring(mutate, wait_s=wait_s)
2587
2724
 
2588
2725
 
2589
- def update_stats_heal_event(heal_id: str, **fields) -> bool:
2726
+ def update_stats_heal_event(
2727
+ heal_id: str, *, wait_s: float = _STATS_HEAL_RING_WAIT_S, **fields,
2728
+ ) -> bool:
2590
2729
  """Update the entry MATCHING ``heal_id``, and no other.
2591
2730
 
2592
2731
  Admission coalesces several detections into one run, so an update keyed by
@@ -2604,6 +2743,24 @@ def update_stats_heal_event(heal_id: str, **fields) -> bool:
2604
2743
  return events
2605
2744
  return None
2606
2745
 
2746
+ return _mutate_stats_heal_ring(mutate, wait_s=wait_s)
2747
+
2748
+
2749
+ def record_stats_heal_coalesced(heal_id: str) -> bool:
2750
+ """Count one losing detector on the elected occurrence's single event."""
2751
+ if not heal_id:
2752
+ return False
2753
+
2754
+ def mutate(events):
2755
+ for event in events:
2756
+ if event.get("healId") == heal_id:
2757
+ event["coalescedDetections"] = (
2758
+ int(event.get("coalescedDetections") or 0) + 1
2759
+ )
2760
+ event["updatedAtUtc"] = _cctally_core.now_utc_iso()
2761
+ return events
2762
+ return None
2763
+
2607
2764
  return _mutate_stats_heal_ring(mutate)
2608
2765
 
2609
2766
 
@@ -2854,24 +3011,29 @@ def cmd_stats_epoch_rebuild_internal(args) -> int:
2854
3011
 
2855
3012
 
2856
3013
  def _raw_user_version(path) -> int:
2857
- """Read ``PRAGMA user_version`` via a raw RO connect (no open_db side
2858
- effects). -1 when the file cannot be read."""
3014
+ """Read the SQLite header's user_version without opening the family.
3015
+
3016
+ A raw read-only SQLite connection is not side-effect-free for a WAL
3017
+ database: it can create SHM/WAL paths and, more importantly, it bypasses
3018
+ the maintenance-shared opener fence. The deferred-epoch probe needs only
3019
+ the fixed big-endian header field, so read exactly that instead.
3020
+ """
2859
3021
  try:
2860
- c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
2861
- try:
2862
- return c.execute("PRAGMA user_version").fetchone()[0]
2863
- finally:
2864
- c.close()
2865
- except sqlite3.DatabaseError:
3022
+ with pathlib.Path(path).open("rb") as handle:
3023
+ header = handle.read(64)
3024
+ except OSError:
3025
+ return -1
3026
+ if len(header) < 64 or header[:16] != b"SQLite format 3\x00":
2866
3027
  return -1
3028
+ return int.from_bytes(header[60:64], "big", signed=False)
2867
3029
 
2868
3030
 
2869
3031
  def stats_epoch_rebuild_pending(path=None) -> bool:
2870
3032
  """Whether an ordinary live open would require whole-journal replay.
2871
3033
 
2872
- Missing indexes are cheap fresh installs. Unreadable indexes belong to the
3034
+ Missing indexes are cheap fresh installs. Invalid headers belong to the
2873
3035
  corruption classifier, and legacy indexes take the one-time cutover path.
2874
- Only a readable post-legacy index at a non-current epoch is deferrable.
3036
+ Only a post-legacy main-file header at a non-current epoch is deferrable.
2875
3037
  """
2876
3038
  candidate = pathlib.Path(
2877
3039
  _cctally_core.DB_PATH if path is None else path
@@ -2908,6 +3070,12 @@ def resolve_stats_epoch_mismatch():
2908
3070
  maint_fd = _acquire_stats_maintenance_reentrant(
2909
3071
  _cctally_core.STATS_LOCK_MAINTENANCE_PATH)
2910
3072
  try:
3073
+ # #538: this is the first operation after maintenance-exclusive
3074
+ # ownership and it performs no SQLite open. A previous-epoch
3075
+ # dashboard may still hold the WAL/SHM mapping; applying the new
3076
+ # DELETE policy before proving a cold family would attempt an
3077
+ # in-place journal-mode conversion under that old handle.
3078
+ _require_stats_legacy_wal_cold(path)
2911
3079
  # Locked re-check: a sibling process may have already rebuilt it.
2912
3080
  if _raw_user_version(path) != _cctally_core.STATS_INDEX_EPOCH:
2913
3081
  hw, journal_has_bytes = (