cctally 1.115.0 → 1.116.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/_cctally_cache.py +85 -4
  3. package/bin/_cctally_completion.py +80 -12
  4. package/bin/_cctally_core.py +106 -9
  5. package/bin/_cctally_dashboard.py +6 -9
  6. package/bin/_cctally_dashboard_envelope.py +26 -32
  7. package/bin/_cctally_db.py +85 -4
  8. package/bin/_cctally_doctor.py +38 -5
  9. package/bin/_cctally_five_hour.py +83 -1017
  10. package/bin/_cctally_journal.py +1307 -180
  11. package/bin/_cctally_journal_repair.py +9 -7
  12. package/bin/_cctally_oauth_samples.py +280 -0
  13. package/bin/_cctally_parser.py +5 -0
  14. package/bin/_cctally_quarantine.py +14 -4
  15. package/bin/_cctally_quota.py +70 -8
  16. package/bin/_cctally_quota_model.py +35 -4
  17. package/bin/_cctally_record.py +414 -123
  18. package/bin/_cctally_rederive.py +632 -235
  19. package/bin/_cctally_refresh.py +29 -0
  20. package/bin/_cctally_statusline.py +984 -47
  21. package/bin/_cctally_store.py +972 -58
  22. package/bin/_cctally_tui.py +62 -7
  23. package/bin/_cctally_update.py +25 -0
  24. package/bin/_lib_account_protocol.py +201 -18
  25. package/bin/_lib_clone_copy.py +46 -11
  26. package/bin/_lib_dashboard_sources.py +57 -15
  27. package/bin/_lib_detector_access_registry.py +273 -52
  28. package/bin/_lib_doctor.py +302 -1
  29. package/bin/_lib_journal.py +145 -25
  30. package/bin/_lib_journal_router.py +166 -48
  31. package/bin/_lib_oauth_samples.py +238 -0
  32. package/bin/_lib_pricing_check.py +0 -12
  33. package/bin/_lib_provider_activity.py +770 -0
  34. package/bin/_lib_quarantine.py +3 -2
  35. package/bin/_lib_quota_model.py +96 -6
  36. package/bin/_lib_rebuild_progress.py +146 -0
  37. package/bin/_lib_record.py +22 -0
  38. package/bin/_lib_rederive.py +299 -3
  39. package/bin/_lib_snapshot_cache.py +43 -1
  40. package/bin/_lib_stale_proof.py +708 -0
  41. package/bin/_lib_statusline_candidates.py +65 -5
  42. package/bin/_lib_worker_launches.py +315 -0
  43. package/bin/cctally +6 -11
  44. package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
  45. package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
  46. package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
  47. package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
  48. package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
  49. package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
  50. package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
  51. package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
  52. package/dashboard/static/dashboard.html +1 -1
  53. package/package.json +7 -2
  54. package/bin/_lib_merge_gate.py +0 -452
  55. package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import contextlib
12
12
  import copy
13
+ import dataclasses
13
14
  import datetime as dt
14
15
  import fcntl
15
16
  import hashlib
@@ -28,9 +29,10 @@ import _cctally_core
28
29
  import _cctally_journal as _journal
29
30
  import _cctally_record as _record
30
31
  import _lib_account_protocol
32
+ import _lib_clone_copy
31
33
  import _lib_journal
32
- import _lib_journal_router
33
34
  import _lib_json_envelope
35
+ import _lib_rebuild_progress
34
36
  import _lib_rederive
35
37
  import _cctally_store
36
38
  from _cctally_store import StatsIndexNotCurrent
@@ -77,6 +79,10 @@ class RederivePreview:
77
79
  reviewed_decision_id: "str | None" = None
78
80
  baseline_plan: "_lib_rederive.RederivePlan | None" = None
79
81
  baseline_plan_guard: "dict | None" = None
82
+ # D4: the empty correction batch an unfinished reviewed decision whose
83
+ # plan has no actions completes with. Never reported as `batch_id`, so a
84
+ # preview's output is unchanged; None when there is no such decision.
85
+ empty_decision_batch_id: "str | None" = None
80
86
 
81
87
 
82
88
  @dataclass(frozen=True)
@@ -128,6 +134,15 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
128
134
  The caller must have validated the cache contract first, including
