cctally 1.96.2 → 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,
@@ -334,6 +339,10 @@ def _sum_cost_for_range(*args, **kwargs):
334
339
  return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
335
340
 
336
341
 
342
+ def _sum_cost_and_tokens_for_range(*args, **kwargs):
343
+ return sys.modules["cctally"]._sum_cost_and_tokens_for_range(*args, **kwargs)
344
+
345
+
337
346
  def _compute_cost_for_weekref(*args, **kwargs):
338
347
  return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
339
348
 
@@ -869,6 +878,11 @@ class TuiCurrentWeek:
869
878
  # default `None` keeps fixture modules that construct TuiCurrentWeek
870
879
  # directly (without this field) backwards-compatible.
871
880
  five_hour_block: dict | None = None
881
+ # #556 S1 §3.3 — the current cycle's #104 token total, accumulated in the
882
+ # SAME pass that produces `spent_usd` so the two halves describe one entry
883
+ # set. Appended last with a default so fixture modules that construct
884
+ # `TuiCurrentWeek` positionally stay valid.
885
+ total_tokens: int = 0
872
886
 
873
887
 
874
888
  # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
@@ -1607,7 +1621,11 @@ def _tui_build_current_week(
1607
1621
  latest = samples[-1]
1608
1622
  used_pct = float(latest[1])
1609
1623
  five_hr_pct = float(latest[2]) if latest[2] is not None else None
1610
- spent = _sum_cost_for_range(
1624
+ # #556 S1 §3.3: one walk yields both halves. The range is whatever
1625
+ # `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
1626
+ # above, so a mid-week reset shortens the accumulation and the published
1627
+ # period together.
1628
+ spent, total_tokens = _sum_cost_and_tokens_for_range(
1611
1629
  week_start_at, now_utc, mode="auto", skip_sync=skip_sync
1612
1630
  )
1613
1631
  dpp = (spent / used_pct) if used_pct > 0 else None
@@ -1678,6 +1696,7 @@ def _tui_build_current_week(
1678
1696
  five_hour_block=_select_current_block_for_envelope(
1679
1697
  conn, current_used_pct=used_pct, now_utc=now_utc,
1680
1698
  ),
1699
+ total_tokens=total_tokens,
1681
1700
  )
1682
1701
 
1683
1702
 
@@ -2297,7 +2316,12 @@ def _snapshot_data_version(sig) -> str:
2297
2316
  # in is what leaves the idle short-circuit so the source bundle is rebuilt
2298
2317
  # at all. Empty once the backlog has drained, so it is byte-neutral there.
2299
2318
  backlog = getattr(sig, "codex_ingest_backlog_sig", "")
2300
- 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}"
2301
2325
 
2302
2326
 
2303
2327
  def _tui_source_copy(value: object) -> object:
@@ -2328,6 +2352,38 @@ def _tui_claude_resource_row(
2328
2352
  return wire
2329
2353
 
2330
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
+
2331
2387
  def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
2332
2388
  """Project one completed Claude legacy envelope without further DB reads.
2333
2389
 
@@ -2431,17 +2487,7 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2431
2487
  axis = raw.get("axis")
2432
2488
  vendor = raw.get("vendor")
2433
2489
  metric = raw.get("metric")
2434
- owns_alert = (
2435
- (axis in {"weekly", "five_hour"} and vendor in {None, "claude"})
2436
- # Legacy top-level Claude budget rows predate the additive vendor
2437
- # field; the distinct Codex axis is ``codex_budget``. Treat an
2438
- # absent vendor as that established Claude meaning, while an
2439
- # explicit non-Claude vendor must never be relabeled.
2440
- or (axis == "budget" and vendor in {None, "claude"})
2441
- or axis == "project_budget"
2442
- or (axis == "projected" and metric in {"weekly_pct", "budget_usd"})
2443
- )
2444
- if not owns_alert:
2490
+ if alert_row_owner(axis, vendor, metric) != "claude":
2445
2491
  continue
2446
2492
  alert_rows.append(_tui_claude_resource_row(
2447
2493
  raw,
@@ -2449,8 +2495,6 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2449
2495
  identity=(ordinal, raw.get("axis"), raw.get("threshold"), raw.get("alerted_at")),
2450
2496
  ))
2451
2497
 
2452
- daily_total = daily.get("total_cost_usd", 0.0)
2453
- daily_tokens = daily.get("total_tokens", 0)
2454
2498
  budget_settings = _tui_source_copy(legacy.get("alerts_settings"))
2455
2499
  if isinstance(budget_settings, dict):
2456
2500
  # The legacy top-level settings mirror contains Codex capability flags
@@ -2465,8 +2509,15 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2465
2509
  budget_settings.pop(key, None)
2466
2510
  return {
2467
2511
  "hero": {
2468
- "cost_usd": daily_total,
2469
- "total_tokens": daily_tokens,
2512
+ # #556 S1 §3.4: current-cycle accounting, NOT the thirty-day
2513
+ # rollup. Both providers' `hero.cost_usd` / `hero.total_tokens`
2514
+ # now mean the same thing; the thirty-day figures keep their own
2515
+ # home in `periods.daily`. `None` when no current week resolves —
2516
+ # composition distinguishes an empty provider from an unresolved
2517
+ # cycle by the provider's availability, and a zero here would make
2518
+ # both read as observed spend.
2519
+ "cost_usd": current_week.get("spent_usd"),
2520
+ "total_tokens": current_week.get("total_tokens"),
2470
2521
  "header": _tui_source_copy(legacy.get("header")),
2471
2522
  "current_week": _tui_source_copy(current_week),
2472
2523
  "forecast": _tui_source_copy(legacy.get("forecast")),
@@ -2499,15 +2550,41 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
2499
2550
  }
2500
2551
 
2501
2552
 
2553
+ def _tui_claude_cycle_is_resolved(
2554
+ current_week: object, now_utc: dt.datetime,
2555
+ ) -> bool:
2556
+ """Whether Claude's current week resolves AND has not expired (#556 §4.1).
2557
+
2558
+ A stale percent observation does not enter this: the boundary can be
2559
+ perfectly resolved while the number that reports progress against it is an
2560
+ hour old.
2561
+ """
2562
+ if not isinstance(current_week, dict):
2563
+ return False
2564
+ end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
2565
+ if end is None:
2566
+ return False
2567
+ return now_utc.astimezone(dt.timezone.utc) < dt.datetime.fromisoformat(end)
2568
+
2569
+
2502
2570
  def _tui_claude_domain_freshness(
2503
2571
  source_data: dict[str, object] | None,
2572
+ *,
2573
+ now_utc: dt.datetime,
2504
2574
  ) -> dict[str, str]:
2505
- """Derive Claude axes from its selected weekly snapshot evidence.
2575
+ """Derive Claude's axes from its selected weekly snapshot evidence.
2576
+
2577
+ #556 S1 §4.1 repoints the two axes it publishes. ``quota`` carries the
2578
+ percent-OBSERVATION age, which is what it always described. ``hero`` now
2579
+ carries current-cycle ACCOUNTING resolvability: fresh while the boundary
2580
+ resolves and has not expired, so the backward-looking counters inside it
2581
+ are publishable. Pointing ``hero`` at the percent age is what kept All's
2582
+ combined caveat permanently on, because that clock's 90-second bound is
2583
+ forty times tighter than the Codex weekly one it was joined with.
2506
2584
 
2507
2585
  The legacy current-week label has a third presentation-only ``aging``
2508
2586
  state. The source contract deliberately keeps the frozen fresh/stale
2509
- vocabulary: only the exact stale label moves the weekly hero/quota axes.
2510
- Missing evidence remains a capability/availability concern.
2587
+ vocabulary: only the exact stale label moves the quota axis.
2511
2588
  """
2512
2589
  data = source_data if isinstance(source_data, dict) else {}
2513
2590
  hero = data.get("hero")
@@ -2516,12 +2593,15 @@ def _tui_claude_domain_freshness(
2516
2593
  current_week.get("freshness")
2517
2594
  if isinstance(current_week, dict) else None
2518
2595
  )
2519
- weekly = (
2596
+ quota = (
2520
2597
  "stale"
2521
2598
  if isinstance(freshness, dict) and freshness.get("label") == "stale"
2522
2599
  else "fresh"
2523
2600
  )
2524
- return {"hero": weekly, "quota": weekly, "sessions": "fresh"}
2601
+ accounting = (
2602
+ "fresh" if _tui_claude_cycle_is_resolved(current_week, now_utc) else "stale"
2603
+ )
2604
+ return {"hero": accounting, "quota": quota, "sessions": "fresh"}
2525
2605
 
2526
2606
 
2527
2607
  def _refresh_claude_source_clock(
@@ -2536,29 +2616,41 @@ def _refresh_claude_source_clock(
2536
2616
  return state
2537
2617
  if now_utc.tzinfo is None or now_utc.utcoffset() is None:
2538
2618
  raise ValueError("now_utc must be timezone-aware")
2539
- captured = getattr(current_week, "latest_snapshot_at", None)
2540
- if not isinstance(captured, dt.datetime):
2541
- return state
2542
- if captured.tzinfo is None or captured.utcoffset() is None:
2543
- captured = captured.replace(tzinfo=dt.timezone.utc)
2544
- age_seconds = max(
2545
- 0.0,
2546
- (
2547
- now_utc.astimezone(dt.timezone.utc)
2548
- - captured.astimezone(dt.timezone.utc)
2549
- ).total_seconds(),
2550
- )
2551
- try:
2552
- freshness_config = _get_oauth_usage_config(raw_config)
2553
- except Exception:
2554
- freshness_config = _OAUTH_USAGE_DEFAULTS
2555
- weekly = (
2556
- "stale"
2557
- if _freshness_label(age_seconds, freshness_config) == "stale"
2558
- else "fresh"
2559
- )
2560
2619
  domain_freshness = dict(state.domain_freshness or {})
2561
- domain_freshness.update({"hero": weekly, "quota": weekly})
2620
+ # #556 S1 §4.1: the two axes advance from DIFFERENT evidence. Both legs run
2621
+ # on every tick — this used to write one percent-derived label to both, and
2622
+ # it also returned early on a missing capture, which left the accounting
2623
+ # axis frozen at its build-time value for the whole idle stretch.
2624
+ week_end = getattr(current_week, "week_end_at", None)
2625
+ domain_freshness["hero"] = (
2626
+ "fresh"
2627
+ if isinstance(week_end, dt.datetime)
2628
+ and now_utc.astimezone(dt.timezone.utc) < (
2629
+ week_end if week_end.tzinfo is not None
2630
+ else week_end.replace(tzinfo=dt.timezone.utc)
2631
+ ).astimezone(dt.timezone.utc)
2632
+ else "stale"
2633
+ )
2634
+ captured = getattr(current_week, "latest_snapshot_at", None)
2635
+ if isinstance(captured, dt.datetime):
2636
+ if captured.tzinfo is None or captured.utcoffset() is None:
2637
+ captured = captured.replace(tzinfo=dt.timezone.utc)
2638
+ age_seconds = max(
2639
+ 0.0,
2640
+ (
2641
+ now_utc.astimezone(dt.timezone.utc)
2642
+ - captured.astimezone(dt.timezone.utc)
2643
+ ).total_seconds(),
2644
+ )
2645
+ try:
2646
+ freshness_config = _get_oauth_usage_config(raw_config)
2647
+ except Exception:
2648
+ freshness_config = _OAUTH_USAGE_DEFAULTS
2649
+ domain_freshness["quota"] = (
2650
+ "stale"
2651
+ if _freshness_label(age_seconds, freshness_config) == "stale"
2652
+ else "fresh"
2653
+ )
2562
2654
  refreshed = dataclasses.replace(
2563
2655
  state,
2564
2656
  domain_freshness=domain_freshness,
@@ -2566,6 +2658,215 @@ def _refresh_claude_source_clock(
2566
2658
  return state if refreshed == state else refreshed
2567
2659
 
2568
2660
 
2661
+ def _tui_normalized_period_instant(value: object) -> str | None:
2662
+ """One canonical UTC spelling for a published cycle bound (#556 S1 §3.6).
2663
+
2664
+ Legacy rows carry local-offset spellings and current rows carry `Z`, so the
2665
+ raw text is not an identity: hashing it would rebuild the Claude source on
2666
+ a spelling change that moved no boundary. Returns ``None`` for anything
2667
+ that does not parse as an instant.
2668
+ """
2669
+ if not isinstance(value, str) or not value:
2670
+ return None
2671
+ try:
2672
+ return parse_iso_datetime(value, "period bound").astimezone(
2673
+ dt.timezone.utc).isoformat()
2674
+ except ValueError:
2675
+ return None
2676
+
2677
+
2678
+ def _tui_claude_period_identity(claude_data: object) -> str:
2679
+ """The effective current-cycle bounds, as a version fragment (§3.6).
2680
+
2681
+ `claude_version` carried the database signatures, the semantics identity
2682
+ and the accounts digest but no period, and `reuse_coherent_source_state`
2683
+ returns the exact prior object on an unchanged version — so a week that
2684
+ rolled over with no database movement could be republished as current.
2685
+ The snapshot-level `_snapshot_period_rolled_over` gate already covers the
2686
+ undecorated case; this is the second layer, so provider reuse cannot
2687
+ outlive the cycle even if that gate is later changed.
2688
+
2689
+ The bounds are read from the SAME resolved object the aggregation used
2690
+ (`current_week`, stored after `_apply_midweek_reset_override`), so a stale
2691
+ identity can never accompany a shortened range.
2692
+
2693
+ The literal `"none"` degradation is deliberate and is NOT an oversight. A
2694
+ caller that holds no `claude_data` — the capture and QA paths — contributes
2695
+ a constant fragment, so layer two adds no rollover protection there. Those
2696
+ paths are still covered by the snapshot-level `_snapshot_period_rolled_over`
2697
+ gate, which is layer one and is what production reuse actually runs behind.
2698
+ """
2699
+ data = claude_data if isinstance(claude_data, dict) else {}
2700
+ hero = data.get("hero")
2701
+ current_week = hero.get("current_week") if isinstance(hero, dict) else None
2702
+ if not isinstance(current_week, dict):
2703
+ return "none"
2704
+ start = _tui_normalized_period_instant(current_week.get("week_start_at"))
2705
+ end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
2706
+ if start is None and end is None:
2707
+ return "none"
2708
+ return f"{start or ''}~{end or ''}"
2709
+
2710
+
2711
+ def _tui_resolve_account_scope(stats_conn, provider: str) -> dict[str, object] | None:
2712
+ """Read one provider's authoritative REAL account count (#556 S1 §3.8).
2713
+
2714
+ Returns ``None`` when the count cannot be read. That is the FAIL-CLOSED
2715
+ path: composition withholds the combined figure rather than assuming a
2716
+ single account. It is deliberately the opposite of the builders' existing
2717
+ swallow-and-degrade behaviour for the accounts WIRE, where the fallback
2718
+ loses decoration styling; here it would publish a wrong number.
2719
+
2720
+ The catch is narrow on purpose. ``real_account_count`` is one SELECT over
2721
+ ``accounts`` — no file I/O, no label lookups — so ``sqlite3.Error`` covers
2722
+ every failure the CALL can produce, and anything else is a real defect that
2723
+ must not be swallowed into a silent withholding. ``ImportError`` is caught
2724
+ alongside it because the deferred import is a different failure class from
2725
+ the query: an unimportable module is exactly the "count cannot be read"
2726
+ state this function exists to report, and letting it propagate would fail
2727
+ the whole dashboard tick instead of withholding one figure.
2728
+ """
2729
+ try:
2730
+ import _cctally_account
2731
+ return {
2732
+ "real_account_count": int(
2733
+ _cctally_account.real_account_count(stats_conn, provider)
2734
+ ),
2735
+ }
2736
+ except (ImportError, sqlite3.Error):
2737
+ return None
2738
+
2739
+
2740
+ def _tui_with_account_scope(
2741
+ state: SourceDashboardState, scope: dict[str, object] | None,
2742
+ ) -> SourceDashboardState:
2743
+ """Attach ``scope`` without breaking reuse-by-identity.
2744
+
2745
+ ``reuse_coherent_source_state`` returns the EXACT prior object, and callers
2746
+ assert on that identity, so an unconditional ``dataclasses.replace`` would
2747
+ defeat reuse on every tick. Frozen mappings compare equal to plain dicts,
2748
+ so an unchanged count returns the prior object untouched.
2749
+ """
2750
+ if state.account_scope == scope:
2751
+ return state
2752
+ return dataclasses.replace(state, account_scope=scope)
2753
+
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
+
2569
2870
  def _tui_build_source_bundle(
2570
2871
  *,
2571
2872
  stats_conn,
@@ -2579,6 +2880,7 @@ def _tui_build_source_bundle(
2579
2880
  claude_total_tokens: int,
2580
2881
  claude_data: dict[str, object] | None = None,
2581
2882
  common_range_start: dt.datetime | None = None,
2883
+ projects_envelope: dict | None = None,
2582
2884
  prior_bundle: SourceDashboardBundle | None = None,
2583
2885
  raw_config: dict[str, object] | None = None,
2584
2886
  ) -> SourceDashboardBundle:
@@ -2604,10 +2906,48 @@ def _tui_build_source_bundle(
2604
2906
  cache_conn.execute("BEGIN")
2605
2907
  cache_read_tx = True
2606
2908
  if common_range_start is None:
2607
- 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
+ )
2608
2937
  if common_range_start.tzinfo is None or common_range_start.utcoffset() is None:
2609
2938
  raise ValueError("common_range_start must be timezone-aware")
2610
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
+ )
2611
2951
  semantics = resolve_dashboard_source_semantics(
2612
2952
  raw_config if raw_config is not None else c.load_config(),
2613
2953
  display_tz_name=display_tz_name,
@@ -2630,6 +2970,11 @@ def _tui_build_source_bundle(
2630
2970
  from _cctally_quota import assert_projection_readable
2631
2971
  assert_projection_readable(stats_conn)
2632
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)
2633
2978
  # #341 finding 9: the account registry/active-identity digest. Empty for
2634
2979
  # every <=1-account install (byte-neutral — appended only when non-empty),
2635
2980
  # so single-account source versions stay byte-identical to today; a
@@ -2644,6 +2989,7 @@ def _tui_build_source_bundle(
2644
2989
  generation=c.current_generation(),
2645
2990
  codex_stats_digest=stats_digest,
2646
2991
  accounts_digest=accounts_digest,
2992
+ claude_stats_digest=claude_digest,
2647
2993
  )
2648
2994
  _acct_suffix = f":a{accounts_digest}" if accounts_digest else ""
2649
2995
  # public #5: the hook's budgeted ingest can change what the Codex
@@ -2654,16 +3000,32 @@ def _tui_build_source_bundle(
2654
3000
  # wire. Empty (and so byte-neutral) once the backlog has drained.
2655
3001
  _backlog = getattr(signature, "codex_ingest_backlog_sig", "")
2656
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
+ )
2657
3013
  codex_version = (
2658
3014
  f"codex:{signature.max_codex_id}:"
2659
3015
  f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
2660
3016
  f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
3017
+ f"{_aggregate_suffix}"
2661
3018
  )
3019
+ # #556 S1 §3.6: normalized period identity, so a nominal week rollover
3020
+ # invalidates the generation even when no database signature moved.
3021
+ _period_identity = _tui_claude_period_identity(claude_data)
2662
3022
  claude_version = (
2663
3023
  f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
2664
3024
  f"{signature.max_wus_id}:{signature.max_wcs_id}:"
2665
3025
  f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
2666
- f"{signature.generation}:{semantics.claude_identity}{_acct_suffix}"
3026
+ f"{signature.generation}:{semantics.claude_identity}"
3027
+ f":p{_period_identity}{_acct_suffix}{_aggregate_suffix}"
3028
+ f":x{claude_digest}"
2667
3029
  )
2668
3030
  prior_claude = (
2669
3031
  prior_bundle.sources.get("claude")
@@ -2695,6 +3057,14 @@ def _tui_build_source_bundle(
2695
3057
  claude = reuse_coherent_source_state(
2696
3058
  prior_claude, data_version=claude_version,
2697
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
2698
3068
  if claude is None:
2699
3069
  claude_available = "ok" if (claude_cost_usd or claude_total_tokens) else "empty"
2700
3070
  # #341 Task 4 (Ruling C): the conditional per-account Claude wire,
@@ -2717,6 +3087,47 @@ def _tui_build_source_bundle(
2717
3087
  # wire must never fail the whole dashboard tick — it just falls
2718
3088
  # back to the byte-stable undecorated shape.
2719
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
+ )
2720
3131
  claude = SourceDashboardState(
2721
3132
  source="claude",
2722
3133
  availability=claude_available,
@@ -2737,9 +3148,10 @@ def _tui_build_source_bundle(
2737
3148
  "alerts": CapabilityRecord("supported", "provider-native"),
2738
3149
  },
2739
3150
  data={
2740
- **(
2741
- claude_data
2742
- if claude_data is not None else {
3151
+ **_tui_claude_data_with_aggregates(
3152
+ claude_data,
3153
+ aggregate_payload,
3154
+ fallback={
2743
3155
  "hero": {
2744
3156
  "cost_usd": claude_cost_usd,
2745
3157
  "total_tokens": claude_total_tokens,
@@ -2750,11 +3162,14 @@ def _tui_build_source_bundle(
2750
3162
  "quota": {"blocks": (), "milestones": ()},
2751
3163
  "budget": {"label": "Claude subscription budget"},
2752
3164
  "alerts": {"rows": ()},
2753
- }
3165
+ },
2754
3166
  ),
2755
3167
  **({"accounts": claude_accounts} if claude_accounts else {}),
2756
3168
  },
2757
- domain_freshness=_tui_claude_domain_freshness(claude_data),
3169
+ domain_freshness=_tui_claude_domain_freshness(
3170
+ claude_data, now_utc=now_utc,
3171
+ ),
3172
+ aggregate_scope=claude_aggregate_scope,
2758
3173
  )
2759
3174
  if codex_ingest_failed:
2760
3175
  warning = SourceDashboardWarning(
@@ -2798,6 +3213,13 @@ def _tui_build_source_bundle(
2798
3213
  prior_codex, data_version=codex_version,
2799
3214
  )
2800
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
2801
3223
  if codex is None:
2802
3224
  try:
2803
3225
  codex = build_codex_source_state(
@@ -2817,6 +3239,13 @@ def _tui_build_source_bundle(
2817
3239
  ),
2818
3240
  data_version=codex_version,
2819
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
+ )
2820
3249
  except Exception:
2821
3250
  _lib_log.get_logger("dashboard").error(
2822
3251
  "codex_read_model source build failed",
@@ -2838,6 +3267,15 @@ def _tui_build_source_bundle(
2838
3267
  # guard, so a freshly built state is handed back unchanged. Claude is
2839
3268
  # deliberately untouched.
2840
3269
  codex = refresh_codex_source_clock(codex, now_utc=now_utc)
3270
+ # #556 S1 §3.8: the decoration fact reaches composition as authoritative
3271
+ # server-only metadata. It is attached HERE, after every build / reuse /
3272
+ # degrade / clock branch, so no branch can publish a state without it.
3273
+ claude = _tui_with_account_scope(
3274
+ claude, _tui_resolve_account_scope(stats_conn, "claude"),
3275
+ )
3276
+ codex = _tui_with_account_scope(
3277
+ codex, _tui_resolve_account_scope(stats_conn, "codex"),
3278
+ )
2841
3279
  combined = compose_all_state(claude, codex)
2842
3280
  bundle = SourceDashboardBundle(
2843
3281
  source_schema_version=SOURCE_SCHEMA_VERSION,
@@ -2857,12 +3295,14 @@ def _tui_build_source_bundle(
2857
3295
  cache_read_tx = False
2858
3296
  post_stats_digest = codex_stats_digest(stats_conn)
2859
3297
  post_accounts_digest = accounts_identity_digest(stats_conn)
3298
+ post_claude_digest = claude_stats_digest(stats_conn)
2860
3299
  post_signature = c.compute_signature(
2861
3300
  cache_conn,
2862
3301
  stats_conn,
2863
3302
  generation=c.current_generation(),
2864
3303
  codex_stats_digest=post_stats_digest,
2865
3304
  accounts_digest=post_accounts_digest,
3305
+ claude_stats_digest=post_claude_digest,
2866
3306
  )
2867
3307
  stats_generation_moved = (
2868
3308
  post_signature.max_wus_id != signature.max_wus_id
@@ -2870,6 +3310,7 @@ def _tui_build_source_bundle(
2870
3310
  or post_signature.reset_sig != signature.reset_sig
2871
3311
  or post_stats_digest != stats_digest
2872
3312
  or post_accounts_digest != accounts_digest
3313
+ or post_claude_digest != claude_digest
2873
3314
  )
2874
3315
  if stats_generation_moved:
2875
3316
  if prior_bundle is not None:
@@ -2942,6 +3383,14 @@ def _tui_source_bundle_can_idle(bundle: SourceDashboardBundle | None) -> bool:
2942
3383
  or state.freshness != "fresh"
2943
3384
  or state.data is None):
2944
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
2945
3394
  return True
2946
3395
 
2947
3396
 
@@ -2951,18 +3400,18 @@ def _tui_common_source_range_start(
2951
3400
  now_utc: dt.datetime,
2952
3401
  display_tz: dt.tzinfo | None,
2953
3402
  ) -> dt.datetime:
2954
- """Return the shared provider interval from the already-built daily rows."""
2955
- if daily_panel:
2956
- earliest_day = dt.date.fromisoformat(daily_panel[-1].date)
2957
- if display_tz is not None:
2958
- return dt.datetime.combine(
2959
- earliest_day, dt.time.min, tzinfo=display_tz,
2960
- ).astimezone(dt.timezone.utc)
2961
- # internal fallback: host-local intentional
2962
- return dt.datetime.combine(
2963
- earliest_day, dt.time.min,
2964
- ).astimezone(dt.timezone.utc)
2965
- 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
2966
3415
 
2967
3416
 
2968
3417
  def _tui_build_snapshot(
@@ -3910,6 +4359,7 @@ def _tui_build_snapshot_once(
3910
4359
  claude_total_tokens=daily_total_tokens,
3911
4360
  claude_data=_tui_project_claude_source_data(legacy_envelope),
3912
4361
  common_range_start=common_range_start,
4362
+ projects_envelope=projects_envelope_block,
3913
4363
  prior_bundle=prior_source_bundle,
3914
4364
  raw_config=raw_config,
3915
4365
  )
@@ -4063,6 +4513,7 @@ def _tui_compute_dispatch_signature(stats_conn):
4063
4513
  generation=sc.current_generation(),
4064
4514
  codex_stats_digest=codex_stats_digest(stats_conn),
4065
4515
  accounts_digest=accounts_identity_digest(stats_conn),
4516
+ claude_stats_digest=claude_stats_digest(stats_conn),
4066
4517
  )
4067
4518
  finally:
4068
4519
  cache_conn.close()
@@ -4240,6 +4691,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
4240
4691
  now_utc=now_utc,
4241
4692
  display_tz=source_display_tz,
4242
4693
  ),
4694
+ projects_envelope=prior.projects_envelope,
4243
4695
  prior_bundle=source_bundle,
4244
4696
  raw_config=raw_config,
4245
4697
  )