cctally 1.117.0 → 1.117.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -183,6 +183,7 @@ from __future__ import annotations
183
183
  import argparse
184
184
  import dataclasses
185
185
  import datetime as dt
186
+ import hashlib
186
187
  import io
187
188
  import json
188
189
  import math
@@ -223,6 +224,7 @@ from _lib_display_tz import (
223
224
  _compute_display_block,
224
225
  )
225
226
  from _lib_aggregators import _aggregate_monthly, codex_path_scope
227
+ from _lib_json_envelope import _iso_z
226
228
  from _lib_fmt import _fmt_usd_accounting, stable_sum
227
229
  # Opt-in backend phase-instrumentation collector (issue #276, Session A). Pure
228
230
  # stdlib leaf; near-noop when CCTALLY_PERF_TRACE is unset (phase() returns a
@@ -299,6 +301,7 @@ from _lib_dashboard_sources import (
299
301
  calibration_basis_digest,
300
302
  calibration_supersession_digest,
301
303
  claude_stats_digest,
304
+ claude_weekly_retired_digest,
302
305
  codex_stats_digest,
303
306
  combined_accounting_version,
304
307
  compose_all_state,
@@ -912,6 +915,18 @@ class TuiCurrentWeek:
912
915
  # set. Appended last with a default so fixture modules that construct
913
916
  # `TuiCurrentWeek` positionally stay valid.
914
917
  total_tokens: int = 0
918
+ # #836 (spec 5.1): the displayed account, published as
919
+ # `current_week.account_key` / `account_label` ONLY when Claude is
920
+ # decorated (more than one real account, R8). Filled by the decorated
921
+ # path of `_tui_build_current_week`; the undecorated card leaves all four
922
+ # at their defaults. ``cycle_scope`` is that account's
923
+ # `ClaudeCycleScope`, the one cycle identity the weekly milestones and the
924
+ # scoped history index share with this card. Trailing defaults, for the
925
+ # positional fixture constructors.
926
+ account_key: "str | None" = None
927
+ account_decorated: bool = False
928
+ account_label: "str | None" = None
929
+ cycle_scope: "Any | None" = None
915
930
 
916
931
 
917
932
  # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
@@ -957,10 +972,21 @@ class TuiPercentMilestone:
957
972
 
958
973
  def _tui_build_percent_milestones(
959
974
  conn: sqlite3.Connection,
975
+ *,
976
+ account_key: "str | None" = None,
977
+ cycle_scope: "Any | None" = None,
960
978
  ) -> list[TuiPercentMilestone]:
961
979
  """Return per-percent crossings for the current week's ACTIVE
962
980
  segment, ascending by percent.
963
981
 
982
+ #836 (spec 5.3): with ``account_key`` (a decorated store) the week, its
983
+ active segment and the milestone rows all come from ``cycle_scope`` — the
984
+ displayed account's `ClaudeCycleScope`, the identity the card was built
985
+ from — and only that account's rows are read. A decorated build with no
986
+ resolved scope publishes ``[]``, never a merged list. ``account_key=None``
987
+ is today's merged read below, unchanged, including legitimate
988
+ `unattributed` pre-attribution rows in the same week.
989
+
964
990
  Resolves `week_start_date` from the latest `weekly_usage_snapshots` row
965
991
  — the same path `cmd_percent_breakdown` takes. The post-override
966
992
  `TuiCurrentWeek.week_start_at` is NOT suitable here: after a mid-week
@@ -977,6 +1003,16 @@ def _tui_build_percent_milestones(
977
1003
  Returns [] if no usage snapshot exists, OR if the active segment
978
1004
  has no milestone rows yet (post-credit "fresh" state).
979
1005
  """
1006
+ if account_key is not None:
1007
+ if cycle_scope is None or cycle_scope.account_key != account_key:
1008
+ return []
1009
+ # The scope's segment IS the active one (no second lookup): it was
1010
+ # resolved through the same chokepoint, scoped and as-of bounded.
1011
+ return _tui_percent_milestone_rows([
1012
+ r for r in get_milestones_for_week(
1013
+ conn, cycle_scope.week_start_date, account_key=account_key)
1014
+ if int(r["reset_event_id"] or 0) == int(cycle_scope.reset_event_id)
1015
+ ])
980
1016
  latest = conn.execute(
981
1017
  "SELECT week_start_date, week_end_at FROM weekly_usage_snapshots "
982
1018
  "WHERE week_end_at IS NOT NULL "
@@ -1025,6 +1061,11 @@ def _tui_build_percent_milestones(
1025
1061
  r for r in get_milestones_for_week(conn, latest["week_start_date"])
1026
1062
  if int(r["reset_event_id"] or 0) == active_segment
1027
1063
  ]
1064
+ return _tui_percent_milestone_rows(rows)
1065
+
1066
+
1067
+ def _tui_percent_milestone_rows(rows) -> list[TuiPercentMilestone]:
1068
+ """`percent_milestones` rows as `TuiPercentMilestone`s, in row order."""
1028
1069
  out: list[TuiPercentMilestone] = []
1029
1070
  for r in rows:
1030
1071
  try:
@@ -1394,6 +1435,17 @@ class DataSnapshot:
1394
1435
  # ingest/read-model work. It remains trailing/defaulted for every legacy
1395
1436
  # positional fixture constructor.
1396
1437
  source_bundle: SourceDashboardBundle | None = None
1438
+ # #836 spec 5.7 item 2: the earliest instant after this build's as-of at
1439
+ # which evidence the build excluded by that as-of becomes eligible — a
1440
+ # weekly sample captured after it, a reset taking effect (or detected)
1441
+ # after it — or None (`_tui_evidence_eligible_at`). The clock is frozen
1442
+ # before the dispatch signature is read, so such a row is counted by the
1443
+ # signature yet excluded by the build; the idle gate refuses once a tick's
1444
+ # instant reaches this, and the Claude source version carries it, so the
1445
+ # tick that admits the evidence rebuilds and refuses source reuse.
1446
+ # Never published. Idle copies carry it unchanged. LAST, with a default,
1447
+ # for the positional constructors.
1448
+ evidence_eligible_at: dt.datetime | None = None
1397
1449
 
1398
1450
  @classmethod
1399
1451
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1673,6 +1725,138 @@ class RuntimeState:
1673
1725
  )
1674
1726
 
1675
1727
 
1728
+ def _tui_card_freshness(captured, now_utc: dt.datetime):
1729
+ """``(freshness_label, freshness_age)`` of the card's displayed sample,
1730
+ against the configured oauth_usage thresholds; ``(None, None)`` when the
1731
+ capture is not a datetime. Mirrors the dashboard envelope's cw_freshness
1732
+ derivation in `snapshot_to_envelope`. Refs spec §3.4."""
1733
+ if not isinstance(captured, dt.datetime):
1734
+ return None, None
1735
+ if captured.tzinfo is None:
1736
+ captured = captured.replace(tzinfo=dt.timezone.utc)
1737
+ age_s = max(0.0, (now_utc - captured).total_seconds())
1738
+ try:
1739
+ _fresh_cfg = _get_oauth_usage_config(load_config())
1740
+ except Exception:
1741
+ _fresh_cfg = _OAUTH_USAGE_DEFAULTS
1742
+ return _freshness_label(age_s, _fresh_cfg), int(age_s)
1743
+
1744
+
1745
+ def _tui_five_hour_resets_at(five_hour, now_utc: dt.datetime):
1746
+ """The five-hour reading's reset instant, or None when absent,
1747
+ unparseable or already elapsed (a stale reset would render as
1748
+ "resets 0h 00m")."""
1749
+ if five_hour is None or five_hour.resets_at is None:
1750
+ return None
1751
+ try:
1752
+ resets_at = parse_iso_datetime(
1753
+ five_hour.resets_at, "five_hour_resets_at")
1754
+ except ValueError:
1755
+ return None
1756
+ return None if resets_at <= now_utc else resets_at
1757
+
1758
+
1759
+ def _claude_current_cycle_scope(*args, **kwargs):
1760
+ return sys.modules["cctally"]._claude_current_cycle_scope(*args, **kwargs)
1761
+
1762
+
1763
+ def _claude_select_current_owner(*args, **kwargs):
1764
+ return sys.modules["cctally"]._claude_select_current_owner(*args, **kwargs)
1765
+
1766
+
1767
+ def _claude_cycle_scope_samples(*args, **kwargs):
1768
+ return sys.modules["cctally"]._claude_cycle_scope_samples(*args, **kwargs)
1769
+
1770
+
1771
+ def _tui_build_decorated_current_week(
1772
+ conn: sqlite3.Connection,
1773
+ now_utc: dt.datetime,
1774
+ *,
1775
+ skip_sync: bool = False,
1776
+ ) -> TuiCurrentWeek | None:
1777
+ """The Current Week card on a decorated store: ONE account (#836,
1778
+ spec 5.2).
1779
+
1780
+ The owner is the account of the newest non-held weekly sample at the
1781
+ as-of instant (`_claude_select_current_owner`), so a newer held row of
1782
+ another account cannot choose the window (D-C). Its cycle identity
1783
+ (`_claude_current_cycle_scope`) supplies the window, the active reset
1784
+ and the effective start, so another account's credit cannot re-anchor it
1785
+ (D-A). Samples, the five-hour reading, the block and the spend are all the
1786
+ owner's (U3, D-B): ``$/1%`` divides the owner's spend by the owner's
1787
+ percentage. When the owner's cycle has no eligible non-held sample (its
1788
+ own reset postdates the chosen one) the card is None; a decorated build
1789
+ never falls back to the merged population. `_apply_midweek_reset_override`
1790
+ is not called here.
1791
+ """
1792
+ owner = _claude_select_current_owner(conn, now_utc)
1793
+ if owner is None:
1794
+ return None
1795
+ scope = _claude_current_cycle_scope(conn, now_utc, account_key=owner)
1796
+ if scope is None:
1797
+ return None
1798
+ fetched = _claude_cycle_scope_samples(conn, scope, now_utc)
1799
+ if fetched is None:
1800
+ return None
1801
+ week_start_at, week_end_at, observations, week_scope = fetched
1802
+ week_start_before_override = week_start_at
1803
+ if scope.effective_start_at is not None:
1804
+ # The scope's effective start may also PRECEDE the nominal start (a
1805
+ # same-date early re-anchor, #836 review F7): the card's cycle starts
1806
+ # there either way, as its current index entry does.
1807
+ effective = parse_iso_datetime(
1808
+ scope.effective_start_at, "cycle_scope.effective_start_at")
1809
+ if effective != week_start_at:
1810
+ week_start_at = effective
1811
+ observations = [s for s in observations if s[0] >= effective]
1812
+ weekly_samples = [s for s in observations if not s[3]]
1813
+ if not weekly_samples:
1814
+ return None
1815
+ latest = weekly_samples[-1]
1816
+ used_pct = float(latest[1])
1817
+ five_hour = _current_week_five_hour_reading(
1818
+ conn, now_utc, week_scope, account_key=owner,
1819
+ week_start_at=week_start_at,
1820
+ week_start_before_override=week_start_before_override)
1821
+ spent, total_tokens = _sum_cost_and_tokens_for_range(
1822
+ week_start_at, now_utc, mode="auto", skip_sync=skip_sync,
1823
+ account_key=owner,
1824
+ )
1825
+ # The same corrected divisor as the merged card (#661 S2 section 3.4).
1826
+ used_pct_corrected = corrected_percent_point(used_pct)
1827
+ dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
1828
+ dpp = (spent / dpp_divisor) if used_pct > 0 else None
1829
+ freshness_label, freshness_age = _tui_card_freshness(latest[0], now_utc)
1830
+ import _cctally_account
1831
+ return TuiCurrentWeek(
1832
+ week_start_at=week_start_at,
1833
+ week_end_at=week_end_at,
1834
+ used_pct=used_pct,
1835
+ five_hour_pct=None if five_hour is None else five_hour.percent,
1836
+ five_hour_resets_at=_tui_five_hour_resets_at(five_hour, now_utc),
1837
+ spent_usd=float(spent),
1838
+ dollars_per_percent=dpp,
1839
+ latest_snapshot_at=latest[0],
1840
+ freshness_label=freshness_label,
1841
+ freshness_age=freshness_age,
1842
+ five_hour_block=_select_current_block_for_envelope(
1843
+ conn, current_used_pct=used_pct, now_utc=now_utc,
1844
+ account_key=owner,
1845
+ ),
1846
+ total_tokens=total_tokens,
1847
+ account_key=owner,
1848
+ account_decorated=True,
1849
+ account_label=_cctally_account.display_account_label(conn, owner),
1850
+ cycle_scope=scope,
1851
+ )
1852
+
1853
+
1854
+ def _decorated_claude(conn: sqlite3.Connection) -> bool:
1855
+ """The R8 decoration gate for Claude (`provider_is_decorated`)."""
1856
+ import _cctally_account
1857
+ return _cctally_account.provider_is_decorated(conn, "claude")
1858
+
1859
+
1676
1860
  def _tui_build_current_week(
1677
1861
  conn: sqlite3.Connection,
1678
1862
  now_utc: dt.datetime,
@@ -1682,7 +1866,15 @@ def _tui_build_current_week(
1682
1866
  """Build the TuiCurrentWeek from the latest snapshot + live cost.
1683
1867
 
1684
1868
  Returns None when no current-week usage snapshot exists.
1869
+
1870
+ #836: a decorated store (more than one real Claude account, R8) takes
1871
+ `_tui_build_decorated_current_week`, the one-account card. Below the
1872
+ threshold the merged card below is unchanged.
1685
1873
  """
1874
+ import _cctally_account
1875
+ if _cctally_account.provider_is_decorated(conn, "claude"):
1876
+ return _tui_build_decorated_current_week(
1877
+ conn, now_utc, skip_sync=skip_sync)
1686
1878
  # #769 S11 (#824): ONE read, held-inclusive, split per axis below.
1687
1879
  # Retain the owner of the exact non-held weekly sample this card displays,
1688
1880
  # including when one real account coexists with unattributed history. R8
@@ -1758,35 +1950,11 @@ def _tui_build_current_week(
1758
1950
  # percent and the reset together from one row), so the countdown never
1759
1951
  # describes another row. That reading is held-inclusive (#769 S11, #824),
1760
1952
  # because a held row's five-hour fields are the writing tick's own.
1761
- five_hr_resets_at: dt.datetime | None = None
1762
- if five_hour is not None and five_hour.resets_at is not None:
1763
- try:
1764
- five_hr_resets_at = parse_iso_datetime(
1765
- five_hour.resets_at, "five_hour_resets_at"
1766
- )
1767
- except ValueError:
1768
- five_hr_resets_at = None
1769
- # Suppress stale resets that have already elapsed so renderers
1770
- # don't show "resets 0h 00m" or a negative duration at the boundary.
1771
- if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
1772
- five_hr_resets_at = None
1773
- # Freshness — compute label/age from latest snapshot vs. now using the
1774
- # configured oauth_usage thresholds. Mirrors the dashboard envelope's
1775
- # cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
1776
- captured = latest[0]
1777
- if isinstance(captured, dt.datetime):
1778
- if captured.tzinfo is None:
1779
- captured = captured.replace(tzinfo=dt.timezone.utc)
1780
- age_s = max(0.0, (now_utc - captured).total_seconds())
1781
- try:
1782
- _fresh_cfg = _get_oauth_usage_config(load_config())
1783
- except Exception:
1784
- _fresh_cfg = _OAUTH_USAGE_DEFAULTS
1785
- freshness_label = _freshness_label(age_s, _fresh_cfg)
1786
- freshness_age = int(age_s)
1787
- else:
1788
- freshness_label = None
1789
- freshness_age = None
1953
+ # Suppress stale resets that have already elapsed so renderers don't
1954
+ # show "resets 0h 00m" or a negative duration at the boundary.
1955
+ five_hr_resets_at = _tui_five_hour_resets_at(five_hour, now_utc)
1956
+ # Freshness — label/age from the displayed sample vs. now.
1957
+ freshness_label, freshness_age = _tui_card_freshness(latest[0], now_utc)
1790
1958
  return TuiCurrentWeek(
1791
1959
  week_start_at=week_start_at,
1792
1960
  week_end_at=week_end_at,
@@ -2427,7 +2595,72 @@ def _snapshot_data_version(sig) -> str:
2427
2595
  # moves no numeric leg above, so without this the detail endpoints' change
2428
2596
  # signal stays flat across a tick that added an alert row.
2429
2597
  claude_digest = getattr(sig, "claude_stats_digest", "")
2430
- return out if not claude_digest else f"{out}.x{claude_digest}"
2598
+ out = out if not claude_digest else f"{out}.x{claude_digest}"
2599
+ # #836 spec 5.7 item 1: the Claude weekly rejection state. Empty on a store
2600
+ # with no retired snapshot and no unavailable five-hour join, so it is
2601
+ # byte-neutral there.
2602
+ retired = getattr(sig, "claude_weekly_retired_digest", "")
2603
+ return out if not retired else f"{out}.r{retired}"
2604
+
2605
+
2606
+ def _tui_evidence_eligible_at(conn, as_of_utc: dt.datetime) -> "dt.datetime | None":
2607
+ """The earliest instant after ``as_of_utc`` at which evidence a build at
2608
+ that as-of excluded becomes eligible, or None (#836, spec 5.7 item 2).
2609
+
2610
+ The candidates are the rows a later instant's build could admit:
2611
+
2612
+ * weekly snapshots captured after ``as_of_utc`` whose `week_start_date` is
2613
+ at or after the as-of date minus eight days — every boundaryful window
2614
+ that can contain the as-of instant (including another account's older
2615
+ overlapping window), the date-only leg, a later window and the
2616
+ no-current-card case. That leading range is an `idx_usage_week_time`
2617
+ range, never a whole-table scan; held rows are included, since the
2618
+ five-hour reading reads them;
2619
+ * `week_reset_events` rows taking effect after it (the decorated cycle
2620
+ admits a reset by its effective instant) or detected after it (the
2621
+ merged override admits one by its detection instant).
2622
+
2623
+ Instants are compared with `unixepoch`, never lexically: the columns carry
2624
+ mixed offset spellings. A far-future row (a clock error) only postpones
2625
+ the one rebuild; it never disables the idle path.
2626
+ """
2627
+ as_of = as_of_utc.astimezone(dt.timezone.utc)
2628
+ as_of_iso = _iso_z(as_of)
2629
+ floor_date = (as_of.date() - dt.timedelta(days=8)).isoformat()
2630
+ candidates: list[str] = [
2631
+ row[0] for row in conn.execute(
2632
+ "SELECT captured_at_utc FROM weekly_usage_snapshots "
2633
+ "WHERE week_start_date >= ? "
2634
+ "AND unixepoch(captured_at_utc) > unixepoch(?)",
2635
+ (floor_date, as_of_iso)).fetchall()
2636
+ ]
2637
+ try:
2638
+ for effective, detected in conn.execute(
2639
+ "SELECT effective_reset_at_utc, detected_at_utc "
2640
+ "FROM week_reset_events "
2641
+ "WHERE unixepoch(effective_reset_at_utc) > unixepoch(?) "
2642
+ " OR unixepoch(detected_at_utc) > unixepoch(?)",
2643
+ (as_of_iso, as_of_iso)).fetchall():
2644
+ candidates.extend(v for v in (effective, detected) if v)
2645
+ except sqlite3.OperationalError as exc:
2646
+ if "no such table" not in str(exc).lower():
2647
+ raise
2648
+ eligible: "dt.datetime | None" = None
2649
+ for text in candidates:
2650
+ try:
2651
+ instant = parse_iso_datetime(text, "evidence_eligible_at")
2652
+ except (TypeError, ValueError):
2653
+ continue
2654
+ if instant > as_of and (eligible is None or instant < eligible):
2655
+ eligible = instant
2656
+ return eligible
2657
+
2658
+
2659
+ def _tui_evidence_matured(prior, now_utc: dt.datetime) -> bool:
2660
+ """Has evidence ``prior`` excluded by its as-of become eligible at
2661
+ ``now_utc``? (#836, spec 5.7 item 2: the idle gate refuses then.)"""
2662
+ eligible = getattr(prior, "evidence_eligible_at", None)
2663
+ return eligible is not None and now_utc >= eligible
2431
2664
 
2432
2665
 
2433
2666
  def _tui_source_copy(value: object) -> object:
@@ -2490,6 +2723,35 @@ def alert_row_owner(
2490
2723
  raise ValueError(f"no ownership rule for alert axis {axis!r}")
2491
2724
 
2492
2725
 
2726
+ def _tui_claude_owner_version_fragment(claude_data: object) -> str:
2727
+ """The decorated Claude source-version fragment (#836 spec 5.7), or "".
2728
+
2729
+ A digest of the displayed account, its label and the published scoped
2730
+ history index's identity (each entry's key, detail stamp and current
2731
+ flag), read from the already-projected source data. Only a decorated
2732
+ build publishes ``current_week.account_key``, so every undecorated build
2733
+ returns "" and its version bytes stay as they were.
2734
+ """
2735
+ hero = claude_data.get("hero") if isinstance(claude_data, dict) else None
2736
+ cw = hero.get("current_week") if isinstance(hero, dict) else None
2737
+ if not isinstance(cw, dict):
2738
+ return ""
2739
+ owner = cw.get("account_key")
2740
+ if not isinstance(owner, str) or not owner:
2741
+ return ""
2742
+ index = cw.get("week_index")
2743
+ entries = [
2744
+ (e.get("key"), e.get("detail_stamp"), bool(e.get("is_current")))
2745
+ for e in (index if isinstance(index, list) else ())
2746
+ if isinstance(e, dict)
2747
+ ]
2748
+ material = json.dumps(
2749
+ [owner, cw.get("account_label"), entries],
2750
+ sort_keys=True, separators=(",", ":"), ensure_ascii=True,
2751
+ )
2752
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
2753
+
2754
+
2493
2755
  def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object]:
2494
2756
  """Project one completed Claude legacy envelope without further DB reads.
2495
2757
 
@@ -3843,9 +4105,14 @@ def _tui_build_source_bundle(
3843
4105
  prior_bundle: SourceDashboardBundle | None = None,
3844
4106
  raw_config: dict[str, object] | None = None,
3845
4107
  attempt: dict[str, bool] | None = None,
4108
+ evidence_eligible_at: dt.datetime | None = None,
3846
4109
  ) -> SourceDashboardBundle:
3847
4110
  """Build one frozen source bundle after the dashboard's coordinated ingest.
3848
4111
 
4112
+ ``evidence_eligible_at`` (#836, spec 5.7 item 2) is the build's
4113
+ `DataSnapshot.evidence_eligible_at`; it enters the Claude version only
4114
+ when set.
4115
+
3849
4116
  This helper is reachable only from ``precompute_envelope=True``. It opens
3850
4117
  a fresh cache handle after the ingest handle has closed, then performs
3851
4118
  read-only provider adaptation with no implicit sync or rollout fallback.
@@ -3983,6 +4250,9 @@ def _tui_build_source_bundle(
3983
4250
  # rare event and a rebuild is safe, so per-provider narrowing is not worth
3984
4251
  # the split.
3985
4252
  accounts_digest = accounts_identity_digest(stats_conn)
4253
+ # #836 spec 5.7 item 1: the Claude weekly rejection state; "" (and so
4254
+ # byte-neutral) on a store with nothing retired or unavailable.
4255
+ retired_digest = claude_weekly_retired_digest(stats_conn)
3986
4256
  with _perf.phase("source.signature"):
3987
4257
  signature = c.compute_signature(
3988
4258
  cache_conn,
@@ -3991,6 +4261,7 @@ def _tui_build_source_bundle(
3991
4261
  codex_stats_digest=stats_digest,
3992
4262
  accounts_digest=accounts_digest,
3993
4263
  claude_stats_digest=claude_digest,
4264
+ claude_weekly_retired_digest=retired_digest,
3994
4265
  )
3995
4266
  # #857: the Codex quota-dependency identity, read on the SAME pinned
3996
4267
  # cache snapshot the Codex capture below reads, so a generation built
@@ -4058,6 +4329,23 @@ def _tui_build_source_bundle(
4058
4329
  f":p{_period_identity}{_budget_suffix}{_acct_suffix}"
4059
4330
  f"{_aggregate_suffix}:x{claude_digest}"
4060
4331
  )
4332
+ # #836 spec 5.7: exact-version reuse compares this string, so a
4333
+ # retirement or restoration (`:r`) and a tick that admits evidence an
4334
+ # earlier one excluded (`:e`) both refuse the prior Claude object. Each
4335
+ # is appended only when set, so other stores keep their version bytes.
4336
+ if retired_digest:
4337
+ claude_version = f"{claude_version}:r{retired_digest}"
4338
+ if evidence_eligible_at is not None:
4339
+ claude_version = f"{claude_version}:e{_iso_z(evidence_eligible_at)}"
4340
+ # #836 spec 5.7: the decorated-only fragment. A decorated Claude source
4341
+ # publishes ONE account's card and that account's history index, and
4342
+ # exact-version reuse compares this string, so the owner, its label
4343
+ # and the published index identity (keys, stamps, current flag) enter
4344
+ # it. Undecorated envelopes carry no `account_key`, so their version
4345
+ # bytes are unchanged.
4346
+ _owner_fragment = _tui_claude_owner_version_fragment(claude_data)
4347
+ if _owner_fragment:
4348
+ claude_version = f"{claude_version}:o{_owner_fragment}"
4061
4349
  prior_claude = (
4062
4350
  prior_bundle.sources.get("claude")
4063
4351
  if prior_bundle is not None else None
@@ -5240,7 +5528,10 @@ def _tui_build_snapshot_once(
5240
5528
  )
5241
5529
  )
5242
5530
  and not _snapshot_period_rolled_over(
5243
- prior_snap, now_utc, _build_display_tz)):
5531
+ prior_snap, now_utc, _build_display_tz)
5532
+ # #836 spec 5.7 item 2: evidence the prior build
5533
+ # excluded by its as-of is eligible now.
5534
+ and not _tui_evidence_matured(prior_snap, now_utc)):
5244
5535
  with _perf.phase("idle-decision"):
5245
5536
  idle_snap = _tui_build_idle_snapshot(
5246
5537
  prior_snap, now_utc=now_utc,
@@ -5405,7 +5696,14 @@ def _tui_build_snapshot_once(
5405
5696
  with _perf.phase("build.milestones"):
5406
5697
  try:
5407
5698
  if cw is not None:
5408
- milestones = _tui_build_percent_milestones(conn)
5699
+ # #836 (spec 5.3): a decorated card's milestones are its
5700
+ # owner's, from the card's own cycle identity.
5701
+ if getattr(cw, "account_decorated", False):
5702
+ milestones = _tui_build_percent_milestones(
5703
+ conn, account_key=cw.account_key,
5704
+ cycle_scope=cw.cycle_scope)
5705
+ else:
5706
+ milestones = _tui_build_percent_milestones(conn)
5409
5707
  except Exception as exc:
5410
5708
  capture_failure("milestones", "stats", exc)
5411
5709
  history: list = []
@@ -5597,7 +5895,22 @@ def _tui_build_snapshot_once(
5597
5895
  week_index: list[dict] = []
5598
5896
  with _perf.phase("build.week_index"):
5599
5897
  try:
5600
- week_index = sys.modules["cctally"].build_claude_week_index(conn)
5898
+ # #836 spec 5.5 (U1): a decorated card names its account and
5899
+ # its cycle, and the index is that account's history derived
5900
+ # with the SAME scope, so the current entry is the card's
5901
+ # cycle. Undecorated: today's merged index. A decorated store
5902
+ # with no card publishes no index (it rides `current_week`),
5903
+ # so the merged one is not built for it (review F9); the
5904
+ # extra predicate runs only when there is no card.
5905
+ if cw is not None and getattr(cw, "account_decorated", False):
5906
+ week_index = sys.modules["cctally"].build_claude_week_index(
5907
+ conn, account_key=cw.account_key,
5908
+ cycle_scope=cw.cycle_scope)
5909
+ elif cw is None and _decorated_claude(conn):
5910
+ week_index = []
5911
+ else:
5912
+ week_index = sys.modules["cctally"].build_claude_week_index(
5913
+ conn)
5601
5914
  except Exception as exc:
5602
5915
  capture_failure("week-index", "stats", exc)
5603
5916
  # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
@@ -5784,6 +6097,16 @@ def _tui_build_snapshot_once(
5784
6097
  daily_panel, now_utc=now_utc, display_tz=_build_display_tz,
5785
6098
  )
5786
6099
 
6100
+ # #836 spec 5.7 item 2: recorded on every dashboard (non-idle) build;
6101
+ # the idle gate and the Claude source version read it.
6102
+ evidence_eligible_at: dt.datetime | None = None
6103
+ if precompute_envelope:
6104
+ with _perf.phase("build.evidence_eligibility"):
6105
+ try:
6106
+ evidence_eligible_at = _tui_evidence_eligible_at(
6107
+ conn, now_utc)
6108
+ except Exception as exc:
6109
+ capture_failure("evidence-eligibility", "stats", exc)
5787
6110
  snap = DataSnapshot(
5788
6111
  current_week=cw,
5789
6112
  forecast=fc,
@@ -5821,6 +6144,7 @@ def _tui_build_snapshot_once(
5821
6144
  envelope_precompute=envelope_precompute_block,
5822
6145
  data_version=data_version,
5823
6146
  source_bundle=None,
6147
+ evidence_eligible_at=evidence_eligible_at,
5824
6148
  )
5825
6149
  if precompute_envelope:
5826
6150
  source_bundle: SourceDashboardBundle | None = None
@@ -5872,6 +6196,7 @@ def _tui_build_snapshot_once(
5872
6196
  projects_envelope=projects_envelope_block,
5873
6197
  prior_bundle=prior_source_bundle,
5874
6198
  raw_config=raw_config,
6199
+ evidence_eligible_at=evidence_eligible_at,
5875
6200
  )
5876
6201
  if source_bundle is None:
5877
6202
  raise RuntimeError("source bundle builder returned no bundle")
@@ -6127,6 +6452,8 @@ def _tui_compute_dispatch_signature(stats_conn, *, with_codex_dependency=False):
6127
6452
  codex_stats_digest=codex_stats_digest(stats_conn),
6128
6453
  accounts_digest=accounts_identity_digest(stats_conn),
6129
6454
  claude_stats_digest=claude_stats_digest(stats_conn),
6455
+ claude_weekly_retired_digest=(
6456
+ claude_weekly_retired_digest(stats_conn)),
6130
6457
  )
6131
6458
  if not with_codex_dependency:
6132
6459
  return signature
@@ -6368,6 +6695,8 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
6368
6695
  prior_bundle=source_bundle,
6369
6696
  raw_config=raw_config,
6370
6697
  attempt=source_attempt,
6698
+ evidence_eligible_at=getattr(
6699
+ prior, "evidence_eligible_at", None),
6371
6700
  )
6372
6701
  except _StatsSnapshotCorruption:
6373
6702
  raise