cctally 1.98.0 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,307 @@ 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
+ spent_usd=sum(
3213
+ cost for timestamp, cost in events
3214
+ if start_at <= timestamp < now_utc
3215
+ ),
3216
+ recent_24h_usd=sum(
3217
+ cost for timestamp, cost in events
3218
+ if recent_start <= timestamp < now_utc
3219
+ ),
3220
+ now=now_utc,
3221
+ alert_thresholds=config["alert_thresholds"],
3222
+ )
3223
+ except Exception:
3224
+ _lib_log.get_logger("dashboard").error(
3225
+ "claude budget status could not be computed", exc_info=True,
3226
+ )
3227
+ return {
3228
+ "status_unavailable": _tui_claude_budget_unavailable(
3229
+ "budget_compute_failed", budget_usd=target, period=period),
3230
+ }, ()
3231
+ # #556 S5 Unit 1 review R4 — retain ONLY the events a later reclock can
3232
+ # read. `_refresh_budget_status_clock` consults the carrier for exactly one
3233
+ # quantity, `recent_24h_usd` over `[max(window_start, now - 24h), now)`, and
3234
+ # `now` advances monotonically, so an event older than `now_utc - 24h` is
3235
+ # unreadable for the life of this retained state. `spent_usd` above is
3236
+ # already summed from the FULL set, so nothing published is lost — while a
3237
+ # `calendar-month` budget would otherwise pin up to 31 days of Claude
3238
+ # `session_entries` and make every reclock tick walk all of them.
3239
+ reclock_floor = now_utc - dt.timedelta(hours=24)
3240
+ retained = tuple(
3241
+ (timestamp, cost) for timestamp, cost in events
3242
+ if timestamp >= reclock_floor
3243
+ )
3244
+ return {"status": status}, retained
3245
+
3246
+
3247
+ def _tui_claude_data_with_budget(
3248
+ base: dict[str, object], overlay: dict[str, object],
3249
+ ) -> dict[str, object]:
3250
+ """Merge the budget overlay without mutating the caller's dict.
3251
+
3252
+ An empty overlay returns the base unchanged, which is what keeps the
3253
+ `provider_budget_unset` payload byte-identical.
3254
+ """
3255
+ if not overlay:
3256
+ return base
3257
+ merged = dict(base)
3258
+ merged["budget"] = {**(merged.get("budget") or {}), **overlay}
3259
+ return merged
3260
+
3261
+
2870
3262
  def _tui_build_source_bundle(
2871
3263
  *,
2872
3264
  stats_conn,
@@ -3016,16 +3408,34 @@ def _tui_build_source_bundle(
3016
3408
  f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
3017
3409
  f"{_aggregate_suffix}"
3018
3410
  )
3411
+ # #582: the dedicated accounting ledger is deliberately absent from
3412
+ # the published version string (byte-stable API contract), but an
3413
+ # id-stable token/account/project mutation still has to bypass exact
3414
+ # provider reuse so the incremental path can consume it.
3415
+ _codex_accounting_pending = c._load_sibling(
3416
+ "_lib_snapshot_cache"
3417
+ ).codex_accounting_cache_pending(cache_conn)
3019
3418
  # #556 S1 §3.6: normalized period identity, so a nominal week rollover
3020
3419
  # invalidates the generation even when no database signature moved.
3021
3420
  _period_identity = _tui_claude_period_identity(claude_data)
3421
+ # #556 S5 (Unit 1 review R5) — the CONFIGURED budget period has its own
3422
+ # boundary, and `_period_identity` above tracks only the subscription
3423
+ # week. Empty for an unconfigured or subscription-week budget, so the
3424
+ # version string is byte-identical on every install that had one before.
3425
+ _budget_identity = _tui_claude_budget_window_identity(
3426
+ semantics.claude_budget,
3427
+ now_utc=now_utc,
3428
+ display_tz_name=display_tz_name,
3429
+ week_start_name=semantics.week_start_name,
3430
+ )
3431
+ _budget_suffix = f":b{_budget_identity}" if _budget_identity else ""
3022
3432
  claude_version = (
3023
3433
  f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
3024
3434
  f"{signature.max_wus_id}:{signature.max_wcs_id}:"
3025
3435
  f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
3026
3436
  f"{signature.generation}:{semantics.claude_identity}"
3027
- f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
3028
- f":x{claude_digest}"
3437
+ f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
3438
+ f"{_aggregate_suffix}:x{claude_digest}"
3029
3439
  )
3030
3440
  prior_claude = (
3031
3441
  prior_bundle.sources.get("claude")
@@ -3114,6 +3524,23 @@ def _tui_build_source_bundle(
3114
3524
  c.legacy_project_labels(projects_envelope)
3115
3525
  if projects_envelope is not None else None
3116
3526
  ),
3527
+ max_entry_id=signature.max_entry_id,
3528
+ entry_mutation_seq=signature.entry_mutation_seq,
3529
+ generation=signature.generation,
3530
+ )
3531
+ # #556 S5 §3.2: the Claude budget fold runs HERE, on this
3532
+ # function's own pinned cache snapshot — the same snapshot the S2
3533
+ # folds and the Codex read use. That is the narrow, true claim: the
3534
+ # legacy Claude envelope was constructed BEFORE this call, and
3535
+ # stats.db deliberately stays in statement-scoped autocommit, so
3536
+ # this is not coherence with every number in the envelope.
3537
+ claude_budget_overlay, claude_budget_events = _tui_claude_budget_domain(
3538
+ cache_conn,
3539
+ stats_conn,
3540
+ claude_budget=semantics.claude_budget,
3541
+ now_utc=now_utc,
3542
+ display_tz_name=semantics.display_tz_name,
3543
+ week_start_name=semantics.week_start_name,
3117
3544
  )
3118
3545
  claude_aggregate_scope = build_aggregate_scope(
3119
3546
  published_range, aggregate_outcomes,
@@ -3143,32 +3570,50 @@ def _tui_build_source_bundle(
3143
3570
  "sessions": CapabilityRecord("supported", "legacy-session-rollup"),
3144
3571
  "forensics": CapabilityRecord("supported", "legacy-projection"),
3145
3572
  "quota": CapabilityRecord("supported", "subscription-week"),
3146
- "budget": CapabilityRecord("supported", "subscription-week"),
3573
+ # #556 S5 §3.6: the CONFIGURED period, not a constant. This
3574
+ # record used to say `subscription-week` unconditionally
3575
+ # while Codex advertised `calendar-period`, and once the
3576
+ # published status beside it can carry `calendar-week` or
3577
+ # `calendar-month` the constant contradicts the very object
3578
+ # it describes. The default period IS `subscription-week`,
3579
+ # so an install with no budget configured advertises exactly
3580
+ # what it advertised before.
3581
+ "budget": CapabilityRecord(
3582
+ "supported", _tui_claude_budget_period(
3583
+ semantics.claude_budget),
3584
+ ),
3147
3585
  "projects": CapabilityRecord("supported", "legacy-projection"),
3148
3586
  "alerts": CapabilityRecord("supported", "provider-native"),
3149
3587
  },
3150
3588
  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,
3589
+ **_tui_claude_data_with_budget(
3590
+ _tui_claude_data_with_aggregates(
3591
+ claude_data,
3592
+ aggregate_payload,
3593
+ fallback={
3594
+ "hero": {
3595
+ "cost_usd": claude_cost_usd,
3596
+ "total_tokens": claude_total_tokens,
3597
+ },
3598
+ "periods": {"daily": {"total_cost_usd": claude_cost_usd, "total_tokens": claude_total_tokens}},
3599
+ "sessions": {"rows": ()},
3600
+ "projects": {"rows": ()},
3601
+ "quota": {"blocks": (), "milestones": ()},
3602
+ "budget": {"label": "Claude subscription budget"},
3603
+ "alerts": {"rows": ()},
3158
3604
  },
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
- },
3605
+ ),
3606
+ claude_budget_overlay,
3166
3607
  ),
3167
3608
  **({"accounts": claude_accounts} if claude_accounts else {}),
3168
3609
  },
