cctally 1.97.0 → 1.98.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.
@@ -281,6 +281,11 @@ from _lib_dashboard_sources import (
281
281
  SourceDashboardBundle,
282
282
  SourceDashboardState,
283
283
  SourceDashboardWarning,
284
+ aggregate_range,
285
+ aggregate_scope_failed,
286
+ aggregate_scope_identity,
287
+ build_aggregate_scope,
288
+ claude_stats_digest,
284
289
  codex_stats_digest,
285
290
  compose_all_state,
286
291
  dashboard_resource_key,
@@ -2311,7 +2316,12 @@ def _snapshot_data_version(sig) -> str:
2311
2316
  # in is what leaves the idle short-circuit so the source bundle is rebuilt
2312
2317
  # at all. Empty once the backlog has drained, so it is byte-neutral there.
2313
2318
  backlog = getattr(sig, "codex_ingest_backlog_sig", "")
2314
- return out if not backlog else f"{out}.b{backlog}"
2319
+ out = out if not backlog else f"{out}.b{backlog}"
2320
+ # #556 S3 §2.9: the Claude alert relations. A fired or armed Claude alert
2321
+ # moves no numeric leg above, so without this the detail endpoints' change
2322
+ # signal stays flat across a tick that added an alert row.
2323
+ claude_digest = getattr(sig, "claude_stats_digest", "")
2324
+ return out if not claude_digest else f"{out}.x{claude_digest}"
2315
2325
 
2316
2326
 
2317
2327
  def _tui_source_copy(value: object) -> object:
@@ -2342,6 +2352,38 @@ def _tui_claude_resource_row(
2342
2352
  return wire
2343
2353
 
2344
2354
 
2355
+ def alert_row_owner(
2356
+ axis: object, vendor: object, metric: object,
2357
+ ) -> str:
2358
+ """Total ownership classifier for a legacy alert row (#556 S3 §3.4).
2359
+
2360
+ Raises on an unregistered axis, so adding a seventh axis without deciding
2361
+ its owner fails a test instead of shipping a row invisible everywhere. The
2362
+ predicate this replaced answered `False` for an unknown axis, which reads
2363
+ as "Codex owns it" and is indistinguishable from a real Codex row.
2364
+ """
2365
+ if axis in {"weekly", "five_hour", "budget", "project_budget"}:
2366
+ # An absent vendor is the established Claude meaning: the legacy rows
2367
+ # predate the additive vendor field. An explicit non-Claude vendor is
2368
+ # never relabelled — `project_budget` gained that check here, having
2369
+ # previously claimed every row whatever its vendor said.
2370
+ return "codex" if vendor == "codex" else "claude"
2371
+ if axis == "projected":
2372
+ # The metric is the owner here, and it is enumerated rather than
2373
+ # defaulted. Defaulting an unrecognized metric to Claude would let a
2374
+ # future Codex-side projected metric render in the Claude tab, and
2375
+ # defaulting it to Codex would drop it from every surface without a
2376
+ # word — the two failure modes this classifier exists to prevent.
2377
+ if metric in {"weekly_pct", "budget_usd"}:
2378
+ return "claude"
2379
+ if metric == "codex_budget_usd":
2380
+ return "codex"
2381
+ raise ValueError(f"no ownership rule for projected metric {metric!r}")
2382
+ if axis == "codex_budget":
2383
+ return "codex"
2384
+ raise ValueError(f"no ownership rule for alert axis {axis!r}")
2385
+
2386
+
2345
2387
  def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
2346
2388
  """Project one completed Claude legacy envelope without further DB reads.
2347
2389
 
@@ -2445,17 +2487,7 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2445
2487
  axis = raw.get("axis")
2446
2488
  vendor = raw.get("vendor")
2447
2489
  metric = raw.get("metric")
2448
- owns_alert = (
2449
- (axis in {"weekly", "five_hour"} and vendor in {None, "claude"})
2450
- # Legacy top-level Claude budget rows predate the additive vendor
2451
- # field; the distinct Codex axis is ``codex_budget``. Treat an
2452
- # absent vendor as that established Claude meaning, while an
2453
- # explicit non-Claude vendor must never be relabeled.
2454
- or (axis == "budget" and vendor in {None, "claude"})
2455
- or axis == "project_budget"
2456
- or (axis == "projected" and metric in {"weekly_pct", "budget_usd"})
2457
- )
2458
- if not owns_alert:
2490
+ if alert_row_owner(axis, vendor, metric) != "claude":
2459
2491
  continue
2460
2492
  alert_rows.append(_tui_claude_resource_row(
2461
2493
  raw,
@@ -2720,6 +2752,121 @@ def _tui_with_account_scope(
2720
2752
  return dataclasses.replace(state, account_scope=scope)
2721
2753
 
2722
2754
 
2755
+ _AGGREGATE_FOLD_FAILED = {"state": "failed", "code": "claude_fold_failed"}
2756
+
2757
+
2758
+ def _tui_build_claude_aggregates(
2759
+ cache_conn,
2760
+ *,
2761
+ shared_start: dt.datetime,
2762
+ shared_end_exclusive: dt.datetime,
2763
+ now_utc: dt.datetime,
2764
+ display_tz_name: str | None,
2765
+ # NOT `legacy_project_labels`: that is the name of the public kernel
2766
+ # function this receives the RESULT of (`c.legacy_project_labels`), and a
2767
+ # parameter shadowing it inside a function that also calls it reads as a
2768
+ # recursive reference.
2769
+ legacy_labels: "dict[str, str] | None" = None,
2770
+ ):
2771
+ """Both All-only Claude legs, from ONE candidate read (spec §3.3, §3.4).
2772
+
2773
+ Returns ``(payload, outcomes)`` where ``payload`` holds the published rows
2774
+ for whichever legs succeeded and ``outcomes`` names each leg's state.
2775
+
2776
+ Runs on the caller's PINNED cache connection, beside the Codex read and on
2777
+ the same snapshot. Both legacy paths stay untouched: the attached-cache
2778
+ block continues to serve ``env.projects`` and the Group-A read continues to
2779
+ serve ``env.daily``, for the Claude tab.
2780
+
2781
+ Each fold has its OWN error boundary, so one failure cannot take the bundle
2782
+ or the other leg down. A failure of the shared read itself fails both, since
2783
+ neither leg has rows. A failure is a typed withheld outcome rather than an
2784
+ escaped exception: today an exception inside this helper is caught by the
2785
+ outer handler, which publishes the prior bundle or none at all, so a
2786
+ cold-start fold failure could never become the outcome §3.7 promises.
2787
+ """
2788
+ from zoneinfo import ZoneInfo
2789
+
2790
+ c = _cctally()
2791
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
2792
+ payload: dict[str, object] = {}
2793
+ outcomes: dict[str, object] = {
2794
+ "projects": {"state": "ok"}, "daily": {"state": "ok"},
2795
+ }
2796
+ try:
2797
+ rows = tuple(c.iter_shared_range_entries(
2798
+ cache_conn, start=shared_start, end_exclusive=shared_end_exclusive,
2799
+ ))
2800
+ except Exception:
2801
+ _lib_log.get_logger("dashboard").error(
2802
+ "claude shared-range candidate read failed", exc_info=True,
2803
+ )
2804
+ return {}, {
2805
+ "projects": dict(_AGGREGATE_FOLD_FAILED),
2806
+ "daily": dict(_AGGREGATE_FOLD_FAILED),
2807
+ }
2808
+ if legacy_labels is None:
2809
+ # No projects envelope was built this tick, so the routable population
2810
+ # is unknown. Publishing anyway would relabel every row from the
2811
+ # bounded population, mint different opaque keys, and hand them to a
2812
+ # drill-down that resolves against an envelope it rebuilds for itself
2813
+ # — the rows on screen and the rows the route can serve would be two
2814
+ # different populations, and nothing would say so. Withholding states
2815
+ # the failure instead, and `claude_fold_failed` also disqualifies the
2816
+ # bundle from idle reuse, so the next tick's envelope gets a chance.
2817
+ _lib_log.get_logger("dashboard").error(
2818
+ "claude range projects fold has no projects envelope",
2819
+ )
2820
+ outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
2821
+ else:
2822
+ try:
2823
+ payload["projects"] = c.build_project_aggregate_rows(
2824
+ rows, legacy_labels=legacy_labels,
2825
+ )
2826
+ except Exception:
2827
+ _lib_log.get_logger("dashboard").error(
2828
+ "claude range projects fold failed", exc_info=True,
2829
+ )
2830
+ outcomes["projects"] = dict(_AGGREGATE_FOLD_FAILED)
2831
+ try:
2832
+ payload["daily"] = [
2833
+ c.daily_panel_row_to_wire(row)
2834
+ for row in c.build_daily_aggregate_rows(
2835
+ rows, now_utc=now_utc, display_tz=display_tz,
2836
+ )
2837
+ ]
2838
+ except Exception:
2839
+ _lib_log.get_logger("dashboard").error(
2840
+ "claude range daily fold failed", exc_info=True,
2841
+ )
2842
+ outcomes["daily"] = dict(_AGGREGATE_FOLD_FAILED)
2843
+ return payload, outcomes
2844
+
2845
+
2846
+ def _tui_claude_data_with_aggregates(
2847
+ claude_data: dict[str, object] | None,
2848
+ payload: dict[str, object],
2849
+ *,
2850
+ fallback: dict[str, object],
2851
+ ) -> dict[str, object]:
2852
+ """Attach the rows-only siblings without mutating the caller's dict.
2853
+
2854
+ ``providers.claude.projects.aggregate`` and
2855
+ ``providers.claude.periods.daily_aggregate`` are rows and nothing else — no
2856
+ range, no outcome. Those live once, on the All source.
2857
+ """
2858
+ base = dict(claude_data) if claude_data is not None else dict(fallback)
2859
+ if "projects" in payload:
2860
+ projects = dict(base.get("projects") or {})
2861
+ projects["aggregate"] = {"rows": payload["projects"]}
2862
+ base["projects"] = projects
2863
+ if "daily" in payload:
2864
+ periods = dict(base.get("periods") or {})
2865
+ periods["daily_aggregate"] = {"rows": payload["daily"]}
2866
+ base["periods"] = periods
2867
+ return base
2868
+
2869
+
2723
2870
  def _tui_build_source_bundle(
2724
2871
  *,
2725
2872
  stats_conn,
@@ -2733,6 +2880,7 @@ def _tui_build_source_bundle(
2733
2880
  claude_total_tokens: int,
2734
2881
  claude_data: dict[str, object] | None = None,
2735
2882
  common_range_start: dt.datetime | None = None,
2883
+ projects_envelope: dict | None = None,
2736
2884
  prior_bundle: SourceDashboardBundle | None = None,
2737
2885
  raw_config: dict[str, object] | None = None,
2738
2886
  ) -> SourceDashboardBundle:
@@ -2758,10 +2906,48 @@ def _tui_build_source_bundle(
2758
2906
  cache_conn.execute("BEGIN")
2759
2907
  cache_read_tx = True
2760
2908
  if common_range_start is None:
2761
- common_range_start = now_utc - dt.timedelta(days=30)
2909
+ # Resolved through the SAME helper the callers use, with no daily
2910
+ # panel. A bare `now_utc - 30 days` here is a microsecond-precise
2911
+ # instant that advances on every tick, and the resolved start is
2912
+ # folded into both providers' version material at exactly the
2913
+ # granularity `compose_all_aggregates` compares it — so a start
2914
+ # that moves within a display day makes an unchanged provider's
2915
+ # retained carrier disagree with a rebuilt one's, and both
2916
+ # aggregates are then withheld as `retained_range_mismatch`
2917
+ # permanently. Both production callers pass a resolved start, so
2918
+ # this is the last producer that could reintroduce that shape.
2919
+ #
2920
+ # The zone lookup is guarded because this branch exists to be a
2921
+ # SAFE fallback. An unresolvable `display_tz_name` raising out of
2922
+ # it would take down the whole source build over the one path whose
2923
+ # purpose is to keep going, so an unusable name degrades to UTC —
2924
+ # which is what `resolve_shared_range` already does for `None`.
2925
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
2926
+ _fallback_tz = None
2927
+ if display_tz_name:
2928
+ try:
2929
+ _fallback_tz = ZoneInfo(display_tz_name)
2930
+ except (ZoneInfoNotFoundError, ValueError, OSError):
2931
+ _fallback_tz = None
2932
+ common_range_start, _fallback_end = c.resolve_shared_range(
2933
+ None,
2934
+ now_utc=now_utc,
2935
+ display_tz=_fallback_tz,
2936
+ )
2762
2937
  if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
2763
2938
  raise ValueError("common_range_start must be timezone-aware")
2764
2939
  common_range_start = common_range_start.astimezone(dt.timezone.utc)
2940
+ # #556 S2 §3.2: ONE interval, resolved once and passed immutably to
2941
+ # both Claude folds and to the Codex read. The exclusive upper bound is
2942
+ # what the Codex projects read already applies, so the Codex tab stays
2943
+ # byte-stable. The PUBLISHED `end_at` is `now_utc` itself.
2944
+ shared_end_exclusive = now_utc.astimezone(
2945
+ dt.timezone.utc,
2946
+ ) + dt.timedelta(microseconds=1)
2947
+ published_range = aggregate_range(
2948
+ common_range_start.isoformat(),
2949
+ now_utc.astimezone(dt.timezone.utc).isoformat(),
2950
+ )
2765
2951
  semantics = resolve_dashboard_source_semantics(
2766
2952
  raw_config if raw_config is not None else c.load_config(),
2767
2953
  display_tz_name=display_tz_name,
@@ -2784,6 +2970,11 @@ def _tui_build_source_bundle(
2784
2970
  from _cctally_quota import assert_projection_readable
2785
2971
  assert_projection_readable(stats_conn)
2786
2972
  stats_digest = codex_stats_digest(stats_conn)
2973
+ # #556 S3 §2.9: the Claude alert relations. Nothing else in the
2974
+ # signature moves when a Claude alert fires or is armed, so without
2975
+ # this the idle path can keep serving a prior bundle that predates the
2976
+ # alert.
2977
+ claude_digest = claude_stats_digest(stats_conn)
2787
2978
  # #341 finding 9: the account registry/active-identity digest. Empty for
2788
2979
  # every <=1-account install (byte-neutral — appended only when non-empty),
2789
2980
  # so single-account source versions stay byte-identical to today; a
@@ -2798,6 +2989,7 @@ def _tui_build_source_bundle(
2798
2989
  generation=c.current_generation(),
2799
2990
  codex_stats_digest=stats_digest,
2800
2991
  accounts_digest=accounts_digest,
2992
+ claude_stats_digest=claude_digest,
2801
2993
  )
2802
2994
  _acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
2803
2995
  # public #5: the hook's budgeted ingest can change what the Codex
@@ -2808,10 +3000,21 @@ def _tui_build_source_bundle(
2808
3000
  # wire. Empty (and so byte-neutral) once the backlog has drained.
2809
3001
  _backlog = getattr(signature, "codex_ingest_backlog_sig", "")
2810
3002
  _backlog_suffix = f":b{_backlog}" if _backlog else ""
3003
+ # #556 S2 §3.6: the resolved range and the per-aggregate outcome enter
3004
+ # BOTH providers' version material. Both, so a shared-start change (a
3005
+ # display-day rollover) rebuilds them in lockstep and a coherent pair
3006
+ # can never disagree about the interval their rows cover. The fragment
3007
+ # below assumes SUCCESS, which is what makes it also the reuse gate: a
3008
+ # prior generation whose fold failed carries a different fragment and
3009
+ # therefore cannot be reused.
3010
+ _aggregate_suffix = ":g" + aggregate_scope_identity(
3011
+ build_aggregate_scope(published_range),
3012
+ )
2811
3013
  codex_version = (
2812
3014
  f"codex:{signature.max_codex_id}:"
2813
3015
  f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
2814
3016
  f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
3017
+ f"{_aggregate_suffix}"
2815
3018
  )
2816
3019
  # #556 S1 §3.6: normalized period identity, so a nominal week rollover
2817
3020
  # invalidates the generation even when no database signature moved.
@@ -2821,7 +3024,8 @@ def _tui_build_source_bundle(
2821
3024
  f"{signature.max_wus_id}:{signature.max_wcs_id}:"
2822
3025
  f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
2823
3026
  f"{signature.generation}:{semantics.claude_identity}"
2824
- f":p{_period_identity}{_acct_suffix}"
3027
+ f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
3028
+ f":x{claude_digest}"
2825
3029
  )
2826
3030
  prior_claude = (
2827
3031
  prior_bundle.sources.get("claude")
@@ -2853,6 +3057,14 @@ def _tui_build_source_bundle(
2853
3057
  claude = reuse_coherent_source_state(
2854
3058
  prior_claude, data_version=claude_version,
2855
3059
  )
3060
+ # #556 S2 §3.6, gate 2 of 2. The version fragment above already
3061
+ # rejects a failed generation, but this gate is stated explicitly
3062
+ # rather than left implicit in string arithmetic: exact-version
3063
+ # provider reuse returns the PRIOR OBJECT unchanged, so a caught
3064
+ # fold failure that survived reuse would withhold the aggregate for
3065
+ # the life of the process. Gate 1 is the bundle-level idle guard.
3066
+ if claude is not None and aggregate_scope_failed(claude):
3067
+ claude = None
2856
3068
  if claude is None:
2857
3069
  claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
2858
3070
  # #341 Task 4 (Ruling C): the conditional per-account Claude wire,
@@ -2875,6 +3087,47 @@ def _tui_build_source_bundle(
2875
3087
  # wire must never fail the whole dashboard tick — it just falls
2876
3088
  # back to the byte-stable undecorated shape.
2877
3089
  claude_accounts = []
3090
+ # #556 S2 §3.3: both All-only Claude legs fold HERE, on the pinned
3091
+ # cache connection, after BEGIN and beside the Codex read, so the
3092
+ # two providers describe one snapshot. Folding them earlier — in
3093
+ # the attached-cache block or through the Group-A daily read — runs
3094
+ # against a different connection, and a cache commit in between
3095
+ # would publish Claude generation A beside Codex generation B while
3096
+ # the bundle's version names B.
3097
+ aggregate_payload, aggregate_outcomes = _tui_build_claude_aggregates(
3098
+ cache_conn,
3099
+ shared_start=common_range_start,
3100
+ shared_end_exclusive=shared_end_exclusive,
3101
+ now_utc=now_utc,
3102
+ display_tz_name=semantics.display_tz_name,
3103
+ # The legacy display keys the drill-down route resolves
3104
+ # against. Published rows adopt them wherever they exist, so
3105
+ # the aggregate identity and the legacy one agree and the
3106
+ # bounded rows stay routable. The raw envelope is required —
3107
+ # `claude_data` has already replaced every legacy display key
3108
+ # with an opaque key and dropped `bucket_path`, so the map
3109
+ # cannot be recovered from it.
3110
+ # `None` — not an empty map — when no envelope was built, so
3111
+ # the fold can tell "the legacy population is empty" from "the
3112
+ # legacy population is unknown" and withhold on the second.
3113
+ legacy_labels=(
3114
+ c.legacy_project_labels(projects_envelope)
3115
+ if projects_envelope is not None else None
3116
+ ),
3117
+ )
3118
+ claude_aggregate_scope = build_aggregate_scope(
3119
+ published_range, aggregate_outcomes,
3120
+ )
3121
+ if aggregate_scope_failed(claude_aggregate_scope):
3122
+ # The published version must distinguish a failed fold from a
3123
+ # successful one over the same signature and the same bounds;
3124
+ # otherwise both would publish different rows under one
3125
+ # `data_version`. It also makes the next tick's success-shaped
3126
+ # candidate version mismatch, forcing the rebuild §3.6 requires.
3127
+ claude_version = (
3128
+ f"{claude_version}:x"
3129
+ f"{aggregate_scope_identity(claude_aggregate_scope)}"
3130
+ )
2878
3131
  claude = SourceDashboardState(
2879
3132
  source="claude",
2880
3133
  availability=claude_available,
@@ -2895,9 +3148,10 @@ def _tui_build_source_bundle(
2895
3148
  "alerts": CapabilityRecord("supported", "provider-native"),
2896
3149
  },
2897
3150
  data={
2898
- **(
2899
- claude_data
2900
- if claude_data is not None else {
3151
+ **_tui_claude_data_with_aggregates(
3152
+ claude_data,
3153
+ aggregate_payload,
3154
+ fallback={
2901
3155
  "hero": {
2902
3156
  "cost_usd": claude_cost_usd,
2903
3157
  "total_tokens": claude_total_tokens,
@@ -2908,13 +3162,14 @@ def _tui_build_source_bundle(
2908
3162
  "quota": {"blocks": (), "milestones": ()},
2909
3163
  "budget": {"label": "Claude subscription budget"},
2910
3164
  "alerts": {"rows": ()},
2911
- }
3165
+ },
2912
3166
  ),
2913
3167
  **({"accounts": claude_accounts} if claude_accounts else {}),
2914
3168
  },
2915
3169
  domain_freshness=_tui_claude_domain_freshness(
2916
3170
  claude_data, now_utc=now_utc,
2917
3171
  ),
3172
+ aggregate_scope=claude_aggregate_scope,
2918
3173
  )
2919
3174
  if codex_ingest_failed:
2920
3175
  warning = SourceDashboardWarning(
@@ -2958,6 +3213,13 @@ def _tui_build_source_bundle(
2958
3213
  prior_codex, data_version=codex_version,
2959
3214
  )
2960
3215
  )
3216
+ # #556 S2 §3.6: symmetric with Claude. Codex's rows are already
3217
+ # bounded by this same range, so its carrier records no fold of its
3218
+ # own — but a retained failure state must never be reused, and the
3219
+ # gate is stated on both providers so a future Codex-side fold
3220
+ # inherits it.
3221
+ if codex is not None and aggregate_scope_failed(codex):
3222
+ codex = None
2961
3223
  if codex is None:
2962
3224
  try:
2963
3225
  codex = build_codex_source_state(
@@ -2977,6 +3239,13 @@ def _tui_build_source_bundle(
2977
3239
  ),
2978
3240
  data_version=codex_version,
2979
3241
  )
3242
+ # Attached ONLY on a fresh build, never on the reuse or degrade
3243
+ # paths: those carry rows this tick did not produce, and their
3244
+ # own carrier already describes the range that bounds them.
3245
+ codex = dataclasses.replace(
3246
+ codex,
3247
+ aggregate_scope=build_aggregate_scope(published_range),
3248
+ )
2980
3249
  except Exception:
2981
3250
  _lib_log.get_logger("dashboard").error(
2982
3251
  "codex_read_model source build failed",
@@ -3026,12 +3295,14 @@ def _tui_build_source_bundle(
3026
3295
  cache_read_tx = False
3027
3296
  post_stats_digest = codex_stats_digest(stats_conn)
3028
3297
  post_accounts_digest = accounts_identity_digest(stats_conn)
3298
+ post_claude_digest = claude_stats_digest(stats_conn)
3029
3299
  post_signature = c.compute_signature(
3030
3300
  cache_conn,
3031
3301
  stats_conn,
3032
3302
  generation=c.current_generation(),
3033
3303
  codex_stats_digest=post_stats_digest,
3034
3304
  accounts_digest=post_accounts_digest,
3305
+ claude_stats_digest=post_claude_digest,
3035
3306
  )
3036
3307
  stats_generation_moved = (
3037
3308
  post_signature.max_wus_id != signature.max_wus_id
@@ -3039,6 +3310,7 @@ def _tui_build_source_bundle(
3039
3310
  or post_signature.reset_sig != signature.reset_sig
3040
3311
  or post_stats_digest != stats_digest
3041
3312
  or post_accounts_digest != accounts_digest
3313
+ or post_claude_digest != claude_digest
3042
3314
  )
3043
3315
  if stats_generation_moved:
3044
3316
  if prior_bundle is not None:
@@ -3111,6 +3383,14 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
3111
3383
  or state.freshness != "fresh"
3112
3384
  or state.data is None):
3113
3385
  return False
3386
+ # #556 S2 §3.6, gate 1 of 2. A locally caught fold failure leaves an
3387
+ # otherwise `ok` and `fresh` provider, so without this leg the bundle
3388
+ # would qualify for idle reuse and one transient failure would withhold
3389
+ # the aggregate for the life of the process. Falling through here routes
3390
+ # to the bounded source-adapter rebuild, which re-folds — at most one
3391
+ # rebuild per tick, so it creates no retry loop.
3392
+ if aggregate_scope_failed(state):
3393
+ return False
3114
3394
  return True
3115
3395
 
3116
3396
 
@@ -3120,18 +3400,18 @@ def _tui_common_source_range_start(
3120
3400
  now_utc: dt.datetime,
3121
3401
  display_tz: dt.tzinfo | None,
3122
3402
  ) -> dt.datetime:
3123
- """Return the shared provider interval from the already-built daily rows."""
3124
- if daily_panel:
3125
- earliest_day = dt.date.fromisoformat(daily_panel[-1].date)
3126
- if display_tz is not None:
3127
- return dt.datetime.combine(
3128
- earliest_day, dt.time.min, tzinfo=display_tz,
3129
- ).astimezone(dt.timezone.utc)
3130
- # internal fallback: host-local intentional
3131
- return dt.datetime.combine(
3132
- earliest_day, dt.time.min,
3133
- ).astimezone(dt.timezone.utc)
3134
- return now_utc - dt.timedelta(days=30)
3403
+ """Return the shared provider interval from the already-built daily rows.
3404
+
3405
+ #556 S2 §3.2: the start bound is now resolved by
3406
+ ``_cctally_dashboard.resolve_shared_range``, which also owns the exclusive
3407
+ upper bound the Claude folds enforce. This wrapper stays because every
3408
+ existing caller wants only the start, and because it is the monkeypatch
3409
+ surface the source-invalidation tests already use.
3410
+ """
3411
+ start, _end_exclusive = _cctally().resolve_shared_range(
3412
+ daily_panel, now_utc=now_utc, display_tz=display_tz,
3413
+ )
3414
+ return start
3135
3415
 
3136
3416
 
3137
3417
  def _tui_build_snapshot(
@@ -4079,6 +4359,7 @@ def _tui_build_snapshot_once(
4079
4359
  claude_total_tokens=daily_total_tokens,
4080
4360
  claude_data=_tui_project_claude_source_data(legacy_envelope),
4081
4361
  common_range_start=common_range_start,
4362
+ projects_envelope=projects_envelope_block,
4082
4363
  prior_bundle=prior_source_bundle,
4083
4364
  raw_config=raw_config,
4084
4365
  )
@@ -4232,6 +4513,7 @@ def _tui_compute_dispatch_signature(stats_conn):
4232
4513
  generation=sc.current_generation(),
4233
4514
  codex_stats_digest=codex_stats_digest(stats_conn),
4234
4515
  accounts_digest=accounts_identity_digest(stats_conn),
4516
+ claude_stats_digest=claude_stats_digest(stats_conn),
4235
4517
  )
4236
4518
  finally:
4237
4519
  cache_conn.close()
@@ -4409,6 +4691,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4409
4691
  now_utc=now_utc,
4410
4692
  display_tz=source_display_tz,
4411
4693
  ),
4694
+ projects_envelope=prior.projects_envelope,
4412
4695
  prior_bundle=source_bundle,
4413
4696
  raw_config=raw_config,
4414
4697
  )
@@ -55,13 +55,18 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
55
55
  "project_budget", "PROJECT", "Project budget", "project_budget_milestones"
56
56
  ),
57
57
  # Per-vendor Codex budget alerts (calendar-period; calendar-period-codex-budgets
58
- # feature). Distinct "CODEX" chip vs the global "BUDGET" / per-project
59
- # "PROJECT" chips. As of #143 it shares the unified vendor-tagged
58
+ # feature). #556 S3 §4.3: the chip reads "BUDGET", the same as the Claude
59
+ # budget axis, because it names the METRIC. Attribution is the row's source
60
+ # chip, which under All reads "Codex" — a chip reading "CODEX" beside a
61
+ # source chip reading "Codex" said the same thing twice and named neither
62
+ # the metric nor anything the source chip did not already say. The two
63
+ # BUDGET chips stay visually distinct through `chip--codex_budget`, which
64
+ # keeps its own colour. As of #143 it shares the unified vendor-tagged
60
65
  # `budget_milestones` table with the Claude `budget` axis; the envelope
61
66
  # mapper's `WHERE vendor=?` filter does the row-level split (keyed on the
62
67
  # resolved period-window start instant period_start_at, threshold).
63
68
  AlertAxisDescriptor(
64
- "codex_budget", "CODEX", "Codex budget", "budget_milestones", vendor="codex"
69
+ "codex_budget", "BUDGET", "Codex budget", "budget_milestones", vendor="codex"
65
70
  ),
66
71
  )
67
72