cctally 1.87.2 → 1.88.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.
@@ -183,6 +183,64 @@ def _codex_history_row_is_model_scoped(row: object) -> bool:
183
183
  return bool(isinstance(row, Mapping) and row.get("model_scoped"))
184
184
 
185
185
 
186
+ def _active_row_from_history(
187
+ history_row: Mapping[str, object], *, now_utc: dt.datetime,
188
+ ) -> dict[str, object] | None:
189
+ """Project a serialized quota history row onto its ``summary.active[]`` row.
190
+
191
+ #429 §4.1. The ONE home for the active-window predicate and the active-row
192
+ shape, so the initial build and ``refresh_codex_source_clock`` cannot
193
+ disagree about what ``captured_at`` means — the defect this fixes. Callers
194
+ must keep no separate copy of any part of the predicate, including the #373
195
+ model-pool exclusion: a live Spark/foreign-pool window must never reach an
196
+ account-level aggregate.
197
+
198
+ Both call sites see the same serialized shape. The clock's forecast refresh
199
+ rewrites ``status``, ``remaining_seconds`` and ``projected_percent`` but
200
+ never ``current_percent`` or ``resets_at``, so the fields read here are
201
+ identical at build time and at every tick.
202
+
203
+ #428: the liveness predicate and the emitted ``resets_at`` are the SAME
204
+ ``forecast.resets_at``, which is ``baseline.canonical_resets_at``
205
+ (``_lib_quota.forecast_quota``). The client compares ``active[].resets_at``
206
+ against ``hero.cycle.resets_at`` (``activeWeeklyKeys``) to decide which
207
+ weekly history is the live one, so both must carry that one anchor.
208
+ """
209
+ if _codex_history_row_is_model_scoped(history_row):
210
+ return None
211
+ forecast = history_row.get("forecast")
212
+ if not isinstance(forecast, Mapping):
213
+ return None
214
+ current = forecast.get("current_percent")
215
+ if not isinstance(current, (int, float)) or isinstance(current, bool):
216
+ return None
217
+ resets_at = forecast.get("resets_at")
218
+ try:
219
+ reset = dt.datetime.fromisoformat(
220
+ str(resets_at).replace("Z", "+00:00")
221
+ ).astimezone(UTC)
222
+ except (TypeError, ValueError):
223
+ return None
224
+ if reset <= now_utc:
225
+ return None
226
+ row: dict[str, object] = {
227
+ "key": history_row.get("key"),
228
+ "current_percent": current,
229
+ # #429 §3: evidence recency — the newest PHYSICAL observation, the same
230
+ # one that produced this row's `freshness` and `stale_after_seconds`.
231
+ # Not the interpreted baseline, which is a value axis and belongs to
232
+ # `current_percent` alone.
233
+ "captured_at": history_row.get("captured_at"),
234
+ "resets_at": resets_at,
235
+ "freshness": history_row.get("freshness"),
236
+ "stale_after_seconds": history_row.get("stale_after_seconds"),
237
+ }
238
+ account_key = history_row.get("account_key")
239
+ if account_key:
240
+ row["account_key"] = account_key
241
+ return row
242
+
243
+
186
244
  def _resolve_codex_weekly_cycle(
187
245
  observations: Iterable[object],
188
246
  now_utc: dt.datetime,
@@ -1693,6 +1751,7 @@ def _quota_read_model(
1693
1751
  *,
1694
1752
  accounting_entries: Iterable[object] = (),
1695
1753
  account_key: str | None = None,
1754
+ decorated: bool,
1696
1755
  ) -> dict[str, object]:
1697
1756
  """Use S2's pure history/block/forecast kernels over cache evidence.
1698
1757
 
@@ -1714,9 +1773,11 @@ def _quota_read_model(
1714
1773
  cost_entries = tuple(accounting_entries)
1715
1774
  histories = build_history(quota_observations)
1716
1775
  blocks = build_blocks(quota_observations)
1717
- history_rows: list[dict[str, object]] = []
1718
1776
  milestone_rows: list[dict[str, object]] = []
1719
- active_rows: list[dict[str, object]] = []
1777
+ # #429 §4.2: one candidate unit per identity — (ordinal, history row, active
1778
+ # projection or None) — so the cap retains PAIRS instead of capping the two
1779
+ # lists independently and in different orders.
1780
+ candidates: list[tuple[int, dict[str, object], dict[str, object] | None]] = []
1720
1781
  # R8 (#341 Task 4): the per-account `account_key` is serialized onto each
1721
1782
  # history row ONLY when the Codex provider has >1 REAL account, so the
1722
1783
  # dashboard client can scope per-account quota rows instead of merging them.
@@ -1726,13 +1787,12 @@ def _quota_read_model(
1726
1787
  # public history view is LOSSY — capped at `SOURCE_HISTORY_LIMIT` and without
1727
1788
  # `logical_limit_key` — so it cannot resolve the cycle authoritatively. Build
1728
1789
  # time owns resolution; a `clock_data` decision deadline forces the rebuild.
1729
- _codex_decorated = False
1730
- try:
1731
- import _cctally_account
1732
- _codex_decorated = _cctally_account.provider_is_decorated(
1733
- context.stats_conn, "codex")
1734
- except Exception:
1735
- _codex_decorated = False
1790
+ #
1791
+ # #429 §4.4: the caller owns this gate. Re-querying here, per parent AND per
1792
+ # child, let a transient failure emit decorated scopes whose quota rows were
1793
+ # silently unstamped — and the active-row helper cannot project a field the
1794
+ # history row never carried.
1795
+ _codex_decorated = decorated
1736
1796
  for history in histories:
1737
1797
  identity = history.identity
1738
1798
  key_parts = (
@@ -1777,21 +1837,11 @@ def _quota_read_model(
1777
1837
  "confidence": forecast.confidence,
1778
1838
  },
1779
1839
  }
1780
- history_rows.append(row)
1781
- if _codex_history_row_is_model_scoped(row):
1782
- continue
1783
- # #428: the client compares `active[].resets_at` against
1784
- # `hero.cycle.resets_at` (`activeWeeklyKeys`) to decide which weekly
1785
- # history is the live one, so both must carry the SAME anchor.
1786
- if baseline is not None and baseline.canonical_resets_at > context.now_utc:
1787
- active_rows.append({
1788
- "key": dashboard_resource_key("quota", "codex", *key_parts),
1789
- "current_percent": baseline.used_percent,
1790
- "captured_at": baseline.captured_at.astimezone(UTC).isoformat(),
1791
- "resets_at": baseline.canonical_resets_at.astimezone(UTC).isoformat(),
1792
- "freshness": freshness.state,
1793
- "stale_after_seconds": freshness.stale_after_seconds,
1794
- })
1840
+ candidates.append((
1841
+ len(candidates),
1842
+ row,
1843
+ _active_row_from_history(row, now_utc=context.now_utc),
1844
+ ))
1795
1845
  for block in blocks:
1796
1846
  identity = block.identity
1797
1847
  block_parts = (
@@ -1899,20 +1949,21 @@ def _quota_read_model(
1899
1949
  "marginal_usd": max(0.0, cumulative_usd - previous_cumulative),
1900
1950
  })
1901
1951
  previous_cumulative = cumulative_usd
1902
- latest_percent = max(
1903
- (float(row["current_percent"]) for row in active_rows), default=None,
1904
- )
1905
- active_freshness = (
1906
- "fresh" if active_rows and all(row["freshness"] == "fresh" for row in active_rows)
1907
- else ("unavailable" if not active_rows else "stale")
1908
- )
1909
1952
  # Active account identities are presentation-critical. Independent
1910
1953
  # model-scoped pools are also legitimate provider facts, so reserve the
1911
1954
  # remaining cap space for their newest captures before inactive account
1912
1955
  # history. Opaque resource-key order is only a stable tie-breaker.
1913
- active_keys = {str(row["key"]) for row in active_rows}
1956
+ #
1957
+ # #429 §4.2 — retention decides ONCE per (history, active) unit, then each
1958
+ # list is emitted in its own established order: histories in retention
1959
+ # order, actives in identity order. Emitting both in a single order would
1960
+ # reorder active rows below the cap and move bytes for every install.
1961
+ active_keys = {
1962
+ str(active["key"]) for _, _, active in candidates if active is not None
1963
+ }
1914
1964
 
1915
- def _history_retention_key(row):
1965
+ def _history_retention_key(unit):
1966
+ _, row, _ = unit
1916
1967
  key = str(row["key"])
1917
1968
  if key in active_keys:
1918
1969
  return (0, 0.0, key)
@@ -1927,11 +1978,22 @@ def _quota_read_model(
1927
1978
  return (1, -captured_epoch, key)
1928
1979
  return (2, 0.0, key)
1929
1980
 
1930
- history_rows.sort(key=_history_retention_key)
1931
- history_rows = history_rows[:SOURCE_HISTORY_LIMIT]
1981
+ retained = sorted(candidates, key=_history_retention_key)[:SOURCE_HISTORY_LIMIT]
1982
+ history_rows = [row for _, row, _ in retained]
1983
+ active_rows = [
1984
+ active
1985
+ for _, _, active in sorted(retained, key=lambda unit: unit[0])
1986
+ if active is not None
1987
+ ]
1988
+ latest_percent = max(
1989
+ (float(row["current_percent"]) for row in active_rows), default=None,
1990
+ )
1991
+ active_freshness = (
1992
+ "fresh" if active_rows and all(row["freshness"] == "fresh" for row in active_rows)
1993
+ else ("unavailable" if not active_rows else "stale")
1994
+ )
1932
1995
  milestone_rows.sort(key=lambda row: str(row["captured_at"]), reverse=True)
1933
1996
  milestone_rows = milestone_rows[:SOURCE_HISTORY_LIMIT]
1934
- active_rows = active_rows[:SOURCE_HISTORY_LIMIT]
1935
1997
  return {
1936
1998
  "summary": {
1937
1999
  "window_count": len(blocks),
@@ -2035,6 +2097,117 @@ def _refresh_budget_status_clock(
2035
2097
  }
2036
2098
 
2037
2099
 
2100
+ def _scoped_quota_identity(row: Mapping[str, object]) -> tuple[str, str]:
2101
+ """#429 §3.1. `dashboard_resource_key` carries no account, and two accounts
2102
+ sharing one $CODEX_HOME root emit the same key, so bare key is not an
2103
+ identity under decoration. `"unattributed"` is a legitimate account here."""
2104
+ return (str(row.get("account_key") or ""), str(row.get("key")))
2105
+
2106
+
2107
+ def _reclock_quota_domain(
2108
+ quota: Mapping[str, object], *, now_utc: dt.datetime,
2109
+ ) -> dict[str, object]:
2110
+ """Re-evaluate a quota domain's row freshness and summary against ``now``.
2111
+
2112
+ #429 §4.3. Replaces ONLY `histories` and `summary`; `blocks`, `milestones`
2113
+ and `cycle_index` are carried through untouched, because the per-account
2114
+ scopes carry them and a scope that lost them would render empty.
2115
+
2116
+ Emits TUPLES, matching what `_quota_read_model` publishes. Publication
2117
+ freezes lists into tuples anyway, so this is byte-identical on the wire —
2118
+ but it is what lets the caller detect an unchanged domain by comparing the
2119
+ result against the frozen original. A list would never compare equal to the
2120
+ tuple it was frozen from (``[] != ()``), the caller would report a change on
2121
+ every tick, and the retain/degrade paths that assert the EXACT prior ``data``
2122
+ object is handed back would break.
2123
+ """
2124
+ refreshed = dict(quota)
2125
+ refreshed_histories: list[dict[str, object]] = []
2126
+ active_rows: list[dict[str, object]] = []
2127
+ for raw_history in quota.get("histories", ()):
2128
+ if not isinstance(raw_history, Mapping):
2129
+ continue
2130
+ history = dict(raw_history)
2131
+ # #350 spec §3.9: this is a PER-ROW value and must never shadow the
2132
+ # envelope-level `freshness`. It used to, so after the loop the
2133
+ # envelope held the LAST retained history row's freshness — often an
2134
+ # inactive row, and with a single weekly history the active weekly
2135
+ # one, which silently marked the whole provider stale on an idle
2136
+ # stale crossing and tripped idle eligibility on its own.
2137
+ row_freshness = _clock_freshness(
2138
+ history.get("captured_at"), history.get("stale_after_seconds"), now_utc,
2139
+ )
2140
+ history["freshness"] = row_freshness
2141
+ forecast = history.get("forecast")
2142
+ if isinstance(forecast, Mapping):
2143
+ forecast = dict(forecast)
2144
+ resets_at = forecast.get("resets_at")
2145
+ try:
2146
+ reset = dt.datetime.fromisoformat(
2147
+ str(resets_at).replace("Z", "+00:00")
2148
+ ).astimezone(UTC)
2149
+ except (TypeError, ValueError):
2150
+ reset = None
2151
+ remaining = max(0, int((reset - now_utc).total_seconds())) if reset else None
2152
+ forecast["remaining_seconds"] = remaining
2153
+ sample_count = int(forecast.get("sample_count") or 0)
2154
+ if row_freshness == "future":
2155
+ forecast["status"] = "future"
2156
+ elif row_freshness == "stale":
2157
+ forecast["status"] = "stale"
2158
+ elif sample_count == 0:
2159
+ forecast["status"] = "insufficient-history"
2160
+ else:
2161
+ forecast["status"] = "ok"
2162
+ rate = forecast.get("rate_percent_per_hour")
2163
+ current = forecast.get("current_percent")
2164
+ if (
2165
+ isinstance(rate, (int, float)) and not isinstance(rate, bool)
2166
+ and isinstance(current, (int, float)) and not isinstance(current, bool)
2167
+ and remaining is not None
2168
+ ):
2169
+ forecast["projected_percent"] = min(
2170
+ 100.0, max(float(current), float(current) + float(rate) * remaining / 3600),
2171
+ )
2172
+ history["forecast"] = forecast
2173
+ # Called unconditionally, exactly as the build calls it. The helper
2174
+ # already returns None for a row without a usable forecast, and keeping
2175
+ # a caller-side `isinstance(forecast, Mapping)` guard here would put a
2176
+ # fragment of the predicate back on the caller — the split #429 exists
2177
+ # to remove.
2178
+ active = _active_row_from_history(history, now_utc=now_utc)
2179
+ if active is not None:
2180
+ active_rows.append(active)
2181
+ refreshed_histories.append(history)
2182
+ summary = dict(quota.get("summary") or {})
2183
+ prior_active = summary.get("active")
2184
+ if isinstance(prior_active, (tuple, list)):
2185
+ # #429 §3.1: scoped identity, not bare key — two decorated rows can
2186
+ # share one key and would collapse into a single map entry.
2187
+ active_order = {
2188
+ _scoped_quota_identity(row): index
2189
+ for index, row in enumerate(prior_active)
2190
+ if isinstance(row, Mapping)
2191
+ }
2192
+ active_rows.sort(
2193
+ key=lambda row: active_order.get(
2194
+ _scoped_quota_identity(row), len(active_order)),
2195
+ )
2196
+ summary.update({
2197
+ "active_window_count": len(active_rows),
2198
+ "latest_percent": max(
2199
+ (float(row["current_percent"]) for row in active_rows), default=None),
2200
+ "freshness": (
2201
+ "fresh" if active_rows and all(row["freshness"] == "fresh" for row in active_rows)
2202
+ else ("unavailable" if not active_rows else "stale")
2203
+ ),
2204
+ "active": tuple(active_rows),
2205
+ })
2206
+ refreshed["histories"] = tuple(refreshed_histories)
2207
+ refreshed["summary"] = summary
2208
+ return refreshed
2209
+
2210
+
2038
2211
  def refresh_codex_source_clock(
2039
2212
  state: SourceDashboardState,
2040
2213
  *,
@@ -2059,148 +2232,48 @@ def refresh_codex_source_clock(
2059
2232
  freshness = state.freshness
2060
2233
  domain_freshness = dict(state.domain_freshness or {})
2061
2234
  if isinstance(quota, Mapping):
2062
- quota = dict(quota)
2063
- refreshed_histories: list[dict[str, object]] = []
2064
- active_rows: list[dict[str, object]] = []
2065
- for raw_history in quota.get("histories", ()):
2066
- if not isinstance(raw_history, Mapping):
2067
- continue
2068
- history = dict(raw_history)
2069
- # #350 spec §3.9: this is a PER-ROW value and must never shadow the
2070
- # envelope-level `freshness`. It used to, so after the loop the
2071
- # envelope held the LAST retained history row's freshness — often an
2072
- # inactive row, and with a single weekly history the active weekly
2073
- # one, which silently marked the whole provider stale on an idle
2074
- # stale crossing and tripped idle eligibility on its own.
2075
- row_freshness = _clock_freshness(
2076
- history.get("captured_at"), history.get("stale_after_seconds"), now_utc,
2077
- )
2078
- history["freshness"] = row_freshness
2079
- forecast = history.get("forecast")
2080
- if isinstance(forecast, Mapping):
2081
- forecast = dict(forecast)
2082
- resets_at = forecast.get("resets_at")
2083
- try:
2084
- reset = dt.datetime.fromisoformat(
2085
- str(resets_at).replace("Z", "+00:00")
2086
- ).astimezone(UTC)
2087
- except (TypeError, ValueError):
2088
- reset = None
2089
- remaining = max(0, int((reset - now_utc).total_seconds())) if reset else None
2090
- forecast["remaining_seconds"] = remaining
2091
- sample_count = int(forecast.get("sample_count") or 0)
2092
- if row_freshness == "future":
2093
- forecast["status"] = "future"
2094
- elif row_freshness == "stale":
2095
- forecast["status"] = "stale"
2096
- elif sample_count == 0:
2097
- forecast["status"] = "insufficient-history"
2098
- else:
2099
- forecast["status"] = "ok"
2100
- rate = forecast.get("rate_percent_per_hour")
2101
- current = forecast.get("current_percent")
2102
- if (
2103
- isinstance(rate, (int, float)) and not isinstance(rate, bool)
2104
- and isinstance(current, (int, float)) and not isinstance(current, bool)
2105
- and remaining is not None
2106
- ):
2107
- forecast["projected_percent"] = min(
2108
- 100.0, max(float(current), float(current) + float(rate) * remaining / 3600),
2109
- )
2110
- history["forecast"] = forecast
2111
- # #373: same rule as the initial build, through the same
2112
- # predicate, so the two paths cannot drift.
2113
- if (
2114
- not _codex_history_row_is_model_scoped(history)
2115
- and reset is not None and reset > now_utc and current is not None
2116
- ):
2117
- active_rows.append({
2118
- "key": history.get("key"),
2119
- "current_percent": current,
2120
- "captured_at": history.get("captured_at"),
2121
- "resets_at": resets_at,
2122
- "freshness": row_freshness,
2123
- "stale_after_seconds": history.get("stale_after_seconds"),
2124
- })
2125
- refreshed_histories.append(history)
2126
- quota["histories"] = refreshed_histories
2127
- latest_percent = max(
2128
- (float(row["current_percent"]) for row in active_rows), default=None,
2129
- )
2130
- summary = dict(quota.get("summary") or {})
2131
- prior_active = summary.get("active")
2132
- if isinstance(prior_active, (tuple, list)):
2133
- active_order = {
2134
- str(row.get("key")): index
2135
- for index, row in enumerate(prior_active)
2136
- if isinstance(row, Mapping)
2137
- }
2138
- active_rows.sort(
2139
- key=lambda row: active_order.get(str(row.get("key")), len(active_order)),
2140
- )
2141
- summary.update({
2142
- "active_window_count": len(active_rows),
2143
- "latest_percent": latest_percent,
2144
- "freshness": (
2145
- "fresh" if active_rows and all(row["freshness"] == "fresh" for row in active_rows)
2146
- else ("unavailable" if not active_rows else "stale")
2147
- ),
2148
- "active": active_rows,
2149
- })
2150
- # Only account-level active histories reach ``active_rows``; the shared
2151
- # model-scoped predicate above excludes foreign pools. An unavailable
2152
- # active set is a capability/data-availability fact, not invented
2153
- # staleness, so only the exact stale verdict moves this axis.
2235
+ reclocked = _reclock_quota_domain(quota, now_utc=now_utc)
2236
+ quota_changed = reclocked != quota
2237
+ quota = reclocked
2238
+ data["quota"] = quota
2239
+ # Only account-level active histories reach the summary; the shared
2240
+ # model-scoped predicate excludes foreign pools. An unavailable active
2241
+ # set is a capability/data-availability fact, not invented staleness,
2242
+ # so only the exact stale verdict moves this axis. #429 §4.3 keeps this
2243
+ # deriving from the TOP-LEVEL summary only — the #350 §3.9 rule that a
2244
+ # row-level freshness value never touches `state.freshness` extends to
2245
+ # the per-account scopes clocked below.
2154
2246
  domain_freshness["quota"] = (
2155
- "stale" if summary["freshness"] == "stale" else "fresh"
2247
+ "stale" if quota["summary"]["freshness"] == "stale" else "fresh"
2156
2248
  )
2157
- quota["summary"] = summary
2158
- data["quota"] = quota
2159
- quota_changed = bool(refreshed_histories)
2160
- hero = data.get("hero")
2161
- hero_capability = state.capabilities.get("hero")
2162
- if (
2163
- isinstance(hero, Mapping)
2164
- and isinstance(hero.get("cycle"), Mapping)
2165
- and hero_capability is not None
2166
- and hero_capability.status == "supported"
2167
- ):
2168
- # #350 spec §3.3: the clock no longer RE-DERIVES cycle validity.
2169
- # Its public-history view is lossy (capped, no `logical_limit_key`,
2170
- # no `quota_identity`), so it cannot resolve the cycle correctly —
2171
- # and per §2.2 it cannot simply trust the old verdict forever either,
2172
- # because resolution is time-dependent on frozen evidence. Build time
2173
- # owns resolution and records a decision deadline in `clock_data`; the
2174
- # tick rebuilds authoritatively at the crossing. All the clock keeps
2175
- # is this cheap invariant guard: a cycle that has already RESET cannot
2176
- # bound current accounting, so it degrades exactly as before.
2177
- # Expiry is also deadline candidate #1, so the two paths are disjoint
2178
- # belt-and-suspenders rather than a single mechanism.
2179
- if _clock_cycle_expired(hero.get("cycle"), now_utc):
2180
- hero = dict(hero)
2181
- for field in (
2182
- "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
2183
- "reasoning_output_tokens", "total_tokens", "cycle",
2184
- ):
2185
- hero[field] = None
2186
- data["hero"] = hero
2187
- refreshed_capabilities = dict(state.capabilities)
2188
- refreshed_capabilities["hero"] = CapabilityRecord(
2189
- "unavailable", "missing-or-conflicting-native-cycle",
2190
- )
2191
- capabilities = refreshed_capabilities
2192
- warnings = tuple(
2193
- warning for warning in state.warnings
2194
- if warning.code != "codex_cycle_unavailable"
2195
- ) + (SourceDashboardWarning(
2196
- "codex_cycle_unavailable",
2197
- "Codex native reset cycle is unavailable.",
2198
- "hero",
2199
- ),)
2200
- availability = "partial"
2201
- cycle_changed = True
2249
+ # #429 §4.3: the per-account scopes are independent evidence domains and
2250
+ # were never clocked at all, so a focused account read frozen freshness
2251
+ # forever. The published state is recursively frozen (`MappingProxyType`),
2252
+ # so nothing may be mutated in place — copy outward: the scopes mapping,
2253
+ # then the scope, then only that scope's `quota`.
2254
+ scopes = data.get("account_scopes")
2255
+ scopes_changed = False
2256
+ if isinstance(scopes, Mapping):
2257
+ rebuilt_scopes = dict(scopes)
2258
+ for scope_key, scope in scopes.items():
2259
+ if not isinstance(scope, Mapping):
2260
+ continue
2261
+ scope_quota = scope.get("quota")
2262
+ if not isinstance(scope_quota, Mapping):
2263
+ continue
2264
+ reclocked_scope_quota = _reclock_quota_domain(
2265
+ scope_quota, now_utc=now_utc)
2266
+ if reclocked_scope_quota == scope_quota:
2267
+ continue
2268
+ rebuilt_scope = dict(scope)
2269
+ rebuilt_scope["quota"] = reclocked_scope_quota
2270
+ rebuilt_scopes[scope_key] = rebuilt_scope
2271
+ scopes_changed = True
2272
+ if scopes_changed:
2273
+ data["account_scopes"] = rebuilt_scopes
2202
2274
  budget_domain = data.get("budget")
2203
2275
  budget_changed = False
2276
+ refreshed_budget = None
2204
2277
  if isinstance(budget_domain, Mapping):
2205
2278
  budget_domain = dict(budget_domain)
2206
2279
  refreshed_budget = _refresh_budget_status_clock(
@@ -2214,13 +2287,62 @@ def refresh_codex_source_clock(
2214
2287
  if refreshed_budget is not None:
2215
2288
  budget_domain["status"] = refreshed_budget
2216
2289
  data["budget"] = budget_domain
2217
- hero = data.get("hero")
2218
- if isinstance(hero, Mapping):
2219
- hero = dict(hero)
2220
- hero["budget"] = refreshed_budget
2221
- data["hero"] = hero
2222
2290
  budget_changed = True
2223
- if not (quota_changed or budget_changed or cycle_changed):
2291
+ # #429 §4.5: all three hero mutations compose on ONE copy, in a stated
2292
+ # order. Three independent `dict(hero)` copies let the last write win, which
2293
+ # is how `hero["quota"]` stayed frozen at its build-time value while
2294
+ # `quota["summary"]` advanced.
2295
+ hero = data.get("hero")
2296
+ if isinstance(hero, Mapping):
2297
+ hero = dict(hero)
2298
+ # 1. quota first — `_clock_cycle_expired` reads `hero["cycle"]`, never
2299
+ # `hero["quota"]`, so replacing quota cannot affect the predicate.
2300
+ if isinstance(quota, Mapping):
2301
+ hero["quota"] = quota["summary"]
2302
+ # 2. cycle expiry, with its capability/warning consequences.
2303
+ # #350 spec §3.3: the clock no longer RE-DERIVES cycle validity. Its
2304
+ # public-history view is lossy (capped, no `logical_limit_key`, no
2305
+ # `quota_identity`), so it cannot resolve the cycle correctly — and
2306
+ # per §2.2 it cannot simply trust the old verdict forever either,
2307
+ # because resolution is time-dependent on frozen evidence. Build time
2308
+ # owns resolution and records a decision deadline in `clock_data`; the
2309
+ # tick rebuilds authoritatively at the crossing. All the clock keeps
2310
+ # is this cheap invariant guard: a cycle that has already RESET cannot
2311
+ # bound current accounting, so it degrades exactly as before. Expiry
2312
+ # is also deadline candidate #1, so the two paths are disjoint
2313
+ # belt-and-suspenders rather than a single mechanism.
2314
+ hero_capability = state.capabilities.get("hero")
2315
+ if (
2316
+ isinstance(hero.get("cycle"), Mapping)
2317
+ and hero_capability is not None
2318
+ and hero_capability.status == "supported"
2319
+ and _clock_cycle_expired(hero.get("cycle"), now_utc)
2320
+ ):
2321
+ for field in (
2322
+ "cost_usd", "input_tokens", "cached_input_tokens", "output_tokens",
2323
+ "reasoning_output_tokens", "total_tokens", "cycle",
2324
+ ):
2325
+ hero[field] = None
2326
+ refreshed_capabilities = dict(state.capabilities)
2327
+ refreshed_capabilities["hero"] = CapabilityRecord(
2328
+ "unavailable", "missing-or-conflicting-native-cycle",
2329
+ )
2330
+ capabilities = refreshed_capabilities
2331
+ warnings = tuple(
2332
+ warning for warning in state.warnings
2333
+ if warning.code != "codex_cycle_unavailable"
2334
+ ) + (SourceDashboardWarning(
2335
+ "codex_cycle_unavailable",
2336
+ "Codex native reset cycle is unavailable.",
2337
+ "hero",
2338
+ ),)
2339
+ availability = "partial"
2340
+ cycle_changed = True
2341
+ # 3. budget last
2342
+ if refreshed_budget is not None:
2343
+ hero["budget"] = refreshed_budget
2344
+ data["hero"] = hero
2345
+ if not (quota_changed or budget_changed or cycle_changed or scopes_changed):
2224
2346
  return state
2225
2347
  refreshed_state = SourceDashboardState(
2226
2348
  source=state.source,
@@ -2832,6 +2954,9 @@ def _codex_account_scopes_wire(
2832
2954
  quota = _quota_read_model(
2833
2955
  context, account_observations, accounting_entries=rows,
2834
2956
  account_key=key,
2957
+ # #429 §4.4: this wire is only ever reached under decoration — the
2958
+ # caller gates the whole `account_scopes` surface on it.
2959
+ decorated=True,
2835
2960
  )
2836
2961
  # Each decorated child owns the only honest cycle index for that
2837
2962
  # account. Reusing a merged parent index here would render account A's
@@ -3228,6 +3353,7 @@ def build_codex_source_state(
3228
3353
  context,
3229
3354
  quota_observations,
3230
3355
  accounting_entries=visible_accounting_entries,
3356
+ decorated=_codex_decorated,
3231
3357
  )
3232
3358
  # R8 gate, resolved ONCE and threaded (#341 Task 4 / #416 §5.8). Every
3233
3359
  # per-account decoration below — block/alert/budget `account_key`, the