cctally 1.86.0 → 1.87.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.87.1] - 2026-07-30
9
+
10
+ ### Fixed
11
+ - Codex session names now remain visible in Recent Sessions when an individual account is selected, whenever transcript visibility is enabled. Selecting an account no longer replaces every session name with an em dash; disabling transcript visibility still hides names in both the all-accounts and focused-account views.
12
+
13
+ ## [1.87.0] - 2026-07-30
14
+
15
+ ### Fixed
16
+ - A Codex cycle's per-percent milestone ladder no longer reports `$0.00` for a crossing whose spend is real. cctally already recognised which account a quota window belongs to even when individual samples arrived unlabelled, but the dollars behind those percentages were attributed separately, per rollout file — and for usage recorded before cctally began keeping durable per-file attribution there was no decision to apply, so that spend belonged to nobody. The crossing was confidently the account's while its cost read as zero, the cycle hero under-reported spend by the same amount, and roughly a seventh of one Pro account's weekly Codex spend sat in the `unattributed` bucket instead of on its own card. Codex spend recorded inside an account-level weekly window is now attributed to that window's account whenever the window names exactly one, so the ladder, the cycle hero, the per-account cards, the Codex budget and the CLI all agree. Nothing is guessed: spend that no window can name is left alone, so is spend claimed by two windows naming different accounts, and separate model pools such as GPT-5.3-Codex-Spark are never treated as account quota. Existing history is repaired once on upgrade, and no usage is ever counted twice.
17
+
8
18
  ## [1.86.0] - 2026-07-30
9
19
 
10
20
  ### Fixed
@@ -192,6 +192,11 @@ _iter_message_rows = _lib_conversation.iter_message_rows
192
192
  # so it loads at module-load time alongside _lib_conversation.
193
193
  _lib_codex_conversation = _load_lib("_lib_codex_conversation")
194
194
 
195
+ # Window-scoped spend adoption's decision kernel (2026-07-30 spec). Pure
196
+ # stdlib leaf, same shape as `_lib_codex_pools`, so it loads here rather than
197
+ # through a bare import that would depend on ``bin/`` being on ``sys.path``.
198
+ _lib_codex_account_adoption = _load_lib("_lib_codex_account_adoption")
199
+
195
200
  # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
196
201
  # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
197
202
  # shared no-op singleton), so the sync_cache seam wraps below cost nothing on
@@ -4812,6 +4817,22 @@ def _progress_codex_stderr(stats: CodexIngestStats, *, force: bool = False) -> N
4812
4817
  )
4813
4818
 
4814
4819
 
4820
+ def _extend_codex_touched_span(
4821
+ spans: "dict[str, tuple[dt.datetime, dt.datetime]]",
4822
+ source_root_key: object,
4823
+ moment: "dt.datetime | None",
4824
+ ) -> None:
4825
+ """Widen one root's touched instant span in place."""
4826
+ if not source_root_key or moment is None:
4827
+ return
4828
+ key = str(source_root_key)
4829
+ current = spans.get(key)
4830
+ if current is None:
4831
+ spans[key] = (moment, moment)
4832
+ else:
4833
+ spans[key] = (min(current[0], moment), max(current[1], moment))
4834
+
4835
+
4815
4836
  def sync_codex_cache(
4816
4837
  conn: sqlite3.Connection,
4817
4838
  *,
@@ -4836,6 +4857,13 @@ def sync_codex_cache(
4836
4857
  """
4837
4858
  stats = CodexIngestStats()
4838
4859
  project_after_unlock = False
4860
+ # Per-root instant span this sync wrote — accounting-row timestamps AND
4861
+ # canonical window resets. It bounds the end-of-sync spend-adoption pass to
4862
+ # the windows this sync could have changed; an unchanged tree leaves it empty
4863
+ # and the pass does no SQL at all. A rebuild deliberately passes ``None``
4864
+ # instead (full re-derivation restores the unattributed state, so the repair
4865
+ # has to re-run over everything).
4866
+ adoption_spans: "dict[str, tuple[dt.datetime, dt.datetime]]" = {}
4839
4867
  # #313 P1 review (F4/F1): when the CACHE certificate is current we cannot
4840
4868
  # yet decide whether to skip the reconcile — reconcile's own short-circuit
4841
4869
  # ALSO requires the stats-side quota_projection_state signatures to match
@@ -5646,6 +5674,23 @@ def sync_codex_cache(
5646
5674
  continue
5647
5675
  anchor_resolver.mark_file_committed()
5648
5676
 
5677
+ if not rebuild:
5678
+ # Accounting timestamps share one producer spelling, so the
5679
+ # lexicographic extremes ARE the chronological ones and only two
5680
+ # rows need parsing. Quota anchors are few per file, so they are
5681
+ # parsed individually.
5682
+ if accounting_rows:
5683
+ for _extreme in (
5684
+ min(_r[2] for _r in accounting_rows),
5685
+ max(_r[2] for _r in accounting_rows),
5686
+ ):
5687
+ _extend_codex_touched_span(
5688
+ adoption_spans, discovered.source_root_key,
5689
+ _parse_anchor_iso(_extreme))
5690
+ for _qrow in quota_rows:
5691
+ _extend_codex_touched_span(
5692
+ adoption_spans, _qrow[1], _parse_anchor_iso(_qrow[17]))
5693
+
5649
5694
  # Private test seam (§5.1 post-preflight late-shrink race): fires
5650
5695
  # after each file's successful commit, so a race test can shrink a
5651
5696
  # not-yet-written target and assert the earlier commit stands.
@@ -5698,6 +5743,31 @@ def sync_codex_cache(
5698
5743
  conn.execute("DELETE FROM cache_meta WHERE key = ?",
5699
5744
  ("codex_torn_auth_deferred",))
5700
5745
  conn.commit()
5746
+ # Window-scoped spend adoption (spec
5747
+ # docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md).
5748
+ # Runs AFTER the walk committed and while both cache writer flocks are
5749
+ # still held, so the observation evidence and the accounting rows it
5750
+ # stamps are the same committed generation. Cache-only — no stats.db read
5751
+ # — so the lock-order law is untouched. A failure here is never fatal:
5752
+ # the stamp is fully re-derivable, so the next sync (or the migration)
5753
+ # repeats it.
5754
+ try:
5755
+ adopted = apply_codex_window_spend_adoption(
5756
+ conn, touched=None if rebuild else adoption_spans)
5757
+ conn.commit()
5758
+ # Terse, and silent on zero: a rebuild re-derives every row and so
5759
+ # legitimately re-stamps the same population each time, which would
5760
+ # otherwise read as a recurring anomaly rather than convergence.
5761
+ if adopted:
5762
+ eprint(f"[cache-sync] attributed {adopted} Codex row(s) "
5763
+ "from quota windows")
5764
+ except sqlite3.DatabaseError as exc:
5765
+ conn.rollback()
5766
+ if _cctally_db_sib._is_sqlite_corruption_error(exc):
5767
+ # Classified family corruption belongs to the shared recovery
5768
+ # boundary, never to a best-effort local except.
5769
+ raise
5770
+ eprint(f"[cache-sync] could not adopt Codex window spend: {exc}")
5701
5771
  # Codex creates/extends cache.db sidecars independently of Claude's
5702
5772
  # sync path. Harden them while both cache flocks are still held and
5703
5773
  # after all Codex writes, before the optional checkpoint can rotate a
@@ -5794,6 +5864,188 @@ def sync_codex_cache(
5794
5864
  return stats
5795
5865
 
5796
5866
 
5867
+ _CODEX_ACCOUNT_WEEK = dt.timedelta(
5868
+ minutes=_lib_codex_account_adoption.ACCOUNT_WEEKLY_WINDOW_MINUTES)
5869
+
5870
+
5871
+ def apply_codex_window_spend_adoption(
5872
+ conn: sqlite3.Connection,
5873
+ *,
5874
+ touched: "dict[str, tuple[dt.datetime, dt.datetime]] | None" = None,
5875
+ ) -> int:
5876
+ """Stamp window-derived attribution onto unattributed Codex spend.
5877
+
5878
+ The I/O half of ``_lib_codex_account_adoption``: read the folded window
5879
+ evidence and the candidate rows, hand both to the pure kernel, write back the
5880
+ plan it returns. Cache-only by construction — the window's identified
5881
+ accounts come from ``load_codex_quota_observations`` (which already runs
5882
+ ``adopt_unidentified_observations``) and the nominal range is derived from
5883
+ the canonical reset, so no stats.db read is involved and the lock-order law
5884
+ is untouched. The caller owns the transaction and the commit.
5885
+
5886
+ ``touched`` maps ``source_root_key`` to the ``(low, high)`` instant span this
5887
+ sync wrote — the timestamps of the accounting rows AND the canonical resets
5888
+ of the quota rows. ``None`` runs the pass over all history (``cache-sync
5889
+ --rebuild`` and the one-time migration); an EMPTY map is a no-op that issues
5890
+ NO SQL AT ALL, which is what keeps a quiescent hook tick free.
5891
+
5892
+ A bounded pass must reach the SAME verdict the unbounded one would, because
5893
+ the stamp is one-way (``NULL`` -> key, never back) and an incremental sync
5894
+ followed by a later rebuild would otherwise disagree. That needs the loaded
5895
+ window set to be a SUPERSET of the windows that can claim any candidate the
5896
+ scan offers, so the two bounds are derived together: windows are loaded for
5897
+ resets in ``[low - 7d, high + 7d]``, and candidates are clamped to
5898
+ ``[low - 7d, high]``. Every window claiming an instant ``t`` in that
5899
+ candidate span has its reset in ``(t, t + 7d]``, which the window bound
5900
+ contains — so no window can claim a scanned row unseen. The candidate span
5901
+ still covers everything this sync could have changed: the rows it wrote lie
5902
+ in ``[low, high]``, and a window whose reset it wrote lies in ``[low, high]``
5903
+ too, so that window's whole nominal range lies in ``[low - 7d, high)``.
5904
+
5905
+ Idempotent and re-runnable: ``codex_session_entries`` is fully re-derived on
5906
+ every rebuild, so the pass must re-stamp afterwards, and re-running over an
5907
+ already-stamped cache writes nothing because an identified row is never a
5908
+ candidate. Returns the number of rows actually stamped.
5909
+ """
5910
+ roots: "set[str] | None" = None
5911
+ reset_bounds: "tuple[dt.datetime, dt.datetime] | None" = None
5912
+ candidate_bounds: "tuple[dt.datetime, dt.datetime] | None" = None
5913
+ if touched is not None:
5914
+ spans = {
5915
+ str(root): span for root, span in touched.items()
5916
+ if root and span is not None
5917
+ }
5918
+ # Before any SQL: an unchanged tree must cost this pass nothing.
5919
+ if not spans:
5920
+ return 0
5921
+ roots = set(spans)
5922
+ low = min(span[0] for span in spans.values())
5923
+ high = max(span[1] for span in spans.values())
5924
+ reset_bounds = (low - _CODEX_ACCOUNT_WEEK, high + _CODEX_ACCOUNT_WEEK)
5925
+ candidate_bounds = (low - _CODEX_ACCOUNT_WEEK, high)
5926
+
5927
+ from _cctally_quota import load_codex_quota_observations
5928
+
5929
+ # `_load_lib`, not a bare import: this module is loadable in isolation, where
5930
+ # `bin/` may not be on `sys.path` (see the module docstring).
5931
+ _lib_accounts = _load_lib("_lib_accounts")
5932
+ is_model_scoped_codex_quota = _load_lib(
5933
+ "_lib_codex_pools").is_model_scoped_codex_quota
5934
+ adopt = _lib_codex_account_adoption
5935
+ try:
5936
+ columns = {
5937
+ str(row[1]) for row in conn.execute(
5938
+ "PRAGMA table_info(codex_session_entries)")
5939
+ }
5940
+ except sqlite3.DatabaseError:
5941
+ return 0
5942
+ if not {"account_key", "source_root_key", "timestamp_utc"} <= columns:
5943
+ return 0
5944
+
5945
+ try:
5946
+ observations = load_codex_quota_observations(
5947
+ source_root_keys=roots, cache_conn=conn,
5948
+ canonical_resets_between=reset_bounds,
5949
+ )
5950
+ except sqlite3.DatabaseError:
5951
+ return 0
5952
+
5953
+ # Group on the SAME key the observation fold groups on
5954
+ # (`_lib_quota._physical_window_key`) — the account is deliberately excluded
5955
+ # from it, which is precisely what makes a window able to name an account for
5956
+ # rows that carry none.
5957
+ buckets: "dict[tuple, dict]" = {}
5958
+ for observation in observations:
5959
+ identity = observation.identity
5960
+ bucket = buckets.get(key := _lib_quota._physical_window_key(observation))
5961
+ if bucket is None:
5962
+ bucket = buckets[key] = {
5963
+ "root": identity.source_root_key,
5964
+ "minutes": identity.window_minutes,
5965
+ "reset": observation.canonical_resets_at,
5966
+ "accounts": set(),
5967
+ "model_scoped": False,
5968
+ }
5969
+ if identity.account_key != _lib_accounts.UNATTRIBUTED:
5970
+ bucket["accounts"].add(identity.account_key)
5971
+ # `limit_name` is compare=False on the identity, so the label can differ
5972
+ # across one group's observations; ANY Spark evidence demotes the whole
5973
+ # window out of account weekly quota (#373). That direction only ever
5974
+ # withholds a stamp, never invents one.
5975
+ if is_model_scoped_codex_quota(
5976
+ identity.logical_limit_key, identity.limit_name):
5977
+ bucket["model_scoped"] = True
5978
+
5979
+ windows: "list[object]" = []
5980
+ root_ranges: "dict[str, list[tuple[dt.datetime, dt.datetime]]]" = {}
5981
+ for bucket in buckets.values():
5982
+ window = adopt.SpendAdoptionWindow(
5983
+ source_root_key=bucket["root"],
5984
+ window_minutes=bucket["minutes"],
5985
+ canonical_resets_at=bucket["reset"],
5986
+ identified_accounts=frozenset(bucket["accounts"]),
5987
+ model_scoped=bucket["model_scoped"],
5988
+ )
5989
+ if not window.in_scope:
5990
+ continue
5991
+ windows.append(window)
5992
+ root_ranges.setdefault(window.source_root_key, []).append(
5993
+ (window.nominal_start_at, window.canonical_resets_at))
5994
+ if not windows:
5995
+ return 0
5996
+
5997
+ # SQL bounds the scan to a coarse per-root union of the candidate windows,
5998
+ # clamped to the span the loaded window set provably covers (see the
5999
+ # docstring); exact half-open containment stays in the kernel. `unixepoch`
6000
+ # deliberately accepts both retained spellings (`Z` and `+00:00`) — the
6001
+ # accounting rows are written with the offset form, the quota rows with `Z`.
6002
+ # Both comparisons are INCLUSIVE on the truncated second: `unixepoch` drops
6003
+ # any sub-second fraction, so an exclusive upper bound would discard rows in
6004
+ # the reset's final second if a canonical anchor ever carried one. Admitting
6005
+ # that second here is free — the kernel re-tests containment exactly.
6006
+ candidates = []
6007
+ for root, spans_for_root in root_ranges.items():
6008
+ window_low = min(span[0] for span in spans_for_root)
6009
+ window_high = max(span[1] for span in spans_for_root)
6010
+ if candidate_bounds is not None:
6011
+ window_low = max(window_low, candidate_bounds[0])
6012
+ window_high = min(window_high, candidate_bounds[1])
6013
+ if window_low > window_high:
6014
+ continue
6015
+ for row in conn.execute(
6016
+ "SELECT id, timestamp_utc FROM codex_session_entries "
6017
+ " WHERE source_root_key = ? "
6018
+ " AND (account_key IS NULL OR account_key = '' "
6019
+ " OR account_key = ?) "
6020
+ " AND unixepoch(timestamp_utc) >= unixepoch(?) "
6021
+ " AND unixepoch(timestamp_utc) <= unixepoch(?)",
6022
+ (root, _lib_accounts.UNATTRIBUTED,
6023
+ _codex_anchor_iso(window_low), _codex_anchor_iso(window_high)),
6024
+ ):
6025
+ timestamp = _parse_anchor_iso(row[1])
6026
+ if timestamp is None:
6027
+ continue
6028
+ candidates.append(adopt.SpendAdoptionCandidate(
6029
+ entry_id=int(row[0]), source_root_key=root,
6030
+ timestamp=timestamp, account_key=None,
6031
+ ))
6032
+ if not candidates:
6033
+ return 0
6034
+
6035
+ plan = adopt.build_spend_adoption_plan(windows, candidates)
6036
+ if not plan:
6037
+ return 0
6038
+ before = conn.total_changes
6039
+ conn.executemany(
6040
+ "UPDATE codex_session_entries SET account_key = ? "
6041
+ " WHERE id = ? AND (account_key IS NULL OR account_key = '' "
6042
+ " OR account_key = ?)",
6043
+ [(stamp.account_key, stamp.entry_id, _lib_accounts.UNATTRIBUTED)
6044
+ for stamp in plan],
6045
+ )
6046
+ return conn.total_changes - before
6047
+
6048
+
5797
6049
  def iter_codex_entries(
5798
6050
  conn: sqlite3.Connection,
5799
6051
  range_start: dt.datetime,
@@ -1787,11 +1787,39 @@ def _overlay_claude_source_session_titles(
1787
1787
 
1788
1788
 
1789
1789
  def _codex_source_session_rows(envelope: dict) -> list:
1790
- """Return direct-Codex and All-tab Codex session row lists."""
1790
+ """Return every request-local Codex session row list.
1791
+
1792
+ Besides the merged parent used by ``All accounts``, decorated Codex data
1793
+ carries one ``account_scopes[*].sessions`` child per account. The client
1794
+ swaps that child into view when an account chip is focused, so the private
1795
+ label overlay must cover it under the same per-request transcript gate.
1796
+ """
1791
1797
  sources = envelope.get("sources")
1792
1798
  if not isinstance(sources, Mapping):
1793
1799
  return []
1794
1800
  out = []
1801
+
1802
+ def append_rows(data) -> None:
1803
+ if not isinstance(data, Mapping):
1804
+ return
1805
+ sessions = data.get("sessions")
1806
+ if isinstance(sessions, Mapping):
1807
+ rows = sessions.get("rows")
1808
+ if isinstance(rows, list):
1809
+ out.append(rows)
1810
+ account_scopes = data.get("account_scopes")
1811
+ if not isinstance(account_scopes, Mapping):
1812
+ return
1813
+ for child in account_scopes.values():
1814
+ if not isinstance(child, Mapping):
1815
+ continue
1816
+ sessions = child.get("sessions")
1817
+ if not isinstance(sessions, Mapping):
1818
+ continue
1819
+ rows = sessions.get("rows")
1820
+ if isinstance(rows, list):
1821
+ out.append(rows)
1822
+
1795
1823
  candidates = [(sources.get("codex") or {}).get("data")]
1796
1824
  all_data = (sources.get("all") or {}).get("data")
1797
1825
  if isinstance(all_data, Mapping):
@@ -1799,14 +1827,7 @@ def _codex_source_session_rows(envelope: dict) -> list:
1799
1827
  if isinstance(providers, Mapping):
1800
1828
  candidates.append(providers.get("codex"))
1801
1829
  for data in candidates:
1802
- if not isinstance(data, Mapping):
1803
- continue
1804
- sessions = data.get("sessions")
1805
- if not isinstance(sessions, Mapping):
1806
- continue
1807
- rows = sessions.get("rows")
1808
- if isinstance(rows, list):
1809
- out.append(rows)
1830
+ append_rows(data)
1810
1831
  return out
1811
1832
 
1812
1833
 
@@ -1818,8 +1839,9 @@ def _overlay_codex_source_session_labels(
1818
1839
  ``state_5.sqlite.threads.title`` is derived from transcript prompt content.
1819
1840
  The frozen source state therefore retains it only in a server-private key
1820
1841
  map, outside the published ``data`` tree. Closed requests return before
1821
- consulting that map; open requests match labels to direct and All rows by
1822
- their opaque resource keys. A missed call fails closed.
1842
+ consulting that map; open requests match labels to merged-parent,
1843
+ account-scoped, and All rows by their opaque resource keys. A missed call
1844
+ fails closed.
1823
1845
  """
1824
1846
  if not transcripts_visible:
1825
1847
  return
@@ -6085,6 +6085,56 @@ def _033_codex_reset_anchor_component_closure(
6085
6085
  conn.commit()
6086
6086
 
6087
6087
 
6088
+ @cache_migration("034_codex_window_spend_adoption")
6089
+ def _034_codex_window_spend_adoption(conn: sqlite3.Connection) -> None:
6090
+ """One-time window-scoped spend adoption over existing Codex history.
6091
+
6092
+ Spec:
6093
+ ``docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md``.
6094
+
6095
+ Ingest stamps every window forward from here, but rollout bytes that were
6096
+ already walked keep whatever per-file attribution decided for them — and for
6097
+ history predating the durable attribution map that is nothing at all (#416
6098
+ spec D1). A cycle's milestone ladder then renders ``$0.00`` for a crossing
6099
+ whose spend is real, because the crossing came from the folded observation
6100
+ axis and the dollars from the per-file axis. This handler runs the SAME pass
6101
+ ingest now runs, unbounded, so existing history converges in one pass.
6102
+
6103
+ The whole decision lives in ``_lib_codex_account_adoption`` and the read/write
6104
+ in ``_cctally_cache.apply_codex_window_spend_adoption``; re-implementing the
6105
+ rule here would let the migration and ingest drift.
6106
+
6107
+ Idempotent by construction: only rows whose ``account_key`` is ``NULL`` or
6108
+ empty are candidates, so a re-run over its own output writes nothing. NO
6109
+ self-stamp — the dispatcher central-stamps on a clean return (#140).
6110
+
6111
+ Takes the Codex provider flock like handlers 024-027: this writes a
6112
+ Codex-derived table, so a mid-walk ``sync_codex_cache`` must not interleave.
6113
+ On contention it DEFERS (``MigrationGateNotMet``) before touching any data —
6114
+ the safe side, and free here because that same sync runs the identical pass
6115
+ at its own end anyway.
6116
+
6117
+ ``BEGIN IMMEDIATE`` before the read, like handler 024: the pass reads the
6118
+ folded window evidence and then writes back a plan derived from it, so it
6119
+ must hold the write lock for the whole read-then-write rather than upgrade
6120
+ part way through it.
6121
+ """
6122
+ import _cctally_cache as cache_mod
6123
+
6124
+ held = _acquire_cache_db_codex_provider_flock(
6125
+ conn, migration="034 window spend adoption")
6126
+ try:
6127
+ conn.execute("BEGIN IMMEDIATE")
6128
+ try:
6129
+ cache_mod.apply_codex_window_spend_adoption(conn)
6130
+ conn.commit()
6131
+ except Exception:
6132
+ conn.rollback()
6133
+ raise
6134
+ finally:
6135
+ _release_cache_db_writer_flocks(held)
6136
+
6137
+
6088
6138
  # === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
6089
6139
 
6090
6140
  @stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
@@ -240,6 +240,7 @@ def load_codex_quota_observations(
240
240
  active_at: dt.datetime | None = None,
241
241
  max_rows: int | None = None,
242
242
  physical_signatures: dict[str, str] | None = None,
243
+ canonical_resets_between: "tuple[dt.datetime, dt.datetime] | None" = None,
243
244
  ) -> tuple[QuotaObservation, ...]:
244
245
  """Load only valid root-qualified S1 physical quota rows.
245
246
 
@@ -258,6 +259,14 @@ def load_codex_quota_observations(
258
259
  supplied, exact S2 signatures are accumulated from the same cursor before
259
260
  presentation bounds are applied, so coherence validation does not require
260
261
  a second unbounded observation load.
262
+
263
+ ``canonical_resets_between`` is an INCLUSIVE ``(low, high)`` bound on the
264
+ window's CANONICAL reset, applied in SQL. Unlike ``captured_at_or_after``
265
+ it is a window-IDENTITY bound, so it never fractures a window group: every
266
+ observation of one physical window shares one canonical anchor, and the
267
+ continuity fold below therefore still sees each retained window whole. That
268
+ is what lets the ingest-side spend-adoption pass bound itself to the windows
269
+ one sync touched instead of materializing all history every hook tick.
261
270
  """
262
271
  for name, value in (
263
272
  ("captured_at_or_after", captured_at_or_after), ("active_at", active_at),
@@ -269,6 +278,19 @@ def load_codex_quota_observations(
269
278
  captured_at_or_after = value.astimezone(UTC)
270
279
  else:
271
280
  active_at = value.astimezone(UTC)
281
+ if canonical_resets_between is not None:
282
+ if len(canonical_resets_between) != 2:
283
+ raise ValueError(
284
+ "canonical_resets_between must be a (low, high) pair")
285
+ bounds = []
286
+ for value in canonical_resets_between:
287
+ if value.tzinfo is None or value.utcoffset() is None:
288
+ raise ValueError(
289
+ "canonical_resets_between must be timezone-aware")
290
+ bounds.append(value.astimezone(UTC))
291
+ if bounds[0] > bounds[1]:
292
+ raise ValueError("canonical_resets_between must be ordered")
293
+ canonical_resets_between = (bounds[0], bounds[1])
272
294
  if max_rows is not None:
273
295
  if not isinstance(max_rows, int) or isinstance(max_rows, bool) or max_rows <= 0:
274
296
  raise ValueError("max_rows must be a positive integer or None")
@@ -352,6 +374,23 @@ def load_codex_quota_observations(
352
374
  return ()
353
375
  sql += " AND source_root_key IN (" + ",".join("?" for _ in requested) + ")"
354
376
  params.extend(sorted(requested))
377
+ if canonical_resets_between is not None:
378
+ # COALESCE, not the bare column: a pre-032 row (or one the backfill
379
+ # never reached) carries NULL there and the reader falls back to the
380
+ # raw reset, so the bound has to fall back with it or the row would
381
+ # silently drop out of its own window.
382
+ reset_expr = (
383
+ "COALESCE(canonical_resets_at_utc, resets_at_utc)"
384
+ if has_anchor else "resets_at_utc"
385
+ )
386
+ sql += (
387
+ f" AND unixepoch({reset_expr}) >= unixepoch(?)"
388
+ f" AND unixepoch({reset_expr}) <= unixepoch(?)"
389
+ )
390
+ params.extend((
391
+ _utc_iso(canonical_resets_between[0]),
392
+ _utc_iso(canonical_resets_between[1]),
393
+ ))
355
394
  # When exact signatures are requested this first cursor must cover the
356
395
  # complete root history. Otherwise apply dashboard presentation bounds
357
396
  # in SQL so only the capped evidence crosses the SQLite/Python boundary.
@@ -494,6 +533,7 @@ def load_codex_quota_observations(
494
533
  captured_at_or_after=captured_at_or_after,
495
534
  active_at=active_at,
496
535
  max_rows=max_rows,
536
+ canonical_resets_between=canonical_resets_between,
497
537
  )
498
538
  if max_rows is not None and len(result) > max_rows:
499
539
  result = sorted(
@@ -1464,13 +1504,30 @@ def _codex_cache_account_predicate(
1464
1504
  dollars are these" — widening it IS attribution, which D1 forbids, and it
1465
1505
  puts one row in two scopes.
1466
1506
 
1467
- Three stamping mechanisms exist and must never be conflated: the
1507
+ FOUR stamping mechanisms exist and must never be conflated: the
1468
1508
  quota-observation fold (``adopt_unidentified_observations``, per physical-
1469
1509
  window group, landing post-fold in ``quota_window_blocks`` /
1470
1510
  ``quota_percent_milestones`` and NEVER written back to
1471
1511
  ``quota_window_snapshots``); per-file-range attribution
1472
1512
  (``codex_file_accounts`` -> ``codex_session_entries.account_key``,
1473
- ``stably_absent`` -> NULL); and the stats ``accounts`` registry.
1513
+ ``stably_absent`` -> NULL); WINDOW-SCOPED SPEND ADOPTION (the 2026-07-30
1514
+ spec — ``_lib_codex_account_adoption`` +
1515
+ ``_cctally_cache.apply_codex_window_spend_adoption``, which stamps that same
1516
+ ``codex_session_entries.account_key`` column at ingest from the window's
1517
+ single identified account); and the stats ``accounts`` registry.
1518
+
1519
+ So ``codex_session_entries.account_key`` now carries window-derived
1520
+ attribution IN ADDITION to per-file decisions, and that is precisely what
1521
+ keeps the cost read strict rather than forcing it to widen. The rule above
1522
+ genuinely pointed both ways for that read: the scope key comes from the
1523
+ observation fold while the rows came from per-file attribution — DIFFERENT
1524
+ mechanisms, which reads as "widen" — yet widening a cost read is attribution
1525
+ D1 forbids and puts one row in two scopes. The resolution is to make the ROW
1526
+ carry the window's answer durably, so scope key and row now agree by
1527
+ construction and the strict flavour is correct rather than merely safe. A
1528
+ row the adoption pass declined to stamp (zero or ambiguous identified
1529
+ accounts, an overlap whose windows disagree) is genuinely nobody's and
1530
+ stays in the ``unattributed`` scope, which is the honest answer.
1474
1531
  """
1475
1532
  if account_key is None:
1476
1533
  return "", ()
@@ -1598,7 +1655,19 @@ def codex_quota_breakdown(
1598
1655
  renders an honest ``$0.00``; the dollars stay visible in the
1599
1656
  ``unattributed`` scope, which owns them.
1600
1657
 
1601
- The full rule, and the three stamping mechanisms it turns on, are in
1658
+ That honest ``$0.00`` used to fire for spend that was NOT nobody's. The
1659
+ crossing carries the window's account (the observation fold put it there)
1660
+ while the rows behind it carried none, because per-file attribution had no
1661
+ decision covering those bytes. ``codex_session_entries.account_key`` now also
1662
+ carries WINDOW-DERIVED attribution — stamped durably at ingest by
1663
+ ``apply_codex_window_spend_adoption`` under the same window key and the same
1664
+ single-identified-account guard the fold uses — so the ladder and the
1665
+ dollars agree by construction. The read stays strict on top of it precisely
1666
+ BECAUSE the inference is now durable: doing it here instead would re-file one
1667
+ row under two scopes on every read, while doing it once at ingest moves the
1668
+ row out of ``unattributed`` and into exactly one owner.
1669
+
1670
+ The full rule, and the four stamping mechanisms it turns on, are in
1602
1671
  ``_codex_cache_account_predicate``.
1603
1672
  """
1604
1673
  reset = _parse_utc(resets_at, "resets_at") if isinstance(resets_at, str) else resets_at
@@ -0,0 +1,173 @@
1
+ """Codex window-scoped spend adoption (pure kernel).
2
+
3
+ Spec: ``docs/superpowers/specs/2026-07-30-codex-window-scoped-spend-adoption.md``
4
+
5
+ ``adopt_unidentified_observations`` (``bin/_lib_quota.py``) applies the #341 §2
6
+ window-account continuity rule to the OBSERVATION axis: inside one physical
7
+ quota window, unidentified observations are adopted by the window's account iff
8
+ exactly one identified account is ever observed for that window key. This
9
+ kernel applies the same inference, with the same grouping key and the same
10
+ guard, to the SPEND axis — ``codex_session_entries.account_key``.
11
+
12
+ A pure leaf module: stdlib only, no cctally imports. The caller supplies window
13
+ descriptors (already grouped on ``_lib_quota._physical_window_key`` and already
14
+ folded, so ``identified_accounts`` is the window's post-fold identified set) and
15
+ candidate entries; this kernel decides, and only decides. Every SQL read and
16
+ every write stays in the glue layer.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import datetime as dt
21
+ from dataclasses import dataclass
22
+ from typing import Iterable
23
+
24
+
25
+ #: Native length of the account-level Codex weekly quota window, in minutes.
26
+ ACCOUNT_WEEKLY_WINDOW_MINUTES = 10_080
27
+
28
+
29
+ #: The reserved "account could not be determined" sentinel
30
+ #: (``_lib_accounts.UNATTRIBUTED``), spelled here so this leaf stays import-free.
31
+ UNATTRIBUTED_SENTINEL = "unattributed"
32
+
33
+
34
+ def entry_is_unattributed(account_key: object) -> bool:
35
+ """Whether a ``codex_session_entries`` row is still up for adoption.
36
+
37
+ ``NULL`` is the stamp every never-decided row carries (#416 spec D1:
38
+ ``stably_absent`` -> ``NULL``), and the empty string is its degenerate
39
+ spelling. The literal ``unattributed`` sentinel is admitted too: no producer
40
+ writes it to this column today, but ``_codex_cache_account_predicate`` counts
41
+ it in the ``unattributed`` BUCKET, so excluding it here would make such a row
42
+ permanently unadoptable — visible as nobody's money and ineligible for the
43
+ only mechanism that could give it an owner.
44
+ """
45
+ return (
46
+ account_key is None
47
+ or account_key == ""
48
+ or account_key == UNATTRIBUTED_SENTINEL
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SpendAdoptionWindow:
54
+ """One physical Codex quota window, as the fold left it.
55
+
56
+ ``canonical_resets_at`` is the tolerance-anchored reset (#416 §4.1), never a
57
+ raw jittered provider value — the same anchor
58
+ ``_lib_quota._physical_window_key`` groups on. ``identified_accounts`` is
59
+ the set of non-``unattributed`` account keys observed for that key.
60
+ """
61
+
62
+ source_root_key: str
63
+ window_minutes: int
64
+ canonical_resets_at: dt.datetime
65
+ identified_accounts: frozenset[str] = frozenset()
66
+ model_scoped: bool = False
67
+
68
+ def __post_init__(self) -> None:
69
+ if not isinstance(self.source_root_key, str) or not self.source_root_key:
70
+ raise ValueError("source_root_key must be a non-empty string")
71
+ if (not isinstance(self.window_minutes, int)
72
+ or isinstance(self.window_minutes, bool)
73
+ or self.window_minutes <= 0):
74
+ raise ValueError("window_minutes must be a positive integer")
75
+ reset = self.canonical_resets_at
76
+ if reset.tzinfo is None or reset.utcoffset() is None:
77
+ raise ValueError("canonical_resets_at must be timezone-aware")
78
+ object.__setattr__(
79
+ self, "identified_accounts", frozenset(self.identified_accounts))
80
+
81
+ @property
82
+ def nominal_start_at(self) -> dt.datetime:
83
+ return self.canonical_resets_at - dt.timedelta(
84
+ minutes=self.window_minutes)
85
+
86
+ @property
87
+ def in_scope(self) -> bool:
88
+ """Account-level weekly windows only.
89
+
90
+ A 5h window nests inside the weekly one and adds no evidence; a
91
+ model-scoped pool such as GPT-5.3-Codex-Spark is never account weekly
92
+ quota (#373). Both are excluded from candidacy entirely, so neither
93
+ stamps nor blocks.
94
+ """
95
+ return (
96
+ self.window_minutes == ACCOUNT_WEEKLY_WINDOW_MINUTES
97
+ and not self.model_scoped
98
+ )
99
+
100
+ def covers(self, timestamp: dt.datetime) -> bool:
101
+ """Whether ``timestamp`` falls in the NOMINAL ``[start, reset)`` range.
102
+
103
+ Nominal rather than first-observation: spend before the window's first
104
+ retained observation is still spend inside the cycle.
105
+ """
106
+ return self.nominal_start_at <= timestamp < self.canonical_resets_at
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SpendAdoptionCandidate:
111
+ """One ``codex_session_entries`` row offered to the pass."""
112
+
113
+ entry_id: int
114
+ source_root_key: str
115
+ timestamp: dt.datetime
116
+ account_key: "str | None" = None
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class SpendAdoptionStamp:
121
+ """One decided write: give ``entry_id`` this account."""
122
+
123
+ entry_id: int
124
+ account_key: str
125
+
126
+
127
+ def build_spend_adoption_plan(
128
+ windows: Iterable[SpendAdoptionWindow],
129
+ candidates: Iterable[SpendAdoptionCandidate],
130
+ ) -> tuple[SpendAdoptionStamp, ...]:
131
+ """Return the stamping plan, ordered by ``entry_id``.
132
+
133
+ An in-scope window CLAIMS every candidate its nominal range covers on its own
134
+ root. A candidate is stamped iff the UNION of identified accounts across
135
+ every claiming window is exactly one.
136
+
137
+ A claiming window that identifies no account contributes nothing to that
138
+ union and therefore does NOT block: absence of evidence is not evidence of
139
+ ambiguity. That is the same shape ``adopt_unidentified_observations`` uses on
140
+ the observation axis, which likewise resolves a window from its *identified*
141
+ observations only and treats an unidentified population as no evidence. The
142
+ first implementation blocked on any claiming window that resolved to nothing
143
+ and measured ZERO stamped rows on a real store: weekly resets move by days,
144
+ so one cycle overlaps many neighbours and pre-attribution history is
145
+ unidentified by construction — one such neighbour was always enough to veto.
146
+
147
+ Two claiming windows naming DIFFERENT accounts still leave the entry alone
148
+ (union of two), and so does a single window that itself saw two accounts
149
+ (#341 never-combine). An already-identified row is never re-stamped, so
150
+ re-running over this kernel's own output returns an empty plan.
151
+ """
152
+ by_root: dict[str, list[SpendAdoptionWindow]] = {}
153
+ for window in windows:
154
+ if window.in_scope:
155
+ by_root.setdefault(window.source_root_key, []).append(window)
156
+
157
+ stamps: list[SpendAdoptionStamp] = []
158
+ for candidate in candidates:
159
+ if not entry_is_unattributed(candidate.account_key):
160
+ continue
161
+ identified: set[str] = set()
162
+ for window in by_root.get(candidate.source_root_key, ()):
163
+ if window.covers(candidate.timestamp):
164
+ identified |= window.identified_accounts
165
+ if len(identified) > 1:
166
+ break
167
+ if len(identified) != 1:
168
+ continue
169
+ account = next(iter(identified))
170
+ stamps.append(SpendAdoptionStamp(
171
+ entry_id=candidate.entry_id, account_key=account))
172
+ stamps.sort(key=lambda stamp: (stamp.entry_id, stamp.account_key))
173
+ return tuple(stamps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.86.0",
3
+ "version": "1.87.1",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {
@@ -69,6 +69,7 @@
69
69
  "bin/_lib_cache_report.py",
70
70
  "bin/_lib_cache_writer_lock.py",
71
71
  "bin/_lib_changelog.py",
72
+ "bin/_lib_codex_account_adoption.py",
72
73
  "bin/_lib_codex_conversation.py",
73
74
  "bin/_lib_codex_conversation_export.py",
74
75
  "bin/_lib_codex_conversation_query.py",