cctally 1.98.0 → 1.99.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.
@@ -193,6 +193,7 @@ import sqlite3
193
193
  import sys
194
194
  import threading
195
195
  import time
196
+ from collections.abc import Mapping
196
197
  from dataclasses import dataclass, field
197
198
  from typing import Any, Sequence
198
199
 
@@ -264,6 +265,7 @@ _ensure_sibling_loaded("_cctally_dashboard_sources")
264
265
  from _cctally_dashboard_sources import (
265
266
  DashboardReadContext,
266
267
  _claude_accounts_wire,
268
+ _refresh_budget_status_clock,
267
269
  accounts_identity_digest,
268
270
  build_codex_source_state,
269
271
  codex_decision_deadline_passed,
@@ -2604,6 +2606,54 @@ def _tui_claude_domain_freshness(
2604
2606
  return {"hero": accounting, "quota": quota, "sessions": "fresh"}
2605
2607
 
2606
2608
 
2609
+ def _refresh_claude_budget_clock(
2610
+ state: SourceDashboardState,
2611
+ *,
2612
+ now_utc: dt.datetime,
2613
+ ) -> SourceDashboardState:
2614
+ """Re-run the pure pace kernel over Claude's frozen budget facts.
2615
+
2616
+ #556 S5 §3.7. Separate from ``_refresh_claude_source_clock`` because the two
2617
+ are called from different places: that one runs ONLY on the pure-idle short
2618
+ circuit and needs the legacy ``current_week`` object and the raw config,
2619
+ neither of which the source-bundle builder holds. This one needs only the
2620
+ published status and the server-private cost events, so it can be called
2621
+ unconditionally after every build, reuse and degrade branch — which is what
2622
+ §3.7 requires, because exact-version Claude reuse returns the prior object
2623
+ unchanged and would otherwise republish a budget frozen at the instant it
2624
+ was built.
2625
+
2626
+ Same-instant identity is preserved: the underlying kernel is deterministic
2627
+ in ``now``, so a freshly built state reclocks to itself and the equality
2628
+ guards below hand the caller the exact object it passed in.
2629
+ """
2630
+ if state.source != "claude" or not isinstance(state.data, Mapping):
2631
+ return state
2632
+ if now_utc.tzinfo is None or now_utc.utcoffset() is None:
2633
+ raise ValueError("now_utc must be timezone-aware")
2634
+ now_utc = now_utc.astimezone(dt.timezone.utc)
2635
+ budget_domain = state.data.get("budget")
2636
+ if not isinstance(budget_domain, Mapping):
2637
+ return state
2638
+ status = budget_domain.get("status")
2639
+ if not isinstance(status, Mapping):
2640
+ return state
2641
+ refreshed = _refresh_budget_status_clock(
2642
+ status,
2643
+ now_utc,
2644
+ cost_events=(
2645
+ state.clock_data.get("claude_budget_cost_events", ())
2646
+ if isinstance(state.clock_data, Mapping) else ()
2647
+ ),
2648
+ )
2649
+ if refreshed is None or refreshed == status:
2650
+ return state
2651
+ data = dict(state.data)
2652
+ data["budget"] = {**dict(budget_domain), "status": refreshed}
2653
+ refreshed_state = dataclasses.replace(state, data=data)
2654
+ return state if refreshed_state == state else refreshed_state
2655
+
2656
+
2607
2657
  def _refresh_claude_source_clock(
2608
2658
  state: SourceDashboardState,
2609
2659
  *,
@@ -2655,6 +2705,10 @@ def _refresh_claude_source_clock(
2655
2705
  state,
2656
2706
  domain_freshness=domain_freshness,
2657
2707
  )
2708
+ # #556 S5 §3.7: the budget leg runs on the pure-idle path too. It is the
2709
+ # SAME helper the bundle builder calls after every other branch, so the two
2710
+ # paths cannot drift.
2711
+ refreshed = _refresh_claude_budget_clock(refreshed, now_utc=now_utc)
2658
2712
  return state if refreshed == state else refreshed
2659
2713
 
2660
2714
 
@@ -2767,6 +2821,9 @@ def _tui_build_claude_aggregates(
2767
2821
  # parameter shadowing it inside a function that also calls it reads as a
2768
2822
  # recursive reference.
2769
2823
  legacy_labels: "dict[str, str] | None" = None,
2824
+ max_entry_id: "int | None" = None,
2825
+ entry_mutation_seq: "int | None" = None,
2826
+ generation: int = 0,
2770
2827
  ):
2771
2828
  """Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
2772
2829
 
@@ -2793,6 +2850,32 @@ def _tui_build_claude_aggregates(
2793
2850
  outcomes: dict[str, object] = {
2794
2851
  "projects": {"state": "ok"}, "daily": {"state": "ok"},
2795
2852
  }
2853
+ dashboard_module = sys.modules["_cctally_dashboard"]
2854
+ cache_seams_unpatched = (
2855
+ c.build_project_aggregate_rows
2856
+ is dashboard_module.build_project_aggregate_rows
2857
+ and c.build_daily_aggregate_rows
2858
+ is dashboard_module.build_daily_aggregate_rows
2859
+ )
2860
+ if legacy_labels is not None and cache_seams_unpatched:
2861
+ try:
2862
+ return c.build_cached_claude_range_aggregates(
2863
+ cache_conn,
2864
+ shared_start=shared_start,
2865
+ shared_end_exclusive=shared_end_exclusive,
2866
+ now_utc=now_utc,
2867
+ display_tz=display_tz,
2868
+ legacy_labels=legacy_labels,
2869
+ max_entry_id=max_entry_id,
2870
+ entry_mutation_seq=entry_mutation_seq,
2871
+ generation=generation,
2872
+ ), outcomes
2873
+ except Exception:
2874
+ # The accumulator is only an optimization. Its failure falls
2875
+ # through to the original two independent fold boundaries below.
2876
+ _lib_log.get_logger("dashboard").error(
2877
+ "claude range aggregate cache failed", exc_info=True,
2878
+ )
2796
2879
  try:
2797
2880
  rows = tuple(c.iter_shared_range_entries(
2798
2881
  cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
@@ -2805,6 +2888,7 @@ def _tui_build_claude_aggregates(
2805
2888
  "projects": dict(_AGGREGATE_FOLD_FAILED),
2806
2889
  "daily": dict(_AGGREGATE_FOLD_FAILED),
2807
2890
  }
2891
+ prepared_daily_entries = None
2808
2892
  if legacy_labels is None:
2809
2893
  # No projects envelope was built this tick, so the routable population
2810
2894
  # is unknown. Publishing anyway would relabel every row from the
@@ -2819,10 +2903,14 @@ def _tui_build_claude_aggregates(
2819
2903
  )
2820
2904
  outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
2821
2905
  else:
2906
+ candidate_daily_entries = []
2822
2907
  try:
2823
2908
  payload["projects"] = c.build_project_aggregate_rows(
2824
- rows, legacy_labels=legacy_labels,
2909
+ rows,
2910
+ legacy_labels=legacy_labels,
2911
+ prepared_daily_entries=candidate_daily_entries,
2825
2912
  )
2913
+ prepared_daily_entries = candidate_daily_entries
2826
2914
  except Exception:
2827
2915
  _lib_log.get_logger("dashboard").error(
2828
2916
  "claude range projects fold failed", exc_info=True,
@@ -2832,7 +2920,10 @@ def _tui_build_claude_aggregates(
2832
2920
  payload["daily"] = [
2833
2921
  c.daily_panel_row_to_wire(row)
2834
2922
  for row in c.build_daily_aggregate_rows(
2835
- rows, now_utc=now_utc, display_tz=display_tz,
2923
+ rows,
2924
+ now_utc=now_utc,
2925
+ display_tz=display_tz,
2926
+ prepared_entries=prepared_daily_entries,
2836
2927
  )
2837
2928
  ]
2838
2929
  except Exception:
@@ -2867,6 +2958,312 @@ def _tui_claude_data_with_aggregates(
2867
2958
  return base
2868
2959
 
2869
2960
 
2961
+ # #556 S5 §3.5 — the five dispositions, encoded on the wire. `status` published
2962
+ # means CONFIGURED AND COMPUTED. Everything else is an optional sibling that is
2963
+ # omitted when inapplicable, so the ordinary no-budget payload is byte-identical
2964
+ # to what shipped before this session:
2965
+ #
2966
+ # provider_budget_unset no key at all (the default the client assumes)
2967
+ # account_budgets_only `not_configured.disposition`
2968
+ # period_unresolved `status_unavailable.code`
2969
+ # budget_compute_failed `status_unavailable.code`
2970
+ #
2971
+ # The unavailable shape follows S1's `combined_unavailable` — {code, message,
2972
+ # provider} — so one client reader handles both.
2973
+ _CLAUDE_BUDGET_UNAVAILABLE_MESSAGES = {
2974
+ "period_unresolved": (
2975
+ "Claude's budget period could not be resolved, so no budget status "
2976
+ "is published."
2977
+ ),
2978
+ "budget_compute_failed": (
2979
+ "Claude's budget status could not be computed."
2980
+ ),
2981
+ }
2982
+
2983
+
2984
+ def _tui_claude_budget_period(claude_budget: "Mapping[str, object] | None") -> str:
2985
+ """The configured Claude budget period, defaulting to ``subscription-week``.
2986
+
2987
+ One reader for the capability record and the published status, so the two
2988
+ can never name different periods.
2989
+ """
2990
+ config = claude_budget if isinstance(claude_budget, Mapping) else {}
2991
+ return str(config.get("period") or "subscription-week")
2992
+
2993
+
2994
+ def _tui_claude_budget_window_identity(
2995
+ claude_budget: "Mapping[str, object] | None",
2996
+ *,
2997
+ now_utc: dt.datetime,
2998
+ display_tz_name: str | None,
2999
+ week_start_name: str,
3000
+ ) -> str:
3001
+ """The configured CALENDAR budget window's end, as a version fragment.
3002
+
3003
+ #556 S5 Unit 1 review R5, kept as DEFENCE IN DEPTH — not a fix for a
3004
+ shipped user-visible defect. `claude_version` carries
3005
+ `_tui_claude_period_identity`, which reads the SUBSCRIPTION-WEEK bounds and
3006
+ therefore tracks that period's boundary and no other. On a `calendar-week`
3007
+ or `calendar-month` budget the fragment that already moved was S2's
3008
+ aggregate-range fragment, and that one moves at DISPLAY-TIMEZONE midnight:
3009
+ `resolve_shared_range` (`bin/_cctally_dashboard.py`) floors the earliest
3010
+ day to midnight in the resolved display zone, and production passes ONE
3011
+ resolved zone object to both legs — `_tui_build_snapshot` sets
3012
+ `source_display_tz_name` from `_build_display_tz` and hands the same object
3013
+ to `_tui_common_source_range_start`, while `_resolve_display_tz_obj` always
3014
+ returns a `ZoneInfo`. A `calendar-week` or `calendar-month` boundary falls
3015
+ at local midnight, so the aggregate fragment already moved with it.
3016
+
3017
+ The Unit 2 review corrected the Unit 1 claim that this reproduced a
3018
+ production defect: the reproduction went red only because the test helper
3019
+ paired `display_tz_name="America/New_York"` with a UTC range start, which
3020
+ is a configuration production cannot construct. The fragment stays because
3021
+ it makes the budget window's own boundary the thing that invalidates the
3022
+ budget's own generation, rather than leaving that to a neighbouring
3023
+ fragment that happens to move at the same instant.
3024
+
3025
+ Returns `""` for `subscription-week`, which `_tui_claude_period_identity`
3026
+ already covers from the same bounds, and for an unconfigured budget — so an
3027
+ install with no budget produces a byte-identical version string. Calendar
3028
+ resolution is PURE (no database), which is what lets it run before the reuse
3029
+ decision on every tick.
3030
+
3031
+ A resolution failure contributes `""` rather than raising: the same failure
3032
+ reaches `_tui_claude_budget_domain`, which names `budget_compute_failed`.
3033
+ """
3034
+ config = claude_budget if isinstance(claude_budget, Mapping) else {}
3035
+ if config.get("weekly_usd") is None:
3036
+ return ""
3037
+ period = _tui_claude_budget_period(config)
3038
+ if period == "subscription-week":
3039
+ return ""
3040
+ try:
3041
+ window = _tui_claude_budget_window(
3042
+ None,
3043
+ period=period,
3044
+ now_utc=now_utc,
3045
+ display_tz_name=display_tz_name,
3046
+ week_start_name=week_start_name,
3047
+ )
3048
+ except Exception:
3049
+ return ""
3050
+ if window is None:
3051
+ return ""
3052
+ return window[1].isoformat()
3053
+
3054
+
3055
+ def _tui_claude_budget_unavailable(
3056
+ code: str,
3057
+ *,
3058
+ budget_usd: float | None = None,
3059
+ period: str | None = None,
3060
+ ) -> dict[str, object]:
3061
+ """The `{code, message, provider}` sibling, plus what the user configured.
3062
+
3063
+ #556 S5 Unit 2 review F5. §4.6 requires `period_unresolved` to render "the
3064
+ configured amount with the window named as unresolved", and the client had
3065
+ no amount to render: this payload carried the code, the message and the
3066
+ provider, so the block printed the bare code and nothing else. Both codes
3067
+ are reached ONLY from a configured budget, so both carry the configured
3068
+ amount and period; they are additive and omitted when the caller has
3069
+ nothing to state, which keeps every other consumer's shape unchanged.
3070
+ """
3071
+ payload: dict[str, object] = {
3072
+ "code": code,
3073
+ "message": _CLAUDE_BUDGET_UNAVAILABLE_MESSAGES[code],
3074
+ "provider": "claude",
3075
+ }
3076
+ if budget_usd is not None:
3077
+ try:
3078
+ payload["budget_usd"] = float(budget_usd)
3079
+ except (TypeError, ValueError):
3080
+ # The `budget_compute_failed` caller runs INSIDE the only exception
3081
+ # boundary the Claude build has, so a second raise here would take
3082
+ # the whole bundle down. Omitting the amount degrades this one line.
3083
+ pass
3084
+ if period is not None:
3085
+ payload["period"] = period
3086
+ return payload
3087
+
3088
+
3089
+ def _tui_claude_budget_window(
3090
+ stats_conn,
3091
+ *,
3092
+ period: str,
3093
+ now_utc: dt.datetime,
3094
+ display_tz_name: str | None,
3095
+ week_start_name: str,
3096
+ ):
3097
+ """Resolve the Claude budget window, or ``None`` when it cannot be resolved.
3098
+
3099
+ #556 S5 §3.1: the IMPURE resolvers stay here and are injected into the pure
3100
+ kernel. Subscription-week resolution reads ``weekly_usage_snapshots`` (and
3101
+ returns ``None`` before the first snapshot lands, which the CLI reports as
3102
+ ``status: "no_data"``); calendar resolution goes through the same DST-correct
3103
+ `_resolve_calendar_window` the Codex side already uses, including its
3104
+ per-instant `display.tz = local` path.
3105
+ """
3106
+ from zoneinfo import ZoneInfo
3107
+
3108
+ c = _cctally()
3109
+ forecast = c._load_sibling("_cctally_forecast")
3110
+ if period == "subscription-week":
3111
+ window = forecast._resolve_current_budget_window(stats_conn, now_utc)
3112
+ if window is None:
3113
+ return None
3114
+ start_at, end_at = window
3115
+ else:
3116
+ tz = ZoneInfo(display_tz_name) if display_tz_name else None
3117
+ start_at, end_at = forecast._resolve_calendar_window(
3118
+ period, now_utc, {"collector": {"week_start": week_start_name}}, tz,
3119
+ )
3120
+ return (
3121
+ start_at.astimezone(dt.timezone.utc),
3122
+ end_at.astimezone(dt.timezone.utc),
3123
+ )
3124
+
3125
+
3126
+ def _tui_claude_budget_cost_events(
3127
+ cache_conn, *, start_at: dt.datetime, end_at: dt.datetime,
3128
+ ) -> tuple[tuple[dt.datetime, float], ...]:
3129
+ """Freeze every configured-window Claude cost event for idle pace updates.
3130
+
3131
+ #556 S5 §3.7: trailing-24h spend CANNOT be derived from the status
3132
+ aggregate — `_refresh_budget_status_clock` iterates individual events — so
3133
+ the same per-event carrier Codex keeps in server-private `clock_data` is
3134
+ built here for Claude. It runs on the caller's PINNED cache connection and
3135
+ reuses `iter_shared_range_entries` plus `_shared_range_row_to_usage_entry`,
3136
+ which route through the `claude_usage_dict` chokepoint and therefore price
3137
+ the 1-hour cache-write portion correctly (#195).
3138
+ """
3139
+ c = _cctally()
3140
+ events: list[tuple[dt.datetime, float]] = []
3141
+ for row in c.iter_shared_range_entries(
3142
+ cache_conn, start=start_at, end_exclusive=end_at,
3143
+ ):
3144
+ entry = c._shared_range_row_to_usage_entry(row)
3145
+ timestamp = entry.timestamp
3146
+ if timestamp.tzinfo is None or timestamp.utcoffset() is None:
3147
+ timestamp = timestamp.replace(tzinfo=dt.timezone.utc)
3148
+ events.append((
3149
+ timestamp.astimezone(dt.timezone.utc),
3150
+ c._calculate_entry_cost(
3151
+ entry.model, entry.usage, mode="auto", cost_usd=entry.cost_usd,
3152
+ ),
3153
+ ))
3154
+ return tuple(events)
3155
+
3156
+
3157
+ def _tui_claude_budget_domain(
3158
+ cache_conn,
3159
+ stats_conn,
3160
+ *,
3161
+ claude_budget: "Mapping[str, object] | None",
3162
+ now_utc: dt.datetime,
3163
+ display_tz_name: str | None,
3164
+ week_start_name: str,
3165
+ ) -> tuple[dict[str, object], tuple[tuple[dt.datetime, float], ...]]:
3166
+ """Return ``(budget_domain_overlay, cost_events)`` for the Claude provider.
3167
+
3168
+ The status is VENDOR-WIDE (spec §3.4). A populated `budget.accounts` is NOT
3169
+ consumed to simulate per-account scoping: Claude publishes no
3170
+ `account_scopes` for such a map to describe, so an account-only
3171
+ configuration is its own disposition rather than a fabricated status.
3172
+
3173
+ Every failure is caught HERE. The Claude build has no error boundary of its
3174
+ own — unlike the Codex build at `_tui_build_source_bundle`'s
3175
+ `source_build_failed` handler — so an escaping budget error would take the
3176
+ whole bundle down, not merely the provider.
3177
+ """
3178
+ config = claude_budget if isinstance(claude_budget, Mapping) else {}
3179
+ target = config.get("weekly_usd")
3180
+ if target is None:
3181
+ if config.get("accounts"):
3182
+ return {"not_configured": {"disposition": "account_budgets_only"}}, ()
3183
+ return {}, ()
3184
+ # Resolved BEFORE the boundary so the `budget_compute_failed` payload can
3185
+ # still name the configured period (#556 S5 Unit 2 review F5); the reader
3186
+ # is a pure config lookup with no failure mode of its own.
3187
+ period = _tui_claude_budget_period(config)
3188
+ try:
3189
+ c = _cctally()
3190
+ window = _tui_claude_budget_window(
3191
+ stats_conn,
3192
+ period=period,
3193
+ now_utc=now_utc,
3194
+ display_tz_name=display_tz_name,
3195
+ week_start_name=week_start_name,
3196
+ )
3197
+ if window is None:
3198
+ return {
3199
+ "status_unavailable": _tui_claude_budget_unavailable(
3200
+ "period_unresolved", budget_usd=target, period=period),
3201
+ }, ()
3202
+ start_at, end_at = window
3203
+ events = _tui_claude_budget_cost_events(
3204
+ cache_conn, start_at=start_at, end_at=end_at,
3205
+ )
3206
+ recent_start = max(start_at, now_utc - dt.timedelta(hours=24))
3207
+ status = c.budget_status_payload(
3208
+ period=period,
3209
+ window_start_at=start_at,
3210
+ window_end_at=end_at,
3211
+ target_usd=target,
3212
+ # stable_sum, not sum: both figures are published on the dashboard
3213
+ # wire and byte-compared by the dashboard goldens, and the built-in
3214
+ # sum() switched to Neumaier compensated summation for floats in
3215
+ # CPython 3.12, so the same spend renders 49.20424485 on 3.12+ and
3216
+ # 49.204244850000016 on 3.11.
3217
+ spent_usd=stable_sum(
3218
+ cost for timestamp, cost in events
3219
+ if start_at <= timestamp < now_utc
3220
+ ),
3221
+ recent_24h_usd=stable_sum(
3222
+ cost for timestamp, cost in events
3223
+ if recent_start <= timestamp < now_utc
3224
+ ),
3225
+ now=now_utc,
3226
+ alert_thresholds=config["alert_thresholds"],
3227
+ )
3228
+ except Exception:
3229
+ _lib_log.get_logger("dashboard").error(
3230
+ "claude budget status could not be computed", exc_info=True,
3231
+ )
3232
+ return {
3233
+ "status_unavailable": _tui_claude_budget_unavailable(
3234
+ "budget_compute_failed", budget_usd=target, period=period),
3235
+ }, ()
3236
+ # #556 S5 Unit 1 review R4 — retain ONLY the events a later reclock can
3237
+ # read. `_refresh_budget_status_clock` consults the carrier for exactly one
3238
+ # quantity, `recent_24h_usd` over `[max(window_start, now - 24h), now)`, and
3239
+ # `now` advances monotonically, so an event older than `now_utc - 24h` is
3240
+ # unreadable for the life of this retained state. `spent_usd` above is
3241
+ # already summed from the FULL set, so nothing published is lost — while a
3242
+ # `calendar-month` budget would otherwise pin up to 31 days of Claude
3243
+ # `session_entries` and make every reclock tick walk all of them.
3244
+ reclock_floor = now_utc - dt.timedelta(hours=24)
3245
+ retained = tuple(
3246
+ (timestamp, cost) for timestamp, cost in events
3247
+ if timestamp >= reclock_floor
3248
+ )
3249
+ return {"status": status}, retained
3250
+
3251
+
3252
+ def _tui_claude_data_with_budget(
3253
+ base: dict[str, object], overlay: dict[str, object],
3254
+ ) -> dict[str, object]:
3255
+ """Merge the budget overlay without mutating the caller's dict.
3256
+
3257
+ An empty overlay returns the base unchanged, which is what keeps the
3258
+ `provider_budget_unset` payload byte-identical.
3259
+ """
3260
+ if not overlay:
3261
+ return base
3262
+ merged = dict(base)
3263
+ merged["budget"] = {**(merged.get("budget") or {}), **overlay}
3264
+ return merged
3265
+
3266
+
2870
3267
  def _tui_build_source_bundle(
2871
3268
  *,
2872
3269
  stats_conn,
@@ -3016,16 +3413,34 @@ def _tui_build_source_bundle(
3016
3413
  f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
3017
3414
  f"{_aggregate_suffix}"
3018
3415
  )
3416
+ # #582: the dedicated accounting ledger is deliberately absent from
3417
+ # the published version string (byte-stable API contract), but an
3418
+ # id-stable token/account/project mutation still has to bypass exact
3419
+ # provider reuse so the incremental path can consume it.
3420
+ _codex_accounting_pending = c._load_sibling(
3421
+ "_lib_snapshot_cache"
3422
+ ).codex_accounting_cache_pending(cache_conn)
3019
3423
  # #556 S1 §3.6: normalized period identity, so a nominal week rollover
3020
3424
  # invalidates the generation even when no database signature moved.
3021
3425
  _period_identity = _tui_claude_period_identity(claude_data)
3426
+ # #556 S5 (Unit 1 review R5) — the CONFIGURED budget period has its own
3427
+ # boundary, and `_period_identity` above tracks only the subscription
3428
+ # week. Empty for an unconfigured or subscription-week budget, so the
3429
+ # version string is byte-identical on every install that had one before.
3430
+ _budget_identity = _tui_claude_budget_window_identity(
3431
+ semantics.claude_budget,
3432
+ now_utc=now_utc,
3433
+ display_tz_name=display_tz_name,
3434
+ week_start_name=semantics.week_start_name,
3435
+ )
3436
+ _budget_suffix = f":b{_budget_identity}" if _budget_identity else ""
3022
3437
  claude_version = (
3023
3438
  f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
3024
3439
  f"{signature.max_wus_id}:{signature.max_wcs_id}:"
3025
3440
  f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
3026
3441
  f"{signature.generation}:{semantics.claude_identity}"
3027
- f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
3028
- f":x{claude_digest}"
3442
+ f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
3443
+ f"{_aggregate_suffix}:x{claude_digest}"
3029
3444
  )
3030
3445
  prior_claude = (
3031
3446
  prior_bundle.sources.get("claude")
@@ -3114,6 +3529,23 @@ def _tui_build_source_bundle(
3114
3529
  c.legacy_project_labels(projects_envelope)
3115
3530
  if projects_envelope is not None else None
3116
3531
  ),
3532
+ max_entry_id=signature.max_entry_id,
3533
+ entry_mutation_seq=signature.entry_mutation_seq,
3534
+ generation=signature.generation,
3535
+ )
3536
+ # #556 S5 §3.2: the Claude budget fold runs HERE, on this
3537
+ # function's own pinned cache snapshot — the same snapshot the S2
3538
+ # folds and the Codex read use. That is the narrow, true claim: the
3539
+ # legacy Claude envelope was constructed BEFORE this call, and
3540
+ # stats.db deliberately stays in statement-scoped autocommit, so
3541
+ # this is not coherence with every number in the envelope.
3542
+ claude_budget_overlay, claude_budget_events = _tui_claude_budget_domain(
3543
+ cache_conn,
3544
+ stats_conn,
3545
+ claude_budget=semantics.claude_budget,
3546
+ now_utc=now_utc,
3547
+ display_tz_name=semantics.display_tz_name,
3548
+ week_start_name=semantics.week_start_name,
3117
3549
  )
3118
3550
  claude_aggregate_scope = build_aggregate_scope(
3119
3551
  published_range, aggregate_outcomes,
@@ -3143,32 +3575,50 @@ def _tui_build_source_bundle(
3143
3575
  "sessions": CapabilityRecord("supported", "legacy-session-rollup"),
3144
3576
  "forensics": CapabilityRecord("supported", "legacy-projection"),
3145
3577
  "quota": CapabilityRecord("supported", "subscription-week"),
3146
- "budget": CapabilityRecord("supported", "subscription-week"),
3578
+ # #556 S5 §3.6: the CONFIGURED period, not a constant. This
3579
+ # record used to say `subscription-week` unconditionally
3580
+ # while Codex advertised `calendar-period`, and once the
3581
+ # published status beside it can carry `calendar-week` or
3582
+ # `calendar-month` the constant contradicts the very object
3583
+ # it describes. The default period IS `subscription-week`,
3584
+ # so an install with no budget configured advertises exactly
3585
+ # what it advertised before.
3586
+ "budget": CapabilityRecord(
3587
+ "supported", _tui_claude_budget_period(
3588
+ semantics.claude_budget),
3589
+ ),
3147
3590
  "projects": CapabilityRecord("supported", "legacy-projection"),
3148
3591
  "alerts": CapabilityRecord("supported", "provider-native"),
3149
3592
  },
3150
3593
  data={
3151
- **_tui_claude_data_with_aggregates(
3152
- claude_data,
3153
- aggregate_payload,
3154
- fallback={
3155
- "hero": {
3156
- "cost_usd": claude_cost_usd,
3157
- "total_tokens": claude_total_tokens,
3594
+ **_tui_claude_data_with_budget(
3595
+ _tui_claude_data_with_aggregates(
3596
+ claude_data,
3597
+ aggregate_payload,
3598
+ fallback={
3599
+ "hero": {
3600
+ "cost_usd": claude_cost_usd,
3601
+ "total_tokens": claude_total_tokens,
3602
+ },
3603
+ "periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
3604
+ "sessions": {"rows": ()},
3605
+ "projects": {"rows": ()},
3606
+ "quota": {"blocks": (), "milestones": ()},
3607
+ "budget": {"label": "Claude subscription budget"},
3608
+ "alerts": {"rows": ()},
3158
3609
  },
3159
- "periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
3160
- "sessions": {"rows": ()},
3161
- "projects": {"rows": ()},
3162
- "quota": {"blocks": (), "milestones": ()},
3163
- "budget": {"label": "Claude subscription budget"},
3164
- "alerts": {"rows": ()},
3165
- },
3610
+ ),
3611
+ claude_budget_overlay,
3166
3612
  ),
3167
3613
  **({"accounts": claude_accounts} if claude_accounts else {}),
3168
3614
  },
3169
3615
  domain_freshness=_tui_claude_domain_freshness(
3170
3616
  claude_data, now_utc=now_utc,
3171
3617
  ),
3618
+ # #556 S5 §3.7: server-private, NEVER published. The idle clock
3619
+ # recomputes trailing-24h spend by iterating individual events,
3620
+ # which the status aggregate cannot supply.
3621
+ clock_data={"claude_budget_cost_events": claude_budget_events},
3172
3622
  aggregate_scope=claude_aggregate_scope,
3173
3623
  )
3174
3624
  if codex_ingest_failed:
@@ -3204,7 +3654,8 @@ def _tui_build_source_bundle(
3204
3654
  # construction: one rebuild per crossing, not one per tick.
3205
3655
  codex = (
3206
3656
  None if prior_codex is not None and (
3207
- any(
3657
+ _codex_accounting_pending
3658
+ or any(
3208
3659
  warning.code == "codex_projection_incoherent"
3209
3660
  for warning in prior_codex.warnings
3210
3661
  )
@@ -3264,9 +3715,18 @@ def _tui_build_source_bundle(
3264
3715
  # cycle's expiry invariant holds on EVERY path, including the reuse
3265
3716
  # path that returns the exact prior object (§2.5). Same-instant identity
3266
3717
  # is preserved by ``refresh_codex_source_clock``'s own data-equality
3267
- # guard, so a freshly built state is handed back unchanged. Claude is
3268
- # deliberately untouched.
3718
+ # guard, so a freshly built state is handed back unchanged.
3269
3719
  codex = refresh_codex_source_clock(codex, now_utc=now_utc)
3720
+ # #556 S5 §3.7: Claude is clocked here too, on the same terms and for
3721
+ # the same reason. This comment used to say Claude was deliberately
3722
+ # untouched, and that was correct only while Claude published nothing
3723
+ # time-dependent. It now publishes a budget pace, and exact-version
3724
+ # reuse returns the PRIOR OBJECT unchanged — so any tick another source
3725
+ # forced would have republished a budget frozen at its build instant.
3726
+ # Only the budget leg runs here: the two freshness axes need the legacy
3727
+ # `current_week` object, which this builder does not hold, and they are
3728
+ # already advanced by the pure-idle clock that does.
3729
+ claude = _refresh_claude_budget_clock(claude, now_utc=now_utc)
3270
3730
  # #556 S1 §3.8: the decoration fact reaches composition as authoritative
3271
3731
  # server-only metadata. It is attached HERE, after every build / reuse /
3272
3732
  # degrade / clock branch, so no branch can publish a state without it.
@@ -4338,31 +4798,37 @@ def _tui_build_snapshot_once(
4338
4798
  "fingerprint": "source-projection",
4339
4799
  },
4340
4800
  )
4341
- legacy_envelope = _cctally().snapshot_to_envelope(
4342
- source_snapshot,
4343
- now_utc=now_utc,
4344
- display_tz_pref_override=display_tz_pref_override,
4345
- runtime_bind=runtime_bind,
4346
- )
4347
- source_bundle = _tui_build_source_bundle(
4348
- stats_conn=conn,
4349
- now_utc=now_utc,
4350
- display_tz_name=(
4351
- getattr(_build_display_tz, "key", None)
4352
- if _build_display_tz is not None else None
4353
- ),
4354
- codex_ingest_contended=codex_ingest_contended,
4355
- codex_ingest_failed=codex_ingest_failed,
4356
- claude_ingest_contended=claude_ingest_contended,
4357
- claude_ingest_failed=claude_ingest_failed,
4358
- claude_cost_usd=daily_total_cost_usd,
4359
- claude_total_tokens=daily_total_tokens,
4360
- claude_data=_tui_project_claude_source_data(legacy_envelope),
4361
- common_range_start=common_range_start,
4362
- projects_envelope=projects_envelope_block,
4363
- prior_bundle=prior_source_bundle,
4364
- raw_config=raw_config,
4365
- )
4801
+ # #566 §5.1 item 6: both calls carry their own phase. The two
4802
+ # of them are the tail of the build and were the only region
4803
+ # the trace never entered, so a slow store attributed 79% of
4804
+ # its build to the root's unnamed remainder.
4805
+ with _perf.phase("envelope.legacy_projection"):
4806
+ legacy_envelope = _cctally().snapshot_to_envelope(
4807
+ source_snapshot,
4808
+ now_utc=now_utc,
4809
+ display_tz_pref_override=display_tz_pref_override,
4810
+ runtime_bind=runtime_bind,
4811
+ )
4812
+ with _perf.phase("build.source_bundle"):
4813
+ source_bundle = _tui_build_source_bundle(
4814
+ stats_conn=conn,
4815
+ now_utc=now_utc,
4816
+ display_tz_name=(
4817
+ getattr(_build_display_tz, "key", None)
4818
+ if _build_display_tz is not None else None
4819
+ ),
4820
+ codex_ingest_contended=codex_ingest_contended,
4821
+ codex_ingest_failed=codex_ingest_failed,
4822
+ claude_ingest_contended=claude_ingest_contended,
4823
+ claude_ingest_failed=claude_ingest_failed,
4824
+ claude_cost_usd=daily_total_cost_usd,
4825
+ claude_total_tokens=daily_total_tokens,
4826
+ claude_data=_tui_project_claude_source_data(legacy_envelope),
4827
+ common_range_start=common_range_start,
4828
+ projects_envelope=projects_envelope_block,
4829
+ prior_bundle=prior_source_bundle,
4830
+ raw_config=raw_config,
4831
+ )
4366
4832
  if source_bundle is None:
4367
4833
  raise RuntimeError("source bundle builder returned no bundle")
4368
4834
  except QuotaProjectionIncomplete as exc: