cctally 1.97.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.
@@ -45,7 +45,55 @@ 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
+ # 7 -> 8 (#556 S5): the Claude provider's `capabilities.budget` detail changed
72
+ # MEANING. It said `subscription-week` unconditionally, as a constant, while
73
+ # Codex advertised `calendar-period`; it now names the CONFIGURED period, so on
74
+ # an install with `budget.period = calendar-month` the same field reads
75
+ # `calendar-month`. `docs/cli-contract.md:58` says an optional additive key
76
+ # alone does not bump but a changed value or meaning does, which is exactly how
77
+ # S1, S2 and S3 justified theirs. The Claude source also gained an optional
78
+ # `data.budget.status` — the same object Codex publishes — plus its
79
+ # `status_unavailable` / `not_configured` siblings, and the Codex budget domain
80
+ # gained the same optional `status_unavailable` sibling. Those are additive and
81
+ # omitted when inapplicable, so an install with no budget configured publishes
82
+ # byte-identical bytes and the bump rests on the capability detail alone.
83
+ # `sources.all.capabilities.budget` is a DIFFERENT field and is unchanged at
84
+ # `not_applicable` / `provider-native`: rendering two provider-native child
85
+ # budgets side by side does not create an All-level budget quantity.
86
+ # 8 -> 9 (#564): on a decorated Codex provider, a card whose totals do not come
87
+ # from a live weekly cycle — a real account whose boundary is not live, and the
88
+ # unattributed sentinel — now covers one native cycle width ending at `now`
89
+ # instead of the whole ~30-day accounting range. `accounts[].spendUsd`, its five
90
+ # token siblings, and the decorated `hero.cost_usd` / `hero.total_tokens` summed
91
+ # from them therefore changed VALUE without changing shape, which is the same
92
+ # class of change the 4 -> 5 entry records. Those cards additionally publish the
93
+ # optional `spendWindow` bounds, which a cycle-bounded card omits. No client
94
+ # branches on this number; after an in-place `execvp` update a still-loaded old
95
+ # client renders the new figures under old copy until it reloads.
96
+ SOURCE_SCHEMA_VERSION = 9
49
97
  DEFAULT_SOURCE = "claude"
50
98
  SOURCE_ORDER = ("claude", "codex", "all")
51
99
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -161,6 +209,24 @@ class SourceDashboardState:
161
209
  # publishes only ``data``, then the HTTP/SSE envelope layer injects a label
162
210
  # into its request-local copies when that request's transcript gate is open.
163
211
  private_session_labels: Mapping[str, str] | None = None
212
+ # #556 S2 §3.6 — the per-aggregate carrier. Server-only, in the same class
213
+ # as ``clock_data`` and ``account_scope``: the resolved shared range never
214
+ # enters a provider ``data`` domain, because composition embeds each
215
+ # provider's ``data`` under the All source and a range inside it would be
216
+ # published three times over. Shape:
217
+ #
218
+ # {"range": {"kind", "label", "start_at", "end_at"},
219
+ # "projects": {"state": "ok"} | {"state": "failed", "code": ...},
220
+ # "daily": {"state": "ok"} | {"state": "failed", "code": ...}}
221
+ #
222
+ # ``account_scope`` is the right precedent for STORAGE CLASS and the wrong
223
+ # one for LIFECYCLE. Account scope is deliberately reattached from the
224
+ # current tick after every build, reuse and degrade branch; doing that here
225
+ # would overwrite the range that describes RETAINED rows with the range of a
226
+ # tick that produced none. This carrier therefore travels with the rows it
227
+ # describes and is never re-derived on a reuse or degrade path. Explicit
228
+ # constructors must copy it.
229
+ aggregate_scope: Mapping[str, object] | None = None
164
230
 
165
231
  def __post_init__(self) -> None:
166
232
  validate_dashboard_selection(self.source)
@@ -206,6 +272,10 @@ class SourceDashboardState:
206
272
  object.__setattr__(self, "clock_data", _freeze(self.clock_data))
207
273
  if self.account_scope is not None:
208
274
  object.__setattr__(self, "account_scope", _freeze(self.account_scope))
275
+ if self.aggregate_scope is not None:
276
+ object.__setattr__(
277
+ self, "aggregate_scope", _freeze(self.aggregate_scope),
278
+ )
209
279
  if self.private_session_labels is not None:
210
280
  private_session_labels = {
211
281
  _nonempty_string(key, "private session label key"):
@@ -325,6 +395,11 @@ def degrade_source_state(
325
395
  # `account_scope_unresolved` on an install whose count read fine.
326
396
  account_scope=prior.account_scope,
327
397
  private_session_labels=prior.private_session_labels,
398
+ # #556 S2 §3.6: the carrier travels with the rows it describes. A
399
+ # degraded generation retains `prior.data`, so it must retain the range
400
+ # that bounded those rows — re-deriving it from the current tick would
401
+ # publish a range the retained rows do not cover.
402
+ aggregate_scope=prior.aggregate_scope,
328
403
  )
329
404
 
330
405
 
@@ -521,6 +596,44 @@ def _period_instant(value: object) -> str | None:
521
596
  return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
522
597
 
523
598
 
599
+ def canonical_alerted_at(value: object) -> str:
600
+ """Normalize an aware ISO-8601 firing instant to one UTC ``Z`` spelling.
601
+
602
+ #556 S3 §2.2. The union sorted on a field one writer never wrote, so a
603
+ missing or malformed value must raise here rather than degrade to a
604
+ sentinel that sorts silently. Sub-second precision is truncated, so two
605
+ alerts firing in the same second compare equal and fall back to source
606
+ order; no writer emits it today.
607
+ """
608
+ if not isinstance(value, str) or not value:
609
+ raise ValueError(f"alerted_at must be a non-empty ISO-8601 string, got {value!r}")
610
+ try:
611
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
612
+ except ValueError as exc:
613
+ raise ValueError(f"unparseable alerted_at {value!r}") from exc
614
+ if parsed.tzinfo is None:
615
+ raise ValueError(f"naive alerted_at {value!r}; an aware instant is required")
616
+ return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
617
+
618
+
619
+ def canonical_alerted_at_sql(column: str = "alerted_at") -> str:
620
+ """The SQL twin of :func:`canonical_alerted_at`, for ordering in SQLite.
621
+
622
+ #556 S3 §2.3. Every per-axis ``LIMIT`` decides MEMBERSHIP, not merely
623
+ order, so a row excluded by a textual comparison of two spellings of one
624
+ instant cannot be recovered by any later projection. SQLite parses the
625
+ timezone indicator, so for every aware spelling this returns the same
626
+ canonical UTC ``Z`` string the Python helper returns —
627
+ ``tests/test_556_s3_alert_ordering.py`` pins that agreement over the
628
+ committed estate. The twins diverge on exactly one input: SQLite reads a
629
+ naive value as UTC where the Python helper raises, so such a row is ordered
630
+ here and rejected later. An unparseable value yields SQL ``NULL``, which
631
+ sorts last under ``DESC``, so the ``LIMIT`` usually drops that row before
632
+ composition can reject it — corruption is truncated away, not surfaced.
633
+ """
634
+ return f"strftime('%Y-%m-%dT%H:%M:%SZ', {column})"
635
+
636
+
524
637
  def _leg_period(
525
638
  provider: PhysicalSource, hero: Mapping[str, object] | None,
526
639
  ) -> Mapping[str, object] | None:
@@ -722,6 +835,256 @@ def _combined_outcome(
722
835
  }, None
723
836
 
724
837
 
838
+ # === #556 S2 — the shared cross-provider aggregates (spec §3.5.1, §3.7) =====
839
+ #
840
+ # Two range rules coexist. A combined TOTAL sums provider-native cycles (S1,
841
+ # above). A cross-provider RANKING uses one shared absolute calendar range, and
842
+ # that is what these aggregates publish. Both are deliberate.
843
+ #
844
+ # Withholding is a typed outcome rather than an empty list, because an empty
845
+ # list renders as honest emptiness and a range problem is not emptiness.
846
+
847
+ AGGREGATE_NAMES: tuple[str, ...] = ("projects", "daily")
848
+ AGGREGATE_RANGE_KIND = "absolute_range"
849
+ AGGREGATE_RANGE_LABEL = "Shared range"
850
+
851
+ # Precedence is TOTAL and follows the declared code order (§3.5.1). The first
852
+ # two are ordered so the predicates stay mutually exclusive: `_coherent_provider`
853
+ # already subsumes unavailability, so testing incoherence first would make
854
+ # `provider_unavailable` unreachable.
855
+ _AGGREGATE_CAUSE_RANK: Mapping[str, int] = MappingProxyType({
856
+ "range_unresolved": 1,
857
+ "provider_unavailable": 2,
858
+ "provider_incoherent": 3,
859
+ "claude_fold_failed": 4,
860
+ "retained_range_mismatch": 5,
861
+ })
862
+
863
+ # One server warning maps to one published qualification. The figure stays
864
+ # AVAILABLE: the server already publishes a qualified Codex projects subset in
865
+ # this state, and withholding the whole ranking over it would discard real data.
866
+ _AGGREGATE_QUALIFYING_WARNINGS: Mapping[str, tuple[str, str]] = MappingProxyType({
867
+ # warning code -> (published qualification code, aggregate it qualifies)
868
+ "codex_metadata_incomplete": ("codex_project_metadata_partial", "projects"),
869
+ })
870
+
871
+
872
+ def aggregate_range(start_at: object, end_at: object) -> dict | None:
873
+ """Canonicalise one resolved absolute range, or ``None`` if it does not."""
874
+ start = _period_instant(start_at)
875
+ end = _period_instant(end_at)
876
+ if start is None or end is None:
877
+ return None
878
+ return {
879
+ "kind": AGGREGATE_RANGE_KIND,
880
+ "label": AGGREGATE_RANGE_LABEL,
881
+ "start_at": start,
882
+ "end_at": end,
883
+ }
884
+
885
+
886
+ def build_aggregate_scope(
887
+ published_range: Mapping[str, object] | None,
888
+ outcomes: Mapping[str, object] | None = None,
889
+ ) -> dict:
890
+ """The server-only carrier a freshly built provider generation gets."""
891
+ scope: dict = {"range": dict(published_range) if published_range else None}
892
+ for name in AGGREGATE_NAMES:
893
+ entry = (outcomes or {}).get(name)
894
+ scope[name] = dict(entry) if isinstance(entry, Mapping) else {"state": "ok"}
895
+ return scope
896
+
897
+
898
+ def aggregate_scope_failed(value: object) -> bool:
899
+ """Whether a provider generation records a failed aggregate fold.
900
+
901
+ Accepts either a ``SourceDashboardState`` or a raw carrier mapping, because
902
+ both gates need the predicate and one of them runs before the state exists.
903
+
904
+ A failure must not become permanent. A locally caught fold failure leaves an
905
+ otherwise `ok` and `fresh` provider, and that bundle would qualify for idle
906
+ reuse while exact-version provider reuse returns the prior object unchanged
907
+ — so one transient failure would withhold the aggregate for the life of the
908
+ process. This predicate is read at BOTH gates.
909
+ """
910
+ scope = (
911
+ value if isinstance(value, Mapping)
912
+ else getattr(value, "aggregate_scope", None)
913
+ )
914
+ if not isinstance(scope, Mapping):
915
+ return False
916
+ for name in AGGREGATE_NAMES:
917
+ entry = scope.get(name)
918
+ if isinstance(entry, Mapping) and entry.get("state") != "ok":
919
+ return True
920
+ return False
921
+
922
+
923
+ def aggregate_scope_identity(scope: object) -> str:
924
+ """The version fragment a provider's aggregate carrier contributes.
925
+
926
+ Carries the resolved range START and the per-aggregate outcome, so a failed
927
+ and a successful fold over the same database signature can never publish
928
+ different rows under one ``data_version``.
929
+
930
+ ``end_at`` is deliberately EXCLUDED. It is ``now_utc``, which advances on
931
+ every tick by construction, so folding it in would make every provider
932
+ version unique per tick and defeat `reuse_coherent_source_state` on every
933
+ path — including the reuse §3.6 itself reasons about. The START is the bound
934
+ that can actually move (a display-day rollover), and it is folded into BOTH
935
+ providers' versions so they rebuild in lockstep and a coherent pair can
936
+ never disagree about it.
937
+
938
+ The start participates as the EXACT canonical instant, at the same
939
+ granularity `compose_all_aggregates` compares it. That is the point: the
940
+ composition publishes a range only when every coherent provider's canonical
941
+ ``start_at`` is the same string, and this identity is what forces the two
942
+ providers to rebuild in lockstep so they can be. A coarser identity would
943
+ make a difference the composition rejects invisible to the gate that is
944
+ supposed to resolve it — an unchanged provider would keep reusing the old
945
+ carrier while a rebuilt one recorded the new instant, and both aggregates
946
+ would be withheld as ``retained_range_mismatch`` on every subsequent tick.
947
+ That is the original defect, and one value read at two granularities is its
948
+ structural shape.
949
+
950
+ An earlier revision folded the start at DAY granularity to protect against
951
+ a ``now_utc - 30 days`` fallback that advanced on every tick. That fallback
952
+ is gone: every producer of this bound now floors to display-timezone
953
+ midnight — `resolve_shared_range` on both its branches, and
954
+ `_tui_build_source_bundle`'s own fallback, which resolves through the same
955
+ helper. So the exact instant changes at most once per display day, which is
956
+ a tick that must rebuild anyway because the daily panel rolled over.
957
+
958
+ ``end_at`` is the only value still excluded, for the reason above.
959
+ """
960
+ if not isinstance(scope, Mapping):
961
+ return "none"
962
+ published = scope.get("range")
963
+ start = (
964
+ published.get("start_at") if isinstance(published, Mapping) else None
965
+ )
966
+ parts = [str(start or "")]
967
+ for name in AGGREGATE_NAMES:
968
+ entry = scope.get(name)
969
+ state = entry.get("state") if isinstance(entry, Mapping) else None
970
+ code = entry.get("code") if isinstance(entry, Mapping) else None
971
+ parts.append(f"{name}:{state or 'unknown'}" + (f":{code}" if code else ""))
972
+ return "|".join(parts)
973
+
974
+
975
+ def _aggregate_scope_range(state: SourceDashboardState) -> dict | None:
976
+ scope = getattr(state, "aggregate_scope", None)
977
+ if not isinstance(scope, Mapping):
978
+ return None
979
+ published = scope.get("range")
980
+ if not isinstance(published, Mapping):
981
+ return None
982
+ return aggregate_range(published.get("start_at"), published.get("end_at"))
983
+
984
+
985
+ def _aggregate_fold_failed(state: SourceDashboardState, name: str) -> bool:
986
+ scope = getattr(state, "aggregate_scope", None)
987
+ if not isinstance(scope, Mapping):
988
+ return False
989
+ entry = scope.get(name)
990
+ return isinstance(entry, Mapping) and entry.get("state") != "ok"
991
+
992
+
993
+ def _aggregate_qualifications(
994
+ claude: SourceDashboardState, codex: SourceDashboardState, name: str,
995
+ ) -> list[dict]:
996
+ """Notes that qualify a PUBLISHED aggregate. Empty means omit the key."""
997
+ qualifications: list[dict] = []
998
+ for provider, state in (("claude", claude), ("codex", codex)):
999
+ for warning in state.warnings:
1000
+ mapped = _AGGREGATE_QUALIFYING_WARNINGS.get(warning.code)
1001
+ if mapped is not None and mapped[1] == name:
1002
+ qualifications.append(
1003
+ {"code": mapped[0], "provider": provider},
1004
+ )
1005
+ return qualifications
1006
+
1007
+
1008
+ def compose_all_aggregates(
1009
+ claude: SourceDashboardState, codex: SourceDashboardState,
1010
+ ) -> dict:
1011
+ """The single public ``sources.all.data.aggregates`` object (§3.5.1).
1012
+
1013
+ The outcome carries STATE AND REASON only; the rows live on the provider
1014
+ domains and the client composes them. That is what keeps exactly one public
1015
+ copy of the range and one public copy of the rows.
1016
+
1017
+ Every cause is evaluated per aggregate, so a Projects fold failure cannot
1018
+ withhold Daily.
1019
+ """
1020
+ pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
1021
+ ("claude", claude), ("codex", codex),
1022
+ )
1023
+ coherent = [
1024
+ (provider, state) for provider, state in pairs
1025
+ if _coherent_provider(state)
1026
+ ]
1027
+ ranges = {
1028
+ provider: _aggregate_scope_range(state) for provider, state in coherent
1029
+ }
1030
+ resolved = [value for value in ranges.values() if value is not None]
1031
+ starts = {value["start_at"] for value in resolved}
1032
+
1033
+ shared: list[tuple[int, int, str, PhysicalSource | None]] = []
1034
+ if coherent and len(resolved) != len(coherent):
1035
+ # A coherent provider whose rows are not bounded by a known range.
1036
+ shared.append((_AGGREGATE_CAUSE_RANK["range_unresolved"], 0,
1037
+ "range_unresolved", None))
1038
+ for rank_provider, (provider, state) in enumerate(pairs):
1039
+ if state.availability == "unavailable":
1040
+ shared.append((_AGGREGATE_CAUSE_RANK["provider_unavailable"],
1041
+ rank_provider, "provider_unavailable", provider))
1042
+ for rank_provider, (provider, state) in enumerate(pairs):
1043
+ if state.availability != "unavailable" and not _coherent_provider(state):
1044
+ shared.append((_AGGREGATE_CAUSE_RANK["provider_incoherent"],
1045
+ rank_provider, "provider_incoherent", provider))
1046
+ if len(starts) > 1:
1047
+ shared.append((_AGGREGATE_CAUSE_RANK["retained_range_mismatch"], 0,
1048
+ "retained_range_mismatch", None))
1049
+
1050
+ published_range: dict | None = None
1051
+ if coherent and len(resolved) == len(coherent) and len(starts) == 1:
1052
+ # Published only when EVERY coherent provider supplied a range and they
1053
+ # agree. Publishing one leg's range while the other's is unresolved or
1054
+ # different would state a span the composed rows do not cover.
1055
+ #
1056
+ # A reused provider provably has no new accounting rows — its physical
1057
+ # signature is part of the version that made the reuse legal — so the
1058
+ # later of the two ends is the instant BOTH legs are complete to.
1059
+ published_range = {
1060
+ **resolved[0],
1061
+ "end_at": max(value["end_at"] for value in resolved),
1062
+ }
1063
+
1064
+ aggregates: dict = {"range": published_range}
1065
+ for name in AGGREGATE_NAMES:
1066
+ causes = list(shared)
1067
+ if _aggregate_fold_failed(claude, name):
1068
+ causes.append((_AGGREGATE_CAUSE_RANK["claude_fold_failed"], 0,
1069
+ "claude_fold_failed", "claude"))
1070
+ if causes:
1071
+ _rank, _provider_rank, code, provider = min(
1072
+ causes, key=lambda cause: (cause[0], cause[1]),
1073
+ )
1074
+ aggregates[name] = {
1075
+ "state": "withheld",
1076
+ "code": code,
1077
+ **({"provider": provider} if provider is not None else {}),
1078
+ }
1079
+ continue
1080
+ qualifications = _aggregate_qualifications(claude, codex, name)
1081
+ aggregates[name] = {
1082
+ "state": "available",
1083
+ **({"qualifications": qualifications} if qualifications else {}),
1084
+ }
1085
+ return aggregates
1086
+
1087
+
725
1088
  def _combined_alert_rows(
726
1089
  claude: SourceDashboardState,
727
1090
  codex: SourceDashboardState,
@@ -739,13 +1102,23 @@ def _combined_alert_rows(
739
1102
  if not isinstance(row, Mapping) or row.get("source") != source:
740
1103
  continue
741
1104
  ordered.append(row)
1105
+
1106
+ def _instant(row: Mapping[str, object]) -> str:
1107
+ try:
1108
+ return canonical_alerted_at(row.get("alerted_at"))
1109
+ except ValueError as exc:
1110
+ identity = row.get("id") if row.get("id") is not None else row.get("key")
1111
+ raise ValueError(
1112
+ f"{row.get('source')!r} alert row {identity!r}: {exc}"
1113
+ ) from exc
1114
+
1115
+ # #556 S3 §2.5: composition is the chokepoint. The previous sort keyed on
1116
+ # `created_at`, which the Claude projection never wrote, so every Claude
1117
+ # row collapsed to "" and sorted last. Validating here means a future leg,
1118
+ # axis or provider that omits the canonical instant fails visibly instead.
742
1119
  # 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
- ))
1120
+ # native order, when firing instants tie.
1121
+ return tuple(sorted(ordered, key=_instant, reverse=True))
749
1122
 
