cctally 1.116.0 → 1.117.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 (72) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +224 -7
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +34 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +389 -18
  15. package/bin/_cctally_forecast.py +30 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_oauth_samples.py +185 -7
  18. package/bin/_cctally_parser.py +38 -0
  19. package/bin/_cctally_project.py +34 -8
  20. package/bin/_cctally_quarantine.py +821 -343
  21. package/bin/_cctally_quota.py +69 -4
  22. package/bin/_cctally_quota_calibration.py +677 -3
  23. package/bin/_cctally_quota_model.py +927 -127
  24. package/bin/_cctally_record.py +922 -445
  25. package/bin/_cctally_rederive.py +119 -15
  26. package/bin/_cctally_statusline.py +1048 -176
  27. package/bin/_cctally_store.py +544 -110
  28. package/bin/_cctally_tui.py +93 -9
  29. package/bin/_cctally_update.py +77 -14
  30. package/bin/_lib_account_protocol.py +227 -19
  31. package/bin/_lib_claude_attribution.py +421 -0
  32. package/bin/_lib_clone_copy.py +30 -0
  33. package/bin/_lib_codex_conversation_query.py +45 -9
  34. package/bin/_lib_codex_conversation_watch.py +72 -13
  35. package/bin/_lib_codex_rollout_container.py +234 -19
  36. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  37. package/bin/_lib_credit.py +22 -13
  38. package/bin/_lib_dashboard_sources.py +163 -10
  39. package/bin/_lib_detector_access_registry.py +1335 -166
  40. package/bin/_lib_doctor.py +276 -92
  41. package/bin/_lib_ingest_frontier.py +269 -36
  42. package/bin/_lib_journal.py +273 -22
  43. package/bin/_lib_jsonl.py +12 -0
  44. package/bin/_lib_meter_rate_change.py +72 -0
  45. package/bin/_lib_outcome_quarantine.py +917 -554
  46. package/bin/_lib_provider_activity.py +24 -6
  47. package/bin/_lib_quota_calibration.py +57 -3
  48. package/bin/_lib_quota_copy.py +7 -2
  49. package/bin/_lib_quota_lineage.py +613 -0
  50. package/bin/_lib_quota_model.py +435 -22
  51. package/bin/_lib_rate_change_delivery.py +16 -0
  52. package/bin/_lib_reclaim_planner.py +6 -4
  53. package/bin/_lib_rederive.py +2046 -21
  54. package/bin/_lib_selector_state.py +131 -3
  55. package/bin/_lib_stale_proof.py +113 -4
  56. package/bin/_lib_statusline.py +72 -3
  57. package/bin/_lib_statusline_candidates.py +246 -42
  58. package/bin/_lib_write_io.py +418 -5
  59. package/bin/cctally +24 -11
  60. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-Dcfn8T6f.js} +1 -1
  61. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-BqmXbnhn.js} +1 -1
  62. package/dashboard/static/assets/{ModalRoot-HlxoagIa.js → ModalRoot-Ck3WJK2V.js} +1 -1
  63. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-B-HZkDnT.js} +1 -1
  64. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-QhJkHB7i.js} +1 -1
  65. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CkXEIQcK.js} +1 -1
  66. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  67. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-HKS1s56o.js} +3 -3
  68. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-BNGaNPCp.js} +1 -1
  69. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  70. package/dashboard/static/dashboard.html +3 -3
  71. package/package.json +5 -1
  72. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -278,6 +278,7 @@ import secrets
278
278
  import shutil
279
279
  import signal as _signal
280
280
  import socket
281
+ import socketserver
281
282
  import select
282
283
  import sqlite3
283
284
  import sys
@@ -338,6 +339,21 @@ class _QuietThreadingHTTPServer(ThreadingHTTPServer):
338
339
  self._reject_workers: list[threading.Thread] = []
339
340
  super().__init__(*args, **kwargs)
340
341
 
342
+ def server_bind(self) -> None:
343
+ """Bind and record the bound address without a reverse-DNS lookup.
344
+
345
+ Replaces `http.server.HTTPServer.server_bind`, which sets `server_name`
346
+ to `socket.getfqdn(host)`: a PTR lookup that blocks for the resolver's
347
+ whole timeout where the resolver never answers it (35s measured on a
348
+ LAN runner), before the dashboard can print its URL or serve (#1021).
349
+ Nothing here reads `server_name` as a hostname, so it records the bound
350
+ address as `getsockname()` reports it.
351
+ """
352
+ socketserver.TCPServer.server_bind(self)
353
+ host, port = self.server_address[:2]
354
+ self.server_name = host
355
+ self.server_port = port
356
+
341
357
  def _reject_request(self, request: socket.socket) -> None:
