cctally 1.92.1 → 1.92.3

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,6 +40,7 @@ from dataclasses import dataclass, field, replace as _dc_replace
40
40
  import _cctally_core
41
41
  import _lib_accounts
42
42
  import _lib_journal
43
+ import _lib_journal_router
43
44
  import _lib_record
44
45
 
45
46
 
@@ -178,12 +179,10 @@ def _repair_torn_tail(fd: int) -> None:
178
179
  # public append surface
179
180
  # --------------------------------------------------------------------------
180
181
 
181
- def _is_codex_quota_obs(record: dict) -> bool:
182
- return (
183
- record.get("t") == "obs"
184
- and record.get("provider") == "codex"
185
- and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
186
- )
182
+ # NOTE: `_is_codex_quota_obs` is defined ONCE, further down beside
183
+ # `_QUOTA_OBS_KIND`. A duplicate definition used to sit here and was shadowed by
184
+ # that one at import time, so it was dead code an edit here would silently not
185
+ # reach (#496 S4). Do not reintroduce a second definition.
187
186
 
188
187
 
189
188
  def _codex_quota_natural_key(record: dict) -> str | None:
@@ -244,7 +243,7 @@ def _load_quota_dedup_keys() -> None:
244
243
  _QUOTA_DEDUP_KEYS.clear()
245
244
 
246
245
  for name in list_segments():
247
- with (journal_dir / name).open("rb") as fh:
246
+ with _open_segment_for_read(journal_dir / name) as fh:
248
247
  for raw in fh:
249
248
  if not raw.endswith(b"\n"):
250
249
  break
@@ -952,13 +951,38 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
952
951
  _SEGMENT_READ_CHUNK = 256 * 1024
953
952
 
954
953
 
955
- def _iter_segment_lines(seg_path, lo: int, hi: int):
954
+ def _open_segment_for_read(seg_path):
955
+ """The single physical read boundary for a journal segment.
956
+
957
+ Both read routes go through here — the streaming line reader and the
958
+ prefix hasher — so a test can observe the exact sequence of segment opens
959
+ a pass performs. A per-pass line or byte counter cannot: it stays correct
960
+ while an implementation reopens a segment behind it, which is precisely the
961
+ hidden re-read this session removes (#496 S5 §4.2).
962
+
963
+ The cutover's bootstrap-reuse digest and the quota dedupe-index rebuild are
964
+ the module's other two read-only segment scans, and they come through here
965
+ as well, so "every physical read of a segment" is a property a test can
966
+ check rather than a claim in prose. The append path is deliberately NOT
967
+ routed here: it holds a read-write handle of its own for the torn-tail scan.
968
+ """
969
+ return open(seg_path, "rb")
970
+
971
+
972
+ def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
956
973
  """Stream `(basename, absolute-offset, raw-line-without-newline)` for every
957
974
  complete line in `[lo, hi)`, holding at most one chunk plus one partial line
958
975
  in memory. `hi` is a line boundary (a HW snapshot size or an immutable prior
959
- segment's full size), so no partial trailing line appears."""
976
+ segment's full size), so no partial trailing line appears.
977
+
978
+ `on_bytes` receives each chunk exactly as it is read, before any line
979
+ splitting. It exists so a caller can reproduce `journal_prefix_hash` from
980
+ the bytes this pass is already reading (#496 S4 §5.2) rather than re-reading
981
+ the segment; it must therefore see the raw `[lo, hi)` range verbatim,
982
+ including a torn trailing partial line that is never yielded.
983
+ """
960
984
  name = seg_path.name
961
- with open(seg_path, "rb") as fh:
985
+ with _open_segment_for_read(seg_path) as fh:
962
986
  fh.seek(lo)
963
987
  pos = lo
964
988
  buf = b""
@@ -968,6 +992,8 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
968
992
  if not data:
969
993
  break
970
994
  pos += len(data)
995
+ if on_bytes is not None:
996
+ on_bytes(data)
971
997
  buf = buf + data if buf else data
972
998
  start = 0
973
999
  while True:
@@ -981,14 +1007,6 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
981
1007
  buf_at += start
982
1008
 
983
1009
 
984
- def _read_segment_lines(seg_path, lo: int, hi: int) -> list[tuple[str, int, bytes]]:
985
- """Materialized form of :func:`_iter_segment_lines` (see it for the
986
- contract). Callers that walk a whole range at once should prefer
987
- :func:`iter_range`; this list form is retained for the ingest cycle, which
988
- needs the batch as an indexable sequence."""
989
- return list(_iter_segment_lines(seg_path, lo, hi))
990
-
991
-
992
1010
  def iter_range(cursor, hw):
993
1011
  """Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
994
1012
 
@@ -1002,8 +1020,26 @@ def iter_range(cursor, hw):
1002
1020
  materialized form for the ingest cycle, which genuinely needs the batch as
1003
1021
  an indexable sequence (prefix-stop indices address into it).
1004
1022
  """
1023
+ yield from _iter_range_with_segments(cursor, hw, list_segments())
1024
+
1025
+
1026
+ def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
1027
+ on_bytes=None):
1028
+ """`iter_range` over a segment list the CALLER snapshotted (#496 S4 §4).
1029
+
1030
+ `list_segments()` enumerates the journal directory at call time and orders
1031
+ bootstrap segments before observation segments, so a bootstrap segment
1032
+ appearing mid-rebuild would insert ahead of the high-water segment and shift
1033
+ the indices this function addresses by. A rebuild takes ONE snapshot at its
1034
+ pinned high-water and drives every pass from it, so two passes of the same
1035
+ rebuild cannot disagree about the journal's shape.
1036
+
1037
+ `on_segment` is called for EVERY segment in the range, including one this
1038
+ function then skips because it holds no bytes in range: `journal_prefix_hash`
1039
+ frames a zero-byte segment, so a hash accumulator has to be told it exists.
1040
+ `on_bytes` is forwarded to `_iter_segment_lines`.
1041
+ """
1005
1042
  hw_seg, hw_size = hw
1006
- segments = list_segments()
1007
1043
  if hw_seg not in segments:
1008
1044
  return
1009
1045
  hw_idx = segments.index(hw_seg)
@@ -1020,9 +1056,11 @@ def iter_range(cursor, hw):
1020
1056
  seg_path = _cctally_core.JOURNAL_DIR / seg
1021
1057
  lo = start_off if idx == start_idx else 0
1022
1058
  hi = hw_size if idx == hw_idx else os.path.getsize(seg_path)
1059
+ if on_segment is not None:
1060
+ on_segment(seg)
1023
1061
  if lo >= hi:
1024
1062
  continue
1025
- yield from _iter_segment_lines(seg_path, lo, hi)
1063
+ yield from _iter_segment_lines(seg_path, lo, hi, on_bytes=on_bytes)
1026
1064
 
1027
1065
 
1028
1066
  def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
@@ -1031,7 +1069,13 @@ def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
1031
1069
 
1032
1070
 
1033
1071
  def journal_prefix_hash(high_water) -> "str | None":
1034
- """Hash exact raw segment bytes through one canonical high-water."""
1072
+ """Hash exact raw segment bytes through one canonical high-water.
1073
+
1074
+ The framing is durable: per segment, the 4-byte big-endian name length, the
1075
+ name, the 8-byte big-endian data length, then the data. These digests are
1076
+ recorded inside `journal_protocol_resolution` payloads, so a change to the
1077
+ framing invalidates every acknowledgement already written.
1078
+ """
1035
1079
  if high_water is None:
1036
1080
  return None
1037
1081
  digest = hashlib.sha256()
@@ -1039,7 +1083,8 @@ def journal_prefix_hash(high_water) -> "str | None":
1039
1083
  for segment in list_segments():
1040
1084
  path = _cctally_core.JOURNAL_DIR / segment
1041
1085
  size = high_water[1] if segment == high_water[0] else path.stat().st_size
1042
- data = path.read_bytes()[:size]
1086
+ with _open_segment_for_read(path) as handle:
1087
+ data = handle.read(size)
1043
1088
  if len(data) != size:
1044
1089
  raise OSError(f"journal segment changed while reading: {segment}")
1045
1090
  name = segment.encode("utf-8")
@@ -1057,20 +1102,28 @@ def journal_prefix_hash(high_water) -> "str | None":
1057
1102
  return "sha256:" + digest.hexdigest()
1058
1103
 
1059
1104
 
1060
- def _capture_protocol_prefix_evidence(record, prior_high_water, evidence) -> None:
1061
- """Capture the actual raw prefix immediately preceding one audit record."""
1105
+ def _capture_protocol_prefix_evidence(
1106
+ record, prior_high_water, evidence, hasher=None
1107
+ ) -> None:
1108
+ """Capture the actual raw prefix immediately preceding one audit record.
1109
+
1110
+ `hasher` is a `_lib_journal_router.PrefixHashAccumulator` fed by the caller's
1111
+ streaming pass. When supplied, the digest comes from bytes that pass has
1112
+ already read; otherwise `journal_prefix_hash` re-reads the whole prefix from
1113
+ disk, which is what the streaming callers exist to avoid (#496 S4 §5.2). The
1114
+ two produce the identical durable digest.
1115
+ """
1062
1116
  if (
1063
1117
  record.get("t") == "op"
1064
1118
  and isinstance(record.get("payload"), dict)
1065
1119
  and record["payload"].get("kind")
1066
1120
  == _lib_journal._PROTOCOL_RESOLUTION_KIND
1067
1121
  ):
1068
- evidence.append(
1069
- (
1070
- prior_high_water,
1071
- journal_prefix_hash(prior_high_water),
1072
- )
1122
+ digest = (
1123
+ hasher.digest_at(prior_high_water) if hasher is not None
1124
+ else journal_prefix_hash(prior_high_water)
1073
1125
  )
1126
+ evidence.append((prior_high_water, digest))
1074
1127
 
1075
1128
 
1076
1129
  # --------------------------------------------------------------------------
@@ -2017,6 +2070,8 @@ def _derive_account_last_seen(conn, records) -> None:
2017
2070
  prior observe already created (never invents an account row)."""
2018
2071
  latest: dict = {}
2019
2072
  for rec in records:
2073
+ if rec is None:
2074
+ continue
2020
2075
  key = _account_of(rec)
2021
2076
  at = rec.get("at")
2022
2077
  if not key or not at:
@@ -2024,6 +2079,17 @@ def _derive_account_last_seen(conn, records) -> None:
2024
2079
  prev = latest.get(key)
2025
2080
  if prev is None or at > prev:
2026
2081
  latest[key] = at
2082
+ _apply_account_last_seen(conn, latest)
2083
+
2084
+
2085
+ def _apply_account_last_seen(conn, latest) -> None:
2086
+ """Apply a precomputed `{account_key: max_at}` map.
2087
+
2088
+ Split out so the rebuild can accumulate the map during its single streaming
2089
+ pass (#496 S4 §4.2) instead of walking every record again inside the
2090
+ publication transaction. The rebuild's retained list no longer contains
2091
+ observations at all, so calling `_derive_account_last_seen` over it would
2092
+ silently drop every observation's contribution."""
2027
2093
  for key, at in latest.items():
2028
2094
  conn.execute(
2029
2095
  "UPDATE accounts SET last_seen_utc = ? WHERE account_key = ? "
@@ -3428,6 +3494,10 @@ def _correction_commit_high_water(batch_id, hw=None):
3428
3494
  selector or by the live metadata row that names it. The earliest matching
3429
3495
  commit is the narrowest complete prefix and remains stable even when later
3430
3496
  journal bytes or crash-replayed duplicate markers exist.
3497
+
3498
+ Streams rather than materializing (#496 S4): the previous form built the
3499
+ whole prefix through `_read_range` before its first-match return, so a
3500
+ marker in the first segment still paid for every later one.
3431
3501
  """
3432
3502
  if not batch_id:
3433
3503
  return None
@@ -3435,7 +3505,7 @@ def _correction_commit_high_water(batch_id, hw=None):
3435
3505
  hw = journal_high_water()
3436
3506
  if hw is None:
3437
3507
  return None
3438
- for segment, offset, raw in _read_range(None, hw):
3508
+ for segment, offset, raw in iter_range(None, hw):
3439
3509
  record = _lib_journal.decode_line(raw)
3440
3510
  if (
3441
3511
  record is not None
@@ -4259,6 +4329,25 @@ class RebuildResult:
4259
4329
  # batches remain tainted; this is diagnostic/audit state, never validity.
4260
4330
  acknowledged_protocol_violations: tuple = ()
4261
4331
  quarantine_dir: "pathlib.Path | None" = None
4332
+ # #496 S4 §8.7 — ADDITIVE instrumentation. Names, units and pass boundaries
4333
+ # are fixed by the spec so the gate's assertions are unambiguous; adding
4334
+ # them does not bump the rebuild record's `schemaVersion` and no existing
4335
+ # field changes meaning.
4336
+ #: float seconds per phase. Keys: journal_read_decode, cutover_suffix,
4337
+ #: protocol_evidence, effective_selection, quota_cache_leg, stats_fold,
4338
+ #: scratch_validate, publication. The phases are DISJOINT — evidence hashing
4339
+ #: happens inside the read loop and is subtracted from journal_read_decode.
4340
+ phase_seconds: dict = field(default_factory=dict)
4341
+ #: per named pass, `{lines, bytes, decodes}`. Passes: stats_prefix (the
4342
+ #: router), cutover_suffix (zero unless the §5.1 fallback ran),
4343
+ #: protocol_evidence (`bytes` hashed and `lines` digests computed, both zero
4344
+ #: on a journal with no resolution op), quota_replay (the in-leg decode,
4345
+ #: where `bytes` is the retained byte total and `lines` equals `decodes`).
4346
+ traversal: dict = field(default_factory=dict)
4347
+ #: `tracemalloc` peak over the pre-publication window; 0 when not tracing.
4348
+ peak_heap_bytes: int = 0
4349
+ #: cache writer flock acquisition to release, in seconds.
4350
+ quota_lock_hold_seconds: float = 0.0
4262
4351
 
4263
4352
 
4264
4353
  def _remove_db_sidecars_strict(path) -> None:
@@ -4482,26 +4571,56 @@ def stats_index_matches_journal_prefix(
4482
4571
  conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
4483
4572
  try:
4484
4573
  _validate_rebuilt_stats_index(conn, high_water)
4485
- decoded: list[dict] = []
4574
+ # Same streaming router as the rebuild (#496 S4 §7). This function's
4575
+ # only output is a selection compared against
4576
+ # `journal_effective_events`, so it needs the decision records and
4577
+ # nothing else: no observation retention, no quota bytes, no second
4578
+ # from-byte-zero cutover scan. The `None` placeholders keep every
4579
+ # `enumerate` sequence — and therefore every violation fingerprint —
4580
+ # identical to what the rebuild wrote.
4581
+ decoded: list = []
4486
4582
  protocol_evidence = []
4487
4583
  prior_high_water = None
4584
+ cutover_captured = _CUTOVER_UNSEEN
4585
+ all_segments = list_segments()
4586
+ segments = all_segments
4587
+ hasher = _lib_journal_router.PrefixHashAccumulator()
4488
4588
  if high_water is not None:
4489
- for segment, offset, raw in _read_range(None, high_water):
4589
+ if high_water[0] in segments:
4590
+ segments = segments[:segments.index(high_water[0]) + 1]
4591
+ for segment, offset, raw in _iter_range_with_segments(
4592
+ None, high_water, segments,
4593
+ on_segment=lambda name: hasher.begin_segment(
4594
+ name, prior_high_water),
4595
+ on_bytes=hasher.extend,
4596
+ ):
4490
4597
  record = _lib_journal.decode_line(raw)
4491
4598
  if record is not None:
4492
4599
  _capture_protocol_prefix_evidence(
4493
4600
  record,
4494
4601
  prior_high_water,
4495
4602
  protocol_evidence,
4603
+ hasher=hasher,
4604
+ )
4605
+ if (cutover_captured is _CUTOVER_UNSEEN
4606
+ and record.get("id") == CUTOVER_OP_ID):
4607
+ cutover_captured = _cutover_value_of(record)
4608
+ decoded.append(
4609
+ record
4610
+ if record.get("t")
4611
+ in _lib_journal_router.RETAINED_RECORD_TYPES
4612
+ else None
4496
4613
  )
4497
- decoded.append(record)
4498
4614
  prior_high_water = (
4499
4615
  segment,
4500
4616
  offset + len(raw) + 1,
4501
4617
  )
4502
- cutover_claude = resolve_cutover_claude_account()
4618
+ hasher = None
4619
+ cutover_claude = _resolve_cutover_for_rebuild(
4620
+ cutover_captured, high_water, all_segments)
4503
4621
  for record in decoded:
4504
- _normalize_legacy_account_stamp(record, cutover_claude)
4622
+ if record is not None:
4623
+ _normalize_legacy_account_stamp(record, cutover_claude)
4505
4624
  selection = _lib_journal.resolve_effective_events(
4506
4625
  decoded,
4507
4626
  protocol_prefix_evidence=protocol_evidence,
@@ -5449,6 +5568,51 @@ def _publish_stats_index_in_place(
5449
5568
  return _FALL_BACK
5450
5569
  raise
5451
5570
 
5571
+ # Readability is not structural health. An integrity failure may consist
5572
+ # only of pages which no sqlite_schema object and no freelist entry names.
5573
+ # The table-by-table in-place swap cannot discover or reclaim such pages,
5574
+ # so publishing into that file would preserve the damage and fail its
5575
+ # post-publication verdict forever. Use the independently validated scratch
5576
+ # as a physical replacement before any live mutation instead.
5577
+ try:
5578
+ destination_integrity = [
5579
+ str(row[0]) for row in conn.execute("PRAGMA integrity_check")
5580
+ ]
5581
+ except BaseException as exc:
5582
+ try:
5583
+ conn.close()
5584
+ except Exception:
5585
+ pass
5586
+ if sp.may_fall_back_to_replacement(exc):
5587
+ print(
5588
+ "[rebuild] the live stats index failed its integrity probe "
5589
+ f"({exc}); publishing by replacement instead",
5590
+ file=sys.stderr,
5591
+ )
5592
+ record["inPlaceAttempt"] = {
5593
+ "phase": sp.PRE_COMMIT,
5594
+ "stage": "destination_integrity",
5595
+ "error": f"{type(exc).__name__}: {exc}"[:500],
5596
+ }
5597
+ return _FALL_BACK
5598
+ raise
5599
+ if destination_integrity != ["ok"]:
5600
+ try:
5601
+ conn.close()
5602
+ except Exception:
5603
+ pass
5604
+ print(
5605
+ "[rebuild] the live stats index failed integrity_check; "
5606
+ "publishing by replacement instead",
5607
+ file=sys.stderr,
5608
+ )
5609
+ record["inPlaceAttempt"] = {
5610
+ "phase": sp.PRE_COMMIT,
5611
+ "stage": "destination_integrity",
5612
+ "error": "destination failed integrity_check",
5613
+ }
5614
+ return _FALL_BACK
5615
+
5452
5616
  started_at = _utc_iso_now()
5453
5617
  record_path = pathlib.Path(context.record_path)
5454
5618
  live = dict(record)
@@ -5559,9 +5723,10 @@ def _publish_rebuilt_stats_index(
5559
5723
 
5560
5724
  In-place transactional publication is the mechanism (#496 S3). Physical
5561
5725
  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.
5726
+ on structurally or fails an integrity check. The mechanism is chosen
5727
+ against the destination in front of this run, not by the trigger that
5728
+ reached it: readability alone does not prove that an object-level swap can
5729
+ reclaim every damaged page.
5565
5730
 
5566
5731
  Publication is a two-phase durable transaction (#496 S1 F1) under either
5567
5732
  mechanism. A published file carries the current epoch, so `open_db`'s
@@ -5714,9 +5879,36 @@ def _publish_rebuilt_stats_index(
5714
5879
  return incident
5715
5880
 
5716
5881
 
5717
- def _rebuild_quota_cache_leg(records) -> None:
5882
+ def _decoded_quota_stream(quota_raw, cutover_claude, counters=None):
5883
+ """Decode and normalize retained observation bytes ONE AT A TIME.
5884
+
5885
+ Peak heap therefore holds one record rather than the whole population.
5886
+ Normalization runs HERE, on the record decoded from the retained bytes: a
5887
+ dict normalized during the router pass is discarded with the pass, so
5888
+ stamping it there would be lost and every legacy observation would
5889
+ re-materialize with a NULL account_key. A Codex legacy line maps to
5890
+ `unattributed` regardless of the cutover value, so this does not depend on
5891
+ capture ordering (#496 S4 §6.3).
5892
+ """
5893
+ for raw in quota_raw:
5894
+ if counters is not None:
5895
+ counters["lines"] += 1
5896
+ counters["bytes"] += len(raw) + 1
5897
+ record = _lib_journal.decode_line(raw)
5898
+ if record is None: # pragma: no cover — retained bytes decoded once already
5899
+ continue
5900
+ if counters is not None:
5901
+ counters["decodes"] += 1
5902
+ _normalize_legacy_account_stamp(record, cutover_claude)
5903
+ yield record
5904
+
5905
+
5906
+ def _rebuild_quota_cache_leg_raw(
5907
+ quota_raw, decoded, cutover_claude, counters=None
5908
+ ) -> float:
5718
5909
  """Re-materialize cache.db `quota_window_snapshots` AND the #416 Codex
5719
- attribution map from the journal (spec §5.4 + #416 spec §3.4).
5910
+ attribution map from the journal (spec §5.4 + #416 spec §3.4), fed RAW
5911
+ ENCODED LINES for the observations instead of decoded dicts.
5720
5912
 
5721
5913
  The journal records are the DURABLE source (§1 latent data-loss hole — the
5722
5914
  rollout JSONL evaporates); this INSERT-OR-IGNOREs the quota obs on their
@@ -5730,14 +5922,31 @@ def _rebuild_quota_cache_leg(records) -> None:
5730
5922
  followed by the `cache.db.codex.lock` provider flock (lock-order law).
5731
5923
  Best-effort: a missing/busy cache.db is a clean skip (the records stay
5732
5924
  durable in the journal; the stats quota projection pass then degrades
5733
- cleanly)."""
5734
- quota_obs = [r for r in records if _is_codex_quota_obs(r)]
5735
- file_accounts = [r for r in records if _is_codex_file_account_op(r)]
5736
- if not quota_obs and not file_accounts:
5737
- return
5925
+ cleanly).
5926
+
5927
+ Taking raw bytes is what makes the rebuild affordable. 1.81M decoded
5928
+ observation dictionaries are roughly six gigabytes against 1.64 GB of raw
5929
+ bytes, so retaining bytes removes about four gigabytes of peak heap while
5930
+ adding NO file input and NO second traversal — only the JSON decode of
5931
+ records already in memory moves inside the flocks (#496 S4 §6.3). That
5932
+ decode is why the measured hold is longer than it was before S4; see the
5933
+ spec's §6.3 for the measured figures.
5934
+
5935
+ Ordering is preserved: file-account decisions are ops, so the router already
5936
+ retains them decoded and they are available before the observation loop
5937
+ begins, exactly as the §3.5 precedence rule requires.
5938
+
5939
+ Returns the measured flock hold in seconds, which acceptance criterion 7
5940
+ caps.
5941
+ """
5942
+ file_accounts = [
5943
+ r for r in decoded if r is not None and _is_codex_file_account_op(r)
5944
+ ]
5945
+ if not quota_raw and not file_accounts:
5946
+ return 0.0
5738
5947
  cache_path = _cctally_core.CACHE_DB_PATH
5739
5948
  if not cache_path.exists():
5740
- return
5949
+ return 0.0
5741
5950
  from _lib_cache_writer_lock import (
5742
5951
  acquire_cache_writer_flocks,
5743
5952
  release_cache_writer_flocks,
@@ -5752,22 +5961,26 @@ def _rebuild_quota_cache_leg(records) -> None:
5752
5961
  )
5753
5962
  except OSError as exc:
5754
5963
  print(f"[rebuild] quota cache leg lock failed: {exc}", file=sys.stderr)
5755
- return
5964
+ return 0.0
5756
5965
  if held is None:
5757
5966
  print("[rebuild] quota cache leg locks busy; skipping", file=sys.stderr)
5758
- return
5967
+ return 0.0
5968
+ held_from = time.monotonic()
5759
5969
  try:
5760
5970
  try:
5761
5971
  cache = sqlite3.connect(str(cache_path), timeout=15.0)
5762
5972
  except sqlite3.Error as exc: # pragma: no cover — cache.db unopenable
5763
5973
  print(f"[rebuild] quota cache leg connect failed: {exc}", file=sys.stderr)
5764
- return
5974
+ return time.monotonic() - held_from
5765
5975
  try:
5766
5976
  cache.execute("PRAGMA busy_timeout=15000")
5767
5977
  cache.execute("BEGIN IMMEDIATE")
5768
5978
  # Decisions FIRST — same §3.5 precedence ordering as `_cache_applier`.
5769
5979
  _, _file_conflicts = _apply_file_account_records(cache, file_accounts)
5770
- _apply_quota_records(cache, quota_obs)
5980
+ _apply_quota_records(
5981
+ cache,
5982
+ _decoded_quota_stream(quota_raw, cutover_claude, counters),
5983
+ )
5771
5984
  cache.commit()
5772
5985
  _report_file_account_conflicts(_file_conflicts)
5773
5986
  except sqlite3.Error as exc:
@@ -5780,6 +5993,66 @@ def _rebuild_quota_cache_leg(records) -> None:
5780
5993
  cache.close()
5781
5994
  finally:
5782
5995
  release_cache_writer_flocks(held)
5996
+ return time.monotonic() - held_from
5997
+
5998
+
5999
+ #: `_resolve_cutover_for_rebuild` distinguishes "the streaming pass never saw the
6000
+ #: op" from "it saw the op and the op recorded no account". `find_accounts_cutover_op`
6001
+ #: makes the same distinction by returning at the first matching RECORD id, so a
6002
+ #: plain `None` cannot stand in for both without changing which answer wins.
6003
+ _CUTOVER_UNSEEN = object()
6004
+
6005
+
6006
+ def _cutover_value_of(record) -> "str | None":
6007
+ """The cutover op's recorded `claude_legacy_account`, or None when this
6008
+ record is not the canonical cutover op. Shared by the rebuild's inline
6009
+ capture and `find_accounts_cutover_op` so the two cannot disagree."""
6010
+ if record is None or record.get("id") != CUTOVER_OP_ID:
6011
+ return None
6012
+ payload = record.get("payload")
6013
+ if not isinstance(payload, dict):
6014
+ return None
6015
+ return payload.get("claude_legacy_account")
6016
+
6017
+
6018
+ def _resolve_cutover_for_rebuild(captured, hw, segments, counters=None) -> str:
6019
+ """The cutover account for one rebuild, reading each byte at most once.
6020
+
6021
+ `captured` is what the streaming pass saw inside the pinned prefix, or
6022
+ `_CUTOVER_UNSEEN`. When the prefix did not contain the op — reachable,
6023
+ because correction recovery, journal repair and rederive all pin
6024
+ high-waters, and the op sits at 92.9% of a production journal — scan ONLY
6025
+ the unvisited suffix, from the pinned high-water to the current one,
6026
+ stopping at the first match. Resolving from the prefix alone would flip
6027
+ those rebuilds to `unattributed` and restamp every legacy Claude
6028
+ observation, moving `accounts.last_seen_utc` with it (#496 S4 §5.1).
6029
+ """
6030
+ if captured is not _CUTOVER_UNSEEN:
6031
+ return captured if captured is not None else _lib_accounts.UNATTRIBUTED
6032
+ if hw is None or not segments:
6033
+ return _lib_accounts.UNATTRIBUTED
6034
+ current = journal_high_water()
6035
+ if current is None or current == hw:
6036
+ return _lib_accounts.UNATTRIBUTED
6037
+ if current[0] not in segments:
6038
+ # A segment appended after this rebuild's snapshot. Every pass of one
6039
+ # rebuild reads the same snapshot (§4), so the suffix stops at its end
6040
+ # rather than silently adopting a different journal shape.
6041
+ last = segments[-1]
6042
+ current = (last, os.path.getsize(_cctally_core.JOURNAL_DIR / last))
6043
+ if current == hw:
6044
+ return _lib_accounts.UNATTRIBUTED
6045
+ for _segment, _offset, raw in _iter_range_with_segments(hw, current, segments):
6046
+ if counters is not None:
6047
+ counters["lines"] += 1
6048
+ counters["bytes"] += len(raw) + 1
6049
+ record = _lib_journal.decode_line(raw)
6050
+ if counters is not None and record is not None:
6051
+ counters["decodes"] += 1
6052
+ if record is not None and record.get("id") == CUTOVER_OP_ID:
6053
+ value = _cutover_value_of(record)
6054
+ return value if value is not None else _lib_accounts.UNATTRIBUTED
6055
+ return _lib_accounts.UNATTRIBUTED
5783
6056
 
5784
6057
 
5785
6058
  def rebuild_stats_index(
@@ -5812,6 +6085,11 @@ def rebuild_stats_index(
5812
6085
  replaces the main file. A `target_path` build uses the same atomic
5813
6086
  publication but does not create a live-family quarantine incident.
5814
6087
  """
6088
+ # Imported HERE, not at module scope: `_cctally_journal` is on the ingest
6089
+ # path every status-line tick reaches, and `import tracemalloc` measured
6090
+ # 2.9 ms. Only a rebuild reads the peak, so only a rebuild pays for it.
6091
+ import tracemalloc
6092
+
5815
6093
  start = time.monotonic()
5816
6094
  context = context.validate()
5817
6095
  # Resolve the rebuild record's path ONCE, here, because preservation runs
@@ -5831,7 +6109,13 @@ def rebuild_stats_index(
5831
6109
  # and belong to the next ingest cycle (they replay idempotently); mirrors the
5832
6110
  # live cycle's §5.2.1 HW-prefix rule.
5833
6111
  hw = high_water if high_water is not None else journal_high_water()
5834
- segments = list_segments()
6112
+ # ONE segment snapshot for the whole rebuild (#496 S4 §4). `list_segments()`
6113
+ # re-enumerates the directory at call time and orders bootstrap segments
6114
+ # first, so a bootstrap segment appearing mid-rebuild would shift the indices
6115
+ # `iter_range` addresses by; before this, the read pass and the cutover scan
6116
+ # each listed separately and could already disagree about the journal's shape.
6117
+ all_segments = list_segments()
6118
+ segments = all_segments
5835
6119
  if hw is not None:
5836
6120
  if hw[0] not in segments:
5837
6121
  raise JournalError(
@@ -5854,12 +6138,42 @@ def rebuild_stats_index(
5854
6138
  conn = _cctally_core.open_db(_target_path=str(scratch))
5855
6139
  malformed = 0
5856
6140
  lines_folded = 0
6141
+ phase_seconds: dict = {}
6142
+ traversal = {
6143
+ name: {"lines": 0, "bytes": 0, "decodes": 0}
6144
+ for name in ("stats_prefix", "cutover_suffix", "protocol_evidence",
6145
+ "quota_replay")
6146
+ }
6147
+ quota_lock_hold = 0.0
6148
+ tracing = tracemalloc.is_tracing()
6149
+ if tracing:
6150
+ tracemalloc.reset_peak()
5857
6151
  try:
6152
+ # ONE streaming pass. Decode each line once, feed the account last-seen
6153
+ # accumulator, capture the cutover inline, and retain only what a
6154
+ # consumer actually needs: the decision records decoded (5.08% of a
6155
+ # production journal) and the Codex quota observations as RAW BYTES
6156
+ # (1.64 GB against roughly six gigabytes of dicts). Everything else is
6157
+ # dropped as soon as it has contributed (#496 S4 §4).
5858
6158
  decoded: list = []
6159
+ quota_raw: list = []
5859
6160
  protocol_evidence = []
5860
6161
  prior_high_water = None
6162
+ cutover_captured = _CUTOVER_UNSEEN
6163
+ last_seen = _lib_journal_router.LastSeenAccumulator()
6164
+ hasher = _lib_journal_router.PrefixHashAccumulator()
6165
+ evidence_seconds = 0.0
6166
+ prefix = traversal["stats_prefix"]
6167
+ read_started = time.monotonic()
5861
6168
  if hw is not None:
5862
- for segment, offset, raw in _read_range(None, hw):
6169
+ for segment, offset, raw in _iter_range_with_segments(
6170
+ None, hw, segments,
6171
+ on_segment=lambda name: hasher.begin_segment(
6172
+ name, prior_high_water),
6173
+ on_bytes=hasher.extend,
6174
+ ):
6175
+ prefix["lines"] += 1
6176
+ prefix["bytes"] += len(raw) + 1
5863
6177
  rec = _lib_journal.decode_line(raw)
5864
6178
  if rec is None:
5865
6179
  malformed += 1
@@ -5868,46 +6182,104 @@ def rebuild_stats_index(
5868
6182
  offset + len(raw) + 1,
5869
6183
  )
5870
6184
  continue
5871
- _capture_protocol_prefix_evidence(
5872
- rec,
5873
- prior_high_water,
5874
- protocol_evidence,
5875
- )
5876
- decoded.append(rec)
6185
+ prefix["decodes"] += 1
6186
+ # `_capture_protocol_prefix_evidence` returns immediately for
6187
+ # anything that is not an op, so this guard changes nothing it
6188
+ # does — it moves the phase attribution's two clock reads from
6189
+ # every record to every op. That is 195 ops against 1,954,007
6190
+ # lines on a production journal, where the phase itself measures
6191
+ # zero because the journal carries no resolution operation.
6192
+ if rec.get("t") == "op":
6193
+ evidence_started = time.monotonic()
6194
+ _capture_protocol_prefix_evidence(
6195
+ rec,
6196
+ prior_high_water,
6197
+ protocol_evidence,
6198
+ hasher=hasher,
6199
+ )
6200
+ evidence_seconds += time.monotonic() - evidence_started
6201
+ # First cutover op wins, exactly as `find_accounts_cutover_op`
6202
+ # scans — captured here so the rebuild reads the journal once.
6203
+ if (cutover_captured is _CUTOVER_UNSEEN
6204
+ and rec.get("id") == CUTOVER_OP_ID):
6205
+ cutover_captured = _cutover_value_of(rec)
6206
+ last_seen.observe(rec, classify_legacy_provider)
6207
+ if rec.get("t") in _lib_journal_router.RETAINED_RECORD_TYPES:
6208
+ decoded.append(rec)
6209
+ else:
6210
+ if update_quota_cache and _is_codex_quota_obs(rec):
6211
+ quota_raw.append(raw)
6212
+ # A PLACEHOLDER, not a dropped element. `resolve_effective_events`
6213
+ # numbers candidates with `enumerate(records)`, and three of the
6214
+ # seven structural violation kinds put that number inside
6215
+ # `ProtocolViolation.evidence` — which the fingerprint hashes.
6216
+ # That fingerprint is durable: it lands in
6217
+ # `journal_protocol_violations` and is referenced by name from a
6218
+ # `journal_protocol_resolution` op, which `_cctally_journal_repair`
6219
+ # mints from the UNFILTERED record list. Renumbering here would
6220
+ # therefore make a previously acknowledged violation unresolvable
6221
+ # and raise on every later rebuild. The selector skips a non-dict
6222
+ # element, so this costs one pointer and keeps every sequence
6223
+ # identical to the pre-change numbering (#496 S4; corrects §4.7).
6224
+ decoded.append(None)
5877
6225
  prior_high_water = (
5878
6226
  segment,
5879
6227
  offset + len(raw) + 1,
5880
6228
  )
6229
+ phase_seconds["journal_read_decode"] = round(
6230
+ max(0.0, time.monotonic() - read_started - evidence_seconds), 6)
6231
+ phase_seconds["protocol_evidence"] = round(evidence_seconds, 6)
6232
+ traversal["protocol_evidence"]["bytes"] = hasher.bytes_hashed
6233
+ traversal["protocol_evidence"]["lines"] = hasher.digests_computed
6234
+ hasher = None
5881
6235
 
5882
6236
  # Legacy account normalisation (#341, spec §2 / handoff item 2): a
5883
6237
  # pre-#341 real-account line lacks an account stamp — inject the cutover
5884
6238
  # mapping BEFORE the fold (Claude legacy -> the cutover op's account;
5885
6239
  # Codex legacy -> unattributed). `*`-families + already-stamped lines are
5886
- # untouched. Resolved once from the journal's own cutover op (falls back
5887
- # to `unattributed` when none is present), so a fresh single-account
5888
- # rebuild is byte-neutral (everything is already `unattributed`).
5889
- cutover_claude = resolve_cutover_claude_account()
6240
+ # untouched. Resolved from the journal's own cutover op inline when the
6241
+ # streamed prefix contained it, otherwise from the unvisited suffix alone
6242
+ # (falls back to `unattributed` when neither has it), so a fresh
6243
+ # single-account rebuild is byte-neutral.
6244
+ cutover_started = time.monotonic()
6245
+ cutover_claude = _resolve_cutover_for_rebuild(
6246
+ cutover_captured, hw, all_segments, traversal["cutover_suffix"])
6247
+ phase_seconds["cutover_suffix"] = round(
6248
+ time.monotonic() - cutover_started, 6)
5890
6249
  for rec in decoded:
5891
- _normalize_legacy_account_stamp(rec, cutover_claude)
6250
+ if rec is not None:
6251
+ _normalize_legacy_account_stamp(rec, cutover_claude)
5892
6252
 
5893
6253
  # Resolve corrections BEFORE either disposable index is mutated. A
5894
6254
  # malformed revision, divergent same-revision candidate, or invalid
5895
6255
  # committed manifest leaves the existing destination untouched.
6256
+ selection_started = time.monotonic()
5896
6257
  effective = _lib_journal.resolve_effective_events(
5897
6258
  decoded,
5898
6259
  protocol_prefix_evidence=protocol_evidence,
5899
6260
  )
6261
+ phase_seconds["effective_selection"] = round(
6262
+ time.monotonic() - selection_started, 6)
5900
6263
 
5901
6264
  # Cache leg BEFORE any stats txn (provider-flock lock-order): journal
5902
- # Codex quota obs -> cache.db quota_window_snapshots.
6265
+ # Codex quota obs -> cache.db quota_window_snapshots. The retained bytes
6266
+ # are decoded inside the leg's existing transaction and freed here, so
6267
+ # the SQLite fold never runs on top of them.
6268
+ leg_started = time.monotonic()
5903
6269
  if update_quota_cache:
5904
- _rebuild_quota_cache_leg(decoded)
6270
+ quota_lock_hold = _rebuild_quota_cache_leg_raw(
6271
+ quota_raw, decoded, cutover_claude, traversal["quota_replay"])
6272
+ quota_raw = []
6273
+ phase_seconds["quota_cache_leg"] = round(
6274
+ time.monotonic() - leg_started, 6)
5905
6275
 
5906
6276
  # One ordered fold stream: op-folds (order 5) + evts, keyed by
5907
6277
  # (fold_order, canonical seq) so referenced families resolve before
5908
6278
  # referencing ones and crash-replay duplicates fold idempotently.
5909
6279
  stream: list = []
5910
6280
  for seq, rec in enumerate(decoded):
6281
+ if rec is None:
6282
+ continue
5911
6283
  t = rec.get("t")
5912
6284
  kind = (rec.get("payload") or {}).get("kind")
5913
6285
  if t == "op" and kind in FOLD_APPLIERS:
@@ -5919,6 +6291,7 @@ def rebuild_stats_index(
5919
6291
  tail = [s for s in stream if s[0] >= _REBUILD_MILESTONE_ORDER]
5920
6292
 
5921
6293
  _stats_rebuild_test_pause("rebuild_fold_started")
6294
+ fold_started = time.monotonic()
5922
6295
 
5923
6296
  # Phase 1 (txn A) — structural folds: op floors, snapshot_accept, cost
5924
6297
  # snapshots, resets+suppression, block_close, arming, credit effects.
@@ -5966,9 +6339,14 @@ def rebuild_stats_index(
5966
6339
  _apply_evt(conn, rec)
5967
6340
  lines_folded += 1
5968
6341
  # Fold-time `last_seen_utc` derivation (#341): re-derive each
5969
- # account's last-seen from the whole journal (the observe ops folded
5970
- # in the structural phase already created the rows).
5971
- _derive_account_last_seen(conn, decoded)
6342
+ # account's last-seen from the whole journal. The map was
6343
+ # accumulated during the single read pass `decoded` no longer
6344
+ # contains observations, so deriving from it here would silently
6345
+ # drop every observation's contribution (#496 S4 §4.6).
6346
+ _apply_account_last_seen(
6347
+ conn,
6348
+ last_seen.resolve(cutover_claude, _lib_accounts.UNATTRIBUTED),
6349
+ )
5972
6350
  if hw is not None:
5973
6351
  _write_cursor(conn, hw[0], hw[1])
5974
6352
  conn.commit()
@@ -5978,7 +6356,9 @@ def rebuild_stats_index(
5978
6356
  except Exception:
5979
6357
  pass
5980
6358
  raise
6359
+ phase_seconds["stats_fold"] = round(time.monotonic() - fold_started, 6)
5981
6360
 
6361
+ validate_started = time.monotonic()
5982
6362
  rows_by_table = {}
5983
6363
  for tbl in _REBUILD_COUNT_TABLES:
5984
6364
  try:
@@ -5992,6 +6372,8 @@ def rebuild_stats_index(
5992
6372
  raise JournalError("rebuilt stats index WAL could not be drained")
5993
6373
  _validate_rebuilt_stats_index(conn, hw)
5994
6374
  _stats_rebuild_test_pause("rebuild_scratch_complete")
6375
+ phase_seconds["scratch_validate"] = round(
6376
+ time.monotonic() - validate_started, 6)
5995
6377
  finally:
5996
6378
  conn.close()
5997
6379
 
@@ -6009,8 +6391,13 @@ def rebuild_stats_index(
6009
6391
  conflicts = effective.conflicts
6010
6392
  protocol_violations = effective.protocol_violations
6011
6393
  acknowledged = effective.acknowledged_protocol_violations
6394
+ # The pre-publication window is what F9's memory acceptance is measured
6395
+ # over: everything after this point is publication, whose own WAL cost S3
6396
+ # already accounts for.
6397
+ peak_heap_bytes = (
6398
+ tracemalloc.get_traced_memory()[1] if tracing else 0)
6012
6399
  decoded = effective = stream = structural = tail = None
6013
- segments = protocol_evidence = None
6400
+ segments = protocol_evidence = last_seen = None
6014
6401
 
6015
6402
  # First fresh-connection validation (#496 S1 F1). A failure here raises
6016
6403
  # BEFORE any preservation, so no incident is created and the old family
@@ -6025,6 +6412,7 @@ def rebuild_stats_index(
6025
6412
  # `os.replace` as a stray artifact.
6026
6413
  _remove_db_sidecars_strict(scratch)
6027
6414
 
6415
+ publication_started = time.monotonic()
6028
6416
  incident = _publish_rebuilt_stats_index(
6029
6417
  scratch=scratch,
6030
6418
  destination=dest,
@@ -6048,8 +6436,21 @@ def rebuild_stats_index(
6048
6436
  "rowsByTable": rows_by_table,
6049
6437
  "buildSeconds": round(time.monotonic() - start, 3),
6050
6438
  "prePublicationValidation": {"ok": True, "error": None},
6439
+ # Additive instrumentation (#496 S4 §8.7). Additive keys do not bump
6440
+ # `schemaVersion` and no existing field changes meaning. `publication`
6441
+ # is absent HERE and present on `RebuildResult`: publication copies
6442
+ # this dict before it writes it, so its own duration cannot be known
6443
+ # at the time the record is written.
6444
+ "phaseSeconds": dict(phase_seconds),
6445
+ "traversal": {
6446
+ name: dict(counts) for name, counts in traversal.items()
6447
+ },
6448
+ "peakHeapBytes": peak_heap_bytes,
6449
+ "quotaLockHoldSeconds": round(quota_lock_hold, 6),
6051
6450
  },
6052
6451
  )
6452
+ phase_seconds["publication"] = round(
6453
+ time.monotonic() - publication_started, 6)
6053
6454
 
6054
6455
  return RebuildResult(
6055
6456
  rows_by_table=rows_by_table, malformed=malformed,
@@ -6058,6 +6459,10 @@ def rebuild_stats_index(
6058
6459
  protocol_violations=protocol_violations,
6059
6460
  acknowledged_protocol_violations=acknowledged,
6060
6461
  quarantine_dir=incident,
6462
+ phase_seconds=phase_seconds,
6463
+ traversal=traversal,
6464
+ peak_heap_bytes=peak_heap_bytes,
6465
+ quota_lock_hold_seconds=round(quota_lock_hold, 6),
6061
6466
  )
6062
6467
 
6063
6468
 
@@ -6326,19 +6731,14 @@ def _cutover_segment_name(now_utc: dt.datetime) -> str:
6326
6731
  return f"{_lib_journal.BOOTSTRAP_PREFIX}{ts}.jsonl"
6327
6732
 
6328
6733
 
6329
- def _write_bootstrap_segment(seg_name: str, lines: list) -> int:
6330
- """Materialize the bootstrap segment atomically (spec §8 rename-then-stamp):
6331
- encode all lines, write to a `.partial` sibling, fsync file + dir, verify the
6332
- line count, then `os.replace` into `seg_name`. Returns the final byte size.
6333
- Every line must fit the torn-tail window (append discipline)."""
6334
- journal_dir = _cctally_core.JOURNAL_DIR
6335
- dir_created = not journal_dir.exists()
6336
- journal_dir.mkdir(parents=True, exist_ok=True)
6337
- if dir_created:
6338
- try:
6339
- os.chmod(journal_dir, 0o700)
6340
- except OSError:
6341
- pass
6734
+ def _encode_bootstrap_lines(lines: list) -> bytes:
6735
+ """The cutover export as one verified blob (spec §8 verify step).
6736
+
6737
+ Encoding is separated from writing because `run_cutover` digests the blob
6738
+ before it decides whether a byte-identical segment already exists (#496 S5
6739
+ §3). Encoding twice would compute the reuse digest over a different object
6740
+ than the one written, so this is the single encode both uses.
6741
+ """
6342
6742
  encoded = []
6343
6743
  for rec in lines:
6344
6744
  data = _lib_journal.encode_line(rec)
@@ -6351,6 +6751,81 @@ def _write_bootstrap_segment(seg_name: str, lines: list) -> int:
6351
6751
  if blob.count(b"\n") != len(lines):
6352
6752
  raise JournalError(
6353
6753
  "cutover export line count mismatch (spec §8 verify step)")
6754
+ return blob
6755
+
6756
+
6757
+ def _reusable_bootstrap(candidate_digest: str, candidate_size: int):
6758
+ """`(name, size)` of a published bootstrap identical to the candidate blob.
6759
+
6760
+ Every published bootstrap is REPORTED, because `reusable_bootstrap_name`
6761
+ refuses a match that is not the canonically newest one; only segments whose
6762
+ byte length already equals the candidate's are READ, so the comparison costs
6763
+ one pass over the same-size candidates rather than one over the journal.
6764
+ `list_segments` excludes `.partial` files, so a cutover that is still writing
6765
+ can never be reused (#496 S5 §3). A segment whose length cannot be read is
6766
+ reported with a `None` length rather than dropped, which refuses reuse
6767
+ instead of promoting an older segment to canonically newest.
6768
+ """
6769
+ journal_dir = _cctally_core.JOURNAL_DIR
6770
+ if not journal_dir.exists():
6771
+ return None
6772
+ existing = []
6773
+ for name in list_segments():
6774
+ if not name.startswith(_lib_journal.BOOTSTRAP_PREFIX):
6775
+ continue
6776
+ path = journal_dir / name
6777
+ try:
6778
+ size = os.path.getsize(path)
6779
+ except OSError:
6780
+ # Report it anyway. Dropping the entry would let an OLDER match
6781
+ # look canonically newest, which is the reuse this scan refuses.
6782
+ existing.append((name, None, None))
6783
+ continue
6784
+ if size != candidate_size:
6785
+ existing.append((name, size, None))
6786
+ continue
6787
+ digest = hashlib.sha256()
6788
+ with _open_segment_for_read(path) as handle:
6789
+ while True:
6790
+ chunk = handle.read(_SEGMENT_READ_CHUNK)
6791
+ if not chunk:
6792
+ break
6793
+ digest.update(chunk)
6794
+ existing.append((name, size, digest.hexdigest()))
6795
+ name = _lib_journal.reusable_bootstrap_name(
6796
+ candidate_digest, candidate_size, existing)
6797
+ return None if name is None else (name, candidate_size)
6798
+
6799
+
6800
+ def _fsync_published_segment(seg_name: str) -> None:
6801
+ """Make an already-renamed segment and its directory entry durable.
6802
+
6803
+ `_write_bootstrap_segment` establishes this for a segment it writes itself.
6804
+ A reused segment was published by a different attempt, which may have
6805
+ crashed anywhere in that sequence, so the reuse path repeats the file and
6806
+ directory fsyncs before anything durable is allowed to name the file.
6807
+ """
6808
+ journal_dir = _cctally_core.JOURNAL_DIR
6809
+ fd = os.open(str(journal_dir / seg_name), os.O_RDONLY)
6810
+ try:
6811
+ os.fsync(fd)
6812
+ finally:
6813
+ os.close(fd)
6814
+ _fsync_dir(journal_dir)
6815
+
6816
+
6817
+ def _write_bootstrap_segment(seg_name: str, blob: bytes) -> int:
6818
+ """Materialize the bootstrap segment atomically (spec §8 rename-then-stamp):
6819
+ write the verified blob to a `.partial` sibling, fsync file + dir, then
6820
+ `os.replace` into `seg_name`. Returns the final byte size."""
6821
+ journal_dir = _cctally_core.JOURNAL_DIR
6822
+ dir_created = not journal_dir.exists()
6823
+ journal_dir.mkdir(parents=True, exist_ok=True)
6824
+ if dir_created:
6825
+ try:
6826
+ os.chmod(journal_dir, 0o700)
6827
+ except OSError:
6828
+ pass
6354
6829
  partial = journal_dir / (seg_name + ".partial")
6355
6830
  fd = os.open(str(partial), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
6356
6831
  try:
@@ -6379,7 +6854,14 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
6379
6854
  commit rolls the whole thing back (the legacy DB stays fully usable); the
6380
6855
  next open retries idempotently (stable bootstrap ids). A truly empty install
6381
6856
  (nothing to export) just stamps the epoch — no bootstrap file. Returns the
6382
- bootstrap segment basename, or None when nothing was exported."""
6857
+ bootstrap segment basename, or None when nothing was exported.
6858
+
6859
+ A crash between the `os.replace` and the commit leaves a published segment
6860
+ the rolled-back transaction never referenced. The retry re-exports the same
6861
+ rows byte for byte, so it reuses that orphan rather than publishing a twin
6862
+ (#496 S5 §3) — the retry is now idempotent on disk, not only on fold. When
6863
+ the retry's export genuinely differs, no digest matches and a new segment is
6864
+ written exactly as before."""
6383
6865
  if now_utc is None:
6384
6866
  now_utc = dt.datetime.now(dt.timezone.utc)
6385
6867
  epoch = _cctally_core.STATS_INDEX_EPOCH
@@ -6401,8 +6883,19 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
6401
6883
  conn.commit()
6402
6884
  return None
6403
6885
 
6404
- seg_name = _cutover_segment_name(now_utc)
6405
- seg_size = _write_bootstrap_segment(seg_name, lines)
6886
+ blob = _encode_bootstrap_lines(lines)
6887
+ reuse = _reusable_bootstrap(
6888
+ hashlib.sha256(blob).hexdigest(), len(blob))
6889
+ if reuse is None:
6890
+ seg_name = _cutover_segment_name(now_utc)
6891
+ seg_size = _write_bootstrap_segment(seg_name, blob)
6892
+ else:
6893
+ seg_name, seg_size = reuse
6894
+ # The adopted segment was renamed by ANOTHER attempt, whose rename
6895
+ # may still be only in the page cache. The cursor stamped below is
6896
+ # made durable by SQLite's own commit fsync, so without this the
6897
+ # index could name a bootstrap that a power loss then leaves absent.
6898
+ _fsync_published_segment(seg_name)
6406
6899
 
6407
6900
  for table, rowid in stamp:
6408
6901
  conn.execute(
@@ -6472,17 +6965,24 @@ def _resolve_claude_cutover_identity(claude_json_path=None) -> str:
6472
6965
  def find_accounts_cutover_op():
6473
6966
  """Scan the journal for the canonical cutover op; return its recorded
6474
6967
  ``claude_legacy_account`` (spec §2 payload), or None when it has not been
6475
- appended yet. Cheap enough for the one-time transition + the retry check."""
6968
+ appended yet. Cheap enough for the one-time transition + the retry check.
6969
+
6970
+ Streams rather than materializing (#496 S4): the previous form built each
6971
+ segment's whole line list before its first-match return, so the early exit
6972
+ could not stop reading inside the containing segment. The None-on-absence
6973
+ contract is UNCHANGED — the cache and conversations migrations depend on it
6974
+ to defer their backfill.
6975
+ """
6476
6976
  for seg in list_segments():
6477
6977
  seg_path = _cctally_core.JOURNAL_DIR / seg
6478
6978
  try:
6479
6979
  size = os.path.getsize(seg_path)
6480
6980
  except OSError:
6481
6981
  continue
6482
- for _name, _off, raw in _read_segment_lines(seg_path, 0, size):
6982
+ for _name, _off, raw in _iter_segment_lines(seg_path, 0, size):
6483
6983
  rec = _lib_journal.decode_line(raw)
6484
6984
  if rec is not None and rec.get("id") == CUTOVER_OP_ID:
6485
- return (rec.get("payload") or {}).get("claude_legacy_account")
6985
+ return _cutover_value_of(rec)
6486
6986
  return None
6487
6987
 
6488
6988