cctally 1.98.0 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -888,6 +888,58 @@ def note_stats_maintenance_released() -> None:
888
888
  _STATS_MAINTENANCE_HELD.set(max(0, _STATS_MAINTENANCE_HELD.get() - 1))
889
889
 
890
890
 
891
+ # === #500 §8.1 ordered-partial-release apply lock set =====================
892
+ #
893
+ # `_cctally_rederive.codex_attribution_apply_locks` takes the two cache writer
894
+ # flocks INSIDE the stats maintenance and ingest locks, and the repository
895
+ # lock-order law requires every cache write to be committed and unlocked before
896
+ # the stats transaction opens. The ordered partial release is what satisfies
897
+ # that law; this counter is what MAKES it a law rather than a convention, by
898
+ # letting `_run_stats_ingest_once(locks_held=True)` refuse while they are still
899
+ # held instead of quietly running the stats transaction underneath them.
900
+ #
901
+ # A ContextVar like the two above, but NOT for their reason, and the difference
902
+ # is worth stating because copying their rationale here inverts it. Those two
903
+ # are AUTHORIZATION counters: a global would let one sanctioned context
904
+ # authorize an unsanctioned one, so per-context isolation is what fails safe.
905
+ # This one is a REFUSAL guard, and for a refusal per-context isolation fails
906
+ # OPEN — a context that cannot see the hold does not refuse.
907
+ #
908
+ # What the ContextVar buys is therefore narrower and still worth having: the
909
+ # guard reports the state of the context that is about to open the stats
910
+ # transaction, so it can never be silenced by an unrelated dashboard thread that
911
+ # happens to hold the cache flocks for its own read, and it can never be left
912
+ # armed by one. It is a guard against the apply sequence's own steps running out
913
+ # of order, not a process-wide interlock.
914
+ #
915
+ # The cost of that choice: the guard is ADVISORY across a context boundary. A
916
+ # bare `os.fork()` inherits the value by copy and a thread spawned inside the
917
+ # `with` block starts from a copy of the spawning context, so neither child sees
918
+ # a later release, and a thread created BEFORE the acquisition never sees the
919
+ # hold at all. `codex_attribution_apply_locks` therefore forbids both inside its
920
+ # block; the flocks themselves remain the real mutual exclusion.
921
+
922
+ _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD = contextvars.ContextVar(
923
+ "cctally_attribution_apply_cache_flocks_held", default=0
924
+ )
925
+
926
+
927
+ def holds_attribution_apply_cache_flocks() -> bool:
928
+ """True while this context still holds the #500 apply set's cache flocks."""
929
+ return _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() > 0
930
+
931
+
932
+ def note_attribution_apply_cache_flocks_acquired() -> None:
933
+ _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.set(
934
+ _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() + 1)
935
+
936
+
937
+ def note_attribution_apply_cache_flocks_released() -> None:
938
+ """Clamped at zero, for the reason the maintenance twin above states."""
939
+ _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.set(
940
+ max(0, _ATTRIBUTION_APPLY_CACHE_FLOCKS_HELD.get() - 1))
941
+
942
+
891
943
  # === Alerts validation cluster ======================================
892
944
 
893
945
 
@@ -262,6 +262,7 @@ from __future__ import annotations
262
262
 
263
263
  import argparse
264
264
  import bisect
265
+ import copy
265
266
  import contextlib
266
267
  import dataclasses
267
268
  import datetime as dt
@@ -383,7 +384,14 @@ from _lib_display_tz import (
383
384
  normalize_display_tz_value,
384
385
  _compute_display_block,
385
386
  )
386
- from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
387
+ from _lib_aggregators import (
388
+ _aggregate_daily,
389
+ _aggregate_monthly,
390
+ _aggregate_weekly,
391
+ _new_bucket_acc,
392
+ _fold_entry,
393
+ _finalize_bucket,
394
+ )
387
395
  from _lib_fmt import stable_sum
388
396
  from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
389
397
  _short_model_name, claude_usage_dict)
@@ -3554,18 +3562,43 @@ def _shared_range_row_to_usage_entry(row):
3554
3562
  )
3555
3563
 
3556
3564
 
3557
- def fold_daily_over_range(rows, *, display_tz=None, mode: str = "auto"):
3565
+ def _fold_prepared_daily_entries(
3566
+ accumulators, entries, *, display_tz=None, mode: str = "auto",
3567
+ ):
3568
+ """Append prepared entries through the canonical daily fold primitive."""
3569
+ for entry in entries:
3570
+ if entry.model == "<synthetic>":
3571
+ continue
3572
+ key = entry.timestamp.astimezone(display_tz).strftime("%Y-%m-%d")
3573
+ accumulator = accumulators.get(key)
3574
+ if accumulator is None:
3575
+ accumulator = _new_bucket_acc()
3576
+ accumulators[key] = accumulator
3577
+ _fold_entry(accumulator, entry, mode)
3578
+
3579
+
3580
+ def _finalize_daily_accumulators(accumulators):
3581
+ return [
3582
+ _finalize_bucket(key, accumulators[key])
3583
+ for key in sorted(accumulators)
3584
+ ]
3585
+
3586
+
3587
+ def fold_daily_over_range(
3588
+ rows, *, display_tz=None, mode: str = "auto", prepared_entries=None,
3589
+ ):
3558
3590
  """Fold the shared candidate stream into per-day ``BucketUsage``.
3559
3591
 
3560
3592
  Consumes the SAME already-materialised sequence the projects fold reads
3561
3593
  (spec §3.4 — one candidate read, two folds). ``_aggregate_daily`` skips
3562
3594
  ``<synthetic>`` rows itself, so both folds share that policy.
3563
3595
  """
3564
- return _aggregate_daily(
3565
- [_shared_range_row_to_usage_entry(row) for row in rows],
3566
- mode=mode,
3567
- tz=display_tz,
3596
+ entries = (
3597
+ prepared_entries
3598
+ if prepared_entries is not None and mode == "auto"
3599
+ else [_shared_range_row_to_usage_entry(row) for row in rows]
3568
3600
  )
3601
+ return _aggregate_daily(entries, mode=mode, tz=display_tz)
3569
3602
 
3570
3603
 
3571
3604
  def build_daily_aggregate_rows(
@@ -3575,6 +3608,7 @@ def build_daily_aggregate_rows(
3575
3608
  display_tz=None,
3576
3609
  n: int = 30,
3577
3610
  mode: str = "auto",
3611
+ prepared_entries=None,
3578
3612
  ) -> "list[DailyPanelRow]":
3579
3613
  """The complete canonical thirty-day shape for the All Daily aggregate.
3580
3614
 
@@ -3583,7 +3617,29 @@ def build_daily_aggregate_rows(
3583
3617
  the legacy panel uses, then materializes the contiguous calendar — so an
3584
3618
  empty Claude provider still publishes a full zero-cost shape (§6.3a).
3585
3619
  """
3586
- buckets = fold_daily_over_range(rows, display_tz=display_tz, mode=mode)
3620
+ buckets = fold_daily_over_range(
3621
+ rows,
3622
+ display_tz=display_tz,
3623
+ mode=mode,
3624
+ prepared_entries=prepared_entries,
3625
+ )
3626
+ return _build_daily_aggregate_rows_from_buckets(
3627
+ buckets,
3628
+ now_utc=now_utc,
3629
+ display_tz=display_tz,
3630
+ n=n,
3631
+ mode=mode,
3632
+ )
3633
+
3634
+
3635
+ def _build_daily_aggregate_rows_from_buckets(
3636
+ buckets,
3637
+ *,
3638
+ now_utc,
3639
+ display_tz=None,
3640
+ n: int = 30,
3641
+ mode: str = "auto",
3642
+ ):
3587
3643
  view = _cctally().build_daily_view(
3588
3644
  (), now_utc=now_utc, display_tz=display_tz, mode=mode,
3589
3645
  aggregated_override=buckets,
@@ -3625,6 +3681,7 @@ def _fold_projects_entry(
3625
3681
  *,
3626
3682
  resolver_cache: dict,
3627
3683
  week_start: "dt.datetime | None",
3684
+ prepared_daily_entries: "list | None" = None,
3628
3685
  ) -> "float | None":
3629
3686
  """Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
3630
3687
  per-row body, #271 §20 Codex-P1a).
@@ -3660,19 +3717,30 @@ def _fold_projects_entry(
3660
3717
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3661
3718
  if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
3662
3719
  return None
3720
+ usage = claude_usage_dict( # #195 chokepoint
3721
+ input_tokens=input_tok,
3722
+ output_tokens=output_tok,
3723
+ cache_creation_tokens=cache_create,
3724
+ cache_read_tokens=cache_read,
3725
+ cache_1h_tokens=cache_1h,
3726
+ speed=speed,
3727
+ )
3663
3728
  entry_cost = _calculate_entry_cost(
3664
3729
  model,
3665
- claude_usage_dict( # #195 chokepoint
3666
- input_tokens=input_tok,
3667
- output_tokens=output_tok,
3668
- cache_creation_tokens=cache_create,
3669
- cache_read_tokens=cache_read,
3670
- cache_1h_tokens=cache_1h,
3671
- speed=speed,
3672
- ),
3730
+ usage,
3673
3731
  mode="auto",
3674
3732
  cost_usd=cost_raw,
3675
3733
  )
3734
+ if prepared_daily_entries is not None:
3735
+ # #567: preserve the canonical daily entry and aggregator while
3736
+ # handing off the effective cost this pass already computed.
3737
+ prepared_daily_entries.append(c.UsageEntry(
3738
+ timestamp=dt.datetime.fromisoformat(ts_iso),
3739
+ model=model,
3740
+ usage=usage,
3741
+ cost_usd=entry_cost,
3742
+ source_path=source_path,
3743
+ ))
3676
3744
  pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
3677
3745
  bp = pkey.bucket_path
3678
3746
  a = mut.get(bp)
@@ -3699,7 +3767,9 @@ def _fold_projects_entry(
3699
3767
  return entry_cost
3700
3768
 
3701
3769
 
3702
- def fold_projects_over_range(rows, *, resolver_cache=None) -> "dict[str, dict]":
3770
+ def fold_projects_over_range(
3771
+ rows, *, resolver_cache=None, prepared_daily_entries=None,
3772
+ ) -> "dict[str, dict]":
3703
3773
  """Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
3704
3774
 
3705
3775
  #556 S2 §3.4. Takes rows rather than a connection because one candidate read
@@ -3722,7 +3792,13 @@ def fold_projects_over_range(rows, *, resolver_cache=None) -> "dict[str, dict]":
3722
3792
  mut: "dict[str, dict]" = {}
3723
3793
  cache = {} if resolver_cache is None else resolver_cache
3724
3794
  for row in rows:
3725
- _fold_projects_entry(mut, row, resolver_cache=cache, week_start=None)
3795
+ _fold_projects_entry(
3796
+ mut,
3797
+ row,
3798
+ resolver_cache=cache,
3799
+ week_start=None,
3800
+ prepared_daily_entries=prepared_daily_entries,
3801
+ )
3726
3802
  return mut
3727
3803
 
3728
3804
 
@@ -3759,7 +3835,11 @@ def legacy_project_labels(projects_envelope: object) -> "dict[str, str]":
3759
3835
 
3760
3836
 
3761
3837
  def build_project_aggregate_rows(
3762
- rows, *, resolver_cache=None, legacy_labels=None,
3838
+ rows,
3839
+ *,
3840
+ resolver_cache=None,
3841
+ legacy_labels=None,
3842
+ prepared_daily_entries=None,
3763
3843
  ) -> "list[dict]":
3764
3844
  """Published `providers.claude.projects.aggregate.rows` (spec §3.5.1).
3765
3845
 
@@ -3831,8 +3911,17 @@ def build_project_aggregate_rows(
3831
3911
  total and means nothing over an absolute range. No ``bucket_path``, git
3832
3912
  root or raw source path — opaque keys stay non-reversible.
3833
3913
  """
3914
+ folded = fold_projects_over_range(
3915
+ rows,
3916
+ resolver_cache=resolver_cache,
3917
+ prepared_daily_entries=prepared_daily_entries,
3918
+ )
3919
+ return _project_aggregate_rows_from_folded(folded, legacy_labels)
3920
+
3921
+
3922
+ def _project_aggregate_rows_from_folded(folded, legacy_labels):
3923
+ """Finalize cached/raw project accumulators into the public row shape."""
3834
3924
  c = _cctally()
3835
- folded = fold_projects_over_range(rows, resolver_cache=resolver_cache)
3836
3925
  bucket_paths_sorted = sorted(folded)
3837
3926
  augmented_by_idx = c._project_disambiguate_labels(
3838
3927
  [{"key": folded[bp]["first_key"]} for bp in bucket_paths_sorted],
@@ -3865,6 +3954,273 @@ def build_project_aggregate_rows(
3865
3954
  return published
3866
3955
 
3867
3956
 
3957
+ _CLAUDE_RANGE_AGGREGATE_MEMO: dict[str, object] = {"state": None}
3958
+
3959
+
3960
+ def reset_claude_range_aggregate_memo() -> None:
3961
+ """Drop the process-local #567 range-fold accumulator."""
3962
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = None
3963
+
3964
+
3965
+ def _shared_range_store_identity(conn):
3966
+ for _seq, name, path in conn.execute("PRAGMA database_list"):
3967
+ if name == "main":
3968
+ if not path:
3969
+ return (f":memory:{id(conn)}", None, None)
3970
+ try:
3971
+ stat = os.stat(path)
3972
+ except OSError:
3973
+ return (str(path), None, None)
3974
+ return (str(path), int(stat.st_dev), int(stat.st_ino))
3975
+ return f":connection:{id(conn)}"
3976
+
3977
+
3978
+ def _shared_range_entry_signature(conn) -> tuple[int, int]:
3979
+ max_id = int(conn.execute(
3980
+ "SELECT COALESCE(MAX(id), 0) FROM main.session_entries"
3981
+ ).fetchone()[0])
3982
+ try:
3983
+ max_seq = int(conn.execute(
3984
+ "SELECT COALESCE(MAX(mutation_seq), 0) FROM main.session_entries"
3985
+ ).fetchone()[0])
3986
+ except sqlite3.OperationalError:
3987
+ max_seq = 0
3988
+ return max_id, max_seq
3989
+
3990
+
3991
+ def _shared_range_session_files_signature(conn) -> tuple[int, int]:
3992
+ """Cheap identity signal for lazy session/project metadata backfills."""
3993
+ try:
3994
+ row = conn.execute(
3995
+ "SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM session_files"
3996
+ ).fetchone()
3997
+ except sqlite3.Error:
3998
+ return (0, 0)
3999
+ return int(row[0]), int(row[1])
4000
+
4001
+
4002
+ def _shared_range_entries_after_id(conn, after_id: int):
4003
+ """Yield appended rows in canonical timestamp/id fold order."""
4004
+ cur = conn.execute(
4005
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
4006
+ " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
4007
+ " e.cost_usd_raw, e.source_path, "
4008
+ " sf.session_id, sf.project_path, "
4009
+ " e.cache_create_1h_tokens, e.speed "
4010
+ "FROM session_entries e "
4011
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
4012
+ "WHERE e.id > ? "
4013
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
4014
+ (after_id,),
4015
+ )
4016
+ yield from cur
4017
+
4018
+
4019
+ def _shared_range_prior_row_mutated(
4020
+ conn, *, after_seq: int, through_id: int,
4021
+ ) -> bool:
4022
+ try:
4023
+ row = conn.execute(
4024
+ "SELECT 1 FROM main.session_entries "
4025
+ "WHERE mutation_seq > ? AND id <= ? LIMIT 1",
4026
+ (after_seq, through_id),
4027
+ ).fetchone()
4028
+ except sqlite3.OperationalError:
4029
+ return True
4030
+ return row is not None
4031
+
4032
+
4033
+ def _shared_range_cache_base(
4034
+ conn, *, shared_start, display_tz, generation: int,
4035
+ ):
4036
+ tz_key = getattr(display_tz, "key", None)
4037
+ if tz_key is None:
4038
+ tz_key = str(display_tz) if display_tz is not None else "local"
4039
+ return (
4040
+ _shared_range_store_identity(conn),
4041
+ shared_start.astimezone(dt.timezone.utc).isoformat(),
4042
+ tz_key,
4043
+ int(generation),
4044
+ _shared_range_session_files_signature(conn),
4045
+ int(conn.execute(
4046
+ "SELECT COALESCE(MIN(id), 0) FROM main.session_entries"
4047
+ ).fetchone()[0]),
4048
+ )
4049
+
4050
+
4051
+ def _shared_range_cache_payload(
4052
+ state,
4053
+ *,
4054
+ legacy_labels,
4055
+ now_utc,
4056
+ display_tz,
4057
+ ):
4058
+ project_rows = _project_aggregate_rows_from_folded(
4059
+ state["project_mut"], legacy_labels,
4060
+ )
4061
+ daily_buckets = _finalize_daily_accumulators(state["daily_accumulators"])
4062
+ daily_rows = _build_daily_aggregate_rows_from_buckets(
4063
+ daily_buckets, now_utc=now_utc, display_tz=display_tz,
4064
+ )
4065
+ c = _cctally()
4066
+ return {
4067
+ "projects": project_rows,
4068
+ "daily": [c.daily_panel_row_to_wire(row) for row in daily_rows],
4069
+ }
4070
+
4071
+
4072
+ def build_cached_claude_range_aggregates(
4073
+ conn,
4074
+ *,
4075
+ shared_start,
4076
+ shared_end_exclusive,
4077
+ now_utc,
4078
+ display_tz,
4079
+ legacy_labels,
4080
+ max_entry_id: "int | None" = None,
4081
+ entry_mutation_seq: "int | None" = None,
4082
+ generation: int = 0,
4083
+ ):
4084
+ """Build or increment the one-snapshot Claude range folds (#567).
4085
+
4086
+ Pure appends are folded onto the cached raw accumulators. A shifted range
4087
+ floor, backwards clock, generation or session-file identity change,
4088
+ non-monotone signature, or an id-stable mutation of an already-folded row
4089
+ falls back to one full ordered pass. The cache stores no public labels, so
4090
+ the current legacy population is reapplied on every publication.
4091
+ """
4092
+ if max_entry_id is None or entry_mutation_seq is None:
4093
+ observed_id, observed_seq = _shared_range_entry_signature(conn)
4094
+ if max_entry_id is None:
4095
+ max_entry_id = observed_id
4096
+ if entry_mutation_seq is None:
4097
+ entry_mutation_seq = observed_seq
4098
+ max_entry_id = int(max_entry_id)
4099
+ entry_mutation_seq = int(entry_mutation_seq)
4100
+ base = _shared_range_cache_base(
4101
+ conn,
4102
+ shared_start=shared_start,
4103
+ display_tz=display_tz,
4104
+ generation=generation,
4105
+ )
4106
+ prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
4107
+ state = None
4108
+ if isinstance(prior, dict) and prior.get("base") == base:
4109
+ monotone = (
4110
+ max_entry_id >= prior["max_entry_id"]
4111
+ and entry_mutation_seq >= prior["entry_mutation_seq"]
4112
+ and shared_end_exclusive >= prior["end_exclusive"]
4113
+ )
4114
+ old_row_changed = (
4115
+ entry_mutation_seq != prior["entry_mutation_seq"]
4116
+ and _shared_range_prior_row_mutated(
4117
+ conn,
4118
+ after_seq=prior["entry_mutation_seq"],
4119
+ through_id=prior["max_entry_id"],
4120
+ )
4121
+ )
4122
+ if monotone and not old_row_changed:
4123
+ project_mut = copy.deepcopy(prior["project_mut"])
4124
+ daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
4125
+ resolver_cache = dict(prior["resolver_cache"])
4126
+ delta_by_id = {}
4127
+ for row in _shared_range_entries_after_id(
4128
+ conn, prior["max_entry_id"],
4129
+ ):
4130
+ ts = parse_iso_datetime(
4131
+ row[1], "session_entries.timestamp_utc",
4132
+ )
4133
+ if shared_start <= ts < shared_end_exclusive:
4134
+ delta_by_id[row[0]] = row
4135
+ if shared_end_exclusive > prior["end_exclusive"]:
4136
+ for row in iter_shared_range_entries(
4137
+ conn,
4138
+ start=prior["end_exclusive"],
4139
+ end_exclusive=shared_end_exclusive,
4140
+ ):
4141
+ if row[0] <= prior["max_entry_id"]:
4142
+ delta_by_id[row[0]] = row
4143
+ delta_rows = sorted(
4144
+ delta_by_id.values(),
4145
+ key=lambda row: (row[1], row[0]),
4146
+ )
4147
+ prior_tail = prior["tail"]
4148
+ if prior_tail is None or all(
4149
+ (row[1], row[0]) > prior_tail
4150
+ for row in delta_rows
4151
+ if row[2] != "<synthetic>"
4152
+ ):
4153
+ prepared = []
4154
+ for row in delta_rows:
4155
+ _fold_projects_entry(
4156
+ project_mut,
4157
+ row,
4158
+ resolver_cache=resolver_cache,
4159
+ week_start=None,
4160
+ prepared_daily_entries=prepared,
4161
+ )
4162
+ _fold_prepared_daily_entries(
4163
+ daily_accumulators,
4164
+ prepared,
4165
+ display_tz=display_tz,
4166
+ )
4167
+ tail = prior_tail
4168
+ real_delta = [
4169
+ row for row in delta_rows if row[2] != "<synthetic>"
4170
+ ]
4171
+ if real_delta:
4172
+ last = real_delta[-1]
4173
+ tail = (last[1], last[0])
4174
+ state = {
4175
+ "base": base,
4176
+ "max_entry_id": max_entry_id,
4177
+ "entry_mutation_seq": entry_mutation_seq,
4178
+ "end_exclusive": shared_end_exclusive,
4179
+ "tail": tail,
4180
+ "project_mut": project_mut,
4181
+ "daily_accumulators": daily_accumulators,
4182
+ "resolver_cache": resolver_cache,
4183
+ }
4184
+ if state is None:
4185
+ rows = tuple(iter_shared_range_entries(
4186
+ conn, start=shared_start, end_exclusive=shared_end_exclusive,
4187
+ ))
4188
+ prepared = []
4189
+ resolver_cache = {}
4190
+ project_mut = fold_projects_over_range(
4191
+ rows,
4192
+ resolver_cache=resolver_cache,
4193
+ prepared_daily_entries=prepared,
4194
+ )
4195
+ daily_accumulators = {}
4196
+ _fold_prepared_daily_entries(
4197
+ daily_accumulators, prepared, display_tz=display_tz,
4198
+ )
4199
+ real_rows = [row for row in rows if row[2] != "<synthetic>"]
4200
+ tail = None
4201
+ if real_rows:
4202
+ last = real_rows[-1]
4203
+ tail = (last[1], last[0])
4204
+ state = {
4205
+ "base": base,
4206
+ "max_entry_id": max_entry_id,
4207
+ "entry_mutation_seq": entry_mutation_seq,
4208
+ "end_exclusive": shared_end_exclusive,
4209
+ "tail": tail,
4210
+ "project_mut": project_mut,
4211
+ "daily_accumulators": daily_accumulators,
4212
+ "resolver_cache": resolver_cache,
4213
+ }
4214
+ payload = _shared_range_cache_payload(
4215
+ state,
4216
+ legacy_labels=legacy_labels,
4217
+ now_utc=now_utc,
4218
+ display_tz=display_tz,
4219
+ )
4220
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
4221
+ return payload
4222
+
4223
+
3868
4224
  def _aggregate_projects_week_raw(
3869
4225
  conn: "sqlite3.Connection",
3870
4226
  *,
@@ -1662,7 +1662,8 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1662
1662
  ls.ColumnSpec(key="current", label="Current", align="right"),
1663
1663
  ls.ColumnSpec(key="projected", label="Projected", align="right"),
1664
1664
  ),
1665
- rows=tuple(rows), chart=None, totals=(), notes=(), generated_at=end,
1665
+ rows=tuple(rows), chart=None, totals=(),
1666
+ notes=_share_budget_notes(data), generated_at=end,
1666
1667
  version=sys.modules["cctally"]._share_resolve_version(),
1667
1668
  template_id=template_id, source="codex", source_label="Codex",
1668
1669
  availability=availability, availability_reason=reason,
@@ -1979,6 +1980,38 @@ def _share_scope_codex_state(state, account: "str | None"):
1979
1980
  return replace(state, data=MappingProxyType({**dict(data), **scoped}))
1980
1981
 
1981
1982
 
1983
+ def _share_budget_notes(data) -> tuple[str, ...]:
1984
+ """The configured-budget status, as an artifact note (#556 S5 §5.12).
1985
+
1986
+ The Forecast artifact carried quota projections and nothing about the
1987
+ CONFIGURED budget, so a shared Forecast said less than the panel it was
1988
+ taken from — and after S5 the panel renders both side by side. The note is
1989
+ ADDITIVE and omitted when no status is published, so an install with no
1990
+ budget produces a byte-identical artifact.
1991
+
1992
+ ``data`` is already the ACCOUNT-SCOPED provider body when the request named
1993
+ an account (`_share_scope_codex_state` rewrites the scoped
1994
+ domains before this runs), so a focused share carries that account's own
1995
+ budget and never the vendor-wide one.
1996
+ """
1997
+ budget = data.get("budget") if isinstance(data, Mapping) else None
1998
+ status = budget.get("status") if isinstance(budget, Mapping) else None
1999
+ if not isinstance(status, Mapping):
2000
+ return ()
2001
+ try:
2002
+ spent = float(status["spent_usd"])
2003
+ target = float(status["budget_usd"])
2004
+ consumed = float(status["consumption_pct"])
2005
+ period = str(status["period"])
2006
+ verdict = str(status["verdict"])
2007
+ except (KeyError, TypeError, ValueError):
2008
+ return ()
2009
+ return (
2010
+ f"Budget ({period}): ${spent:,.2f} of ${target:,.2f} "
2011
+ f"({consumed:.1f}%) — {verdict}",
2012
+ )
2013
+
2014
+
1982
2015
  def _share_build_source_snapshots(*, ls, template, template_id: str,
1983
2016
  panel: str, options: dict, source: str,
1984
2017
  source_explicit: bool, data_snap,
@@ -2010,6 +2043,22 @@ def _share_build_source_snapshots(*, ls, template, template_id: str,
2010
2043
  claude_snapshot = _share_apply_current_week_freshness(
2011
2044
  claude_snapshot, claude_state, panel,
2012
2045
  )
2046
+ # #556 S5 §5.12 (Unit 2 review F7). The budget note was wired into
2047
+ # the Codex builder only, while §5.12 says "the configured-budget
2048
+ # sections" and the Claude Forecast panel renders one after S5 — so
2049
+ # a shared Claude Forecast said less than the panel it came from.
2050
+ # Gated exactly like the freshness stamp above: a source-less
2051
+ # request is the shipped legacy Claude contract and stays
2052
+ # byte-identical, because it has no source state to read at all.
2053
+ if panel == "forecast":
2054
+ notes = _share_budget_notes(
2055
+ getattr(claude_state, "data", None) or {},
2056
+ )
2057
+ if notes:
2058
+ claude_snapshot = replace(
2059
+ claude_snapshot,
2060
+ notes=tuple(claude_snapshot.notes) + notes,
2061
+ )
2013
2062
 
2014
2063
  codex_snapshot = None
2015
2064
  codex_state = None