342
358
  try:
343
359
  # Read only the bounded request headers so a close with unread
@@ -741,7 +757,7 @@ from _cctally_cache import (
741
757
  get_entries, iter_entries, iter_entries_with_id,
742
758
  open_cache_db as _raw_open_cache_db,
743
759
  open_conversations_db, sync_cache, sync_claude_conversations,
744
- sync_codex_conversations,
760
+ sync_codex_conversations, read_codex_startup_deferral_facts,
745
761
  # #780 read-route admission. Imported here so the conversation route module
746
762
  # reaches them the same way it reaches `open_conversations_db` — through
747
763
  # `sys.modules["_cctally_dashboard"]`, which is the seam its tests patch.
@@ -2615,7 +2631,9 @@ def _conversation_frontier_context():
2615
2631
  frontier_mod = _cctally()._load_sibling("_lib_ingest_frontier")
2616
2632
  claude_roots = tuple(_cctally_core._resolve_claude_projects_dirs())
2617
2633
  codex_homes = tuple(_cctally()._codex_home_roots())
2618
- codex_roots = tuple(root / "sessions" for root in codex_homes)
2634
+ # #970: native-home recognition, never a hard-coded `<home>/sessions`, so
2635
+ # an archive directory is certified evidence and a permitted location.
2636
+ codex_roots = frontier_mod.codex_frontier_roots(codex_homes)
2619
2637
  claude_guards = (_cctally_core.CLAUDE_SETTINGS_PATH,)
2620
2638
 
2621
2639
  try:
@@ -2689,15 +2707,121 @@ def _conversation_frontier_plans(conn):
2689
2707
 
2690
2708
 
2691
2709
  class _ConversationPassStatus(str):
2692
- """Validated status string carrying fixed-size pass diagnostics."""
2710
+ """Validated status string carrying fixed-size pass diagnostics.
2711
+
2712
+ ``drain`` is the pass's evidence for writeIo's startup admission hold
2713
+ (#939 D5, `_conversation_drain_info`), or ``None``."""
2693
2714
 
2694
- def __new__(cls, status, *, modes=None, files=None):
2715
+ def __new__(cls, status, *, modes=None, files=None, drain=None):
2695
2716
  obj = str.__new__(cls, status)
2696
2717
  obj.modes = dict(modes or {})
2697
2718
  obj.files = dict(files or {})
2719
+ obj.drain = drain
2698
2720
  return obj
2699
2721
 
2700
2722
 
2723
+ def _conversation_drain_info(conn, status, modes, plans, results,
2724
+ certifiable) -> dict:
2725
+ """What one ``ok`` pass tells writeIo's admission hold (#939 D5).
2726
+
2727
+ Per provider: its census and residual (``None`` for ``caught_up``, which
2728
+ carries the previous one forward), plus an empty ``"pass"`` status entry
2729
+ (an error pass's is non-empty: `_conversation_failed_drain`). For Codex,
2730
+ its unpublished deferrals and, while the hold is placed, the stored facts
2731
+ of every deferral the evaluator tracks, read at the pass's end. The pass
2732
+ is census-complete only when its status is ``ok``, both censuses are
2733
+ complete, the facts could be read and no detached Codex worker is in
2734
+ progress at its end.
2735
+ """
2736
+ import _lib_ingest_frontier as frontier_lib
2737
+ providers = {"pass": {"mode": "full", "residual": ()}}
2738
+ census = status == "ok"
2739
+ for provider in ("claude", "codex"):
2740
+ mode = modes.get(provider, "not_observed")
2741
+ stats = results.get(provider)
2742
+ plan = plans.get(provider) if plans else None
2743
+ expected = len(plan.paths) if plan is not None else 0
2744
+ census = census and frontier_lib.conversation_census_complete(
2745
+ mode, stats, expected_paths=expected)
2746
+ providers[provider] = {
2747
+ "mode": mode,
2748
+ "residual": (None if mode == "caught_up"
2749
+ else _lib_write_io.conversation_residual(stats)),
2750
+ }
2751
+ codex_mode = modes.get("codex")
2752
+ deferrals = (None if codex_mode == "caught_up" or "codex" not in results
2753
+ else _lib_write_io.unpublished_deferrals(results.get("codex")))
2754
+ facts = {}
2755
+ if conn is not None and _lib_write_io.admission_held():
2756
+ entries = dict(_lib_write_io.startup_watch())
2757
+ for path, (identity, _covered, keys) in (deferrals or {}).items():
2758
+ prior = entries.get(path)
2759
+ entries[path] = (identity,
2760
+ frozenset(keys) | (prior[1] if prior else frozenset()))
2761
+ entries = {path: entry for path, entry in entries.items()
2762
+ if entry[0] is not None}
2763
+ if entries:
2764
+ try:
2765
+ facts = read_codex_startup_deferral_facts(conn, entries)
2766
+ except Exception as exc: # noqa: BLE001 — missing facts keep the hold
2767
+ eprint(f"[conversations] startup facts unavailable: {exc}")
2768
+ census = False
2769
+ if census:
2770
+ census = not _codex_worker_in_progress()
2771
+ return {"census_complete": bool(census), "certifiable": bool(certifiable),
2772
+ "providers": providers, "deferrals": deferrals, "facts": facts}
2773
+
2774
+
2775
+ def _codex_worker_in_progress() -> bool:
2776
+ """Whether a detached Codex worker is requested or running (#939 D5); an
2777
+ unknown worker state counts as one (it keeps the hold)."""
2778
+ import _lib_codex_worker_lifecycle as lifecycle
2779
+ try:
2780
+ return bool(lifecycle.any_in_progress(
2781
+ _cctally_core.APP_DIR, now=time.time()))
2782
+ except Exception: # noqa: BLE001 — unknown worker state: keep the hold
2783
+ return True
2784
+
2785
+
2786
+ def _connection_changes(conn) -> "int | None":
2787
+ """``conn.total_changes``: rows changed on the connection since it was
2788
+ created, including the store open's own migrations, imports and replays
2789
+ (#939 R9-01) and changes later rolled back; None when it cannot be read
2790
+ (unknown reads as changed). A double without the attribute changes
2791
+ nothing."""
2792
+ try:
2793
+ return int(getattr(conn, "total_changes", 0) or 0)
2794
+ except Exception: # noqa: BLE001
2795
+ return None
2796
+
2797
+
2798
+ def _conversation_failed_drain(status: str, error_name: str, *,
2799
+ changed_rows: bool) -> dict:
2800
+ """What a pass that ended in ``error`` or ``store_unavailable`` tells
2801
+ writeIo's admission hold (#939 D5).
2802
+
2803
+ A failure can commit work before it propagates (D1's prepared prefix, a
2804
+ Codex publication unit), and no residual can prove that work was not
2805
+ catch-up progress (#939 R7-01, R8-01). So a failing pass that changed any
2806
+ row on its own connection since its creation, the open's migrations
2807
+ included (``changed_rows``, R9-01), and a failed open, which leaves no
2808
+ connection to ask (R10-01), are incomplete censuses: they never anchor a
2809
+ settle. A pass that opened and changed nothing can repeat for the dashboard's whole life, so it is
2810
+ census-complete (unless a detached Codex worker is in progress) with its
2811
+ status and exception class as its residual: an identical failure settles,
2812
+ and a later ``ok`` pass, whose status entry is empty, changes the digest.
2813
+ It is never certifiable, so it never releases as drained. The provider
2814
+ residuals and deferrals carry forward."""
2815
+ return {"census_complete": (not changed_rows
2816
+ and not _codex_worker_in_progress()),
2817
+ "certifiable": False,
2818
+ "providers": {"pass": {
2819
+ "mode": "full",
2820
+ "residual": (("status", status),
2821
+ ("exception", error_name))}},
2822
+ "deferrals": None, "facts": {}}
2823
+
2824
+
2701
2825
  def _conversation_sync_pass() -> str:
2702
2826
  """One WHOLE transcript-ingest pass (#583 S4 / F5).
2703
2827
 
@@ -2742,17 +2866,37 @@ def _conversation_sync_pass() -> str:
2742
2866
  conn = open_conversations_db()
2743
2867
  except (OSError, sqlite3.DatabaseError) as exc:
2744
2868
  eprint(f"[conversations] background sync unavailable: {exc}")
2745
- return _ConversationPassStatus("store_unavailable")
2869
+ # The open can commit work before it fails (a resumable migration's
2870
+ # chunks), and with no connection to ask nothing proves it did not:
2871
+ # file metadata cannot (a reused WAL keeps its size, mtimes are
2872
+ # coarse), so a failed open is inconclusive, read as changed
2873
+ # (#939 R9-01, R10-01).
2874
+ return _ConversationPassStatus(
2875
+ "store_unavailable",
2876
+ drain=_conversation_failed_drain(
2877
+ "store_unavailable", type(exc).__name__, changed_rows=True))
2746
2878
  status = "ok"
2879
+ error_name = None
2880
+ # Rows changed on this pass's connection, from its creation (#939 R8-01,
2881
+ # R9-01): the open's migrations and imports run on it too.
2882
+ changed_rows = True
2747
2883
  modes = {"claude": "not_observed", "codex": "not_observed"}
2748
2884
  files = {"claude": 0, "codex": 0}
2885
+ results = {}
2886
+ plans = None
2887
+ certifiable = False
2888
+ drain = None
2749
2889
  try:
2750
2890
  planned = _conversation_frontier_plans(conn)
2751
- results = {}
2752
2891
  if planned is None:
2753
2892
  modes = {"claude": "full", "codex": "full"}
2754
2893
  results["claude"] = sync_claude_conversations(conn)
2755
2894
  results["codex"] = sync_codex_conversations(conn)
2895
+ import _lib_ingest_frontier as frontier_lib
2896
+ certifiable = all(
2897
+ frontier_lib.conversation_sync_certifiable(
2898
+ "full", results.get(provider))
2899
+ for provider in ("claude", "codex"))
2756
2900
  else:
2757
2901
  frontier_mod, frontier, cutoff, context, plans = planned
2758
2902
  modes = {
@@ -2801,9 +2945,18 @@ def _conversation_sync_pass() -> str:
2801
2945
  frontier.commit_provider(
2802
2946
  plan, conn, roots=roots, guard_paths=guards,
2803
2947
  trusted=trusted, cutoff=cutoff)
2948
+ try:
2949
+ drain = _conversation_drain_info(
2950
+ conn, status, modes, plans, results, certifiable)
2951
+ except Exception as exc: # noqa: BLE001 — the hold's evidence is
2952
+ # diagnostic: it must never fail a pass that ingested. A missing
2953
+ # record is read as a pass that did not cover its census.
2954
+ eprint(f"[conversations] startup drain evidence unavailable: {exc}")
2955
+ drain = None
2804
2956
  except (OSError, sqlite3.DatabaseError) as exc:
2805
2957
  eprint(f"[conversations] background sync unavailable: {exc}")
2806
2958
  status = "store_unavailable"
2959
+ error_name = type(exc).__name__
2807
2960
  except Exception as exc: # noqa: BLE001
2808
2961
  # Transcript parsing/normalization is deliberately outside the core
2809
2962
  # freshness loop. Keep this worker alive so a later clean tick can
@@ -2814,7 +2967,10 @@ def _conversation_sync_pass() -> str:
2814
2967
  f"{type(exc).__name__}: {exc}"
2815
2968
  )
2816
2969
  status = "error"
2970
+ error_name = type(exc).__name__
2817
2971
  finally:
2972
+ changes = _connection_changes(conn)
2973
+ changed_rows = changes is None or changes > 0
2818
2974
  try:
2819
2975
  conn.close()
2820
2976
  except Exception: # noqa: BLE001
@@ -2824,7 +2980,18 @@ def _conversation_sync_pass() -> str:
2824
2980
  if provider in results:
2825
2981
  files[provider] = int(
2826
2982
  getattr(results.get(provider), "files_total", 0) or 0)
2827
- return _ConversationPassStatus(status, modes=modes, files=files)
2983
+ if status != "ok":
2984
+ # An error or an unavailable store ended the pass: its status is its
2985
+ # residual, so a failure that repeats identically settles (#939 D5).
2986
+ # An error pass is never drained.
2987
+ drain = _conversation_failed_drain(status, error_name or "Exception",
2988
+ changed_rows=changed_rows)
2989
+ elif drain is None:
2990
+ # An ok pass whose evidence could not be built: not census-complete.
2991
+ drain = {"census_complete": False, "certifiable": False,
2992
+ "providers": {}, "deferrals": None, "facts": {}}
2993
+ return _ConversationPassStatus(status, modes=modes, files=files,
2994
+ drain=drain)
2828
2995
 
2829
2996
 
2830
2997
  def _conversation_sync_loop(
@@ -2847,12 +3014,31 @@ def _conversation_sync_loop(
2847
3014
  The previous fixed `wait(interval)` prevented literal 100% duty for finite
2848
3015
  work but provided no scale-independent ceiling below it: a 30 s pass ran
2849
3016
  30-on/5-off, about 86% duty, and nothing bounded that as the store grew.
3017
+
3018
+ #939 D5: every completed pass is fed to writeIo's startup admission hold,
3019
+ whose verdict ("drained" or "settled") releases it; the loop's end
3020
+ releases it in a ``finally`` ("loopExit"), so a dead loop never holds
3021
+ admission. An exception from the evaluator is logged once and read as no
3022
+ verdict (the hold stays), never as the end of ingest.
2850
3023
  """
3024
+ try:
3025
+ _conversation_sync_loop_body(
3026
+ stop=stop, interval=interval, run_iteration=run_iteration,
3027
+ monotonic=monotonic, thread_time_ns=thread_time_ns, wait=wait,
3028
+ record=record)
3029
+ finally:
3030
+ _lib_write_io.release_admission("loopExit")
3031
+
3032
+
3033
+ def _conversation_sync_loop_body(
3034
+ *, stop, interval, run_iteration, monotonic, thread_time_ns, wait, record,
3035
+ ) -> None:
2851
3036
  if thread_time_ns is None:
2852
3037
  thread_time_ns = time.thread_time_ns
2853
3038
  if wait is None:
2854
3039
  wait = stop.wait
2855
3040
  seq = 0
3041
+ evaluator_failed = False
2856
3042
  while not stop.is_set():
2857
3043
  t0 = monotonic()
2858
3044
  cpu0 = thread_time_ns()
@@ -2866,6 +3052,20 @@ def _conversation_sync_loop(
2866
3052
  work = max(0.0, monotonic() - t0)
2867
3053
  cpu_ns = max(0, thread_time_ns() - cpu0)
2868
3054
  seq += 1
3055
+ try:
3056
+ verdict = _lib_write_io.observe_conversation_pass(
3057
+ getattr(status, "drain", None), started_ns=int(t0 * 1e9),
3058
+ seq=seq)
3059
+ if verdict is not None:
3060
+ _lib_write_io.release_admission(verdict)
3061
+ except Exception as exc: # noqa: BLE001 — an evaluator bug must not
3062
+ # stop ingest: the verdict is read as None (the hold stays, fail
3063
+ # closed like every other missing-evidence path) and the loop
3064
+ # keeps running. Logged once per loop.
3065
+ if not evaluator_failed:
3066
+ evaluator_failed = True
3067
+ eprint("[conversations] writeIo startup evaluator failed: "
3068
+ f"{type(exc).__name__}: {exc}")
2869
3069
  if record is not None:
2870
3070
  record(
2871
3071
  seq=seq,
@@ -9236,6 +9436,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
9236
9436
  "conversation_sync": [
9237
9437
  r.as_wire() for r in tick_state.conversation_records
9238
9438
  ],
9439
+ # #939 D5: the records' `started_ns`/`ended_ns` clock (the
9440
+ # process monotonic clock) anchored to wall time in this
9441
+ # response, so a harness can place its own judgements before
9442
+ # or after a pass. One pair, not a field per record: the ring
9443
+ # sits at the frozen 65,536-byte state budget.
9444
+ "conversation_sync_clock": {
9445
+ "monotonic_ns": time.monotonic_ns(),
9446
+ "unix_ns": time.time_ns(),
9447
+ },
9239
9448
  # #780: the maintenance phases, published beside the sync ring
9240
9449
  # rather than inside it. A reclaim that ran for its whole budget
9241
9450
  # used to be indistinguishable from a fast one, because both
@@ -12539,6 +12748,11 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12539
12748
  _lib_write_io.register_local_instance(
12540
12749
  instance_id=write_instance_id, host=args.host,
12541
12750
  port=_resolve_dashboard_port(args.port))
12751
+ # #939 D5: hold warm admission through the startup catch-up. Placed before
12752
+ # the main sync thread can publish a non-cold snapshot, and only when the
12753
+ # conversation loop (which releases it) will run.
12754
+ if not args.no_sync:
12755
+ _lib_write_io.hold_admission()
12542
12756
  sync_thread = (
12543
12757
  None if args.no_sync
12544
12758
  else _DashboardSyncThread(
@@ -12689,6 +12903,9 @@ def cmd_dashboard(args: argparse.Namespace) -> int:
12689
12903
  conversation_sync_stop.set()
12690
12904
  if conversation_sync_thread is not None:
12691
12905
  conversation_sync_thread.join(timeout=2)
12906
+ # #939 D5: the loop's own `finally` releases the hold; a loop still
12907
+ # finishing a pass after the bounded join must not leave it placed.
12908
+ _lib_write_io.release_admission("loopExit")
12692
12909
  update_check_stop.set()
12693
12910
  memory_shutdown_errors = []
12694
12911
  try:
@@ -46,6 +46,8 @@ import base64
46
46
  import collections
47
47
  import dataclasses
48
48
  import hashlib
49
+ import os
50
+ import pathlib
49
51
  import secrets
50
52
  import select
51
53
  import threading
@@ -444,17 +446,86 @@ def _cached_file_sigs(conn, paths):
444
446
  return out
445
447
 
446
448
 
447
- #: The cursor columns a Codex live-tail signature is computed from (#968 5.9).
449
+ #: The cursor columns a Codex live-tail signature is computed from (#968 5.9;
450
+ #: #970 appends ``container_path``, the accepted location).
448
451
  _CODEX_WATCH_SIG_COLUMNS = (
449
452
  "path, size_bytes, container_kind, container_size_bytes, mtime_ns, "
450
- "device_id, inode, pending_container_fingerprint")
453
+ "device_id, inode, pending_container_fingerprint, container_path")
451
454
 
452
455
 
453
456
  def _codex_watch_kernel():
454
457
  return sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
455
458
 
456
459
 
457
- def _codex_acknowledged_sig(row):
460
+ def _codex_watch_locations():
461
+ """The configured Codex container locations (#970), for one batch."""
462
+ return tuple(_rc.CodexLocations(root.walk_root, root.archive_root)
463
+ for root in _codex_provider_roots())
464
+
465
+
466
+ def _codex_lazy_locations():
467
+ """A memoized locations getter: resolved on first need, at most once.
468
+
469
+ A home without archived containers never needs it, so its signatures
470
+ cost exactly what they cost before #970.
471
+ """
472
+ memo: list = []
473
+
474
+ def get():
475
+ if not memo:
476
+ memo.append(_codex_watch_locations())
477
+ return memo[0]
478
+
479
+ return get
480
+
481
+
482
+ def _codex_sig_location(container_path, logical, locations) -> str:
483
+ """The location a stored container path names (#970 A8).
484
+
485
+ NULL is the logical path (dated). A container whose directory is the
486
+ rollout's archive directory — canonically, when the spellings differ —
487
+ is ``"archive"``; anything else (the dated twin, an uncontained stored
488
+ path) is ``"dated"``. This is exactly how selection names a location, so
489
+ a committed signature equals the observation of the same container.
490
+ """
491
+ if not container_path:
492
+ return _rc.DATED
493
+ parent = pathlib.Path(container_path).parent
494
+ if parent == pathlib.Path(logical).parent:
495
+ return _rc.DATED
496
+ archive_dir = _rc.archive_dir_for(logical, locations())
497
+ if archive_dir is None:
498
+ return _rc.DATED
499
+ if parent == archive_dir:
500
+ return _rc.ARCHIVE
501
+ try:
502
+ same = os.path.realpath(parent) == os.path.realpath(archive_dir)
503
+ except (OSError, ValueError):
504
+ return _rc.DATED
505
+ return _rc.ARCHIVE if same else _rc.DATED
506
+
507
+
508
+ def _codex_observe_watch_sig(path, locations=None):
509
+ """The live probe: the selected container's signature, archive included.
510
+
511
+ The dated containers are tried first through the leaf alone, so a dated
512
+ rollout costs exactly today's probe; only when neither exists, and the
513
+ path is a dated rollout of a native home, are its archive candidates
514
+ observed (#970 A8), the signature then carrying ``"archive"``.
515
+ """
516
+ sig = _rc.observe_watch_sig(path)
517
+ if sig is not None:
518
+ return sig
519
+ if _rc.archive_name_date(pathlib.Path(str(path)).name) is None:
520
+ return None
521
+ archive_dir = _rc.archive_dir_for(
522
+ path, (locations or _codex_watch_locations)())
523
+ if archive_dir is None:
524
+ return None
525
+ return _rc.observe_watch_sig(path, archive_dir=archive_dir)
526
+
527
+
528
+ def _codex_acknowledged_sig(row, locations=None):
458
529
  """The live-tail signature one ``codex_conversation_source_files`` row
459
530
  acknowledges (#968 spec 5.9).
460
531
 
@@ -463,15 +534,20 @@ def _codex_acknowledged_sig(row):
463
534
  it, and rescheduling it on every poll is the non-convergence I9 forbids.
464
535
  Otherwise the accepted container's signature: ``("plain", size)`` for
465
536
  plain, which is today's committed size, or the container identity for a
466
- zstd container.
537
+ zstd container. #970: either carries the container's location exactly as
538
+ the observation does (``"archive"`` appended only outside the dated
539
+ location), derived from the recorded container path.
467
540
  """
468
- _path, size, kind, csize, mtime_ns, device, inode, pending = row
541
+ path, size, kind, csize, mtime_ns, device, inode, pending, stored = row
542
+ locations = locations or _codex_lazy_locations()
469
543
  if pending:
470
544
  fingerprint = _rc.ContainerFingerprint.from_json(pending)
471
545
  if fingerprint is not None:
472
- return fingerprint.watch_sig()
546
+ return fingerprint.watch_sig(
547
+ _codex_sig_location(fingerprint.path, path, locations))
473
548
  return _codex_watch_kernel().committed_watch_sig(
474
- kind, size, csize, mtime_ns, device, inode)
549
+ kind, size, csize, mtime_ns, device, inode,
550
+ location=_codex_sig_location(stored, path, locations))
475
551
 
476
552
 
477
553
  def _codex_cached_file_sigs(conn, paths):
@@ -494,8 +570,9 @@ def _codex_cached_file_sigs(conn, paths):
494
570
  f"WHERE path IN ({placeholders})", list(paths)).fetchall()
495
571
  except sqlite3.OperationalError:
496
572
  return out
573
+ locations = _codex_lazy_locations()
497
574
  for row in rows:
498
- out[row[0]] = _codex_acknowledged_sig(row)
575
+ out[row[0]] = _codex_acknowledged_sig(row, locations)
499
576
  return out
500
577
 
501
578
 
@@ -511,7 +588,8 @@ def _codex_all_committed_sizes(conn):
511
588
  "FROM codex_conversation_source_files").fetchall()
512
589
  except sqlite3.OperationalError:
513
590
  return {}
514
- return {row[0]: _codex_acknowledged_sig(row) for row in rows}
591
+ locations = _codex_lazy_locations()
592
+ return {row[0]: _codex_acknowledged_sig(row, locations) for row in rows}
515
593
 
516
594
 
517
595
  def _codex_seal_token(conn):
@@ -586,6 +664,9 @@ def _codex_watch_probes(conn):
586
664
  deferred: dict = {} # path -> (pre-ingest signature, seal token)
587
665
  accounting_wait: dict = {} # path -> observed container awaiting accounting
588
666
  rearmed: set = set()
667
+ # #970: resolved once per stream, and only when an archived container
668
+ # is actually met.
669
+ locations = _codex_lazy_locations()
589
670
 
590
671
  def accounting_accepted(path, sig):
591
672
  try:
@@ -595,7 +676,8 @@ def _codex_watch_probes(conn):
595
676
  "WHERE path=? AND container_state='accepted'", (path,)).fetchone()
596
677
  except sqlite3.Error:
597
678
  return False
598
- return row is not None and _codex_acknowledged_sig(row) == sig
679
+ return (row is not None
680
+ and _codex_acknowledged_sig(row, locations) == sig)
599
681
 
600
682
  def executor_owns_observations(paths):
601
683
  # Publication-unit planning can encounter the same pending container
@@ -620,12 +702,13 @@ def _codex_watch_probes(conn):
620
702
  before = observed.get(path)
621
703
  if (fingerprint is None or before is None
622
704
  or before[0] != _rc.ZSTD
623
- or fingerprint.watch_sig() != before):
705
+ or fingerprint.watch_sig(_codex_sig_location(
706
+ fingerprint.path, path, locations)) != before):
624
707
  return False
625
708
  return True
626
709
 
627
710
  def stat_fn(path):
628
- sig = _rc.observe_watch_sig(path)
711
+ sig = _codex_observe_watch_sig(path, locations)
629
712
  observed[path] = sig
630
713
  waiting = accounting_wait.get(path)
631
714
  if waiting is not None:
@@ -741,11 +824,13 @@ def _codex_classified_paths(conn, paths):
741
824
 
742
825
 
743
826
  def _codex_walk_root_for_conversation(conn, conversation_key):
744
- """The configured ``walk_root`` of the conversation's OWN provider root (the
745
- frontier's scope — §5.4). Resolves the conversation's ``source_root_key`` via
746
- its thread facts, then matches it to a currently-configured provider root.
747
- ``None`` when the conversation has no thread row or its root is no longer
748
- configured (the frontier is then skipped — DB re-resolve alone still runs)."""
827
+ """The container locations of the conversation's OWN provider root (the
828
+ frontier's scope — §5.4): its configured ``walk_root`` and, for a native
829
+ home, its ``archive_root`` (#970), as ``_rc.CodexLocations``. Resolves the
830
+ conversation's ``source_root_key`` via its thread facts, then matches it to
831
+ a currently-configured provider root. ``None`` when the conversation has no
832
+ thread row or its root is no longer configured (the frontier is then
833
+ skipped — DB re-resolve alone still runs)."""
749
834
  cq = _conversation_query_impl_codex()
750
835
  thread = cq._thread_facts(conn, conversation_key)
751
836
  if thread is None:
@@ -753,7 +838,7 @@ def _codex_walk_root_for_conversation(conn, conversation_key):
753
838
  source_root_key = thread[3]
754
839
  for root in _codex_provider_roots():
755
840
  if root.source_root_key == source_root_key:
756
- return str(root.walk_root)
841
+ return _rc.CodexLocations(root.walk_root, root.archive_root)
757
842
  return None
758
843
 
759
844
 
@@ -1748,11 +1833,12 @@ def _make_codex_discovery_step(handler, conn, conversation_key, cq_codex):
1748
1833
  frontier is then skipped — the DB re-resolve of layer 1 still runs). The
1749
1834
  frontier is constructed ONCE per SSE connection so its directory index,
1750
1835
  pending-candidate set, and rotation cursors accumulate across cycles."""
1751
- walk_root = _codex_walk_root_for_conversation(conn, conversation_key)
1752
- if walk_root is None:
1836
+ locations = _codex_walk_root_for_conversation(conn, conversation_key)
1837
+ if locations is None:
1753
1838
  return None
1754
1839
  fw = sys.modules["cctally"]._load_sibling("_lib_codex_conversation_watch")
1755
- frontier = fw.CodexChildFrontier(walk_root)
1840
+ frontier = fw.CodexChildFrontier(
1841
+ str(locations.walk_root), archive_root=locations.archive_root)
1756
1842
 
1757
1843
  def _discovery(files, seen):
1758
1844
  # Diff brand-new *.jsonl against every tracked Codex file; the same
@@ -987,6 +987,34 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
987
987
  METER_RATE_CHANGE_LIMIT = 20
988
988
 
989
989
 
990
+ def _rate_change_supersessions(conn: sqlite3.Connection) -> dict:
991
+ """Wire `supersession` objects by transition identity (#1015 spec 5.7).
992
+
993
+ Read through `read_rate_change_supersessions` on the build's own stats
994
+ connection, so both the explicit `meter_rate_change_supersessions` rows
995
+ and the supersessions derived from a transition's lineage before any
996
+ sweep journals one are covered. `superseded_at` is the explicit event's
997
+ sweep clock, or null for a derived-only state. Unreadable facts publish
998
+ no supersession — the pre-#1015 row — rather than emptying the array: the
999
+ marker and delivery paths refuse on their own when the facts cannot be
1000
+ verified.
1001
+ """
1002
+ try:
1003
+ qcal = sys.modules["cctally"]._load_sibling(
1004
+ "_cctally_quota_calibration")
1005
+ state = qcal.read_rate_change_supersessions(conn)
1006
+ except Exception: # noqa: BLE001
1007
+ return {}
1008
+ return {
1009
+ tuple(identity): {
1010
+ "reason": record.reason,
1011
+ "superseded_at": record.superseded_at,
1012
+ "corrections": list(record.corrections),
1013
+ }
1014
+ for identity, record in state.by_identity.items()
1015
+ }
1016
+
1017
+
990
1018
  def _build_meter_rate_change_array(
991
1019
  conn: sqlite3.Connection, limit: int = METER_RATE_CHANGE_LIMIT,
992
1020
  ) -> list:
@@ -1046,6 +1074,7 @@ def _build_meter_rate_change_array(
1046
1074
  # background worker. An empty array renders as "no change recorded",
1047
1075
  # which is the truth on that store, rather than emptying the envelope.
1048
1076
  return []
1077
+ supersessions = _rate_change_supersessions(conn) if rows else {}
1049
1078
  out: list[dict] = []
1050
1079
  for (provider, account_key, effective_from, previous, new, severity,
1051
1080
  detected_at, created_at, status, causes, provenance,
@@ -1082,6 +1111,11 @@ def _build_meter_rate_change_array(
1082
1111
  "composition_provenance": _decode_evidence_array(provenance),
1083
1112
  "baseline_withheld_days": (
1084
1113
  None if baseline_days is None else int(baseline_days)),
1114
+ # #1015 spec 5.7 (U2): published always and nulled, so the client
1115
+ # can tell an ordinary row (null) from one an older server sent
1116
+ # (absent). The row and its historical values are kept.
1117
+ "supersession": supersessions.get(
1118
+ (str(provider), str(account_key), str(effective_from))),
1085
1119
  }
1086
1120
  entry.update(account_fields(str(provider), account_key))
1087
1121
  out.append(entry)