cctally 1.92.3 → 1.93.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.
@@ -30,7 +30,7 @@ import sys
30
30
  from dataclasses import replace
31
31
 
32
32
  from _cctally_core import make_week_ref, parse_iso_datetime
33
- from _cctally_quota import codex_quota_breakdown
33
+ from _cctally_quota import assert_projection_readable, codex_quota_breakdown
34
34
  from _lib_accounts import UNATTRIBUTED
35
35
  from _lib_codex_pools import is_model_scoped_codex_quota
36
36
  from _lib_dashboard_sources import dashboard_resource_key
@@ -677,6 +677,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
677
677
  # `quota_window_blocks.account_key` is NOT NULL DEFAULT 'unattributed'.
678
678
  account_clause = "" if account_key is None else "AND account_key=? "
679
679
  account_params = () if account_key is None else (account_key,)
680
+ # BEFORE the SQL, per #496 S5b section 4.7.
681
+ assert_projection_readable(stats_conn)
680
682
  rows = stats_conn.execute(
681
683
  "SELECT source_root_key, logical_limit_key, observed_slot, "
682
684
  " window_minutes, limit_id, limit_name, account_key, "
@@ -808,6 +810,7 @@ def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
808
810
  never-combine rule.
809
811
  """
810
812
  orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
813
+ assert_projection_readable(stats_conn)
811
814
  return stats_conn.execute(
812
815
  "SELECT source_root_key, logical_limit_key, observed_slot, "
813
816
  " window_minutes, limit_id, limit_name, account_key, "
@@ -768,12 +768,14 @@ def cmd_project(args: argparse.Namespace) -> int:
768
768
  # Shareable-reports gate: --format short-circuits the JSON / table
769
769
  # dispatch via `_share_render_and_emit`. The mutex in
770
770
  # `_add_share_args` keeps `--format` and `--json` from coexisting.
771
- # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
772
- # before rendering, so default output anonymizes project labels to
773
- # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
774
- # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
775
- # / `ChartPoint.x_label` with REAL names; the wrapper-level scrubber is
776
- # the single chokepoint that rewrites them.
771
+ # Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
772
+ # the RAW snapshot the wrapper hands it, so default output anonymizes
773
+ # project labels to `project-1` / `project-2` / ...; `--reveal-projects`
774
+ # opts back in. The builder populates `ProjectCell.label` /
775
+ # `ChartPoint.project_label` / `ChartPoint.x_label` with REAL names;
776
+ # `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
777
+ # that function is retained for backward compatibility and no production
778
+ # path calls it.)
777
779
  if getattr(args, "format", None):
778
780
  # Note: --breakdown is a no-op under --format (snapshot focuses on
779
781
  # the headline per-project usage table + HBar chart; per-model
@@ -15,7 +15,7 @@ import sqlite3
15
15
  import sys
16
16
  import time
17
17
  from dataclasses import dataclass
18
- from typing import Callable, Iterable, Mapping, Sequence
18
+ from typing import Callable, Iterable, Mapping, NoReturn, Sequence
19
19
 
20
20
  import _cctally_core
21
21
  import _lib_accounts
@@ -62,6 +62,354 @@ import _lib_quota_ledger as _ledger
62
62
 
63
63
  UTC = dt.timezone.utc
64
64
  _DASHBOARD_PROJECTION_CERTIFICATE_KEY = "codex_quota_projection_certificate"
65
+
66
+
67
+ # --------------------------------------------------------------------------
68
+ # the incomplete-quota-projection read gate (#496 S5b §4.7)
69
+ # --------------------------------------------------------------------------
70
+ #
71
+ # The stats quota projection is materialized FROM cache.db, so a rebuild that
72
+ # could not fully recover the cache publishes a semantically PARTIAL projection
73
+ # inside an otherwise valid generation. Completing the cache later does not by
74
+ # itself reconcile that projection.
75
+ #
76
+ # "The next open reconciles it" is not enforceable: `RebuildResult` is
77
+ # process-local, a current-epoch `open_db` returns early without any
78
+ # reconciliation gate, and — decisively — in-place publication deliberately
79
+ # keeps already-open readers alive, so a connection can finish its old read
80
+ # transaction and observe the incomplete new generation without ever calling
81
+ # `open_db` again. The gate is therefore durable and PER TRANSACTION.
82
+ #
83
+ # Three properties decide its shape:
84
+ #
85
+ # 1. It cannot be an authorizer-style denial, because projection reads are
86
+ # scattered across the dashboard, milestone-history, quota and library
87
+ # modules rather than centralized.
88
+ # 2. It must run BEFORE any fallback-catching SQL. Most of those reads sit
89
+ # inside `except sqlite3.Error` handlers that would render a denial as empty
90
+ # data instead of as an error.
91
+ # 3. It never acquires a lock. Inside a caller transaction it raises a typed
92
+ # retry signal rather than starting reconciliation, because taking the
93
+ # maintenance and cache locks after a SQLite transaction has opened inverts
94
+ # the repository's lock order. The caller ends its transaction and retries.
95
+
96
+ #: The one remedy every refusal names. Only two things clear the durable
97
+ #: incomplete flag — a reconciliation, which only `cctally cache-sync` and the
98
+ #: dashboard server arm, and a later rebuild whose coverage came back complete —
99
+ #: so a surface that says only "incomplete" leaves the user with no next step.
100
+ #: It is appended to every `QuotaProjectionIncomplete` message rather than left
101
+ #: to each caller, because the ten raise sites reach surfaces that render the
102
+ #: exception STRING (the TUI's `last_sync_error`, the dashboard's error paths)
103
+ #: and would otherwise each have to restate it.
104
+ QUOTA_PROJECTION_REMEDY = (
105
+ "run `cctally cache-sync` to reconcile it"
106
+ )
107
+
108
+
109
+ class QuotaProjectionIncomplete(Exception):
110
+ """The published quota projection is incomplete — a RETRY signal.
111
+
112
+ Not an error verdict: the projection is reconcilable, and the caller's job
113
+ is to end its transaction and retry rather than to report a failure. It
114
+ carries the VERSIONED recovery target rather than a bare coordinate, so a
115
+ target written by one binary is never misread by another.
116
+
117
+ The decided contract for a gated caller is: end the transaction, let a
118
+ maintenance-capable process reconcile (`cctally cache-sync`, or the
119
+ dashboard server, which arms
120
+ `_cctally_journal.reconcile_incomplete_quota_projection` at its own opens),
121
+ and retry once — render a "reconciling" state naming that remedy rather than
122
+ empty data if the retry is still refused.
123
+
124
+ **Where the contract is implemented, by path rather than by module.** Every
125
+ message carries `QUOTA_PROJECTION_REMEDY`, so any surface that renders the
126
+ exception string names the remedy.
127
+
128
+ The three handlers in `bin/_cctally_tui.py` catch this ahead of their
129
+ `except Exception` neighbours, so the refusal is attributed to its own
130
+ `quota-projection` leg instead of being sanitized into a generic
131
+ stats-or-cache failure. **Those handlers cover the dashboard's snapshot
132
+ build too**, and an earlier version of this docstring wrongly said they did
133
+ not: `bin/_cctally_dashboard.py` builds its snapshot by calling
134
+ `_tui_build_snapshot(..., precompute_envelope=True)`, and
135
+ `_tui_build_source_bundle` — which reaches all four
136
+ `bin/_cctally_dashboard_sources.py` sites — is reachable only under that
137
+ flag. `_sync_failure_envelope` turns the resulting attribution into a
138
+ rendered `quota_projection_incomplete` state whose `action` names
139
+ `cctally cache-sync`, on the existing rendered contract, so no
140
+ `dashboard/web/` change and no real-browser QA gate is involved.
141
+
142
+ The two `bin/_cctally_dashboard.py` HTTP route handlers — `/api/milestones`
143
+ cycle detail and the `/api/source/…` route that reaches
144
+ `_build_codex_block_detail`, and through it the two
145
+ `bin/_cctally_milestone_history.py` sites — are a separate path with no
146
+ snapshot and no envelope. They answer a typed **503**
147
+ `quota_projection_incomplete` carrying the same `action`, instead of the
148
+ generic 500 and 400 that reported a server fault and named no remedy.
149
+ """
150
+
151
+ def __init__(self, message, *, target_version=0, recovery_target=None):
152
+ super().__init__(message)
153
+ self.target_version = target_version
154
+ self.recovery_target = recovery_target
155
+
156
+
157
+ #: Every direct read of `quota_window_blocks` or `quota_projection_state` whose
158
+ #: TABLE NAME IS A LITERAL, as `<file>::<function>::<table>`. A static guard
159
+ #: keeps this complete, the same discipline `FROZEN_WRITE_SITES` applies to
160
+ #: writes — a new site has to be a deliberate act rather than a silent one.
161
+ #:
162
+ #: The name says "read sites" and one member is a `DELETE FROM
163
+ #: quota_projection_state`: the scanner resolves a target after `FROM` or
164
+ #: `JOIN`, and a DELETE writes through the same `FROM`. It is classified
165
+ #: `projector` so the outcome is identical either way, and it is left in rather
166
+ #: than special-cased, because a scanner that skipped `DELETE ... FROM` would
167
+ #: also have to decide what to do with every other statement form and would
168
+ #: acquire a blind spot doing it.
169
+ #:
170
+ #: A site whose target is INTERPOLATED is invisible to this set — `FROM {table}`
171
+ #: has no literal table name. `PROJECTION_DYNAMIC_READ_SITES` below freezes
172
+ #: those by count, which is the only property a static scan can freeze.
173
+ PROJECTION_READ_CHOKEPOINTS: "frozenset[str]" = frozenset({
174
+ "_cctally_dashboard.py::_build_codex_block_detail::quota_window_blocks",
175
+ "_cctally_dashboard.py::_handle_get_milestones_week::quota_window_blocks",
176
+ "_cctally_dashboard_sources.py::_codex_weekly_periods::quota_window_blocks",
177
+ "_cctally_dashboard_sources.py::codex_projection_coherence::"
178
+ "quota_projection_state",
179
+ "_cctally_dashboard_sources.py::_quota_wire::quota_window_blocks",
180
+ "_cctally_dashboard_sources.py::_codex_block_account_keys::"
181
+ "quota_window_blocks",
182
+ "_cctally_milestone_history.py::_load_codex_cycles::quota_window_blocks",
183
+ "_cctally_milestone_history.py::_codex_five_hour_rows::quota_window_blocks",
184
+ "_lib_dashboard_sources.py::<module>::quota_projection_state",
185
+ "_lib_dashboard_sources.py::<module>::quota_window_blocks",
186
+ "_cctally_quota.py::_stats_projection_signatures_match::"
187
+ "quota_projection_state",
188
+ "_cctally_quota.py::_blocks_missing_reverse_map::quota_window_blocks",
189
+ "_cctally_quota.py::_root_group_pairs::quota_window_blocks",
190
+ "_cctally_quota.py::_root_accounts::quota_window_blocks",
191
+ "_cctally_quota.py::<module>::quota_window_blocks",
192
+ "_cctally_quota.py::_orphan_unseen::quota_window_blocks",
193
+ "_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks",
194
+ "_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state",
195
+ })
196
+
197
+ #: What each enumerated site does about the gate.
198
+ #:
199
+ #: `gate` — `assert_projection_readable` runs in that function before its SQL.
200
+ #: `gate_at_caller` — the read is in a pure kernel that may not import this
201
+ #: module, so its callers gate it instead. Every such site MUST name those
202
+ #: callers in `PROJECTION_GATE_CALLERS`, and the guard resolves each named
203
+ #: caller and fails when that function does not call the gate.
204
+ #: `projector` — a read by the projection MACHINERY itself. Gating these would
205
+ #: deadlock recovery: they are what re-materializes the projection and clears
206
+ #: the flag, so they must be able to read while it is set.
207
+ #: `diagnostic` — a debug surface that reports a raw row COUNT and renders no
208
+ #: projection value. Gating one would replace a diagnostic answer with an
209
+ #: exception at exactly the moment an operator is diagnosing the incomplete
210
+ #: projection, which inverts what the surface is for.
211
+ PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
212
+ "_cctally_dashboard.py::_build_codex_block_detail::quota_window_blocks":
213
+ "gate",
214
+ "_cctally_dashboard.py::_handle_get_milestones_week::quota_window_blocks":
215
+ "gate",
216
+ "_cctally_dashboard_sources.py::_codex_weekly_periods::quota_window_blocks":
217
+ "gate",
218
+ "_cctally_dashboard_sources.py::codex_projection_coherence::"
219
+ "quota_projection_state": "gate",
220
+ "_cctally_dashboard_sources.py::_quota_wire::quota_window_blocks": "gate",
221
+ "_cctally_dashboard_sources.py::_codex_block_account_keys::"
222
+ "quota_window_blocks": "gate",
223
+ "_cctally_milestone_history.py::_load_codex_cycles::quota_window_blocks":
224
+ "gate",
225
+ "_cctally_milestone_history.py::_codex_five_hour_rows::quota_window_blocks":
226
+ "gate",
227
+ # `codex_stats_digest`'s relation table is module-level in a pure kernel
228
+ # that must not import this module, so its callers gate it. They are named
229
+ # in `PROJECTION_GATE_CALLERS` and the guard verifies each one.
230
+ "_lib_dashboard_sources.py::<module>::quota_projection_state":
231
+ "gate_at_caller",
232
+ "_lib_dashboard_sources.py::<module>::quota_window_blocks":
233
+ "gate_at_caller",
234
+ "_cctally_quota.py::_stats_projection_signatures_match::"
235
+ "quota_projection_state": "projector",
236
+ "_cctally_quota.py::_blocks_missing_reverse_map::quota_window_blocks":
237
+ "projector",
238
+ "_cctally_quota.py::_root_group_pairs::quota_window_blocks": "projector",
239
+ "_cctally_quota.py::_root_accounts::quota_window_blocks": "projector",
240
+ # A module-level sweep-scoping SQL constant, not a function body.
241
+ "_cctally_quota.py::<module>::quota_window_blocks": "projector",
242
+ "_cctally_quota.py::_orphan_unseen::quota_window_blocks": "projector",
243
+ "_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks":
244
+ "projector",
245
+ "_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state":
246
+ "projector",
247
+ }
248
+
249
+ #: For each `gate_at_caller` site, the `<file>::<function>` callers that run the
250
+ #: gate on its behalf. Naming them is what makes the classification checkable:
251
+ #: an unnamed caller reduces `gate_at_caller` to an assertion nothing tests, and
252
+ #: that is how the first version of this map came to claim a gating caller that
253
+ #: neither called the kernel nor called the gate.
254
+ PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
255
+ "_lib_dashboard_sources.py::<module>::quota_projection_state": (
256
+ "_cctally_tui.py::_tui_build_source_bundle",
257
+ "_cctally_tui.py::_tui_compute_dispatch_signature",
258
+ ),
259
+ "_lib_dashboard_sources.py::<module>::quota_window_blocks": (
260
+ "_cctally_tui.py::_tui_build_source_bundle",
261
+ "_cctally_tui.py::_tui_compute_dispatch_signature",
262
+ ),
263
+ }
264
+
265
+ #: Dynamic-target read sites per file — a `FROM`/`JOIN` whose target is
266
+ #: interpolated, so only the COUNT can be frozen. `bin/cctally` carries none.
267
+ #:
268
+ #: This exists because `PROJECTION_READ_CHOKEPOINTS`' scanner reads string
269
+ #: LITERALS, and a read written as `f"SELECT COUNT(*) FROM {table} WHERE
270
+ #: {where}"` therefore reaches `quota_window_blocks` while being invisible to
271
+ #: it — which is exactly what happened to `_cctally_dashboard._debug_source_
272
+ #: counts`. The count cannot say which table a site reaches, but it does make a
273
+ #: NEW dynamic read impossible to add silently, and the author then has to
274
+ #: classify it in `PROJECTION_DYNAMIC_READ_ACTIONS` if it reaches a projection
275
+ #: family. It mirrors `FROZEN_DYNAMIC_SITES` in
276
+ #: `tests/test_stats_writer_surface_386.py`, which solved the same problem for
277
+ #: the write surface.
278
+ PROJECTION_DYNAMIC_READ_SITES: "dict[str, int]" = {
279
+ "_cctally_account.py": 1,
280
+ # Three, all in `_import_legacy_conversation_rows`: `FROM main.{table}`,
281
+ # `FROM cache_db.{table}` and the `SELECT … FROM cache_db.{table}` of the
282
+ # copy. They were invisible to BOTH guards until #496 S5b gave the read
283
+ # patterns the schema-qualifier prefix the write scan already had, which is
284
+ # the hole `PROJECTION_DYNAMIC_READ_SITES` exists to close. `{table}` there
285
+ # iterates a hardcoded conversation-table tuple, so none of them reaches a
286
+ # projection family and none needs an entry below.
287
+ "_cctally_cache.py": 3,
288
+ "_cctally_core.py": 2,
289
+ "_cctally_dashboard.py": 3,
290
+ "_cctally_dashboard_envelope.py": 5,
291
+ "_cctally_db.py": 7,
292
+ "_cctally_doctor.py": 1,
293
+ "_cctally_five_hour.py": 1,
294
+ "_cctally_journal.py": 18,
295
+ "_cctally_pricing_check.py": 1,
296
+ "_cctally_quota.py": 1,
297
+ "_cctally_record.py": 1,
298
+ "_cctally_release.py": 4,
299
+ "_cctally_setup.py": 3,
300
+ "_cctally_tui.py": 1,
301
+ "_lib_conversation_query.py": 1,
302
+ "_lib_conversation_retention.py": 2,
303
+ "_lib_doctor.py": 1,
304
+ "_lib_snapshot_cache.py": 1,
305
+ "_lib_subscription_weeks.py": 1,
306
+ }
307
+
308
+ #: The dynamic-target reads that provably reach a projection family, named by
309
+ #: hand as `<file>::<function>`, with the same action vocabulary as
310
+ #: `PROJECTION_READ_SITE_ACTIONS`. The scan cannot resolve `{table}`, so this is
311
+ #: the human half of the count freeze above.
312
+ PROJECTION_DYNAMIC_READ_ACTIONS: "dict[str, str]" = {
313
+ # `_DEBUG_SOURCE_STATS_TABLES` carries `("quota_window_blocks",
314
+ # "source='codex'")` and the query is built as
315
+ # `f"SELECT COUNT(*) FROM {table} WHERE {where}"`. It answers a debug
316
+ # endpoint with a row count and renders no projection value, so it is
317
+ # `diagnostic` rather than `gate` — see the vocabulary above.
318
+ "_cctally_dashboard.py::_debug_source_counts": "diagnostic",
319
+ }
320
+
321
+
322
+ def _refuse_unreadable_flag(exc: "sqlite3.Error") -> NoReturn:
323
+ """Fail closed on an unreadable flag — but never OWN a corrupt index.
324
+
325
+ Annotated `NoReturn` because both call sites depend on it: each is an
326
+ `except sqlite3.Error` arm followed immediately by a statement reading the
327
+ name the failed query would have bound, so a return here would raise
328
+ `UnboundLocalError` instead of failing closed. The annotation makes "always
329
+ raises" checkable rather than a property a reader has to infer.
330
+
331
+ A corrupt `stats.db` fails this probe like any other unreadable one, and
332
+ wrapping it would file it as "the quota projection is incomplete". That is
333
+ the wrong owner and it costs the right one its signal: `_cctally_tui`
334
+ catches `QuotaProjectionIncomplete` ahead of the corruption branch, so a
335
+ wrapped corruption error never became `_StatsSnapshotCorruption` and never
336
+ reached the #407 heal — the index stayed corrupt while every surface
337
+ reported a reconcilable quota view. A corruption error is therefore
338
+ re-raised UNCHANGED, which is still fail-closed: no projection value is
339
+ served either way, and the caller that owns corruption gets to see it.
340
+ """
341
+ import _cctally_db
342
+
343
+ if _cctally_db._is_sqlite_corruption_error(exc):
344
+ raise exc
345
+ raise QuotaProjectionIncomplete(
346
+ f"the quota projection flag could not be read ({exc}); "
347
+ + QUOTA_PROJECTION_REMEDY
348
+ ) from exc
349
+
350
+
351
+ def assert_projection_readable(conn) -> None:
352
+ """Refuse a projection read while the published projection is incomplete.
353
+
354
+ Raises :class:`QuotaProjectionIncomplete`. Acquires no lock and starts no
355
+ reconciliation, so it is safe inside a caller transaction — which is the
356
+ only placement that covers a connection opened BEFORE the publication.
357
+
358
+ Exactly ONE condition is read as "readable": a MISSING
359
+ `stats_quota_projection_state` table. That table arrived with the
360
+ epoch-1009 stats index, so its absence means the index predates the flag
361
+ and there is no incomplete projection for the flag to describe. Every other
362
+ `sqlite3.Error` fails CLOSED and raises, because "I could not read the flag"
363
+ is not "the flag is clear".
364
+
365
+ Absence is decided STRUCTURALLY, by asking `sqlite_master`, rather than by
366
+ matching `no such table` against an error message. The message form has no
367
+ constructible false negative, but it does have a false POSITIVE — a message
368
+ embedding that phrase for another reason, such as a view or trigger
369
+ resolving through a missing table — and a false positive here fails OPEN,
370
+ which is the direction this gate exists to prevent. The extra query is one
371
+ cheap lookup, and it still raises in the unreadable-database case because
372
+ `sqlite_master` is unreadable there too.
373
+
374
+ The distinction matters because the old justification — that an epoch-1009
375
+ index always carries the table, so a failing probe cannot be serving an
376
+ incomplete projection — is false. A connection can be alive, hold the
377
+ epoch-1009 index open and still fail its reads for a reason that is not
378
+ absence, and that connection is exactly the one §4.7's per-transaction gate
379
+ exists to cover.
380
+ """
381
+ if conn is None:
382
+ return
383
+ try:
384
+ present = conn.execute(
385
+ "SELECT 1 FROM sqlite_master WHERE type='table' "
386
+ "AND name='stats_quota_projection_state'"
387
+ ).fetchone()
388
+ except sqlite3.Error as exc:
389
+ _refuse_unreadable_flag(exc)
390
+ if present is None:
391
+ return
392
+ try:
393
+ row = conn.execute(
394
+ "SELECT incomplete, target_version, recovery_target_json "
395
+ "FROM stats_quota_projection_state WHERE id = 1"
396
+ ).fetchone()
397
+ except sqlite3.Error as exc:
398
+ _refuse_unreadable_flag(exc)
399
+ if row is None or not int(row[0] or 0):
400
+ return
401
+ target = None
402
+ if row[2]:
403
+ try:
404
+ target = json.loads(row[2])
405
+ except ValueError:
406
+ target = None
407
+ raise QuotaProjectionIncomplete(
408
+ "the published quota projection is incomplete; "
409
+ + QUOTA_PROJECTION_REMEDY,
410
+ target_version=int(row[1] or 0),
411
+ recovery_target=target,
412
+ )
65
413
  # 2 -> 3 (public #5): the root physical signature is now COMPOSED from per-group
66
414
  # digests instead of digested over a whole root's observation tuples. The
67
415
  # semantics are unchanged — still an exact-equality function of the physical
@@ -717,7 +1065,7 @@ def _blocks_missing_reverse_map(stats_conn: sqlite3.Connection) -> bool:
717
1065
 
718
1066
  A scoped sweep matches on ``physical_group_key``, so a NULL there would
719
1067
  silently escape it and the stale block would survive indefinitely. The
720
- epoch rebuild (1005 introduced the column; current epoch 1008) stamps every row, and
1068
+ epoch rebuild (1005 introduced the column; current epoch 1009) stamps every row, and
721
1069
  this is the guard that turns the
722
1070
  one shape it cannot reach — a block written by an older binary against an
723
1071
  already-current index — into a full pass rather than a missed one.
@@ -2180,7 +2528,56 @@ def _apply_quota_projection_rows(
2180
2528
  )
2181
2529
 
2182
2530
 
2183
- def rematerialize_quota_projection_for_rebuild(stats_conn, *, now=None) -> None:
2531
+ @dataclass(frozen=True)
2532
+ class QuotaProjectionBundle:
2533
+ """Everything the rebuild's projection pass reads out of `cache.db`.
2534
+
2535
+ Spec §4.4 requires the rebuild to capture the coverage certificate, the
2536
+ physical mutation sequence, the source roots, the observations and the
2537
+ ledger state from ONE read-only WAL snapshot. Before this existed the leg
2538
+ captured the first two and the projection opened its own connection later,
2539
+ so a destructive clear landing between them published a generation whose
2540
+ quota projection was materialized from a cleared cache while the coverage
2541
+ verdict already read `covered`.
2542
+
2543
+ The projection CERTIFICATE §4.4 also names is not carried here, and that is
2544
+ not an omission: the rebuild pass runs `_apply_quota_projection_rows` with
2545
+ `holder=None`, so it neither reads nor writes that certificate. Adding a
2546
+ field nothing consumes would be the same defect the recovery progress
2547
+ record's applied-count was.
2548
+ """
2549
+
2550
+ active_roots: "set[str]"
2551
+ observations: object
2552
+ watermark: "int | None"
2553
+
2554
+
2555
+ def load_quota_projection_bundle(cache_conn) -> QuotaProjectionBundle:
2556
+ """Read the bundle from ``cache_conn``, inside whatever transaction it holds.
2557
+
2558
+ The caller owns the transaction, which is the whole point: on the rebuild's
2559
+ intact path the connection is the one that already read the coverage
2560
+ certificate, and under WAL its read snapshot is fixed at that first read, so
2561
+ these three reads and that certificate describe one cache state.
2562
+ """
2563
+ active_roots = _cache_root_keys(cache_conn)
2564
+ observations = load_codex_quota_observations(
2565
+ source_root_keys=None, cache_conn=cache_conn,
2566
+ )
2567
+ # A rebuild is a whole-history pass by definition, so it also initializes
2568
+ # the watermark: every ledger entry up to here is already reflected in what
2569
+ # it materialized, and leaving the watermark at zero would make the next
2570
+ # tick replay the entire ledger for nothing.
2571
+ watermark = _ledger_max_seq(cache_conn)
2572
+ return QuotaProjectionBundle(
2573
+ active_roots=active_roots, observations=observations,
2574
+ watermark=watermark,
2575
+ )
2576
+
2577
+
2578
+ def rematerialize_quota_projection_for_rebuild(
2579
+ stats_conn, *, now=None, bundle=None,
2580
+ ) -> None:
2184
2581
  """Rebuild path (spec §5.4 / §5.3 "projection"): re-run the Codex quota
2185
2582
  projection over the materialized cache.db ``quota_window_snapshots`` directly
2186
2583
  onto the fresh rebuilt ``stats_conn``, side-effect-free.
@@ -2193,28 +2590,31 @@ def rematerialize_quota_projection_for_rebuild(stats_conn, *, now=None) -> None:
2193
2590
  SAME ``active_roots`` source as the live reconcile (``_cache_root_keys`` over
2194
2591
  the cache) so the materialized projection matches live. A missing cache.db is
2195
2592
  a clean no-op (the journal quota obs remain the durable source; a later
2196
- ``cache-sync`` + reconcile re-materializes)."""
2593
+ ``cache-sync`` + reconcile re-materializes).
2594
+
2595
+ ``bundle`` is the §4.4 single snapshot. When the caller supplies one this
2596
+ function opens NO cache connection of its own, so the projection is
2597
+ materialized from the same cache state the coverage verdict was decided
2598
+ against. When it is absent — the recovery path, where the leg wrote to the
2599
+ cache and a snapshot taken before those writes would miss them — the
2600
+ function reads its own, which is what it always did."""
2197
2601
  if now is None:
2198
2602
  now = dt.datetime.now(UTC)
2199
2603
  if now.tzinfo is None or now.utcoffset() is None:
2200
2604
  raise ValueError("now must be timezone-aware")
2201
2605
  now_iso = _utc_iso(now)
2202
- try:
2203
- cache = _cache_connection()
2204
- except (FileNotFoundError, sqlite3.Error):
2205
- return
2206
- try:
2207
- active_roots = _cache_root_keys(cache)
2208
- observations = load_codex_quota_observations(
2209
- source_root_keys=None, cache_conn=cache,
2210
- )
2211
- # A rebuild is a whole-history pass by definition, so it also
2212
- # initializes the watermark: every ledger entry up to here is already
2213
- # reflected in what it just materialized, and leaving the watermark at
2214
- # zero would make the next tick replay the entire ledger for nothing.
2215
- watermark = _ledger_max_seq(cache)
2216
- finally:
2217
- cache.close()
2606
+ if bundle is None:
2607
+ try:
2608
+ cache = _cache_connection()
2609
+ except (FileNotFoundError, sqlite3.Error):
2610
+ return
2611
+ try:
2612
+ bundle = load_quota_projection_bundle(cache)
2613
+ finally:
2614
+ cache.close()
2615
+ active_roots = bundle.active_roots
2616
+ observations = bundle.observations
2617
+ watermark = bundle.watermark
2218
2618
  _apply_quota_projection_rows(
2219
2619
  stats_conn, observations=observations, active_roots=active_roots,
2220
2620
  now=now, now_iso=now_iso, sink=None,
@@ -573,12 +573,14 @@ def cmd_session(args: argparse.Namespace) -> int:
573
573
  # Shareable-reports gate: --format short-circuits the JSON / table
574
574
  # dispatch via `_share_render_and_emit`. The mutex in
575
575
  # `_add_share_args` keeps `--format` and `--json` from coexisting.
576
- # Privacy invariant (Section 8.4 / 5.3): the wrapper runs `_lib_share._scrub`
577
- # before rendering, so default output anonymizes project labels to
578
- # `project-1` / `project-2` / ...; `--reveal-projects` opts back in.
579
- # The builder populates `ProjectCell.label` / `ChartPoint.project_label`
580
- # / `ChartPoint.x_label` with REAL basenames; the wrapper-level scrubber
581
- # is the single chokepoint that rewrites them.
576
+ # Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
577
+ # the RAW snapshot the wrapper hands it, so default output anonymizes
578
+ # project labels to `project-1` / `project-2` / ...; `--reveal-projects`
579
+ # opts back in. The builder populates `ProjectCell.label` /
580
+ # `ChartPoint.project_label` / `ChartPoint.x_label` with REAL basenames;
581
+ # `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
582
+ # that function is retained for backward compatibility and no production
583
+ # path calls it.)
582
584
  if getattr(args, "format", None):
583
585
  # --top-n validation. Spec convention (Implementor 6 fix-loop):
584
586
  # invalid flag combinations exit 2; the soft-warn upper threshold