750
1123
 
751
1124
  def compose_all_state(
@@ -756,6 +1129,11 @@ def compose_all_state(
756
1129
  if claude.source != "claude" or codex.source != "codex":
757
1130
  raise ValueError("all composition requires Claude and Codex provider states")
758
1131
  combined, combined_unavailable = _combined_outcome(claude, codex)
1132
+ aggregates = compose_all_aggregates(claude, codex)
1133
+ # Computed ONCE: the same ordered union is hashed into the version below
1134
+ # and published in `data` further down, so the identity and the rows can
1135
+ # never describe different orderings.
1136
+ combined_alerts = _combined_alert_rows(claude, codex)
759
1137
  providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
760
1138
  if providers_coherent:
761
1139
  availability: Availability = (
@@ -790,6 +1168,20 @@ def compose_all_state(
790
1168
  # `combined is not None` would leave materially different All
791
1169
  # states sharing one `data_version` (invariant 6).
792
1170
  combined, combined_unavailable,
1171
+ # #556 S2 §3.6: the COMPLETE `AllAggregates` value, not merely the
1172
+ # range. A failed and a successful fold over the same database
1173
+ # signature and the same bounds publish different rows, so hashing
1174
+ # only the range would leave them sharing one `data_version`.
1175
+ aggregates,
1176
+ # #556 S3 §2.9: the ordered alert union's identity. Without it the
1177
+ # version material omitted alerts entirely, so two materially
1178
+ # different unions — a different order, a different membership, a
1179
+ # newly fired alert — collided on one `data_version`.
1180
+ [
1181
+ (str(row.get("source")), str(row.get("id") or row.get("key")),
1182
+ canonical_alerted_at(row.get("alerted_at")))
1183
+ for row in combined_alerts
1184
+ ],
793
1185
  ],
794
1186
  separators=(",", ":"),
795
1187
  sort_keys=True,
@@ -823,7 +1215,11 @@ def compose_all_state(
823
1215
  # Emitted iff the figure is withheld; omitted-when-inapplicable.
824
1216
  **({"combined_unavailable": combined_unavailable}
825
1217
  if combined is None else {}),
826
- "alerts": {"rows": _combined_alert_rows(claude, codex)},
1218
+ "alerts": {"rows": combined_alerts},
1219
+ # #556 S2 §3.5.1: the ONE public copy of the shared range and of
1220
+ # both aggregate outcomes. The rows stay on the provider domains
1221
+ # under `providers` below, so nothing is published twice.
1222
+ "aggregates": aggregates,
827
1223
  "providers": {
828
1224
  "claude": claude.data,
829
1225
  "codex": codex.data,
@@ -907,25 +1303,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
907
1303
  )
908
1304
 
909
1305
 
910
- def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
911
- """Hash exact, canonically ordered Codex-derived stats relations.
1306
+ # #556 S3 §2.9. The Claude twin of the relation table above, over the five
1307
+ # alert tables the Claude projection reads. `codex_stats_digest` already covers
1308
+ # Codex's alert rows; Claude's were covered by nothing, and the dispatch
1309
+ # signature's stats legs are `MAX(id)` over the two weekly snapshot tables plus
1310
+ # the reset-event change signal — none of which a milestone INSERT or an
1311
+ # `alerted_at` arming UPDATE touches. A fired Claude alert could therefore
1312
+ # leave the idle path short-circuiting on a retained prior bundle. Measured
1313
+ # before the leg was added: inserting a `budget_milestones` row with
1314
+ # `vendor='claude'` left every existing leg byte-identical.
1315
+ #
1316
+ # Only the alert-bearing columns are selected, for the same reason the Codex
1317
+ # table selects a fixed list: the digest is an identity over what the surface
1318
+ # publishes, not a checksum of the table file.
1319
+ _CLAUDE_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
1320
+ (
1321
+ "percent_milestones",
1322
+ "SELECT week_start_date, percent_threshold, captured_at_utc, "
1323
+ "cumulative_cost_usd, reset_event_id, account_key, alerted_at "
1324
+ "FROM percent_milestones WHERE alerted_at IS NOT NULL "
1325
+ "ORDER BY week_start_date, percent_threshold, reset_event_id, account_key, "
1326
+ "captured_at_utc, cumulative_cost_usd, alerted_at",
1327
+ ),
1328
+ (
1329
+ "five_hour_milestones",
1330
+ "SELECT five_hour_window_key, percent_threshold, captured_at_utc, "
1331
+ "block_cost_usd, reset_event_id, account_key, alerted_at "
1332
+ "FROM five_hour_milestones WHERE alerted_at IS NOT NULL "
1333
+ "ORDER BY five_hour_window_key, percent_threshold, reset_event_id, account_key, "
1334
+ "captured_at_utc, block_cost_usd, alerted_at",
1335
+ ),
1336
+ (
1337
+ "budget_milestones",
1338
+ "SELECT vendor, period_start_at, period, threshold, budget_usd, spent_usd, "
1339
+ "consumption_pct, crossed_at_utc, account_key, alerted_at "
1340
+ "FROM budget_milestones WHERE vendor <> 'codex' AND alerted_at IS NOT NULL "
1341
+ "ORDER BY vendor, period_start_at, period, threshold, account_key, "
1342
+ "budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
1343
+ ),
1344
+ (
1345
+ "projected_milestones",
1346
+ "SELECT week_start_at, period, metric, threshold, projected_value, denominator, "
1347
+ "crossed_at_utc, account_key, alerted_at FROM projected_milestones "
1348
+ "WHERE metric <> 'codex_budget_usd' AND alerted_at IS NOT NULL "
1349
+ "ORDER BY week_start_at, period, metric, threshold, account_key, "
1350
+ "projected_value, denominator, crossed_at_utc, alerted_at",
1351
+ ),
1352
+ (
1353
+ "project_budget_milestones",
1354
+ "SELECT week_start_at, project_key, threshold, budget_usd, spent_usd, "
1355
+ "consumption_pct, crossed_at_utc, account_key, alerted_at "
1356
+ "FROM project_budget_milestones WHERE alerted_at IS NOT NULL "
1357
+ "ORDER BY week_start_at, project_key, threshold, account_key, "
1358
+ "budget_usd, spent_usd, consumption_pct, crossed_at_utc, alerted_at",
1359
+ ),
1360
+ )
912
1361
 
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:
1362
+
1363
+ def _stats_relations_digest(
1364
+ stats_conn: sqlite3.Connection,
1365
+ relations: tuple[tuple[str, str], ...],
1366
+ ) -> str:
1367
+ relation_rows: list[list[list[object]]] = []
1368
+ for _name, query in relations:
920
1369
  try:
921
1370
  rows = stats_conn.execute(query).fetchall()
922
1371
  except sqlite3.OperationalError as exc:
923
1372
  if "no such table" not in str(exc).lower():
924
1373
  raise
925
1374
  rows = ()
926
- relations.append([list(row) for row in rows])
1375
+ relation_rows.append([list(row) for row in rows])
927
1376
  canonical = json.dumps(
928
- relations,
1377
+ relation_rows,
929
1378
  allow_nan=False,
930
1379
  ensure_ascii=False,
931
1380
  separators=(",", ":"),
@@ -933,6 +1382,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
933
1382
  return hashlib.sha256(canonical).hexdigest()
934
1383
 
935
1384
 
1385
+ def claude_stats_digest(stats_conn: sqlite3.Connection) -> str:
1386
+ """Hash the Claude-owned alert relations, canonically ordered.
1387
+
1388
+ A missing table is an empty relation, so an older or fresh stats database
1389
+ still has a stable digest — the same posture ``codex_stats_digest`` takes.
1390
+ """
1391
+ return _stats_relations_digest(stats_conn, _CLAUDE_STATS_DIGEST_RELATIONS)
1392
+
1393
+
1394
+ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
1395
+ """Hash exact, canonically ordered Codex-derived stats relations.
1396
+
1397
+ A missing table is an empty relation so an older/fresh stats database has a
1398
+ stable digest. Other SQLite failures remain visible to the builder, which
1399
+ then follows the source all-or-prior failure matrix instead of publishing a
1400
+ guessed identity.
1401
+ """
1402
+ return _stats_relations_digest(stats_conn, _CODEX_STATS_DIGEST_RELATIONS)
1403
+
1404
+
936
1405
  def assess_codex_projection_coherence(
937
1406
  *,
938
1407
  active_root_keys: tuple[str, ...] | list[str] | set[str],