129
135
  ``validate_codex_cache_contract`` when ``include_codex`` is set (#874):
130
136
  a missing column here is a raw sqlite error, not a typed data gap.
137
+
138
+ #997 D16: the digest is `_fingerprint` of
139
+ ``{"codexEntries"?, "sessionEntries", "sessionFiles"}`` (each a list of
140
+ row lists), but the canonical JSON is fed to SHA-256 row by row as the
141
+ cursor yields it, in sorted key order with `_canonical_bytes`' encoder
142
+ settings, instead of materializing every row and one document of them
143
+ (+0.7 GB transient on the maintainer's cache). The bytes hashed, and so
144
+ the digest, are identical, so a preview's plan guard and an apply's
145
+ recheck agree across the change.
131
146
  """
132
147
  entry_columns = (
133
148
  "source_path", "line_offset", "timestamp_utc", "model", "input_tokens",
@@ -135,27 +150,39 @@ def _cache_fingerprint(cache_conn: sqlite3.Connection, *, include_codex=False) -
135
150
  "cache_create_1h_tokens", "cost_usd_raw", "speed", "account_key",
136
151
  )
137
152
  file_columns = ("path", "session_id", "project_path")
138
- entries = [
139
- list(row) for row in cache_conn.execute(
153
+ sections = {
154
+ "sessionEntries": (
140
155
  "SELECT " + ",".join(entry_columns)
141
- + " FROM session_entries ORDER BY source_path, line_offset")
142
- ]
143
- files = [
144
- list(row) for row in cache_conn.execute(
156
+ + " FROM session_entries ORDER BY source_path, line_offset"),
157
+ "sessionFiles": (
145
158
  "SELECT " + ",".join(file_columns)
146
- + " FROM session_files ORDER BY path")
147
- ]
148
- basis = {"sessionEntries": entries, "sessionFiles": files}
159
+ + " FROM session_files ORDER BY path"),
160
+ }
149
161
  if include_codex:
150
- basis["codexEntries"] = [
151
- list(row) for row in cache_conn.execute(
152
- "SELECT source_path,line_offset,timestamp_utc,session_id,model,"
153
- "input_tokens,cached_input_tokens,output_tokens,"
154
- "reasoning_output_tokens,account_key "
155
- "FROM codex_session_entries ORDER BY source_path,line_offset"
156
- )
157
- ]
158
- return _fingerprint(basis)
162
+ sections["codexEntries"] = (
163
+ "SELECT source_path,line_offset,timestamp_utc,session_id,model,"
164
+ "input_tokens,cached_input_tokens,output_tokens,"
165
+ "reasoning_output_tokens,account_key "
166
+ "FROM codex_session_entries ORDER BY source_path,line_offset"
167
+ )
168
+ encode = json.JSONEncoder(
169
+ sort_keys=True, separators=(",", ":"), ensure_ascii=False,
170
+ ).encode
171
+ digest = hashlib.sha256()
172
+ digest.update(b"{")
173
+ for index, key in enumerate(sorted(sections)):
174
+ if index:
175
+ digest.update(b",")
176
+ digest.update(encode(key).encode("utf-8") + b":[")
177
+ first = True
178
+ for row in cache_conn.execute(sections[key]):
179
+ if not first:
180
+ digest.update(b",")
181
+ first = False
182
+ digest.update(encode(list(row)).encode("utf-8"))
183
+ digest.update(b"]")
184
+ digest.update(b"}")
185
+ return "sha256:" + digest.hexdigest()
159
186
 
160
187
 
161
188
  def _split_matches(parts, total) -> bool:
@@ -544,24 +571,30 @@ def _scratch_read_adapters(cache_conn: sqlite3.Connection):
544
571
  setattr(cctally, name, value)
545
572
 
546
573
 
574
+ #: Op kinds the scratch replay runs through the pipeline hooks.
575
+ _REDERIVABLE_OP_KINDS = frozenset({
576
+ "weekly_credit_floor", "account_observe", "account_label",
577
+ "accounts_cutover", "sync_week",
578
+ })
579
+
580
+
581
+ def _is_rederivable_raw(record: dict) -> bool:
582
+ """Whether the scratch replay runs ``record`` through the pipeline hooks:
583
+ a Claude reading from a recording source, or a deriving/registry op
584
+ (#997 spec D5: a per-record predicate, so the replay can test a record it
585
+ prepared one at a time)."""
586
+ t = record.get("t")
587
+ if t == "obs":
588
+ return (record.get("provider") == "claude"
589
+ and record.get("src") in _record._CLAUDE_OBS_SRCS)
590
+ if t != "op":
591
+ return False
592
+ kind = (record.get("payload") or {}).get("kind")
593
+ return kind in _REDERIVABLE_OP_KINDS or kind in _REVIEWED_WEEKLY_KINDS
594
+
595
+
547
596
  def _rederivable_raw_records(records: list[dict]) -> list[dict]:
548
- out = []
549
- for record in records:
550
- if record.get("t") == "obs":
551
- if (
552
- record.get("provider") == "claude"
553
- and record.get("src") in _record._CLAUDE_OBS_SRCS
554
- ):
555
- out.append(record)
556
- continue
557
- if record.get("t") != "op":
558
- continue
559
- kind = (record.get("payload") or {}).get("kind")
560
- if kind in {"weekly_credit_floor", "account_observe",
561
- "account_label", "accounts_cutover", "sync_week",
562
- *_REVIEWED_WEEKLY_KINDS}:
563
- out.append(record)
564
- return out
597
+ return [record for record in records if _is_rederivable_raw(record)]
565
598
 
566
599
 
567
600
  #: #825/#832 spec 5.1: the previous release validates a reviewed weekly
@@ -596,6 +629,16 @@ _REVIEWED_OP_KEYS_V2 = frozenset({
596
629
  "journal_prefix_hash", "reason", "weekly_axis_decisions",
597
630
  "snapshot_identity_decisions",
598
631
  }) | _REVIEWED_OP_EXPECTATION_KEYS
632
+ #: #797/#823/#840 spec 4.7 (Q5): payload schema 3 adds the canonical
633
+ #: `weekly_axis_retractions` list beside the hold projection. Manifest schema 3
634
+ #: keeps schema 2's keys and admits the `retract` disposition.
635
+ _REVIEWED_OP_KEYS_V3 = _REVIEWED_OP_KEYS_V2 | frozenset({
636
+ "weekly_axis_retractions",
637
+ })
638
+ _REVIEWED_MANIFEST_DISPOSITIONS = {
639
+ 1: ("hold", "accept"), 2: ("hold", "accept"),
640
+ 3: ("hold", "accept", "retract"),
641
+ }
599
642
 
600
643
 
601
644
  def _reviewed_weekly_op_from_manifest(path) -> dict:
@@ -612,9 +655,11 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
612
655
  version = manifest.get("schemaVersion")
613
656
  expected_keys = (
614
657
  _REVIEWED_MANIFEST_KEYS_V1 if version == 1
615
- else _REVIEWED_MANIFEST_KEYS_V2 if version == 2
658
+ else _REVIEWED_MANIFEST_KEYS_V2 if version in (2, 3)
616
659
  else None
617
660
  )
661
+ if type(version) is not int:
662
+ expected_keys = None
618
663
  actual_keys = set(manifest)
619
664
  if (expected_keys is None
620
665
  or actual_keys not in (
@@ -622,7 +667,8 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
622
667
  expected_keys | _REVIEWED_MANIFEST_EXPECTATION_KEYS,
623
668
  )):
624
669
  raise _lib_rederive.RederiveConflict(
625
- "reviewed weekly decision manifest requires schemaVersion 1 or 2"
670
+ "reviewed weekly decision manifest requires schemaVersion 1, 2 "
671
+ "or 3 with exactly its fields"
626
672
  )
627
673
  for key in _REVIEWED_MANIFEST_EXPECTATION_KEYS & actual_keys:
628
674
  value = manifest[key]
@@ -684,7 +730,8 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
684
730
  or set(item) != {"observationId", "disposition"}
685
731
  or not isinstance(item["observationId"], str)
686
732
  or not item["observationId"]
687
- or item["disposition"] not in ("hold", "accept")):
733
+ or item["disposition"]
734
+ not in _REVIEWED_MANIFEST_DISPOSITIONS[version]):
688
735
  raise _lib_rederive.RederiveConflict(
689
736
  "reviewed weekly decision entry is malformed"
690
737
  )
@@ -723,19 +770,18 @@ def _reviewed_weekly_op_from_manifest(path) -> dict:
723
770
  item["acceptedObservationId"], item["replayObservationId"],
724
771
  ))
725
772
  normalized_identity.append(dict(item))
726
- payload = {
773
+ base = {
727
774
  "kind": _REVIEWED_WEEKLY_KIND,
728
775
  "schema_version": version,
729
776
  "journal_high_water": dict(hw),
730
777
  "journal_prefix_hash": digest,
731
778
  "reason": reason,
732
779
  }
733
- if version == 1:
734
- payload["decisions"] = sorted(
735
- normalized, key=lambda item: item["observationId"])
736
- else:
737
- payload["weekly_axis_decisions"] = sorted(
738
- normalized, key=lambda item: item["observationId"])
780
+ # #797/#823/#840 spec 4.7: a retraction persists as an ordinary `hold` in
781
+ # the decision list plus the canonical `weekly_axis_retractions` list.
782
+ payload = _lib_rederive.reviewed_weekly_payload(
783
+ version=version, decisions=normalized, base=base)
784
+ if version != 1:
739
785
  payload["snapshot_identity_decisions"] = sorted(
740
786
  normalized_identity,
741
787
  key=lambda item: (
@@ -776,7 +822,7 @@ def _reviewed_weekly_expected_hashes(path, *, required: bool):
776
822
  "reviewed weekly decision manifest has unexpected fields"
777
823
  )
778
824
  version = manifest.get("schemaVersion")
779
- if version not in (1, 2):
825
+ if version not in (1, 2, 3) or type(version) is not int:
780
826
  return None
781
827
  present = _REVIEWED_MANIFEST_EXPECTATION_KEYS & set(manifest)
782
828
  if not present:
@@ -835,7 +881,13 @@ def _reviewed_op_expected_hashes(record, *, required: bool):
835
881
  return values
836
882
 
837
883
 
838
- def _reviewed_weekly_state(records):
884
+ def _reviewed_weekly_decisions(records):
885
+ """``(effective, identity_pairs, ops)`` of the retained reviewed weekly
886
+ decisions of ``records``: the effective disposition per observation
887
+ (``hold``, ``accept`` or ``retract``; the latest logical decision wins),
888
+ the preserved snapshot identity pairs, and the ops in journal order.
889
+ Every payload goes through the one strict decoder
890
+ (`_lib_rederive.decode_reviewed_weekly_payload`)."""
839
891
  raw_ids = {
840
892
  record.get("id") for record in records
841
893
  if record.get("t") == "obs"
@@ -845,88 +897,54 @@ def _reviewed_weekly_state(records):
845
897
  and "resets_at" in (record.get("payload") or {})
846
898
  }
847
899
  effective = {}
900
+ identity_effective = {}
848
901
  ops = []
849
902
  for record in records:
850
903
  if (record.get("t") != "op" or
851
904
  (record.get("payload") or {}).get("kind")
852
905
  not in _REVIEWED_WEEKLY_KINDS):
853
906
  continue
854
- payload = record["payload"]
855
- version = payload.get("schema_version")
856
- decisions = (
857
- payload.get("decisions") if version == 1
858
- else payload.get("weekly_axis_decisions") if version == 2
859
- else None
860
- )
861
- identity_decisions = (
862
- [] if version == 1 else payload.get("snapshot_identity_decisions")
863
- )
864
- if (not isinstance(decisions, list)
865
- or not isinstance(identity_decisions, list)
866
- or not decisions and not identity_decisions):
867
- raise _lib_rederive.RederiveConflict(
868
- "malformed retained reviewed weekly decision op"
869
- )
870
- seen = set()
871
- for item in decisions:
872
- if (not isinstance(item, dict)
873
- or set(item) != {"observationId", "disposition"}
874
- or not isinstance(item["observationId"], str)
875
- or item["disposition"] not in ("hold", "accept")):
876
- raise _lib_rederive.RederiveConflict(
877
- "malformed retained reviewed weekly decision entry"
878
- )
879
- obs_id = item["observationId"]
880
- if obs_id not in raw_ids or obs_id in seen:
907
+ decoded = _lib_rederive.decode_reviewed_weekly_payload(
908
+ record["payload"])
909
+ for obs_id, disposition in decoded.dispositions.items():
910
+ if obs_id not in raw_ids:
881
911
  raise _lib_rederive.RederiveConflict(
882
912
  f"unknown or duplicate reviewed weekly observation: {obs_id}"
883
913
  )
884
- seen.add(obs_id)
885
- effective[obs_id] = item["disposition"]
886
- identity_ids = set()
887
- for item in identity_decisions:
888
- if (not isinstance(item, dict)
889
- or set(item) != {
890
- "acceptedObservationId", "replayObservationId",
891
- "disposition",
892
- }
893
- or not all(
894
- isinstance(item.get(key), str) and item[key]
895
- for key in (
896
- "acceptedObservationId", "replayObservationId",
897
- )
898
- )
899
- or item.get("disposition") not in (
900
- "preserve", "rederive",
901
- )
902
- or item["acceptedObservationId"]
903
- == item["replayObservationId"]
904
- or item["acceptedObservationId"] in identity_ids
905
- or item["replayObservationId"] in identity_ids):
906
- raise _lib_rederive.RederiveConflict(
907
- "malformed retained snapshot identity decision"
908
- )
909
- identity_ids.update((
910
- item["acceptedObservationId"], item["replayObservationId"],
911
- ))
912
- ops.append(record)
913
- identity_effective = {}
914
- for op in ops:
915
- payload = op["payload"]
916
- for item in payload.get("snapshot_identity_decisions", []):
914
+ effective[obs_id] = disposition
915
+ for item in decoded.identity_decisions:
917
916
  identity_effective[
918
917
  (item["acceptedObservationId"], item["replayObservationId"])
919
918
  ] = item["disposition"]
919
+ ops.append(record)
920
+ return effective, tuple(
921
+ pair for pair, disposition in identity_effective.items()
922
+ if disposition == "preserve"
923
+ ), ops
924
+
925
+
926
+ def _reviewed_weekly_state(records):
927
+ """``(held, accepted, identity_pairs, ops)``. ``held`` is every
928
+ observation whose weekly detection the reviewed replay suppresses: an
929
+ effective `hold` or `retract` (spec 4.7: a retraction is suppressed
930
+ exactly as a hold; only hold's dependency preservation tells them
931
+ apart, `_reviewed_weekly_retractions`)."""
932
+ effective, identity_pairs, ops = _reviewed_weekly_decisions(records)
920
933
  return (frozenset(
921
934
  obs_id for obs_id, disposition in effective.items()
922
- if disposition == "hold"
935
+ if disposition in ("hold", "retract")
923
936
  ), frozenset(
924
937
  obs_id for obs_id, disposition in effective.items()
925
938
  if disposition == "accept"
926
- ), tuple(
927
- pair for pair, disposition in identity_effective.items()
928
- if disposition == "preserve"
929
- ), ops)
939
+ ), identity_pairs, ops)
940
+
941
+
942
+ def _reviewed_weekly_retractions(records) -> frozenset:
943
+ """The observations whose effective reviewed weekly decision is
944
+ `retract`."""
945
+ effective, _pairs, _ops = _reviewed_weekly_decisions(records)
946
+ return frozenset(obs_id for obs_id, disposition in effective.items()
947
+ if disposition == "retract")
930
948
 
931
949
 
932
950
  class _PinnedPrefixDigest:
@@ -952,6 +970,7 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
952
970
  expected_keys = (
953
971
  _REVIEWED_OP_KEYS_V1 if version == 1
954
972
  else _REVIEWED_OP_KEYS_V2 if version == 2
973
+ else _REVIEWED_OP_KEYS_V3 if version == 3
955
974
  else None
956
975
  )
957
976
  if (expected_keys is None or set(payload) != expected_keys
@@ -965,10 +984,16 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
965
984
  or record != _lib_journal.make_op(
966
985
  at=record.get("at"), src=record.get("src"), payload=payload,
967
986
  writer_generation=record.get("wpg"),
987
+ # #797/#823/#840 (spec 4.6 B1): the protocol revision is part
988
+ # of a current decision's content-id core.
989
+ writer_revision=record.get("wpr"),
968
990
  )):
969
991
  raise _lib_rederive.RederiveConflict(
970
992
  "malformed retained reviewed weekly decision op"
971
993
  )
994
+ # #797/#823/#840 spec 4.7: the one strict decoder (schema 3's
995
+ # retractions must each name a same-op projected hold).
996
+ _lib_rederive.decode_reviewed_weekly_payload(payload)
972
997
  _reviewed_op_expected_hashes(record, required=True)
973
998
  expected = (bound["segment"], bound["offset"])
974
999
  if prior_high_water != expected or hasher.digest_at(expected) != (
@@ -980,7 +1005,10 @@ def _validate_retained_reviewed_op(record, prior_high_water, hasher) -> None:
980
1005
  )
981
1006
 
982
1007
 
983
- def _normalize_legacy_accounts(records: list[dict]) -> None:
1008
+ def _planner_cutover_claude(records) -> str:
1009
+ """The journal-wide Claude legacy account the planner normalizes under:
1010
+ every `accounts_cutover` value validated first (missing or conflicting
1011
+ values refuse), `unattributed` when the prefix has no cutover."""
984
1012
  import _lib_accounts
985
1013
 
986
1014
  cutover_values = {
@@ -997,15 +1025,58 @@ def _normalize_legacy_accounts(records: list[dict]) -> None:
997
1025
  raise _lib_rederive.RederiveConflict(
998
1026
  "conflicting accounts_cutover Claude account values"
999
1027
  )
1000
- cutover_claude = (
1028
+ return (
1001
1029
  next(iter(cutover_values))
1002
1030
  if cutover_values
1003
1031
  else _lib_accounts.UNATTRIBUTED
1004
1032
  )
1033
+
1034
+
1035
+ def _normalize_legacy_accounts(records: list[dict]) -> None:
1036
+ """Normalize every record of ``records`` IN PLACE (the pre-#997 planner
1037
+ input; kept for callers that own a private copy)."""
1038
+ cutover_claude = _planner_cutover_claude(records)
1005
1039
  for record in records:
1006
1040
  _journal._normalize_legacy_account_stamp(record, cutover_claude)
1007
1041
 
1008
1042
 
1043
+ def _normalized_planning_view(records, cutover_claude=None) -> list:
1044
+ """#997 spec D5: the planner's normalized records, copy-on-write.
1045
+
1046
+ The same global pass as `_normalize_legacy_accounts` (every cutover value
1047
+ validated first), but a record normalization leaves unchanged is SHARED
1048
+ with the caller, and a changed one is a normalized deep copy. Changed
1049
+ copies are memoized by source-object identity, so one shared object at
1050
+ many positions (a D4 stub) yields one shared normalized object. Order,
1051
+ multiplicity and positions are preserved and the caller's records, which
1052
+ `apply_db_rederive` reads afterwards, are never mutated. Peak memory loses
1053
+ the up-front deep copy of every record normalization leaves alone."""
1054
+ if cutover_claude is None:
1055
+ cutover_claude = _planner_cutover_claude(records)
1056
+ changed: dict = {}
1057
+ view = []
1058
+ for record in records:
1059
+ if not isinstance(record, dict):
1060
+ view.append(record)
1061
+ continue
1062
+ hit = changed.get(id(record))
1063
+ if hit is not None:
1064
+ view.append(hit)
1065
+ continue
1066
+ probe = dict(record)
1067
+ if isinstance(record.get("payload"), dict):
1068
+ probe["payload"] = dict(record["payload"])
1069
+ _journal._normalize_legacy_account_stamp(probe, cutover_claude)
1070
+ if probe == record:
1071
+ view.append(record)
1072
+ continue
1073
+ normalized = copy.deepcopy(record)
1074
+ _journal._normalize_legacy_account_stamp(normalized, cutover_claude)
1075
+ changed[id(record)] = normalized
1076
+ view.append(normalized)
1077
+ return view
1078
+
1079
+
1009
1080
  def _derive_desired_events(records: list[dict], cache_conn: sqlite3.Connection,
1010
1081
  scratch_dir: Path, *,
1011
1082
  held_weekly_observation_ids=frozenset(),
@@ -1047,24 +1118,35 @@ def _derive_desired_events_into(
1047
1118
  + ", ".join(sorted(unknown_ids))
1048
1119
  )
1049
1120
  conn = _cctally_core.open_db(_target_path=str(scratch_path))
1121
+ # #997 (spec D1): a disposable scratch, never published or trusted.
1122
+ import _cctally_store
1123
+ _cctally_store.apply_disposable_scratch_policy(conn)
1050
1124
  # #825/#832 spec 5.6 item 8: the scratch replay re-evaluates the same
1051
1125
  # triggers and certificates over the corrected history in journal order,
1052
1126
  # so it can never plan an outcome ordinary processing withholds.
1053
1127
  import _cctally_quarantine
1054
1128
  fold = _cctally_quarantine.QuarantineFold.empty(held_ids=held_ids)
1055
1129
  try:
1130
+ # #997 spec D5: the planner's view shares unchanged records with the
1131
+ # caller, so the replay prepares a private copy of each record.
1056
1132
  return replay_scratch_records(
1057
1133
  conn, records, cache_conn, fold=fold, held_ids=held_ids,
1058
- block_sources=block_sources)
1134
+ block_sources=block_sources, prepare=copy.deepcopy)
1059
1135
  finally:
1060
1136
  conn.close()
1061
1137
 
1062
1138
 
1139
+ #: #997 (spec D2): modifying inputs per scratch replay transaction — raw inputs
1140
+ #: plus retained `evt`/`correction` offers. One input per commit made every
1141
+ #: input pay a rollback-journal create/fsync/unlink and a database fsync.
1142
+ _SCRATCH_REPLAY_COMMIT_EVERY = 4096
1143
+
1144
+
1063
1145
  def replay_scratch_records(
1064
1146
  conn, records, cache_conn, *, fold, held_ids=frozenset(),
1065
1147
  block_sources: "dict | None" = None, coordinates=None,
1066
1148
  skip_indexes=frozenset(), on_input=None, read_adapters=None,
1067
- retention=None,
1149
+ retention=None, prepare: "Callable[[dict], dict] | None" = None,
1068
1150
  ) -> list[dict]:
1069
1151
  """THE scratch derivation: run every rederivable raw record of
1070
1152
  ``records`` through the live pipeline hooks and harvest, in journal
@@ -1085,9 +1167,10 @@ def replay_scratch_records(
1085
1167
  `_cctally_journal.ScratchRetention`) keeps the scratch projection on the
1086
1168
  retained effective decisions: the emitters consult it, and every
1087
1169
  ``evt``/``correction`` line of ``records`` is offered to it at its own
1088
- position. Returns every candidate in derivation order."""
1089
- raw_ids = {id(record) for record in _rederivable_raw_records(
1090
- [record for record in records if isinstance(record, dict)])}
1170
+ position. ``prepare`` (#997 spec D5) maps each record to the one the
1171
+ fold, the retention and the hooks see, immediately before it is offered
1172
+ (a per-record copy instead of a whole-list one); the raw-input test runs
1173
+ on the prepared record. Returns every candidate in derivation order."""
1091
1174
  events: list[dict] = []
1092
1175
  reviewed_weekly_basis_by_account = {}
1093
1176
  hooks = (
@@ -1099,66 +1182,98 @@ def replay_scratch_records(
1099
1182
  import _lib_account_protocol as _protocol
1100
1183
  adapters = (_scratch_read_adapters(cache_conn) if read_adapters is None
1101
1184
  else read_adapters)
1102
- with adapters:
1103
- for index, record in enumerate(records):
1104
- if not isinstance(record, dict):
1105
- continue
1106
- if record.get("t") == _protocol.EXCLUDED_PLACEHOLDER_TYPE:
1107
- carried = record.get("quarantine") or {}
1108
- fold.on_excluded(
1109
- carried.get("record") or {},
1110
- segment=carried.get("segment"),
1111
- offset=carried.get("offset"))
1112
- continue
1113
- if (retention is not None
1114
- and record.get("t") in ("evt", "correction")):
1115
- conn.execute("BEGIN IMMEDIATE")
1116
- try:
1117
- retention.on_journaled(conn, record)
1118
- conn.commit()
1119
- except BaseException:
1120
- conn.rollback()
1121
- raise
1122
- coordinate = (None if coordinates is None
1123
- else coordinates[index])
1124
- if coordinate is None:
1125
- gate = fold.on_record(record)
1126
- else:
1127
- gate = fold.on_record(
1128
- record, segment=coordinate[0], offset=coordinate[1])
1129
- if id(record) not in raw_ids or index in skip_indexes:
1130
- continue
1131
- before = len(events)
1132
- ctx = _journal.IngestContext(
1133
- conn=conn,
1134
- batch=[record],
1135
- config={},
1136
- event_sink=events,
1137
- projection_writes=False,
1138
- held_weekly_observation_ids=held_ids,
1139
- reviewed_weekly_basis_by_account=(
1140
- reviewed_weekly_basis_by_account),
1141
- block_source_evidence=block_sources,
1142
- quarantine=fold,
1143
- outcome_gate=gate,
1144
- scratch_retention=retention,
1145
- )
1146
- if retention is not None:
1147
- retention.current_input = index
1185
+ # #997 (spec D2): one transaction holds up to
1186
+ # `_SCRATCH_REPLAY_COMMIT_EVERY` modifying inputs. Only the commit boundary
1187
+ # moves: each raw input keeps its own `IngestContext` and `_harvest`, in
1188
+ # journal order, and every reader shares this connection, which sees its
1189
+ # own uncommitted writes. On any exception the open batch rolls back and
1190
+ # the caller discards the scratch.
1191
+ bound = max(1, int(_SCRATCH_REPLAY_COMMIT_EVERY))
1192
+ pending = 0
1193
+
1194
+ def _open_batch():
1195
+ if not conn.in_transaction:
1148
1196
  conn.execute("BEGIN IMMEDIATE")
1149
- try:
1150
- for hook in hooks:
1151
- hook(ctx, record)
1152
- _journal._harvest(ctx)
1153
- conn.commit()
1154
- except BaseException:
1155
- conn.rollback()
1156
- raise
1157
- finally:
1197
+
1198
+ inputs = 0
1199
+ commits = 0
1200
+
1201
+ def _count_input():
1202
+ nonlocal pending, inputs, commits
1203
+ pending += 1
1204
+ inputs += 1
1205
+ if pending >= bound:
1206
+ conn.commit()
1207
+ pending = 0
1208
+ commits += 1
1209
+ # #997 D9: every commit batch (throttled to one write per ten
1210
+ # seconds; a no-op unless a worker installed a progress sink).
1211
+ _lib_rebuild_progress.tick(inputs=inputs, commits=commits)
1212
+ elif not inputs & 1023:
1213
+ _lib_rebuild_progress.tick(inputs=inputs, commits=commits)
1214
+
1215
+ try:
1216
+ with adapters:
1217
+ for index, record in enumerate(records):
1218
+ if not isinstance(record, dict):
1219
+ continue
1220
+ if prepare is not None:
1221
+ record = prepare(record)
1222
+ if record.get("t") == _protocol.EXCLUDED_PLACEHOLDER_TYPE:
1223
+ carried = record.get("quarantine") or {}
1224
+ fold.on_excluded(
1225
+ carried.get("record") or {},
1226
+ segment=carried.get("segment"),
1227
+ offset=carried.get("offset"))
1228
+ continue
1229
+ if (retention is not None
1230
+ and record.get("t") in ("evt", "correction")):
1231
+ _open_batch()
1232
+ retention.on_journaled(conn, record)
1233
+ _count_input()
1234
+ coordinate = (None if coordinates is None
1235
+ else coordinates[index])
1236
+ if coordinate is None:
1237
+ gate = fold.on_record(record)
1238
+ else:
1239
+ gate = fold.on_record(
1240
+ record, segment=coordinate[0], offset=coordinate[1])
1241
+ if not _is_rederivable_raw(record) or index in skip_indexes:
1242
+ continue
1243
+ before = len(events)
1244
+ ctx = _journal.IngestContext(
1245
+ conn=conn,
1246
+ batch=[record],
1247
+ config={},
1248
+ event_sink=events,
1249
+ projection_writes=False,
1250
+ held_weekly_observation_ids=held_ids,
1251
+ reviewed_weekly_basis_by_account=(
1252
+ reviewed_weekly_basis_by_account),
1253
+ block_source_evidence=block_sources,
1254
+ quarantine=fold,
1255
+ outcome_gate=gate,
1256
+ scratch_retention=retention,
1257
+ )
1158
1258
  if retention is not None:
1159
- retention.current_input = None
1160
- if on_input is not None:
1161
- on_input(index, events[before:])
1259
+ retention.current_input = index
1260
+ _open_batch()
1261
+ try:
1262
+ for hook in hooks:
1263
+ hook(ctx, record)
1264
+ _journal._harvest(ctx)
1265
+ finally:
1266
+ if retention is not None:
1267
+ retention.current_input = None
1268
+ _count_input()
1269
+ if on_input is not None:
1270
+ on_input(index, events[before:])
1271
+ if conn.in_transaction:
1272
+ conn.commit()
1273
+ except BaseException:
1274
+ if conn.in_transaction:
1275
+ conn.rollback()
1276
+ raise
1162
1277
  return events
1163
1278
 
1164
1279
 
@@ -1196,8 +1311,9 @@ def plan_claude_usage(
1196
1311
  read view. Task C owns lock/snapshot orchestration; Task B owns the pure
1197
1312
  replay and comparison contract.
1198
1313
  """
1199
- records = copy.deepcopy(list(records))
1200
- _normalize_legacy_accounts(records)
1314
+ # #997 spec D5: a copy-on-write normalized view, not a whole-list deep
1315
+ # copy. Every planner consumer below reads this view.
1316
+ records = _normalized_planning_view(list(records))
1201
1317
  report = _lib_rederive.validate_family_registry(
1202
1318
  evt_kinds=set(_journal._EVT_SPECS),
1203
1319
  op_kinds=_planner_op_kinds(),
@@ -1228,6 +1344,10 @@ def plan_claude_usage(
1228
1344
  held_ids, _accepted_ids, identity_pairs, reviewed_ops = (
1229
1345
  _reviewed_weekly_state(records)
1230
1346
  )
1347
+ # #797/#823/#840 spec 4.7 (D4): a retraction is suppressed in the replay
1348
+ # like a hold (it is in `held_ids`) but is not in hold's
1349
+ # dependency-preservation set.
1350
+ retracted_ids = _reviewed_weekly_retractions(records)
1231
1351
  config_basis = {
1232
1352
  "historicalConfig": "not-retained",
1233
1353
  "projectionPolicy": "retire-and-rematerialize",
@@ -1244,8 +1364,12 @@ def plan_claude_usage(
1244
1364
  key: value for key, value in op["payload"].items()
1245
1365
  if key not in _REVIEWED_OP_EXPECTATION_KEYS
1246
1366
  }
1367
+ # #797/#823/#840 spec 4.6 B1: the logical identity stays the
1368
+ # revision-1 one, so a plan an earlier writer pinned keeps its
1369
+ # config fingerprint across the revision-2 stamp.
1247
1370
  logical = _lib_journal.make_op(
1248
1371
  at=op["at"], src=op["src"], payload=payload,
1372
+ writer_revision=None,
1249
1373
  )
1250
1374
  config_basis["reviewedWeeklyDecisionOps"].append({
1251
1375
  "id": logical["id"], "at": logical["at"],
@@ -1306,7 +1430,8 @@ def plan_claude_usage(
1306
1430
  conflicted_event_ids=owned_conflicted_event_ids(selection),
1307
1431
  raw_observations=raw_records,
1308
1432
  snapshot_identity_decisions=identity_pairs,
1309
- reviewed_weekly_hold_ids=held_ids,
1433
+ reviewed_weekly_hold_ids=held_ids - retracted_ids,
1434
+ reviewed_weekly_retraction_ids=retracted_ids,
1310
1435
  uncertain_cost_fact_ids=(
1311
1436
  _uncertain_cost_facts(selection) | codex_uncertain),
1312
1437
  historical_close_markers=historical_markers,
@@ -1353,6 +1478,51 @@ def owned_conflicted_event_ids(selection) -> frozenset:
1353
1478
  return frozenset(owned)
1354
1479
 
1355
1480
 
1481
+ def _is_inert_codex_obs(record) -> bool:
1482
+ """An admitted Codex quota observation: always admitted (its family is
1483
+ Codex-only), never a rederivable raw input, never a Claude reading."""
1484
+ return (record.get("t") == "obs"
1485
+ and record.get("provider") == "codex"
1486
+ and (record.get("payload") or {}).get("kind")
1487
+ == "quota_window_snapshot")
1488
+
1489
+
1490
+ def _inert_codex_stub(record: dict, table: "dict | None" = None) -> dict:
1491
+ """#997 spec D4: the read-only stand-in for an inert Codex quota
1492
+ observation on the rederive path — `t`, `provider`, `src`, `account` and
1493
+ a `payload` of `kind` plus `account_key`/`vendor` when present, which is
1494
+ everything a consumer on this path reads (admission, positional protocol
1495
+ selection, `_is_rederivable_raw`, `is_claude_reading`, legacy account
1496
+ classification and normalization, the Codex pricing scan, preservation
1497
+ and the reviewed-decision suffix checks). Equal key tuples
1498
+ `(src, account, payload.account_key, payload.vendor)` share one object
1499
+ from ``table`` (the reader's per-read intern table), so 2.19 M positions
1500
+ cost a pointer each. A stub is never mutated: the planner's normalized
1501
+ view copies it once (`_normalized_planning_view`)."""
1502
+ payload = record.get("payload") or {}
1503
+ key = (record.get("src"),
1504
+ "account" in record, record.get("account"),
1505
+ "account_key" in payload, payload.get("account_key"),
1506
+ "vendor" in payload, payload.get("vendor"))
1507
+ try:
1508
+ stub = None if table is None else table.get(key)
1509
+ except TypeError: # an unhashable stamp: keep the record as it is
1510
+ return record
1511
+ if stub is not None:
1512
+ return stub
1513
+ stub_payload = {"kind": payload.get("kind")}
1514
+ for name in ("account_key", "vendor"):
1515
+ if name in payload:
1516
+ stub_payload[name] = payload[name]
1517
+ stub = {"t": "obs", "provider": "codex", "src": record.get("src")}
1518
+ if "account" in record:
1519
+ stub["account"] = record["account"]
1520
+ stub["payload"] = stub_payload
1521
+ if table is not None:
1522
+ table[key] = stub
1523
+ return stub
1524
+
1525
+
1356
1526
  def read_rederive_journal_prefix(
1357
1527
  high_water: "tuple[str, int] | None" = None,
1358
1528
  ):
@@ -1373,6 +1543,11 @@ def read_rederive_journal_prefix(
1373
1543
  `records` in parallel with `record_ends`. Both lists stay complete and
1374
1544
  aligned, so the win in this file is the removed double materialization and
1375
1545
  the removed hash traversals, not reduced retention.
1546
+
1547
+ #997 spec D4: positions are kept, but an admitted Codex quota observation
1548
+ is retained as an interned read-only stub (`_inert_codex_stub`) rather
1549
+ than decoded in full; no consumer on this path reads its quota payload,
1550
+ `id` or `at`.
1376
1551
  """
1377
1552
  if high_water is None:
1378
1553
  high_water = _journal.journal_high_water()
@@ -1389,7 +1564,7 @@ def read_rederive_journal_prefix(
1389
1564
  evidence_owners: list = []
1390
1565
  malformed = 0
1391
1566
  prior_high_water = None
1392
- hasher = _lib_journal_router.PrefixHashAccumulator()
1567
+ hasher = _journal._new_prefix_hasher()
1393
1568
  # #825/#832 (spec 5.3): admission before interpretation, resolved within
1394
1569
  # this one pass. A reviewed decision's validation needs the prefix digest
1395
1570
  # at its own position, so the digest is taken inline and the validation
@@ -1397,19 +1572,32 @@ def read_rederive_journal_prefix(
1397
1572
  pass_boundary = _journal._PassBoundary()
1398
1573
  coordinates: list = []
1399
1574
  pending_reviewed: list = []
1575
+ inert_stubs: dict = {}
1576
+ lines = 0
1400
1577
  for segment, offset, raw in _journal._iter_range_with_segments(
1401
1578
  None,
1402
1579
  high_water,
1403
1580
  segments,
1404
- on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
1581
+ on_segment=lambda name, size: hasher.begin_segment(
1582
+ name, prior_high_water, size=size),
1405
1583
  on_bytes=hasher.extend,
1406
1584
  ):
1585
+ lines += 1
1586
+ if not lines & 4095:
1587
+ # #997 D9 (throttled; a no-op without a progress sink).
1588
+ _lib_rebuild_progress.tick(lines=lines)
1407
1589
  record = _lib_journal.decode_line(raw)
1408
1590
  record_end = (segment, offset + len(raw) + 1)
1409
1591
  if record is None:
1410
1592
  malformed += 1
1411
1593
  prior_high_water = record_end
1412
1594
  continue
1595
+ inert = _is_inert_codex_obs(record)
1596
+ if not inert:
1597
+ # #997 D15: every record retained in full is compacted before
1598
+ # anything holds a reference to it, so the identity checks below
1599
+ # (`records[index] is record`) see only the compacted object.
1600
+ record = _lib_journal.compact_retained(record)
1413
1601
  pass_boundary.observe(record)
1414
1602
  if record.get("t") == "op":
1415
1603
  if (record.get("payload") or {}).get("kind") in _REVIEWED_WEEKLY_KINDS:
@@ -1426,6 +1614,8 @@ def read_rederive_journal_prefix(
1426
1614
  )
1427
1615
  if len(evidence) > len(evidence_owners):
1428
1616
  evidence_owners.append(len(records))
1617
+ elif inert:
1618
+ record = _inert_codex_stub(record, inert_stubs)
1429
1619
  records.append(record)
1430
1620
  record_ends.append(record_end)
1431
1621
  coordinates.append((segment, offset))
@@ -1462,8 +1652,9 @@ def read_rederive_journal_prefix(
1462
1652
  record, prior, _PinnedPrefixDigest(prior, digest),
1463
1653
  )
1464
1654
  # Released before the raise below, so a malformed prefix does not pin the
1465
- # accumulator's buffered segment — 410 MB on the maintainer's journal — on
1466
- # the traceback while the exception unwinds. On the success path the frame
1655
+ # accumulator (its hash states, registered boundaries and segment opener;
1656
+ # since #997 D12 it streams and retains no segment bytes) on the traceback
1657
+ # while the exception unwinds. On the success path the frame
1467
1658
  # dies two statements later, so this mirrors the repair reader, where the
1468
1659
  # release genuinely precedes a loop over every decoded record.
1469
1660
  hasher = None
@@ -1496,6 +1687,22 @@ def _batch_id_for_plan(plan: _lib_rederive.RederivePlan) -> "str | None":
1496
1687
  return f"rederive:{plan.family}:{digest}"
1497
1688
 
1498
1689
 
1690
+ def _empty_decision_batch_id(family: str, decision_id: str) -> str:
1691
+ """D4: the id of the empty batch that completes reviewed decision
1692
+ ``decision_id`` when its plan has no actions.
1693
+
1694
+ The plain path reads a reviewed decision as finished only when its own
1695
+ completed correction batch follows it, so a decision that changes
1696
+ nothing (a later split manifest whose observations an earlier decision
1697
+ already holds) still gets a batch: begin and commit, ``action_count``
1698
+ 0. Keyed by the decision, because every empty action set is the same
1699
+ and each decision needs its own completion.
1700
+ """
1701
+ body = {"family": family, "actions": [], "reviewedDecision": decision_id}
1702
+ digest = hashlib.sha256(_canonical_bytes(body)).hexdigest()
1703
+ return f"rederive:{family}:{digest}"
1704
+
1705
+
1499
1706
  def _iso_now() -> str:
1500
1707
  return (
1501
1708
  dt.datetime.now(dt.timezone.utc)
@@ -1505,45 +1712,111 @@ def _iso_now() -> str:
1505
1712
  )
1506
1713
 
1507
1714
 
1508
- @contextlib.contextmanager
1509
- def _open_sqlite_snapshot(path: Path, *, prefix: str):
1510
- """Yield a query-only SQLite family copy without touching the source."""
1511
- with tempfile.TemporaryDirectory(
1512
- prefix=prefix
1513
- ) as tmp:
1514
- snapshot = Path(tmp) / path.name
1515
- wal = path.with_name(path.name + "-wal")
1516
- # Copy the append-only WAL prefix before the main file. If a checkpoint
1517
- # races this read, a main-file identity change makes us retry rather
1518
- # than combining generations. SQLite validates the copied WAL frames.
1519
- for attempt in range(3):
1520
- before = path.stat()
1521
- snapshot_wal = snapshot.with_name(snapshot.name + "-wal")
1715
+ #: How each read-only snapshot's main file was taken, ``"clone"`` or
1716
+ #: ``"copy"`` (#997 D13). Per process; tests clear and read it.
1717
+ SNAPSHOT_COUNTS: dict = {}
1718
+
1719
+
1720
+ def _device_of(path) -> int:
1721
+ """`st_dev` of ``path`` (a seam: tests report another device)."""
1722
+ return os.stat(path).st_dev
1723
+
1724
+
1725
+ def _snapshot_clone_parent(path: Path, tmpdir: str) -> "str | None":
1726
+ """Where a clonable snapshot's directory goes, or None off Darwin.
1727
+
1728
+ A clone shares blocks only within one volume, so the directory is under
1729
+ `TMPDIR` when `TMPDIR` is on the source's device and otherwise in the
1730
+ source's own directory (a data folder on an external volume with an
1731
+ internal `TMPDIR`)."""
1732
+ if _lib_clone_copy._PLATFORM != "darwin":
1733
+ return None
1734
+ try:
1735
+ same = _device_of(tmpdir) == _device_of(path.parent)
1736
+ except OSError:
1737
+ return tmpdir
1738
+ return tmpdir if same else str(path.parent)
1739
+
1740
+
1741
+ def _take_sqlite_snapshot(path: Path, snapshot: Path, *, clone: bool):
1742
+ """Write a consistent snapshot of ``path`` (and its WAL prefix) to
1743
+ ``snapshot``; return ``"clone"`` or ``"copy"``, or None when ``clone``
1744
+ was asked for and the filesystem refused it."""
1745
+ wal = path.with_name(path.name + "-wal")
1746
+ snapshot_wal = snapshot.with_name(snapshot.name + "-wal")
1747
+ # Copy the append-only WAL prefix before the main file. If a checkpoint
1748
+ # races this read, a main-file identity change makes us retry rather
1749
+ # than combining generations. SQLite validates the copied WAL frames.
1750
+ for attempt in range(3):
1751
+ before = path.stat()
1752
+ snapshot_wal.unlink(missing_ok=True)
1753
+ try:
1754
+ wal_size = wal.stat().st_size
1755
+ with wal.open("rb") as source, snapshot_wal.open("wb") as target:
1756
+ shutil.copyfileobj(source, target, length=1024 * 1024)
1757
+ target.truncate(wal_size)
1758
+ except FileNotFoundError:
1522
1759
  snapshot_wal.unlink(missing_ok=True)
1523
- try:
1524
- wal_size = wal.stat().st_size
1525
- with wal.open("rb") as source, snapshot_wal.open("wb") as target:
1526
- shutil.copyfileobj(source, target, length=1024 * 1024)
1527
- target.truncate(wal_size)
1528
- except FileNotFoundError:
1529
- snapshot_wal.unlink(missing_ok=True)
1760
+ if clone:
1761
+ # A clone needs a new path, so a retry's previous snapshot goes
1762
+ # first (#997 D13).
1763
+ snapshot.unlink(missing_ok=True)
1764
+ if not _lib_clone_copy.try_clone(str(path), str(snapshot)):
1765
+ return None
1766
+ method = "clone"
1767
+ else:
1530
1768
  shutil.copyfile(path, snapshot)
1531
- after = path.stat()
1532
- if (
1533
- before.st_ino,
1534
- before.st_size,
1535
- before.st_mtime_ns,
1536
- ) == (
1537
- after.st_ino,
1538
- after.st_size,
1539
- after.st_mtime_ns,
1540
- ):
1541
- break
1542
- if attempt == 2:
1543
- raise RederiveBusy(
1544
- "cache.db changed during the read-only preview snapshot; "
1545
- "retry shortly"
1546
- )
1769
+ method = "copy"
1770
+ after = path.stat()
1771
+ if (
1772
+ before.st_ino,
1773
+ before.st_size,
1774
+ before.st_mtime_ns,
1775
+ ) == (
1776
+ after.st_ino,
1777
+ after.st_size,
1778
+ after.st_mtime_ns,
1779
+ ):
1780
+ return method
1781
+ if attempt == 2:
1782
+ raise RederiveBusy(
1783
+ "cache.db changed during the read-only preview snapshot; "
1784
+ "retry shortly"
1785
+ )
1786
+ raise AssertionError("unreachable")
1787
+
1788
+
1789
+ @contextlib.contextmanager
1790
+ def _open_sqlite_snapshot(path: Path, *, prefix: str):
1791
+ """Yield a query-only SQLite family copy without touching the source.
1792
+
1793
+ #997 D13: on Darwin the main file is a clone sharing the source's blocks
1794
+ (the cache snapshot was a 2.54 GB copy per preview), its directory placed
1795
+ by `_snapshot_clone_parent`. A refused clone removes that directory and
1796
+ takes the real copy under `TMPDIR`, so a volume that cannot clone never
1797
+ receives a copy beside the data. Elsewhere the snapshot is the real copy
1798
+ under `TMPDIR`. The WAL prefix is always a byte copy truncated to the
1799
+ size read. The directory is removed on exit; a crash leaves it behind.
1800
+ """
1801
+ tmpdir = tempfile.gettempdir()
1802
+ parent = _snapshot_clone_parent(path, tmpdir)
1803
+ holder = None
1804
+ try:
1805
+ method = None
1806
+ if parent is not None:
1807
+ holder = tempfile.TemporaryDirectory(prefix=prefix, dir=parent)
1808
+ snapshot = Path(holder.name) / path.name
1809
+ method = _take_sqlite_snapshot(path, snapshot, clone=True)
1810
+ if method is None and parent != tmpdir:
1811
+ holder.cleanup()
1812
+ holder = None
1813
+ if method is None:
1814
+ if holder is None:
1815
+ holder = tempfile.TemporaryDirectory(prefix=prefix, dir=tmpdir)
1816
+ snapshot = Path(holder.name) / path.name
1817
+ snapshot.unlink(missing_ok=True)
1818
+ method = _take_sqlite_snapshot(path, snapshot, clone=False)
1819
+ SNAPSHOT_COUNTS[method] = SNAPSHOT_COUNTS.get(method, 0) + 1
1547
1820
  conn = sqlite3.connect(snapshot)
1548
1821
  try:
1549
1822
  conn.execute("PRAGMA query_only=ON")
@@ -1551,6 +1824,9 @@ def _open_sqlite_snapshot(path: Path, *, prefix: str):
1551
1824
  yield conn
1552
1825
  finally:
1553
1826
  conn.close()
1827
+ finally:
1828
+ if holder is not None:
1829
+ holder.cleanup()
1554
1830
 
1555
1831
 
1556
1832
  @contextlib.contextmanager
@@ -1719,13 +1995,40 @@ def _preview_from_snapshot(
1719
1995
  batch_id = _batch_id_for_plan(plan)
1720
1996
  generated_at = _iso_now()
1721
1997
  incomplete = False
1722
- if batch_id is not None:
1723
- begins = [
1998
+
1999
+ def _begins_of(identity):
2000
+ return [
1724
2001
  record for record in records
1725
2002
  if record.get("t") == "correction_batch"
1726
2003
  and record.get("phase") == "begin"
1727
- and record.get("id") == batch_id
2004
+ and record.get("id") == identity
1728
2005
  ]
2006
+
2007
+ empty_decision_batch_id = None
2008
+ if (batch_id is None and decision_op is not None
2009
+ and not decision_batch_completed):
2010
+ # D4: an unfinished reviewed decision whose plan is empty completes
2011
+ # with an empty batch, so the plain path reads it as finished. A
2012
+ # retry after an interrupted append reuses the begin's `at`, so its
2013
+ # lines are byte-identical.
2014
+ empty_id = _empty_decision_batch_id(family, decision_op["id"])
2015
+ if empty_id not in selection.completed_batches:
2016
+ empty_id = _lib_journal.revision_batch_id(
2017
+ empty_id, _begins_of(empty_id))
2018
+ if empty_id not in selection.completed_batches:
2019
+ empty_decision_batch_id = empty_id
2020
+ begins = _begins_of(empty_id)
2021
+ if begins:
2022
+ generated_at = str(begins[0]["at"])
2023
+ incomplete = True
2024
+ if batch_id is not None:
2025
+ # #797/#823/#840 spec 4.6 B1: an incomplete batch an earlier writer
2026
+ # began without the revision-2 stamp is retried under a derived id,
2027
+ # never restamped.
2028
+ if batch_id not in selection.completed_batches:
2029
+ batch_id = _lib_journal.revision_batch_id(
2030
+ batch_id, _begins_of(batch_id))
2031
+ begins = _begins_of(batch_id)
1729
2032
  if begins:
1730
2033
  generated_at = str(begins[0]["at"])
1731
2034
  incomplete = batch_id not in selection.completed_batches
@@ -1759,6 +2062,7 @@ def _preview_from_snapshot(
1759
2062
  None if decision_op is None else decision_op["id"]),
1760
2063
  baseline_plan=baseline_plan,
1761
2064
  baseline_plan_guard=baseline_plan_guard,
2065
+ empty_decision_batch_id=empty_decision_batch_id,
1762
2066
  )
1763
2067
  if decision_op is not None and not decision_batch_completed:
1764
2068
  expected_hashes = _reviewed_op_expected_hashes(
@@ -1991,6 +2295,16 @@ def _stats_has_batch(batch_id: str) -> bool:
1991
2295
  "WHERE batch_id = ? LIMIT 1",
1992
2296
  (batch_id,),
1993
2297
  ).fetchone()
2298
+ if row is None:
2299
+ # D4: an empty batch (a reviewed decision whose plan had no
2300
+ # actions) leaves no effective event; the index has folded
2301
+ # it when its selector row is completed.
2302
+ row = conn.execute(
2303
+ "SELECT 1 FROM journal_selector_batches "
2304
+ "WHERE batch_id = ? AND status = 'completed' "
2305
+ "AND action_count = 0",
2306
+ (batch_id,),
2307
+ ).fetchone()
1994
2308
  return row is not None
1995
2309
  except (OSError, sqlite3.Error, StatsIndexNotCurrent):
1996
2310
  return False
@@ -2123,6 +2437,35 @@ def _assert_reviewed_plan_hashes(preview, expected_plan_hashes) -> None:
2123
2437
  )
2124
2438
 
2125
2439
 
2440
+ def _compact_preview(preview: RederivePreview) -> RederivePreview:
2441
+ """#997 D18: the preview without its journal records.
2442
+
2443
+ `records` (every retained record of the prefix) and `record_ends` (one
2444
+ commit coordinate per record) are read only by planning: the exact-retry
2445
+ and materialization checks run before any rebuild starts. Every other
2446
+ field (both plans and their hashes, `batch_id`, `incomplete_batch`, the
2447
+ high-water marks) is kept, so the status choice, the result, the
2448
+ renderers and every error read what they read before. The one release
2449
+ seam: T18 disables the release by patching it to the identity.
2450
+ """
2451
+ return dataclasses.replace(preview, records=(), record_ends=())
2452
+
2453
+
2454
+ def _release_preview_records(*previews):
2455
+ """#997 D18: compact copies of `previews`, in order, for the apply to
2456
+ rebind immediately before a rebuild, so no full preview stays reachable
2457
+ through any name. A preview passed under two names comes back as ONE
2458
+ copy, so an alias stays an alias; `None` (no such preview) stays
2459
+ `None`."""
2460
+ copies: dict = {}
2461
+ released = []
2462
+ for preview in previews:
2463
+ if preview is not None and id(preview) not in copies:
2464
+ copies[id(preview)] = _compact_preview(preview)
2465
+ released.append(None if preview is None else copies[id(preview)])
2466
+ return tuple(released)
2467
+
2468
+
2126
2469
  def apply_db_rederive(
2127
2470
  family: str,
2128
2471
  *,
@@ -2136,6 +2479,9 @@ def apply_db_rederive(
2136
2479
  with _rederive_locks(apply=True, timeout=lock_timeout), \
2137
2480
  _held_past_unlock(lost):
2138
2481
  appended_reviewed_op = False
2482
+ # A reviewed exact retry's preview at the decision's bound; D18
2483
+ # releases it with the others.
2484
+ original = None
2139
2485
  if reviewed_op is not None:
2140
2486
  durable_hashes = _reviewed_op_expected_hashes(
2141
2487
  reviewed_op, required=True,
@@ -2168,12 +2514,15 @@ def apply_db_rederive(
2168
2514
  if record.get("t") == "op"
2169
2515
  and record.get("id") == reviewed_op["id"]
2170
2516
  )
2517
+ # D4: an empty decision's own batch is its empty one.
2518
+ original_batch_id = (
2519
+ original.batch_id or original.empty_decision_batch_id)
2171
2520
  if any(
2172
2521
  not (
2173
2522
  (record.get("t") == "correction_batch"
2174
- and record.get("id") == original.batch_id)
2523
+ and record.get("id") == original_batch_id)
2175
2524
  or (record.get("t") == "correction"
2176
- and record.get("batch") == original.batch_id)
2525
+ and record.get("batch") == original_batch_id)
2177
2526
  )
2178
2527
  for record in preview.records[op_index + 1:]
2179
2528
  ):
@@ -2224,13 +2573,22 @@ def apply_db_rederive(
2224
2573
  preview.latest_completed_batch is not None
2225
2574
  and not _stats_has_batch(preview.latest_completed_batch)
2226
2575
  )
2227
- if plan.actions:
2228
- assert preview.batch_id is not None
2576
+ # D4: an unfinished reviewed decision whose plan is empty appends its
2577
+ # empty completed batch here, exactly like a batch with actions, so
2578
+ # the plain path reads the decision as finished. It reports what the
2579
+ # decision reported before (the latest earlier batch), so the output
2580
+ # is unchanged.
2581
+ batch_id = preview.batch_id
2582
+ reported_batch_id = preview.batch_id
2583
+ if not plan.actions and preview.empty_decision_batch_id is not None:
2584
+ batch_id = preview.empty_decision_batch_id
2585
+ reported_batch_id = preview.latest_completed_batch
2586
+ if batch_id is not None:
2229
2587
  batch = _lib_journal.make_correction_batch(
2230
- batch_id=preview.batch_id,
2588
+ batch_id=batch_id,
2231
2589
  family=family,
2232
2590
  at=preview.generated_at,
2233
- actions=plan.to_correction_actions(),
2591
+ actions=plan.to_correction_actions() if plan.actions else [],
2234
2592
  )
2235
2593
  try:
2236
2594
  batch_high_water = _journal.append_records(
@@ -2242,8 +2600,11 @@ def apply_db_rederive(
2242
2600
  )
2243
2601
  except Exception as exc:
2244
2602
  raise RederiveApplyError(
2245
- "correction append", preview, preview.batch_id, exc
2603
+ "correction append", preview, batch_id, exc
2246
2604
  ) from exc
2605
+ # #997 D18: nothing past planning reads the records.
2606
+ preview, report_preview, original = _release_preview_records(
2607
+ preview, report_preview, original)
2247
2608
  try:
2248
2609
  _call_crash_hook("after-batch-commit")
2249
2610
  result = _journal.rebuild_stats_index(
@@ -2258,21 +2619,25 @@ def apply_db_rederive(
2258
2619
  _call_crash_hook("after-rebuild")
2259
2620
  except _cctally_completion.ResidualObligationNotDurable as exc:
2260
2621
  raise _obligation_lost(
2261
- "stats rebuild", preview, preview.batch_id, exc
2622
+ "stats rebuild", preview, batch_id, exc
2262
2623
  ) from exc
2263
2624
  except Exception as exc:
2264
2625
  raise RederiveApplyError(
2265
- "stats rebuild", preview, preview.batch_id, exc
2626
+ "stats rebuild", preview, batch_id, exc
2266
2627
  ) from exc
2267
2628
  status = (
2268
2629
  "recovered"
2269
2630
  if preview.incomplete_batch or recovering_prior
2631
+ # D4: as before, completing an empty decision this
2632
+ # invocation did not append (an interrupted apply resumed by
2633
+ # an exact retry or a plain rederive) is a recovery.
2634
+ or (not plan.actions and not appended_reviewed_op)
2270
2635
  else "applied"
2271
2636
  )
2272
2637
  return RederiveCommandResult(
2273
2638
  preview=report_preview,
2274
2639
  status=status,
2275
- batch_id=preview.batch_id,
2640
+ batch_id=reported_batch_id,
2276
2641
  rebuild=result,
2277
2642
  )
2278
2643
 
@@ -2282,6 +2647,9 @@ def apply_db_rederive(
2282
2647
  raise RederiveBusy(
2283
2648
  f"completed correction batch {latest} has no commit high-water"
2284
2649
  )
2650
+ # #997 D18: nothing past planning reads the records.
2651
+ preview, report_preview, original = _release_preview_records(
2652
+ preview, report_preview, original)
2285
2653
  try:
2286
2654
  result = _journal.rebuild_stats_index(
2287
2655
  context=_journal.RebuildContext(
@@ -2310,6 +2678,9 @@ def apply_db_rederive(
2310
2678
  rebuild=result,
2311
2679
  )
2312
2680
  if appended_reviewed_op or _reviewed_op_needs_materialization(preview):
2681
+ # #997 D18: released only after the check above has read them.
2682
+ preview, report_preview, original = _release_preview_records(
2683
+ preview, report_preview, original)
2313
2684
  try:
2314
2685
  result = _journal.rebuild_stats_index(
2315
2686
  context=_journal.RebuildContext(trigger="rederive-recovery"),
@@ -2390,6 +2761,7 @@ def _command_payload(
2390
2761
  guarded_plan: "_lib_rederive.RederivePlan | None" = None,
2391
2762
  plan_guard=None,
2392
2763
  reviewed_decision_id=None,
2764
+ reviewed_decision_counts=None,
2393
2765
  ):
2394
2766
  plan = guarded_plan if preview is None else preview.plan
2395
2767
  counts = (
@@ -2434,6 +2806,10 @@ def _command_payload(
2434
2806
  }
2435
2807
  if reviewed_decision_id is not None:
2436
2808
  body["reviewedWeeklyDecisionId"] = reviewed_decision_id
2809
+ if reviewed_decision_counts is not None:
2810
+ # #797/#823/#840 spec 4.7: the manifest's decisions by disposition,
2811
+ # retractions counted on their own (they persist as holds).
2812
+ body["reviewedWeeklyDecisionCounts"] = reviewed_decision_counts
2437
2813
  if plan is not None and plan.uncertain_cost_fact_count:
2438
2814
  body["uncertainCostFactCount"] = plan.uncertain_cost_fact_count
2439
2815
  if preview is not None and preview.baseline_plan is not None:
@@ -2511,6 +2887,23 @@ def _emit_command_payload(payload: dict, *, as_json: bool) -> None:
2511
2887
  "decision action counts: "
2512
2888
  + _counts_text(payload["decisionActionCounts"])
2513
2889
  )
2890
+ if "reviewedWeeklyDecisionCounts" in payload:
2891
+ counts = payload["reviewedWeeklyDecisionCounts"]
2892
+ print("reviewed decisions: " + ", ".join(
2893
+ f"{name}={counts[name]}" for name in ("hold", "accept", "retract")))
2894
+
2895
+
2896
+ def _reviewed_decision_counts(reviewed_op) -> "dict | None":
2897
+ """A reviewed manifest's decisions per disposition (`hold`, `accept`,
2898
+ `retract`), or None without a manifest."""
2899
+ if reviewed_op is None:
2900
+ return None
2901
+ decoded = _lib_rederive.decode_reviewed_weekly_payload(
2902
+ reviewed_op["payload"])
2903
+ counts = dict.fromkeys(_lib_rederive.REVIEWED_WEEKLY_DISPOSITIONS, 0)
2904
+ for disposition in decoded.dispositions.values():
2905
+ counts[disposition] += 1
2906
+ return counts
2514
2907
 
2515
2908
 
2516
2909
  def cmd_db_rederive(args) -> int:
@@ -2598,6 +2991,7 @@ def cmd_db_rederive(args) -> int:
2598
2991
  rebuild=result.rebuild,
2599
2992
  reviewed_decision_id=(
2600
2993
  None if reviewed_op is None else reviewed_op["id"]),
2994
+ reviewed_decision_counts=_reviewed_decision_counts(reviewed_op),
2601
2995
  )
2602
2996
  else:
2603
2997
  preview = preview_db_rederive(family, reviewed_op=reviewed_op)
@@ -2605,6 +2999,8 @@ def cmd_db_rederive(args) -> int:
2605
2999
  "preview"
2606
3000
  if reviewed_op is not None or preview.plan.actions
2607
3001
  or preview.recovery_required
3002
+ # D4: the apply completes an unfinished empty decision.
3003
+ or preview.empty_decision_batch_id is not None
2608
3004
  else "no-op"
2609
3005
  )
2610
3006
  payload = _command_payload(
@@ -2616,6 +3012,7 @@ def cmd_db_rederive(args) -> int:
2616
3012
  else preview.batch_id
2617
3013
  ),
2618
3014
  reviewed_decision_id=preview.reviewed_decision_id,
3015
+ reviewed_decision_counts=_reviewed_decision_counts(reviewed_op),
2619
3016
  )
2620
3017
  except _lib_rederive.RederiveDataGap as exc:
2621
3018
  payload = _command_payload(