cctally 1.114.0 → 1.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/_cctally_account.py +32 -8
  3. package/bin/_cctally_cache.py +4394 -279
  4. package/bin/_cctally_completion.py +1287 -0
  5. package/bin/_cctally_config.py +37 -0
  6. package/bin/_cctally_core.py +178 -2
  7. package/bin/_cctally_dashboard.py +2 -2
  8. package/bin/_cctally_dashboard_conversation.py +292 -23
  9. package/bin/_cctally_dashboard_envelope.py +16 -3
  10. package/bin/_cctally_dashboard_sources.py +18 -9
  11. package/bin/_cctally_db.py +316 -5
  12. package/bin/_cctally_doctor.py +436 -27
  13. package/bin/_cctally_five_hour.py +82 -31
  14. package/bin/_cctally_forecast.py +81 -9
  15. package/bin/_cctally_journal.py +3884 -112
  16. package/bin/_cctally_journal_repair.py +101 -12
  17. package/bin/_cctally_parser.py +42 -0
  18. package/bin/_cctally_project.py +8 -2
  19. package/bin/_cctally_quarantine.py +652 -0
  20. package/bin/_cctally_quota.py +26 -4
  21. package/bin/_cctally_quota_model.py +360 -83
  22. package/bin/_cctally_record.py +677 -149
  23. package/bin/_cctally_rederive.py +297 -32
  24. package/bin/_cctally_refresh.py +84 -13
  25. package/bin/_cctally_source_analytics.py +23 -2
  26. package/bin/_cctally_statusline.py +6137 -517
  27. package/bin/_cctally_store.py +287 -48
  28. package/bin/_cctally_transcript.py +8 -7
  29. package/bin/_cctally_tui.py +57 -53
  30. package/bin/_lib_account_binding.py +152 -0
  31. package/bin/_lib_account_protocol.py +386 -0
  32. package/bin/_lib_aggregators.py +4 -0
  33. package/bin/_lib_cache_coverage.py +13 -1
  34. package/bin/_lib_cache_writer_lock.py +19 -3
  35. package/bin/_lib_clone_copy.py +142 -0
  36. package/bin/_lib_codex_conversation_query.py +133 -19
  37. package/bin/_lib_codex_conversation_watch.py +59 -14
  38. package/bin/_lib_codex_drain_admission.py +214 -0
  39. package/bin/_lib_codex_metadata.py +18 -1
  40. package/bin/_lib_codex_publication_barrier.py +277 -0
  41. package/bin/_lib_codex_rollout_container.py +502 -0
  42. package/bin/_lib_conversation_dispatch.py +3 -0
  43. package/bin/_lib_conversation_query.py +11 -3
  44. package/bin/_lib_detector_access_registry.py +3817 -0
  45. package/bin/_lib_doctor.py +452 -1
  46. package/bin/_lib_five_hour.py +126 -0
  47. package/bin/_lib_ingest_frontier.py +414 -20
  48. package/bin/_lib_journal.py +141 -10
  49. package/bin/_lib_jsonl.py +5 -4
  50. package/bin/_lib_legacy_spool.py +126 -0
  51. package/bin/_lib_legacy_writer_evidence.py +136 -0
  52. package/bin/_lib_merge_gate.py +29 -302
  53. package/bin/_lib_meter_rate_change.py +10 -0
  54. package/bin/_lib_outcome_quarantine.py +690 -0
  55. package/bin/_lib_pricing.py +7 -2
  56. package/bin/_lib_quarantine.py +154 -0
  57. package/bin/_lib_quota_calibration.py +53 -8
  58. package/bin/_lib_quota_copy.py +2 -1
  59. package/bin/_lib_quota_model.py +288 -46
  60. package/bin/_lib_rederive.py +94 -5
  61. package/bin/_lib_segment_summary.py +35 -2
  62. package/bin/_lib_statusline_candidates.py +292 -23
  63. package/bin/cctally +23 -1
  64. package/package.json +14 -1
@@ -59,6 +59,7 @@ true and must not be restored.
59
59
  from __future__ import annotations
60
60
 
61
61
  import contextlib
62
+ import contextvars
62
63
  import datetime as dt
63
64
  import fcntl
64
65
  import json
@@ -858,11 +859,12 @@ def stats_open_time_guard(
858
859
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
859
860
  acquired = True
860
861
  break
861
- except (BlockingIOError, OSError):
862
+ except (BlockingIOError, OSError) as exc:
862
863
  if time.monotonic() >= deadline:
863
- raise _cctally_db.StatsDbMaintenanceError(
864
- _stats_open_maintenance_timeout_message()
865
- )
864
+ _GUARD_FAILURE.set(
865
+ "contention" if isinstance(exc, BlockingIOError)
866
+ else "error")
867
+ raise _maintenance_busy_error("exclusive")
866
868
  time.sleep(0.02)
867
869
  _cctally_core.note_stats_maintenance_acquired()
868
870
  try:
@@ -954,6 +956,27 @@ def _stats_open_maintenance_timeout_message() -> str:
954
956
  return _STATS_OPEN_MAINTENANCE_TIMEOUT_MSG
955
957
 
956
958
 
959
+ #: Why this context's most recent bounded maintenance wait expired
960
+ #: (#825/#832 Codex fix-round-4 Q3): ``contention`` when the lock stayed
961
+ #: held, ``error`` for an OS error no holder caused.
962
+ _GUARD_FAILURE = contextvars.ContextVar("stats_guard_failure", default=None)
963
+
964
+
965
+ def _maintenance_busy_error(mode: str) -> "_cctally_db.StatsDbMaintenanceError":
966
+ """The store guard's bounded-wait expiry, carrying the lock identity the
967
+ failed acquisition lost to (``blocked_by``: a `_cctally_journal.LockBlock`
968
+ for ``maintenance`` in the requested ``mode``) so a deferred statusline
969
+ reduction can leave an obligation for exactly that release (Q3). An
970
+ expiry an I/O error caused is not contention and wakes nobody."""
971
+ import _cctally_journal
972
+
973
+ exc = _cctally_db.StatsDbMaintenanceError(
974
+ _stats_open_maintenance_timeout_message())
975
+ exc.blocked_by = _cctally_journal.LockBlock(
976
+ "maintenance", mode, _GUARD_FAILURE.get() != "error")
977
+ return exc
978
+
979
+
957
980
  def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
958
981
  """Poll for ``operation`` on ``lock_fh`` until ``timeout_s`` expires.
959
982
 
@@ -964,12 +987,15 @@ def _flock_bounded(lock_fh, operation: int, timeout_s: float) -> bool:
964
987
  sequence.
965
988
  """
966
989
  deadline = time.monotonic() + timeout_s
990
+ _GUARD_FAILURE.set(None)
967
991
  while True:
968
992
  try:
969
993
  fcntl.flock(lock_fh, operation | fcntl.LOCK_NB)
970
994
  return True
971
- except (BlockingIOError, OSError):
995
+ except (BlockingIOError, OSError) as exc:
972
996
  if time.monotonic() >= deadline:
997
+ _GUARD_FAILURE.set(
998
+ "contention" if isinstance(exc, BlockingIOError) else "error")
973
999
  return False
974
1000
  time.sleep(0.02)
975
1001
 
@@ -1470,19 +1496,39 @@ def _recover_or_reclaim_interrupted_stats_rebuild(
1470
1496
  "stats.db interrupted-rebuild recovery timed out waiting for "
1471
1497
  "journal ingest serialization; retry after active cctally commands exit"
1472
1498
  )
1499
+ import _cctally_completion
1500
+
1501
+ obligation_failure = None
1473
1502
  try:
1474
1503
  with stats_write_scope("maintenance-interrupted-rebuild"):
1475
- _cctally_journal.rebuild_stats_index(
1476
- context=_cctally_journal.RebuildContext(
1477
- trigger="interrupted-rebuild-recovery"
1478
- ),
1479
- high_water=high_water,
1480
- )
1504
+ try:
1505
+ _cctally_journal.rebuild_stats_index(
1506
+ context=_cctally_journal.RebuildContext(
1507
+ trigger="interrupted-rebuild-recovery"
1508
+ ),
1509
+ high_water=high_water,
1510
+ complete_derivation=True,
1511
+ )
1512
+ except _cctally_completion.ResidualObligationNotDurable as exc:
1513
+ # Fix round K12 (K11R-1): the recovered index IS published;
1514
+ # only its residual obligation was lost. The recovery is
1515
+ # complete, so its cleanup runs and the open continues; the
1516
+ # caller posts the publication wake once its locks are gone.
1517
+ obligation_failure = exc
1481
1518
  _remove_stale_stats_rebuild_artifacts(artifacts)
1482
1519
  _discard_pending_stats_publication_marker(db_path)
1483
- return True
1484
1520
  finally:
1485
1521
  _cctally_journal._release_ingest_lock(ingest_fd)
1522
+ if obligation_failure is not None:
1523
+ print(
1524
+ "cctally: stats.db was recovered from an interrupted rebuild, but "
1525
+ "the record that the inputs written during it still need deriving "
1526
+ f"could not be saved ({obligation_failure}). Those inputs stay in "
1527
+ "the journal and the next ingest derives them; check that the "
1528
+ "data directory is writable.",
1529
+ file=sys.stderr,
1530
+ )
1531
+ return True
1486
1532
 
1487
1533
 
1488
1534
  #: #778 — every guarded stats connection is built with the statement cache OFF.
@@ -1547,9 +1593,7 @@ def stats_open_guarded(
1547
1593
  if not _flock_bounded(
1548
1594
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1549
1595
  ):
1550
- raise _cctally_db.StatsDbMaintenanceError(
1551
- _stats_open_maintenance_timeout_message()
1552
- )
1596
+ raise _maintenance_busy_error("shared")
1553
1597
  if marker.exists():
1554
1598
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1555
1599
  raise _cctally_db.StatsDbMaintenanceError()
@@ -1561,9 +1605,7 @@ def stats_open_guarded(
1561
1605
  if not _flock_bounded(
1562
1606
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1563
1607
  ):
1564
- raise _cctally_db.StatsDbMaintenanceError(
1565
- _stats_open_maintenance_timeout_message()
1566
- )
1608
+ raise _maintenance_busy_error("exclusive")
1567
1609
  try:
1568
1610
  if marker.exists():
1569
1611
  raise _cctally_db.StatsDbMaintenanceError()
@@ -1586,9 +1628,7 @@ def stats_open_guarded(
1586
1628
  if not _flock_bounded(
1587
1629
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1588
1630
  ):
1589
- raise _cctally_db.StatsDbMaintenanceError(
1590
- _stats_open_maintenance_timeout_message()
1591
- )
1631
+ raise _maintenance_busy_error("exclusive")
1592
1632
  # Recovery calls `rebuild_stats_index`, whose in-place publisher
1593
1633
  # reopens the live destination through `stats_open_guarded`.
1594
1634
  # `flock` conflicts are per open-file-DESCRIPTION and apply
@@ -1628,13 +1668,16 @@ def stats_open_guarded(
1628
1668
  _cctally_core.note_stats_maintenance_released()
1629
1669
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1630
1670
  if recovered:
1671
+ # #825/#832 spec 4.3 revision 2: interrupted-rebuild
1672
+ # recovery is a live index publication; its wake is
1673
+ # deferred past any lock the caller of this open holds.
1674
+ import _cctally_completion
1675
+ _cctally_completion.post_publication_wake()
1631
1676
  continue
1632
1677
  if not _flock_bounded(
1633
1678
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1634
1679
  ):
1635
- raise _cctally_db.StatsDbMaintenanceError(
1636
- _stats_open_maintenance_timeout_message()
1637
- )
1680
+ raise _maintenance_busy_error("shared")
1638
1681
  if marker.exists() or pending.exists():
1639
1682
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1640
1683
  continue
@@ -1652,9 +1695,7 @@ def stats_open_guarded(
1652
1695
  if not _flock_bounded(
1653
1696
  lock_fh, fcntl.LOCK_EX, _STATS_OPEN_RESUME_WAIT_S
1654
1697
  ):
1655
- raise _cctally_db.StatsDbMaintenanceError(
1656
- _stats_open_maintenance_timeout_message()
1657
- )
1698
+ raise _maintenance_busy_error("exclusive")
1658
1699
  try:
1659
1700
  _resolve_stats_publication_marker(db_path)
1660
1701
  finally:
@@ -1662,9 +1703,7 @@ def stats_open_guarded(
1662
1703
  if not _flock_bounded(
1663
1704
  lock_fh, fcntl.LOCK_SH, _STATS_OPEN_MAINTENANCE_WAIT_S
1664
1705
  ):
1665
- raise _cctally_db.StatsDbMaintenanceError(
1666
- _stats_open_maintenance_timeout_message()
1667
- )
1706
+ raise _maintenance_busy_error("shared")
1668
1707
  if marker.exists() or pending.exists():
1669
1708
  fcntl.flock(lock_fh, fcntl.LOCK_UN)
1670
1709
  continue
@@ -2633,27 +2672,49 @@ def _run_stats_corruption_heal(request: dict) -> str:
2633
2672
  )
2634
2673
  if not confirmed:
2635
2674
  return "declined-unconfirmed"
2675
+ import _cctally_completion
2676
+ obligation_failure = None
2636
2677
  with stats_write_scope("maintenance-heal"):
2637
- result = _cctally_journal.rebuild_stats_index(
2638
- context=_cctally_journal.RebuildContext(
2639
- trigger="corruption-heal",
2640
- trigger_error=str(request.get("triggerError") or ""),
2641
- forensics_path=request.get("forensicsPath"),
2642
- ),
2643
- high_water=high_water,
2644
- )
2678
+ try:
2679
+ result = _cctally_journal.rebuild_stats_index(
2680
+ context=_cctally_journal.RebuildContext(
2681
+ trigger="corruption-heal",
2682
+ trigger_error=str(
2683
+ request.get("triggerError") or ""),
2684
+ forensics_path=request.get("forensicsPath"),
2685
+ ),
2686
+ high_water=high_water,
2687
+ complete_derivation=True,
2688
+ )
2689
+ except _cctally_completion.ResidualObligationNotDurable \
2690
+ as exc:
2691
+ # Fix round K11 (K9K10-R7): the healed index IS
2692
+ # published; only its residual obligation is lost.
2693
+ obligation_failure = exc
2694
+ result = getattr(exc, "result", None)
2645
2695
  finally:
2646
2696
  if ingest_fd is not None:
2647
2697
  _heal_release_flock(ingest_fd)
2648
- _record_stats_heal_outcome(heal_id, "rebuilt", result=result)
2698
+ _record_stats_heal_outcome(heal_id, "rebuilt", result=result,
2699
+ error=obligation_failure)
2700
+ if obligation_failure is not None:
2701
+ return STATS_HEAL_RESIDUAL_NOT_DURABLE
2649
2702
  return "success"
2650
2703
  finally:
2651
2704
  _heal_release_maintenance_flock(maint_fd)
2652
2705
 
2653
2706
 
2707
+ #: Fix round K11 (K9K10-R7): the heal published the rebuilt index, but its
2708
+ #: residual-derivation obligation could not be made durable. The heal is
2709
+ #: done (the request is finalized and the publication wake posted); the ring
2710
+ #: records the rebuild with the obligation error, and the worker log says so.
2711
+ STATS_HEAL_RESIDUAL_NOT_DURABLE = "rebuilt-residual-not-durable"
2712
+
2713
+
2654
2714
  def cmd_stats_corruption_heal_internal(args) -> int:
2655
2715
  """Hidden detached worker: heal one corrupt stats index exactly once."""
2656
2716
  del args
2717
+ healed = False
2657
2718
  try:
2658
2719
  pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
2659
2720
  worker_fd = os.open(
@@ -2684,12 +2745,17 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2684
2745
  if outcome in ("maintenance-busy", "ingest-busy"):
2685
2746
  _log_stats_heal(outcome, heal_id=heal_id)
2686
2747
  return 0
2687
- if outcome != "success":
2748
+ # Fix round K11 (K9K10-R7): a heal that published but lost its
2749
+ # residual obligation is a heal; its ring outcome is already
2750
+ # recorded (with the obligation error) and it wakes like a success.
2751
+ published = outcome in ("success", STATS_HEAL_RESIDUAL_NOT_DURABLE)
2752
+ if not published:
2688
2753
  _record_stats_heal_outcome(heal_id, outcome)
2689
2754
  if not _finalize_stats_heal_request(heal_id):
2690
2755
  _log_stats_heal("finalization-pending", heal_id=heal_id)
2691
2756
  return 0
2692
2757
  _log_stats_heal(outcome, heal_id=heal_id)
2758
+ healed = published
2693
2759
  return 0
2694
2760
  finally:
2695
2761
  try:
@@ -2697,6 +2763,11 @@ def cmd_stats_corruption_heal_internal(args) -> int:
2697
2763
  except OSError:
2698
2764
  pass
2699
2765
  os.close(worker_fd)
2766
+ if healed:
2767
+ # #825/#832 spec 4.3 revision 2: the healed index posts its
2768
+ # completion wake, with the worker flock released.
2769
+ import _cctally_completion
2770
+ _cctally_completion.post_publication_wake()
2700
2771
 
2701
2772
 
2702
2773
  # --------------------------------------------------------------------------
@@ -3108,8 +3179,62 @@ def defer_stats_epoch_rebuild() -> str:
3108
3179
  os.close(admission_fd)
3109
3180
 
3110
3181
 
3182
+ def stats_rebuild_worker_blocks() -> tuple:
3183
+ """#825/#832 Codex fix-round-4 Q3: one failed try-lock per detached
3184
+ rebuild worker lock (the epoch worker's and the heal worker's), each a
3185
+ `_cctally_journal.LockBlock` naming the worker lock that refused it,
3186
+ whether it was held (contention) or the probe hit an I/O error. A worker
3187
+ lock that is free, or whose file does not exist, yields nothing: an
3188
+ unlocked probe proves no release."""
3189
+ import _cctally_journal
3190
+
3191
+ blocks = []
3192
+ for name, path in (("epoch-worker", _stats_epoch_rebuild_worker_path()),
3193
+ ("heal-worker", _stats_heal_worker_path())):
3194
+ try:
3195
+ fd = os.open(path, os.O_RDONLY)
3196
+ except FileNotFoundError:
3197
+ continue
3198
+ except OSError:
3199
+ blocks.append(_cctally_journal.LockBlock(name, "exclusive", False))
3200
+ continue
3201
+ try:
3202
+ try:
3203
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
3204
+ except OSError as exc:
3205
+ blocks.append(_cctally_journal.LockBlock(
3206
+ name, "exclusive", isinstance(exc, BlockingIOError)))
3207
+ continue
3208
+ try:
3209
+ fcntl.flock(fd, fcntl.LOCK_UN)
3210
+ except OSError:
3211
+ pass
3212
+ finally:
3213
+ os.close(fd)
3214
+ return tuple(blocks)
3215
+
3216
+
3111
3217
  def cmd_stats_epoch_rebuild_internal(args) -> int:
3112
- """Hidden detached worker: converge a pending stats epoch exactly once."""
3218
+ """Hidden detached worker: converge a pending stats epoch exactly once,
3219
+ then — with the worker flock released — post the completion wake that
3220
+ resumes deferred statusline work (#825/#832 spec 4.3 revision 2). Only a
3221
+ publication wakes: a failed rebuild manufactures no wake."""
3222
+ if _stats_epoch_rebuild_worker(args) in (
3223
+ "success", "current", STATS_EPOCH_RESIDUAL_NOT_DURABLE):
3224
+ import _cctally_completion
3225
+ _cctally_completion.post_publication_wake()
3226
+ return 0
3227
+
3228
+
3229
+ #: Fix round K11 (K9K10-R7): the epoch rebuild published a current index, but
3230
+ #: its residual-derivation obligation could not be made durable. The epoch
3231
+ #: converged (the marker is removed and the publication wake posted); the
3232
+ #: worker log records the obligation failure.
3233
+ STATS_EPOCH_RESIDUAL_NOT_DURABLE = "success-residual-not-durable"
3234
+
3235
+
3236
+ def _stats_epoch_rebuild_worker(args) -> str:
3237
+ """The worker body; returns its outcome."""
3113
3238
  del args
3114
3239
  try:
3115
3240
  pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
@@ -3120,25 +3245,31 @@ def cmd_stats_epoch_rebuild_internal(args) -> int:
3120
3245
  )
3121
3246
  except OSError as exc:
3122
3247
  _log_stats_epoch_rebuild("error", error=exc)
3123
- return 0
3248
+ return "error"
3124
3249
  try:
3125
3250
  try:
3126
3251
  fcntl.flock(worker_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
3127
3252
  except OSError:
3128
- return 0
3253
+ return "busy"
3129
3254
  if not stats_epoch_rebuild_pending():
3130
3255
  _unlink_stats_epoch_marker()
3131
3256
  _log_stats_epoch_rebuild("current")
3132
- return 0
3257
+ return "current"
3258
+ import _cctally_completion
3133
3259
  try:
3134
3260
  conn = resolve_stats_epoch_mismatch()
3135
3261
  conn.close()
3262
+ except _cctally_completion.ResidualObligationNotDurable as exc:
3263
+ _unlink_stats_epoch_marker()
3264
+ _log_stats_epoch_rebuild(STATS_EPOCH_RESIDUAL_NOT_DURABLE,
3265
+ error=exc)
3266
+ return STATS_EPOCH_RESIDUAL_NOT_DURABLE
3136
3267
  except Exception as exc:
3137
3268
  _log_stats_epoch_rebuild("error", error=exc)
3138
- return 0
3269
+ return "error"
3139
3270
  _unlink_stats_epoch_marker()
3140
3271
  _log_stats_epoch_rebuild("success")
3141
- return 0
3272
+ return "success"
3142
3273
  finally:
3143
3274
  try:
3144
3275
  fcntl.flock(worker_fd, fcntl.LOCK_UN)
@@ -3188,8 +3319,10 @@ def stats_epoch_rebuild_pending(path=None) -> bool:
3188
3319
 
3189
3320
  def resolve_stats_epoch_mismatch():
3190
3321
  """Resolve a version-mismatched stats.db by journal rebuild (spec §7.1).
3191
- Called by ``open_db`` after ``conn.close()`` — returns a fresh steady-state
3192
- ``open_db()`` connection over the rebuilt index."""
3322
+ Called by the detached epoch worker (``_stats_epoch_rebuild_worker``);
3323
+ an ordinary ``open_db`` defers the epoch to that worker instead of
3324
+ rebuilding inline. Returns a fresh steady-state ``open_db()`` connection
3325
+ over the rebuilt index."""
3193
3326
  global _EPOCH_MISMATCH_ACTIVE
3194
3327
  import _cctally_journal
3195
3328
 
@@ -3248,6 +3381,15 @@ def resolve_stats_epoch_mismatch():
3248
3381
  # Append the idempotent account coordinator input, then let
3249
3382
  # the common rebuild cutover preserve the version-ahead
3250
3383
  # family and atomically publish the current epoch.
3384
+ #
3385
+ # #825/#832 (spec C3/F1; Codex fix-round-1 Q1): the
3386
+ # rebuild runs the shared derivation-completion step, which
3387
+ # journals the decisions of every admitted reading no
3388
+ # current binary derived — including the observation of
3389
+ # the very tick whose open deferred here, and a reading a
3390
+ # previous-release ingest derived unstamped. The replaced
3391
+ # index's applied cursor is never trusted as proof that
3392
+ # derivation completed.
3251
3393
  with stats_write_scope("maintenance-epoch"):
3252
3394
  _cctally_journal.run_epoch_transition()
3253
3395
  finally:
@@ -3258,3 +3400,100 @@ def resolve_stats_epoch_mismatch():
3258
3400
  return _cctally_core.open_db()
3259
3401
  finally:
3260
3402
  _EPOCH_MISMATCH_ACTIVE = False
3403
+
3404
+
3405
+ # --------------------------------------------------------------------------
3406
+ # #825/#832 validated read snapshots (spec section 6)
3407
+ # --------------------------------------------------------------------------
3408
+
3409
+ #: The index object whose presence identifies the account-bound protocol's
3410
+ #: interpretation of the journal. A previous-release generation (epoch 1017)
3411
+ #: publishes no such table, so an index an old binary republished fails this
3412
+ #: check even if its `user_version` were somehow current.
3413
+ PROTOCOL_MARKER_TABLE = "journal_admission_summary"
3414
+
3415
+
3416
+ class StatsIndexNotCurrent(RuntimeError):
3417
+ """The stats index this snapshot reads is not the current interpretation
3418
+ of the journal (another epoch, or no protocol marker): typically a
3419
+ previous-release process republished it. Nothing read from it may be
3420
+ persisted or decided on.
3421
+
3422
+ Deliberately NOT an `sqlite3.DatabaseError`: a sound index at another epoch
3423
+ is not corruption, and the corruption classifier must never see it so."""
3424
+
3425
+
3426
+ def validate_stats_identity(conn: sqlite3.Connection, *, expected_epoch: int,
3427
+ expected_protocol: "str | None") -> None:
3428
+ """Check epoch and protocol identity on the caller's CURRENT snapshot.
3429
+
3430
+ Inside a transaction both reads come from that transaction's snapshot, so
3431
+ the check and the caller's own queries describe one database state."""
3432
+ epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
3433
+ if (epoch <= _cctally_core.LEGACY_STATS_HEAD
3434
+ and int(expected_epoch) == _cctally_core.STATS_INDEX_EPOCH):
3435
+ # A legacy PRE-JOURNAL index (#825/#832 Task 4 regression fix): the
3436
+ # one-time journal cutover has not converted it yet — typically a
3437
+ # gated recompute migration is still deferred — so it was never
3438
+ # materialized from the journal and is not "another interpretation"
3439
+ # of it. No account-bound boundary exists before that cutover
3440
+ # (`AccountBoundCutoverDeferred`), so admission excludes nothing. The
3441
+ # ingest's own open admits exactly this range (`epoch_ok` in
3442
+ # `_run_stats_ingest_once`); rejecting it here made every recording
3443
+ # on such a store exit 1. A previous release's index is at 1017, far
3444
+ # above the legacy head, and stays rejected.
3445
+ return
3446
+ if epoch != int(expected_epoch):
3447
+ raise StatsIndexNotCurrent(
3448
+ f"stats index epoch {epoch} is not the current {expected_epoch}")
3449
+ if expected_protocol is not None:
3450
+ row = conn.execute(
3451
+ "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
3452
+ (PROTOCOL_MARKER_TABLE,),
3453
+ ).fetchone()
3454
+ if row is None:
3455
+ raise StatsIndexNotCurrent(
3456
+ f"stats index lacks the {expected_protocol} protocol marker")
3457
+
3458
+
3459
+ @contextlib.contextmanager
3460
+ def validated_read_snapshot(conn: sqlite3.Connection, *, expected_epoch: int,
3461
+ expected_protocol: "str | None"):
3462
+ """ONE read snapshot whose identity is proven first.
3463
+
3464
+ Joins a transaction the caller already holds; otherwise opens a deferred
3465
+ read transaction and rolls it back afterwards (the `one_read_snapshot`
3466
+ discipline). The epoch and protocol identity are read INSIDE the snapshot
3467
+ the enclosed queries use, so a reader that validated its connection at open
3468
+ cannot read an index a previous-release process republished afterwards.
3469
+ Raises `StatsIndexNotCurrent` before the block runs."""
3470
+ own = not conn.in_transaction
3471
+ if own:
3472
+ conn.execute("BEGIN")
3473
+ try:
3474
+ validate_stats_identity(conn, expected_epoch=expected_epoch,
3475
+ expected_protocol=expected_protocol)
3476
+ yield
3477
+ finally:
3478
+ if own:
3479
+ try:
3480
+ conn.rollback()
3481
+ except sqlite3.Error:
3482
+ pass
3483
+
3484
+
3485
+ def current_stats_snapshot(conn: sqlite3.Connection):
3486
+ """`validated_read_snapshot` at `STATS_INDEX_EPOCH` and the account-bound
3487
+ protocol: the read scope every persisted-decision owner uses."""
3488
+ import _lib_account_protocol
3489
+ return validated_read_snapshot(
3490
+ conn, expected_epoch=_cctally_core.STATS_INDEX_EPOCH,
3491
+ expected_protocol=_lib_account_protocol.PROTOCOL_GENERATION)
3492
+
3493
+
3494
+ def require_current_stats_identity(conn: sqlite3.Connection) -> None:
3495
+ """The same check on the caller's own (write) transaction."""
3496
+ import _lib_account_protocol
3497
+ validate_stats_identity(
3498
+ conn, expected_epoch=_cctally_core.STATS_INDEX_EPOCH,
3499
+ expected_protocol=_lib_account_protocol.PROTOCOL_GENERATION)
@@ -49,13 +49,14 @@ def cmd_transcript(args) -> int:
49
49
  # ---- export ----------------------------------------------------------------
50
50
 
51
51
  _SPEED_ONLY_CODEX_MSG = "transcript: --speed applies only to Codex conversations"
52
- # Deliberately generic about WHAT is pending. Three distinct conditions reach
53
- # this branch — a cache predating migration 025, a store with the contract
54
- # rebuild marker armed, and a store with a byte-zero Codex replay marker armed —
55
- # and naming the migration told users to wait for work applied long ago.
52
+ # Deliberately generic about WHAT is pending. Four distinct conditions reach
53
+ # this branch: a cache predating migration 025, a store with the contract
54
+ # rebuild marker armed, a store with a byte-zero Codex replay marker armed,
55
+ # and (#968) a population conversation the publication barrier has not yet
56
+ # published. The last is finished by a background worker, not by the next
57
+ # cache open, so the note names no mechanism (operator decision D5).
56
58
  _PENDING_EXPORT_MSG = (
57
- "transcript: Codex conversation is not yet normalized "
58
- "(the pending work runs on the next cache open) — retry shortly")
59
+ "transcript: Codex conversation is not yet normalized — retry shortly")
59
60
 
60
61
 
61
62
  def _emit_export(md: str, output) -> None:
@@ -265,7 +266,7 @@ def _cmd_transcript_search_claude(args) -> int:
265
266
 
266
267
  # ---- search (Codex) --------------------------------------------------------
267
268
 
268
- # Same generic wording as `_PENDING_EXPORT_MSG`, for the same reason.
269
+ # The store-wide conditions run on the next cache open; D5 changes only export.
269
270
  _CODEX_SEARCH_PENDING_MSG = (
270
271
  "transcript: Codex conversations are not yet normalized "
271
272
  "(the pending work runs on the next cache open); no results yet")