3169
3610
  domain_freshness=_tui_claude_domain_freshness(
3170
3611
  claude_data, now_utc=now_utc,
3171
3612
  ),
3613
+ # #556 S5 §3.7: server-private, NEVER published. The idle clock
3614
+ # recomputes trailing-24h spend by iterating individual events,
3615
+ # which the status aggregate cannot supply.
3616
+ clock_data={"claude_budget_cost_events": claude_budget_events},
3172
3617
  aggregate_scope=claude_aggregate_scope,
3173
3618
  )
3174
3619
  if codex_ingest_failed:
@@ -3204,7 +3649,8 @@ def _tui_build_source_bundle(
3204
3649
  # construction: one rebuild per crossing, not one per tick.
3205
3650
  codex = (
3206
3651
  None if prior_codex is not None and (
3207
- any(
3652
+ _codex_accounting_pending
3653
+ or any(
3208
3654
  warning.code == "codex_projection_incoherent"
3209
3655
  for warning in prior_codex.warnings
3210
3656
  )
@@ -3264,9 +3710,18 @@ def _tui_build_source_bundle(
3264
3710
  # cycle's expiry invariant holds on EVERY path, including the reuse
3265
3711
  # path that returns the exact prior object (§2.5). Same-instant identity
3266
3712
  # 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.
3713
+ # guard, so a freshly built state is handed back unchanged.
3269
3714
  codex = refresh_codex_source_clock(codex, now_utc=now_utc)
3715
+ # #556 S5 §3.7: Claude is clocked here too, on the same terms and for
3716
+ # the same reason. This comment used to say Claude was deliberately
3717
+ # untouched, and that was correct only while Claude published nothing
3718
+ # time-dependent. It now publishes a budget pace, and exact-version
3719
+ # reuse returns the PRIOR OBJECT unchanged — so any tick another source
3720
+ # forced would have republished a budget frozen at its build instant.
3721
+ # Only the budget leg runs here: the two freshness axes need the legacy
3722
+ # `current_week` object, which this builder does not hold, and they are
3723
+ # already advanced by the pure-idle clock that does.
3724
+ claude = _refresh_claude_budget_clock(claude, now_utc=now_utc)
3270
3725
  # #556 S1 §3.8: the decoration fact reaches composition as authoritative
3271
3726
  # server-only metadata. It is attached HERE, after every build / reuse /
3272
3727
  # degrade / clock branch, so no branch can publish a state without it.
@@ -4338,31 +4793,37 @@ def _tui_build_snapshot_once(
4338
4793
  "fingerprint": "source-projection",
4339
4794
  },
4340
4795
  )
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
- )
4796
+ # #566 §5.1 item 6: both calls carry their own phase. The two
4797
+ # of them are the tail of the build and were the only region
4798
+ # the trace never entered, so a slow store attributed 79% of
4799
+ # its build to the root's unnamed remainder.
4800
+ with _perf.phase("envelope.legacy_projection"):
4801
+ legacy_envelope = _cctally().snapshot_to_envelope(
4802
+ source_snapshot,
4803
+ now_utc=now_utc,
4804
+ display_tz_pref_override=display_tz_pref_override,
4805
+ runtime_bind=runtime_bind,
4806
+ )
4807
+ with _perf.phase("build.source_bundle"):
4808
+ source_bundle = _tui_build_source_bundle(
4809
+ stats_conn=conn,
4810
+ now_utc=now_utc,
4811
+ display_tz_name=(
4812
+ getattr(_build_display_tz, "key", None)
4813
+ if _build_display_tz is not None else None
4814
+ ),
4815
+ codex_ingest_contended=codex_ingest_contended,
4816
+ codex_ingest_failed=codex_ingest_failed,
4817
+ claude_ingest_contended=claude_ingest_contended,
4818
+ claude_ingest_failed=claude_ingest_failed,
4819
+ claude_cost_usd=daily_total_cost_usd,
4820
+ claude_total_tokens=daily_total_tokens,
4821
+ claude_data=_tui_project_claude_source_data(legacy_envelope),
4822
+ common_range_start=common_range_start,
4823
+ projects_envelope=projects_envelope_block,
4824
+ prior_bundle=prior_source_bundle,
4825
+ raw_config=raw_config,
4826
+ )
4366
4827
  if source_bundle is None:
4367
4828
  raise RuntimeError("source bundle builder returned no bundle")
4368
4829
  except QuotaProjectionIncomplete as exc: