cctally 1.97.0 → 1.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,7 +45,30 @@ CapabilityStatus = Literal[
45
45
  # number; after an in-place `execvp` update a still-loaded old client renders the
46
46
  # new figure under old copy until it reloads, and that one-reconnect transient is
47
47
  # accepted, consistent with the 2 -> 3 precedent above.
48
- SOURCE_SCHEMA_VERSION = 5
48
+ # 5 -> 6 (#556 S2): the All source gained a required `aggregates` object —
49
+ # one `range` describing the shared absolute interval every cross-provider
50
+ # ranking covers, plus a typed `available`/`withheld` outcome for Projects and
51
+ # for Daily. Two rows-only siblings appeared beside it on the Claude provider
52
+ # domain, `projects.aggregate` and `periods.daily_aggregate`, whose rows are
53
+ # folded over that same interval. Because those fields are REQUIRED on a v6
54
+ # payload, this supersedes normal-payload byte identity for this version,
55
+ # exactly as S1's `legs` object did for v5; the additive-omission discipline
56
+ # continues to govern every genuinely optional field. Nothing existing changed
57
+ # shape: `projects.current_week`, `projects.trend`, the flat route-lookup
58
+ # `projects.rows` and `periods.daily` are untouched. No client branches on this
59
+ # number — the bump ships as the signal it has always been, which is exactly
60
+ # why the wire change is additive: after an in-place `execvp` update a
61
+ # still-loaded old client renders precisely what it renders today until it
62
+ # reloads, and no forced page reload is required.
63
+ # 6 -> 7 (#556 S3): every Codex alert row gained `alerted_at`, the canonical
64
+ # firing instant, and `created_at` became an equal-valued compatibility alias
65
+ # for it rather than the crossing instant it used to carry. The All source's
66
+ # alert union is ordered by that instant across both providers instead of by a
67
+ # field only one of them wrote. `alerted_at` is additive and `created_at`
68
+ # remains present, so a pre-v7 client reading `created_at` keeps working — but
69
+ # the VALUE it reads changed on two of the three Codex legs, which is why this
70
+ # is a version bump and not a silent addition.
71
+ SOURCE_SCHEMA_VERSION = 7
49
72
  DEFAULT_SOURCE = "claude"
50
73
  SOURCE_ORDER = ("claude", "codex", "all")
51
74
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -161,6 +184,24 @@ class SourceDashboardState:
161
184
  # publishes only ``data``, then the HTTP/SSE envelope layer injects a label
162
185
  # into its request-local copies when that request's transcript gate is open.
163
186
  private_session_labels: Mapping[str, str] | None = None
187
+ # #556 S2 §3.6 — the per-aggregate carrier. Server-only, in the same class
188
+ # as ``clock_data`` and ``account_scope``: the resolved shared range never
189
+ # enters a provider ``data`` domain, because composition embeds each
190
+ # provider's ``data`` under the All source and a range inside it would be
191
+ # published three times over. Shape:
192
+ #
193
+ # {"range": {"kind", "label", "start_at", "end_at"},
194
+ # "projects": {"state": "ok"} | {"state": "failed", "code": ...},
195
+ # "daily": {"state": "ok"} | {"state": "failed", "code": ...}}
196
+ #
197
+ # ``account_scope`` is the right precedent for STORAGE CLASS and the wrong
198
+ # one for LIFECYCLE. Account scope is deliberately reattached from the
199
+ # current tick after every build, reuse and degrade branch; doing that here
200
+ # would overwrite the range that describes RETAINED rows with the range of a
201
+ # tick that produced none. This carrier therefore travels with the rows it
202
+ # describes and is never re-derived on a reuse or degrade path. Explicit
203
+ # constructors must copy it.
204
+ aggregate_scope: Mapping[str, object] | None = None
164
205
 
165
206
  def __post_init__(self) -> None:
166
207
  validate_dashboard_selection(self.source)
@@ -206,6 +247,10 @@ class SourceDashboardState:
206
247
  object.__setattr__(self, "clock_data", _freeze(self.clock_data))
207
248
  if self.account_scope is not None:
208
249
  object.__setattr__(self, "account_scope", _freeze(self.account_scope))
250
+ if self.aggregate_scope is not None:
251
+ object.__setattr__(
252
+ self, "aggregate_scope", _freeze(self.aggregate_scope),
253
+ )
209
254
  if self.private_session_labels is not None:
210
255
  private_session_labels = {
211
256
  _nonempty_string(key, "private session label key"):
@@ -325,6 +370,11 @@ def degrade_source_state(
325
370
  # `account_scope_unresolved` on an install whose count read fine.
326
371
  account_scope=prior.account_scope,
327
372
  private_session_labels=prior.private_session_labels,
373
+ # #556 S2 §3.6: the carrier travels with the rows it describes. A
374
+ # degraded generation retains `prior.data`, so it must retain the range
375
+ # that bounded those rows — re-deriving it from the current tick would
376
+ # publish a range the retained rows do not cover.
377
+ aggregate_scope=prior.aggregate_scope,
328
378
  )
329
379
 
330
380
 
@@ -521,6 +571,44 @@ def _period_instant(value: object) -> str | None:
521
571
  return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
522
572
 
523
573
 
574
+ def canonical_alerted_at(value: object) -> str:
575
+ """Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
576
+
577
+ #556 S3 §2.2. The union sorted on a field one writer never wrote, so a
578
+ missing or malformed value must raise here rather than degrade to a
579
+ sentinel that sorts silently. Sub-second precision is truncated, so two
580
+ alerts firing in the same second compare equal and fall back to source
581
+ order; no writer emits it today.
582
+ """
583
+ if not isinstance(value, str) or not value:
584
+ raise ValueError(f"alerted_at must be a non-empty ISO-8601 string, got {value!r}")
585
+ try:
586
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
587
+ except ValueError as exc:
588
+ raise ValueError(f"unparseable alerted_at {value!r}") from exc
589
+ if parsed.tzinfo is None:
590
+ raise ValueError(f"naive alerted_at {value!r}; an aware instant is required")
591
+ return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
592
+
593
+
594
+ def canonical_alerted_at_sql(column: str = "alerted_at") -> str:
595
+ """The SQL twin of :func:`canonical_alerted_at`, for ordering in SQLite.
596
+
597
+ #556 S3 §2.3. Every per-axis ``LIMIT`` decides MEMBERSHIP, not merely
598
+ order, so a row excluded by a textual comparison of two spellings of one
599
+ instant cannot be recovered by any later projection. SQLite parses the
600
+ timezone indicator, so for every aware spelling this returns the same
601
+ canonical UTC ``Z`` string the Python helper returns —
602
+ ``tests/test_556_s3_alert_ordering.py`` pins that agreement over the
603
+ committed estate. The twins diverge on exactly one input: SQLite reads a
604
+ naive value as UTC where the Python helper raises, so such a row is ordered
605
+ here and rejected later. An unparseable value yields SQL ``NULL``, which
606
+ sorts last under ``DESC``, so the ``LIMIT`` usually drops that row before
607
+ composition can reject it — corruption is truncated away, not surfaced.
608
+ """
609
+ return f"strftime('%Y-%m-%dT%H:%M:%SZ', {column})"
610
+
611
+
524
612
  def _leg_period(
525
613
  provider: PhysicalSource, hero: Mapping[str, object] | None,
526
614
  ) -> Mapping[str, object] | None:
@@ -722,6 +810,256 @@ def _combined_outcome(
722
810
  }, None
723
811
 
724
812
 
813
+ # === #556 S2 — the shared cross-provider aggregates (spec §3.5.1, §3.7) =====
814
+ #
815
+ # Two range rules coexist. A combined TOTAL sums provider-native cycles (S1,
816
+ # above). A cross-provider RANKING uses one shared absolute calendar range, and
817
+ # that is what these aggregates publish. Both are deliberate.
818
+ #
819
+ # Withholding is a typed outcome rather than an empty list, because an empty
820
+ # list renders as honest emptiness and a range problem is not emptiness.
821
+
822
+ AGGREGATE_NAMES: tuple[str, ...] = ("projects", "daily")
823
+ AGGREGATE_RANGE_KIND = "absolute_range"
824
+ AGGREGATE_RANGE_LABEL = "Shared range"
825
+
826
+ # Precedence is TOTAL and follows the declared code order (§3.5.1). The first
827
+ # two are ordered so the predicates stay mutually exclusive: `_coherent_provider`
828
+ # already subsumes unavailability, so testing incoherence first would make
829
+ # `provider_unavailable` unreachable.
830
+ _AGGREGATE_CAUSE_RANK: Mapping[str, int] = MappingProxyType({
831
+ "range_unresolved": 1,
832
+ "provider_unavailable": 2,
833
+ "provider_incoherent": 3,
834
+ "claude_fold_failed": 4,
835
+ "retained_range_mismatch": 5,
836
+ })
837
+
838
+ # One server warning maps to one published qualification. The figure stays
839
+ # AVAILABLE: the server already publishes a qualified Codex projects subset in
840
+ # this state, and withholding the whole ranking over it would discard real data.
841
+ _AGGREGATE_QUALIFYING_WARNINGS: Mapping[str, tuple[str, str]] = MappingProxyType({
842
+ # warning code -> (published qualification code, aggregate it qualifies)
843
+ "codex_metadata_incomplete": ("codex_project_metadata_partial", "projects"),
844
+ })
845
+
846
+
847
+ def aggregate_range(start_at: object, end_at: object) -> dict | None:
848
+ """Canonicalise one resolved absolute range, or ``None`` if it does not."""
849
+ start = _period_instant(start_at)
850
+ end = _period_instant(end_at)
851
+ if start is None or end is None:
852
+ return None
853
+ return {
854
+ "kind": AGGREGATE_RANGE_KIND,
855
+ "label": AGGREGATE_RANGE_LABEL,
856
+ "start_at": start,
857
+ "end_at": end,
858
+ }
859
+
860
+
861
+ def build_aggregate_scope(
862
+ published_range: Mapping[str, object] | None,
863
+ outcomes: Mapping[str, object] | None = None,
864
+ ) -> dict:
865
+ """The server-only carrier a freshly built provider generation gets."""
866
+ scope: dict = {"range": dict(published_range) if published_range else None}
867
+ for name in AGGREGATE_NAMES:
868
+ entry = (outcomes or {}).get(name)
869
+ scope[name] = dict(entry) if isinstance(entry, Mapping) else {"state": "ok"}
870
+ return scope
871
+
872
+
873
+ def aggregate_scope_failed(value: object) -> bool:
874
+ """Whether a provider generation records a failed aggregate fold.
875
+
876
+ Accepts either a ``SourceDashboardState`` or a raw carrier mapping, because
877
+ both gates need the predicate and one of them runs before the state exists.
878
+
879
+ A failure must not become permanent. A locally caught fold failure leaves an
880
+ otherwise `ok` and `fresh` provider, and that bundle would qualify for idle
881
+ reuse while exact-version provider reuse returns the prior object unchanged
882
+ — so one transient failure would withhold the aggregate for the life of the
883
+ process. This predicate is read at BOTH gates.
884
+ """
885
+ scope = (
886
+ value if isinstance(value, Mapping)
887
+ else getattr(value, "aggregate_scope", None)
888
+ )
889
+ if not isinstance(scope, Mapping):
890
+ return False
891
+ for name in AGGREGATE_NAMES:
892
+ entry = scope.get(name)
893
+ if isinstance(entry, Mapping) and entry.get("state") != "ok":
894
+ return True
895
+ return False
896
+
897
+
898
+ def aggregate_scope_identity(scope: object) -> str:
899
+ """The version fragment a provider's aggregate carrier contributes.
900
+
901
+ Carries the resolved range START and the per-aggregate outcome, so a failed
902
+ and a successful fold over the same database signature can never publish
903
+ different rows under one ``data_version``.
904
+
905
+ ``end_at`` is deliberately EXCLUDED. It is ``now_utc``, which advances on
906
+ every tick by construction, so folding it in would make every provider
907
+ version unique per tick and defeat `reuse_coherent_source_state` on every
908
+ path — including the reuse §3.6 itself reasons about. The START is the bound
909
+ that can actually move (a display-day rollover), and it is folded into BOTH
910
+ providers' versions so they rebuild in lockstep and a coherent pair can
911
+ never disagree about it.
912
+
913
+ The start participates as the EXACT canonical instant, at the same
914
+ granularity `compose_all_aggregates` compares it. That is the point: the
915
+ composition publishes a range only when every coherent provider's canonical
916
+ ``start_at`` is the same string, and this identity is what forces the two
917
+ providers to rebuild in lockstep so they can be. A coarser identity would
918
+ make a difference the composition rejects invisible to the gate that is
919
+ supposed to resolve it — an unchanged provider would keep reusing the old
920
+ carrier while a rebuilt one recorded the new instant, and both aggregates
921
+ would be withheld as ``retained_range_mismatch`` on every subsequent tick.
922
+ That is the original defect, and one value read at two granularities is its
923
+ structural shape.
924
+
925
+ An earlier revision folded the start at DAY granularity to protect against
926
+ a ``now_utc - 30 days`` fallback that advanced on every tick. That fallback
927
+ is gone: every producer of this bound now floors to display-timezone
928
+ midnight — `resolve_shared_range` on both its branches, and
929
+ `_tui_build_source_bundle`'s own fallback, which resolves through the same
930
+ helper. So the exact instant changes at most once per display day, which is
931
+ a tick that must rebuild anyway because the daily panel rolled over.
932
+
933
+ ``end_at`` is the only value still excluded, for the reason above.
934
+ """
935
+ if not isinstance(scope, Mapping):
936
+ return "none"
937
+ published = scope.get("range")
938
+ start = (
939
+ published.get("start_at") if isinstance(published, Mapping) else None
940
+ )
941
+ parts = [str(start or "")]
942
+ for name in AGGREGATE_NAMES:
943
+ entry = scope.get(name)
944
+ state = entry.get("state") if isinstance(entry, Mapping) else None
945
+ code = entry.get("code") if isinstance(entry, Mapping) else None
946
+ parts.append(f"{name}:{state or 'unknown'}" + (f":{code}" if code else ""))
947
+ return "|".join(parts)
948
+
949
+
950
+ def _aggregate_scope_range(state: SourceDashboardState) -> dict | None:
951
+ scope = getattr(state, "aggregate_scope", None)
952
+ if not isinstance(scope, Mapping):
953
+ return None
954
+ published = scope.get("range")
955
+ if not isinstance(published, Mapping):
956
+ return None
957
+ return aggregate_range(published.get("start_at"), published.get("end_at"))
958
+
959
+
960
+ def _aggregate_fold_failed(state: SourceDashboardState, name: str) -> bool:
961
+ scope = getattr(state, "aggregate_scope", None)
962
+ if not isinstance(scope, Mapping):
963
+ return False
964
+ entry = scope.get(name)
965
+ return isinstance(entry, Mapping) and entry.get("state") != "ok"
966
+
967
+
968
+ def _aggregate_qualifications(
969
+ claude: SourceDashboardState, codex: SourceDashboardState, name: str,
970
+ ) -> list[dict]:
971
+ """Notes that qualify a PUBLISHED aggregate. Empty means omit the key."""
972
+ qualifications: list[dict] = []
973
+ for provider, state in (("claude", claude), ("codex", codex)):
974
+ for warning in state.warnings:
975
+ mapped = _AGGREGATE_QUALIFYING_WARNINGS.get(warning.code)
976
+ if mapped is not None and mapped[1] == name:
977
+ qualifications.append(
978
+ {"code": mapped[0], "provider": provider},
979
+ )
980
+ return qualifications
981
+
982
+
983
+ def compose_all_aggregates(
984
+ claude: SourceDashboardState, codex: SourceDashboardState,
985
+ ) -> dict:
986
+ """The single public ``sources.all.data.aggregates`` object (§3.5.1).
987
+
988
+ The outcome carries STATE AND REASON only; the rows live on the provider
989
+ domains and the client composes them. That is what keeps exactly one public
990
+ copy of the range and one public copy of the rows.
991
+
992
+ Every cause is evaluated per aggregate, so a Projects fold failure cannot
993
+ withhold Daily.
994
+ """
995
+ pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
996
+ ("claude", claude), ("codex", codex),
997
+ )
998
+ coherent = [
999
+ (provider, state) for provider, state in pairs
1000
+ if _coherent_provider(state)
1001
+ ]
1002
+ ranges = {
1003
+ provider: _aggregate_scope_range(state) for provider, state in coherent
1004
+ }
1005
+ resolved = [value for value in ranges.values() if value is not None]
1006
+ starts = {value["start_at"] for value in resolved}
1007
+
1008
+ shared: list[tuple[int, int, str, PhysicalSource | None]] = []
1009
+ if coherent and len(resolved) != len(coherent):
1010
+ # A coherent provider whose rows are not bounded by a known range.
1011
+ shared.append((_AGGREGATE_CAUSE_RANK["range_unresolved"], 0,
1012
+ "range_unresolved", None))
1013
+ for rank_provider, (provider, state) in enumerate(pairs):
1014
+ if state.availability == "unavailable":
1015
+ shared.append((_AGGREGATE_CAUSE_RANK["provider_unavailable"],
1016
+ rank_provider, "provider_unavailable", provider))
1017
+ for rank_provider, (provider, state) in enumerate(pairs):
1018
+ if state.availability != "unavailable" and not _coherent_provider(state):
1019
+ shared.append((_AGGREGATE_CAUSE_RANK["provider_incoherent"],
1020
+ rank_provider, "provider_incoherent", provider))
1021
+ if len(starts) > 1:
1022
+ shared.append((_AGGREGATE_CAUSE_RANK["retained_range_mismatch"], 0,
1023
+ "retained_range_mismatch", None))
1024
+
1025
+ published_range: dict | None = None
1026
+ if coherent and len(resolved) == len(coherent) and len(starts) == 1:
1027
+ # Published only when EVERY coherent provider supplied a range and they
1028
+ # agree. Publishing one leg's range while the other's is unresolved or
1029
+ # different would state a span the composed rows do not cover.
1030
+ #
1031
+ # A reused provider provably has no new accounting rows — its physical
1032
+ # signature is part of the version that made the reuse legal — so the
1033
+ # later of the two ends is the instant BOTH legs are complete to.
1034
+ published_range = {
1035
+ **resolved[0],
1036
+ "end_at": max(value["end_at"] for value in resolved),
1037
+ }
1038
+
1039
+ aggregates: dict = {"range": published_range}
1040
+ for name in AGGREGATE_NAMES:
1041
+ causes = list(shared)
1042
+ if _aggregate_fold_failed(claude, name):
1043
+ causes.append((_AGGREGATE_CAUSE_RANK["claude_fold_failed"], 0,
1044
+ "claude_fold_failed", "claude"))
1045
+ if causes:
1046
+ _rank, _provider_rank, code, provider = min(
1047
+ causes, key=lambda cause: (cause[0], cause[1]),
1048
+ )
1049
+ aggregates[name] = {
1050
+ "state": "withheld",
1051
+ "code": code,
1052
+ **({"provider": provider} if provider is not None else {}),
1053
+ }
1054
+ continue
1055
+ qualifications = _aggregate_qualifications(claude, codex, name)
1056
+ aggregates[name] = {
1057
+ "state": "available",
1058
+ **({"qualifications": qualifications} if qualifications else {}),
1059
+ }
1060
+ return aggregates
1061
+
1062
+
725
1063
  def _combined_alert_rows(
726
1064
  claude: SourceDashboardState,
727
1065
  codex: SourceDashboardState,
@@ -739,13 +1077,23 @@ def _combined_alert_rows(
739
1077
  if not isinstance(row, Mapping) or row.get("source") != source:
740
1078
  continue
741
1079
  ordered.append(row)
1080
+
1081
+ def _instant(row: Mapping[str, object]) -> str:
1082
+ try:
1083
+ return canonical_alerted_at(row.get("alerted_at"))
1084
+ except ValueError as exc:
1085
+ identity = row.get("id") if row.get("id") is not None else row.get("key")
1086
+ raise ValueError(
1087
+ f"{row.get('source')!r} alert row {identity!r}: {exc}"
1088
+ ) from exc
1089
+
1090
+ # #556 S3 §2.5: composition is the chokepoint. The previous sort keyed on
1091
+ # `created_at`, which the Claude projection never wrote, so every Claude
1092
+ # row collapsed to "" and sorted last. Validating here means a future leg,
1093
+ # axis or provider that omits the canonical instant fails visibly instead.
742
1094
  # Python's stable sort preserves declared source order, then each source's
743
- # native order, when alert timestamps tie.
744
- return tuple(sorted(
745
- ordered,
746
- key=lambda row: str(row.get("created_at") or ""),
747
- reverse=True,
748
- ))
1095
+ # native order, when firing instants tie.
1096
+ return tuple(sorted(ordered, key=_instant, reverse=True))
749
1097
 
750
1098
 
751
1099
  def compose_all_state(
@@ -756,6 +1104,11 @@ def compose_all_state(
756
1104
  if claude.source != "claude" or codex.source != "codex":
757
1105
  raise ValueError("all composition requires Claude and Codex provider states")
758
1106
  combined, combined_unavailable = _combined_outcome(claude, codex)
1107
+ aggregates = compose_all_aggregates(claude, codex)
1108
+ # Computed ONCE: the same ordered union is hashed into the version below
1109
+ # and published in `data` further down, so the identity and the rows can
1110
+ # never describe different orderings.
1111
+ combined_alerts = _combined_alert_rows(claude, codex)
759
1112
  providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
760
1113
  if providers_coherent:
761
1114
  availability: Availability = (
@@ -790,6 +1143,20 @@ def compose_all_state(
790
1143
  # `combined is not None` would leave materially different All
791
1144
  # states sharing one `data_version` (invariant 6).
792
1145
  combined, combined_unavailable,
1146
+ # #556 S2 §3.6: the COMPLETE `AllAggregates` value, not merely the
1147
+ # range. A failed and a successful fold over the same database
1148
+ # signature and the same bounds publish different rows, so hashing
1149
+ # only the range would leave them sharing one `data_version`.
1150
+ aggregates,
1151
+ # #556 S3 §2.9: the ordered alert union's identity. Without it the
1152
+ # version material omitted alerts entirely, so two materially
1153
+ # different unions — a different order, a different membership, a
1154
+ # newly fired alert — collided on one `data_version`.
1155
+ [
1156
+ (str(row.get("source")), str(row.get("id") or row.get("key")),
1157
+ canonical_alerted_at(row.get("alerted_at")))
1158
+ for row in combined_alerts
1159
+ ],
793
1160
  ],
794
1161
  separators=(",", ":"),
795
1162
  sort_keys=True,
@@ -823,7 +1190,11 @@ def compose_all_state(
823
1190
  # Emitted iff the figure is withheld; omitted-when-inapplicable.
824
1191
  **({"combined_unavailable": combined_unavailable}
825
1192
  if combined is None else {}),
826
- "alerts": {"rows": _combined_alert_rows(claude, codex)},
1193
+ "alerts": {"rows": combined_alerts},
1194
+ # #556 S2 §3.5.1: the ONE public copy of the shared range and of
1195
+ # both aggregate outcomes. The rows stay on the provider domains
1196
+ # under `providers` below, so nothing is published twice.
1197
+ "aggregates": aggregates,
827
1198
  "providers": {
828
1199
  "claude": claude.data,
829
1200
  "codex": codex.data,
@@ -907,25 +1278,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
907
1278
  )
908
1279
 
909
1280
 
910
- def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
911
- """Hash exact, canonically ordered Codex-derived stats relations.
1281
+ # #556 S3 §2.9. The Claude twin of the relation table above, over the five
1282
+ # alert tables the Claude projection reads. `codex_stats_digest` already covers
1283
+ # Codex's alert rows; Claude's were covered by nothing, and the dispatch
1284
+ # signature's stats legs are `MAX(id)` over the two weekly snapshot tables plus
1285
+ # the reset-event change signal — none of which a milestone INSERT or an
1286
+ # `alerted_at` arming UPDATE touches. A fired Claude alert could therefore
1287
+ # leave the idle path short-circuiting on a retained prior bundle. Measured
1288
+ # before the leg was added: inserting a `budget_milestones` row with
1289
+ # `vendor='claude'` left every existing leg byte-identical.
1290
+ #
1291
+ # Only the alert-bearing columns are selected, for the same reason the Codex
1292
+ # table selects a fixed list: the digest is an identity over what the surface
1293
+ # publishes, not a checksum of the table file.
1294
+ _CLAUDE_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
1295
+ (
1296
+ "percent_milestones",
1297
+ "SELECT week_start_date, percent_threshold, captured_at_utc, "
1298
+ "cumulative_cost_usd, reset_event_id, account_key, alerted_at "
1299
+ "FROM percent_milestones WHERE alerted_at IS NOT NULL "
1300
+ "ORDER BY week_start_date, percent_threshold, reset_event_id, account_key, "
1301
+ "captured_at_utc, cumulative_cost_usd, alerted_at",
1302
+ ),
1303
+ (
1304
+ "five_hour_milestones",
1305
+ "SELECT five_hour_window_key, percent_threshold, captured_at_utc, "
1306
+ "block_cost_usd, reset_event_id, account_key, alerted_at "
1307
+ "FROM five_hour_milestones WHERE alerted_at IS NOT NULL "
1308
+ "ORDER BY five_hour_window_key, percent_threshold, reset_event_id, account_key, "
1309
+ "captured_at_utc, block_cost_usd, alerted_at",
1310
+ ),
1311
+ (
1312
+ "budget_milestones",
1313
+ "SELECT vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
1314
+ "consumption_pct, crossed_at_utc, account_key, alerted_at "
1315
+ "FROM budget_milestones WHERE vendor <> 'codex' AND alerted_at IS NOT NULL "
1316
+ "ORDER BY vendor, period_start_at, period, threshold, account_key, "
1317
+ "budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
1318
+ ),
1319
+ (
1320
+ "projected_milestones",
1321
+ "SELECT week_start_at, period, metric, threshold, projected_value, denominator, "
1322
+ "crossed_at_utc, account_key, alerted_at FROM projected_milestones "
1323
+ "WHERE metric <> 'codex_budget_usd' AND alerted_at IS NOT NULL "
1324
+ "ORDER BY week_start_at, period, metric, threshold, account_key, "
1325
+ "projected_value, denominator, crossed_at_utc, alerted_at",
1326
+ ),
1327
+ (
1328
+ "project_budget_milestones",
1329
+ "SELECT week_start_at, project_key, threshold, budget_usd, spent_usd, "
1330
+ "consumption_pct, crossed_at_utc, account_key, alerted_at "
1331
+ "FROM project_budget_milestones WHERE alerted_at IS NOT NULL "
1332
+ "ORDER BY week_start_at, project_key, threshold, account_key, "
1333
+ "budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
1334
+ ),
1335
+ )
912
1336
 
913
- A missing table is an empty relation so an older/fresh stats database has a
914
- stable digest. Other SQLite failures remain visible to the builder, which
915
- then follows the source all-or-prior failure matrix instead of publishing a
916
- guessed identity.
917
- """
918
- relations: list[list[list[object]]] = []
919
- for _name, query in _CODEX_STATS_DIGEST_RELATIONS:
1337
+
1338
+ def _stats_relations_digest(
1339
+ stats_conn: sqlite3.Connection,
1340
+ relations: tuple[tuple[str, str], ...],
1341
+ ) -> str:
1342
+ relation_rows: list[list[list[object]]] = []
1343
+ for _name, query in relations:
920
1344
  try:
921
1345
  rows = stats_conn.execute(query).fetchall()
922
1346
  except sqlite3.OperationalError as exc:
923
1347
  if "no such table" not in str(exc).lower():
924
1348
  raise
925
1349
  rows = ()
926
- relations.append([list(row) for row in rows])
1350
+ relation_rows.append([list(row) for row in rows])
927
1351
  canonical = json.dumps(
928
- relations,
1352
+ relation_rows,
929
1353
  allow_nan=False,
930
1354
  ensure_ascii=False,
931
1355
  separators=(",", ":"),
@@ -933,6 +1357,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
933
1357
  return hashlib.sha256(canonical).hexdigest()
934
1358
 
935
1359
 
1360
+ def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
1361
+ """Hash the Claude-owned alert relations, canonically ordered.
1362
+
1363
+ A missing table is an empty relation, so an older or fresh stats database
1364
+ still has a stable digest — the same posture ``codex_stats_digest`` takes.
1365
+ """
1366
+ return _stats_relations_digest(stats_conn, _CLAUDE_STATS_DIGEST_RELATIONS)
1367
+
1368
+
1369
+ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
1370
+ """Hash exact, canonically ordered Codex-derived stats relations.
1371
+
1372
+ A missing table is an empty relation so an older/fresh stats database has a
1373
+ stable digest. Other SQLite failures remain visible to the builder, which
1374
+ then follows the source all-or-prior failure matrix instead of publishing a
1375
+ guessed identity.
1376
+ """
1377
+ return _stats_relations_digest(stats_conn, _CODEX_STATS_DIGEST_RELATIONS)
1378
+
1379
+
936
1380
  def assess_codex_projection_coherence(
937
1381
  *,
938
1382
  active_root_keys: tuple[str, ...] | list[str] | set[str],
@@ -106,6 +106,16 @@ class SnapshotSignature(NamedTuple):
106
106
  # Codex mutation happens along. Empty when nothing is owed (the writer
107
107
  # DELETEs the key at zero), so a fully-ingested store is byte-neutral.
108
108
  codex_ingest_backlog_sig: str = ""
109
+ # #556 S3 §2.9: the Claude twin of `codex_stats_digest`. The stats legs
110
+ # above are `MAX(id)` over the two weekly snapshot tables plus the
111
+ # reset-event change signal, and a Claude milestone INSERT or an
112
+ # `alerted_at` arming UPDATE touches none of them — measured: inserting a
113
+ # `budget_milestones` row with `vendor='claude'` left every other leg
114
+ # byte-identical. Without this leg a fired Claude alert could leave the
115
+ # idle path short-circuiting on a retained prior bundle. Unlike the two
116
+ # legs above, this one is a digest and is never empty: a store with no
117
+ # armed Claude alert carries a constant hash, not the empty string.
118
+ claude_stats_digest: str = ""
109
119
 
110
120
 
111
121
  def _max_id(conn: sqlite3.Connection, table: str) -> int:
@@ -221,6 +231,7 @@ def compute_signature(
221
231
  generation: int,
222
232
  codex_stats_digest: str = "",
223
233
  accounts_digest: str = "",
234
+ claude_stats_digest: str = "",
224
235
  ) -> SnapshotSignature:
225
236
  """Composite data-version signature across cache.db + stats.db (spec §3).
226
237
 
@@ -243,6 +254,7 @@ def compute_signature(
243
254
  codex_stats_digest=str(codex_stats_digest),
244
255
  accounts_digest=str(accounts_digest),
245
256
  codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
257
+ claude_stats_digest=str(claude_stats_digest),
246
258
  )
247
259
 
248
260
 
package/bin/cctally CHANGED
@@ -1372,6 +1372,19 @@ _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
1372
1372
  _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
1373
1373
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
1374
1374
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
1375
+ # #556 S2: the shared cross-provider aggregate range and the two range-native
1376
+ # folds it feeds. Re-exported so the source-bundle builder in
1377
+ # `_cctally_tui._tui_build_source_bundle` reaches them through `_cctally()`,
1378
+ # which is also the seam its fold-failure tests monkeypatch.
1379
+ resolve_shared_range = _cctally_dashboard.resolve_shared_range
1380
+ iter_shared_range_entries = _cctally_dashboard.iter_shared_range_entries
1381
+ fold_projects_over_range = _cctally_dashboard.fold_projects_over_range
1382
+ fold_daily_over_range = _cctally_dashboard.fold_daily_over_range
1383
+ materialise_daily_calendar = _cctally_dashboard.materialise_daily_calendar
1384
+ build_daily_aggregate_rows = _cctally_dashboard.build_daily_aggregate_rows
1385
+ daily_panel_row_to_wire = _cctally_dashboard.daily_panel_row_to_wire
1386
+ build_project_aggregate_rows = _cctally_dashboard.build_project_aggregate_rows
1387
+ legacy_project_labels = _cctally_dashboard.legacy_project_labels
1375
1388
  # _iso_z is NOT bound here anymore — the former dashboard-then-forecast
1376
1389
  # double-bind collapses to a single canonical bind below (#279 S6 W4).
1377
1390
  # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).