cctally 1.96.2 → 1.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,39 @@ CapabilityStatus = Literal[
36
36
  # 3 -> 4 (#465): the Codex cache report retired its transitional
37
37
  # `cache_hit_percent` alias and changed structurally inapplicable figures from
38
38
  # numeric placeholders to null.
39
- SOURCE_SCHEMA_VERSION = 4
39
+ # 4 -> 5 (#556 S1): Claude's `hero.cost_usd` / `hero.total_tokens` changed
40
+ # MEANING — they were a thirty-day accounting rollup and are now current-cycle
41
+ # actuals, matching what the same-named Codex fields have always meant. The All
42
+ # source's `data.combined` also gained a required `legs` object and the optional
43
+ # `qualifications` / `combined_unavailable` companions, which supersedes normal-
44
+ # payload byte identity for this version (spec §3.5). No client branches on this
45
+ # number; after an in-place `execvp` update a still-loaded old client renders the
46
+ # new figure under old copy until it reloads, and that one-reconnect transient is
47
+ # accepted, consistent with the 2 -> 3 precedent above.
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
40
72
  DEFAULT_SOURCE = "claude"
41
73
  SOURCE_ORDER = ("claude", "codex", "all")
42
74
  SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
@@ -133,11 +165,43 @@ class SourceDashboardState:
133
165
  # They are deliberately separate from ``data`` so no internal accounting
134
166
  # evidence becomes part of the public source-envelope contract.
135
167
  clock_data: Mapping[str, object] | None = None
168
+ # #556 S1 §3.8 — the provider's authoritative REAL account count, resolved
169
+ # by the builder and carried here so `compose_all_state` can apply the
170
+ # single-account gate. Server-only, in the same class as `clock_data`:
171
+ # deliberately outside `data`, so no account cardinality enters the public
172
+ # source envelope. Shape: ``{"real_account_count": int}``.
173
+ #
174
+ # ``None`` means UNRESOLVED and must fail closed (withhold the combined
175
+ # figure), never "undecorated". Inferring decoration from the published
176
+ # `data.accounts` is forbidden for exactly this reason: both physical
177
+ # builders swallow a decoration-read failure and fall back to the
178
+ # undecorated shape, so a two-account install whose account read failed
179
+ # would present as single-account and publish the one number §3.2 forbids,
180
+ # on precisely the install where it is wrong.
181
+ account_scope: Mapping[str, object] | None = None
136
182
  # Request-gated transcript content. This mapping is frozen with the source
137
183
  # generation but is deliberately outside ``data``: source serialization
138
184
  # publishes only ``data``, then the HTTP/SSE envelope layer injects a label
139
185
  # into its request-local copies when that request's transcript gate is open.
140
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
141
205
 
142
206
  def __post_init__(self) -> None:
143
207
  validate_dashboard_selection(self.source)
@@ -181,6 +245,12 @@ class SourceDashboardState:
181
245
  object.__setattr__(self, "data", _freeze(self.data))
182
246
  if self.clock_data is not None:
183
247
  object.__setattr__(self, "clock_data", _freeze(self.clock_data))
248
+ if self.account_scope is not None:
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
+ )
184
254
  if self.private_session_labels is not None:
185
255
  private_session_labels = {
186
256
  _nonempty_string(key, "private session label key"):
@@ -295,7 +365,16 @@ def degrade_source_state(
295
365
  domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
296
366
  },
297
367
  clock_data=prior.clock_data,
368
+ # #556 S1 §3.8: a degraded generation must not LOSE the count. Dropping
369
+ # it here would turn a transient provider failure into
370
+ # `account_scope_unresolved` on an install whose count read fine.
371
+ account_scope=prior.account_scope,
298
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,
299
378
  )
300
379
 
301
380
 
@@ -365,67 +444,620 @@ def reuse_coherent_source_state(
365
444
  return prior if _coherent_provider(prior) and prior.data_version == data_version else None
366
445
 
367
446
 
368
- def _hero_cycle_is_stale(state: SourceDashboardState) -> bool:
369
- """Whether a provider's hero is bounded by STALE quota evidence (#350 §3.4).
447
+ # === #556 S1 — the typed combined outcome (spec §3.5, §3.7) =================
448
+ #
449
+ # `combined` is the sum, over both providers, of that provider's accounting
450
+ # actuals within its OWN current cycle: Claude's subscription week and Codex's
451
+ # native 7-day cycle. The two legs are deliberately not one shared range — the
452
+ # property bought is that each leg reconciles with its provider tab, and each
453
+ # leg therefore names the cycle it covers.
454
+
455
+ _PROVIDER_LABELS: Mapping[str, str] = MappingProxyType(
456
+ {"claude": "Claude", "codex": "Codex"},
457
+ )
458
+ _LEG_PERIOD_KINDS: Mapping[str, tuple[str, str, str, str]] = MappingProxyType({
459
+ # provider -> (kind, label, hero container key, (start key, end key))
460
+ "claude": ("subscription_week", "Claude subscription week",
461
+ "current_week", "week_start_at|reset_at_utc"),
462
+ "codex": ("native_7_day_cycle", "Codex native 7-day cycle",
463
+ "cycle", "start_at|resets_at"),
464
+ })
465
+
466
+
467
+ @dataclass(frozen=True)
468
+ class _CombinedCause:
469
+ """One reason the combined figure is withheld, with its precedence rank."""
470
+
471
+ precedence: int
472
+ provider: PhysicalSource
473
+ code: str
474
+ detail: Mapping[str, object] | None = None
475
+
476
+
477
+ def _cause_message(cause: _CombinedCause) -> str:
478
+ """Public-safe prose for one cause. Never echoes a rejected value."""
479
+ provider = _PROVIDER_LABELS.get(cause.provider, cause.provider)
480
+ detail = cause.detail or {}
481
+ if cause.code == "provider_incoherent":
482
+ return (
483
+ f"{provider} data is not current, so a combined total is withheld."
484
+ )
485
+ if cause.code == "account_scope_unresolved":
486
+ return (
487
+ f"{provider}'s account count could not be read, so a combined "
488
+ "total is withheld."
489
+ )
490
+ if cause.code == "multi_account_unsupported":
491
+ count = detail.get("account_count")
492
+ return (
493
+ f"{provider} has {count} accounts on separate cycles, so a "
494
+ "combined total is not published; see the per-account cards."
495
+ )
496
+ if cause.code == "claude_cycle_unresolved":
497
+ return "Claude's current subscription week could not be resolved."
498
+ if cause.code == "codex_projection_incoherent":
499
+ return "Codex quota projection is unavailable."
500
+ if cause.code == "codex_cycle_unavailable":
501
+ return "Codex native reset cycle is unavailable."
502
+ if cause.code == "invalid_counter":
503
+ return (
504
+ f"{provider} reported an unusable {detail.get('field')} counter "
505
+ f"({detail.get('reason')})."
506
+ )
507
+ return f"{provider} data cannot contribute to a combined total."
508
+
509
+
510
+ def _combined_hero(state: SourceDashboardState) -> Mapping[str, object] | None:
511
+ data = state.data
512
+ if not isinstance(data, Mapping):
513
+ return None
514
+ hero = data.get("hero")
515
+ return hero if isinstance(hero, Mapping) else None
516
+
517
+
518
+ def _real_account_count(state: SourceDashboardState) -> int | None:
519
+ """The provider's authoritative REAL account count, or ``None``.
370
520
 
371
- ``hero.cycle_freshness`` is additive and OMITTED while the cycle is fresh, so
372
- this is false for every provider and every generation that predates #350.
521
+ ``None`` is UNRESOLVED and fails closed (§3.8). Decoration is never
522
+ inferred from published data, because both physical builders swallow a
523
+ decoration-read failure and fall back to the undecorated shape.
373
524
  """
374
- if source_domain_freshness(state, "hero") == "stale":
375
- return True
376
- if not isinstance(state.data, Mapping):
377
- return False
378
- hero = state.data.get("hero")
379
- return isinstance(hero, Mapping) and hero.get("cycle_freshness") == "stale"
525
+ scope = getattr(state, "account_scope", None)
526
+ if not isinstance(scope, Mapping):
527
+ return None
528
+ count = scope.get("real_account_count")
529
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
530
+ return None
531
+ return count
380
532
 
381
533
 
382
- def _stale_cycle_providers(
383
- claude: SourceDashboardState,
384
- codex: SourceDashboardState,
385
- ) -> tuple[str, ...]:
386
- return tuple(
387
- label for label, state in (("Claude", claude), ("Codex", codex))
388
- if _hero_cycle_is_stale(state)
534
+ def _counter_reason(value: object, *, integral: bool) -> str | None:
535
+ """Return why ``value`` is unusable as a counter, or ``None`` if it is fine.
536
+
537
+ Booleans are rejected: ``True`` is an ``int`` in Python, and a flag that
538
+ leaked into a counter slot would otherwise be summed as 1.
539
+ """
540
+ if value is None:
541
+ return "missing"
542
+ if isinstance(value, bool):
543
+ return "non_integer"
544
+ if integral:
545
+ if not isinstance(value, int):
546
+ return "non_integer"
547
+ else:
548
+ if not isinstance(value, (int, float)):
549
+ return "non_integer"
550
+ if not math.isfinite(value):
551
+ return "non_finite"
552
+ return "negative" if value < 0 else None
553
+
554
+
555
+ def _period_instant(value: object) -> str | None:
556
+ """One canonical UTC spelling for a published period bound.
557
+
558
+ The two providers reach this with different spellings of the same
559
+ convention — Claude's bounds arrive from the legacy envelope's `_iso_z`
560
+ (`...Z`) and Codex's from `datetime.isoformat()` (`...+00:00`). Publishing
561
+ both would make every client parse two forms of one field for no reason.
562
+ """
563
+ if not isinstance(value, str) or not value:
564
+ return None
565
+ try:
566
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
567
+ except ValueError:
568
+ return None
569
+ if parsed.tzinfo is None:
570
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
571
+ return parsed.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
572
+
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
+
612
+ def _leg_period(
613
+ provider: PhysicalSource, hero: Mapping[str, object] | None,
614
+ ) -> Mapping[str, object] | None:
615
+ """The named cycle a `current` leg covers, or ``None`` when unresolvable."""
616
+ kind, label, container_key, bound_keys = _LEG_PERIOD_KINDS[provider]
617
+ container = hero.get(container_key) if isinstance(hero, Mapping) else None
618
+ if not isinstance(container, Mapping):
619
+ return None
620
+ start_key, end_key = bound_keys.split("|")
621
+ start_at = _period_instant(container.get(start_key))
622
+ end_at = _period_instant(container.get(end_key))
623
+ if start_at is None or end_at is None:
624
+ return None
625
+ return {
626
+ "kind": kind, "label": label, "start_at": start_at, "end_at": end_at,
627
+ }
628
+
629
+
630
+ _CODEX_HERO_FAILURE_ORDER: tuple[str, ...] = (
631
+ "codex_projection_incoherent", "codex_cycle_unavailable",
632
+ )
633
+
634
+
635
+ def _codex_hero_failure_codes(state: SourceDashboardState) -> tuple[str, ...]:
636
+ """EVERY Codex hero failure present, in the order §3.7 fixes.
637
+
638
+ Codex computes projection coherence and cycle resolution independently and
639
+ emits BOTH warnings, so §3.5 lists both as causes and §3.7 fixes their
640
+ order: an incoherent projection certificate invalidates the evidence the
641
+ cycle resolution rests on, and is therefore the earlier cause. The first
642
+ element is the winner, so the top-level `code` is unchanged by listing the
643
+ rest.
644
+ """
645
+ codes = {warning.code for warning in state.warnings}
646
+ found = tuple(code for code in _CODEX_HERO_FAILURE_ORDER if code in codes)
647
+ if found:
648
+ return found
649
+ # No warning names the failure — fall back to the hero capability's own
650
+ # semantics, which can only describe one of the two.
651
+ capability = state.capabilities.get("hero")
652
+ semantics = capability.semantics if capability is not None else None
653
+ if semantics == "projection-incoherent":
654
+ return ("codex_projection_incoherent",)
655
+ return ("codex_cycle_unavailable",)
656
+
657
+
658
+ def _combined_leg(
659
+ state: SourceDashboardState, provider: PhysicalSource,
660
+ ) -> tuple[Mapping[str, object] | None, tuple[_CombinedCause, ...]]:
661
+ """Build one leg, or the causes that stop it contributing.
662
+
663
+ A leg is `empty` when the provider reports no accounting AND no cycle:
664
+ `availability == "empty"` and no period resolves. Both halves are needed.
665
+ `availability` alone is not the fact, because it is computed over the
666
+ dashboard's VISIBLE range while Codex's hero is a separate cycle-bounded
667
+ read — a Codex provider can be `empty` in the visible range while its
668
+ current cycle holds real spend. An unresolved period alone is not the fact
669
+ either, because for Claude that is a FAILURE when accounting exists
670
+ (§3.7, "empty versus unresolved").
671
+ """
672
+ hero = _combined_hero(state)
673
+ capability = state.capabilities.get("hero")
674
+ if capability is None or capability.status not in {"supported", "derived"}:
675
+ codes = (
676
+ _codex_hero_failure_codes(state) if provider == "codex"
677
+ else ("claude_cycle_unresolved",)
678
+ )
679
+ return None, tuple(
680
+ _CombinedCause(4, provider, code) for code in codes
681
+ )
682
+ cost = hero.get("cost_usd") if hero is not None else None
683
+ tokens = hero.get("total_tokens") if hero is not None else None
684
+ period = _leg_period(provider, hero)
685
+ if period is None and state.availability == "empty":
686
+ # Numeric zeros and no period, so nothing presents `$0` as observed
687
+ # spend inside a named cycle.
688
+ return {"state": "empty", "cost_usd": 0.0, "total_tokens": 0}, ()
689
+ if provider == "claude" and cost is None and tokens is None:
690
+ # Accounting exists but no subscription week resolved. Claude's hero
691
+ # capability stays `supported` in that state, so this is its own
692
+ # detection rather than the capability branch above.
693
+ return None, (_CombinedCause(4, provider, "claude_cycle_unresolved"),)
694
+ causes = tuple(
695
+ _CombinedCause(
696
+ 5, provider, "invalid_counter", {"field": field, "reason": reason},
697
+ )
698
+ for field, reason in (
699
+ ("cost_usd", _counter_reason(cost, integral=False)),
700
+ ("total_tokens", _counter_reason(tokens, integral=True)),
701
+ )
702
+ if reason is not None
389
703
  )
704
+ if causes:
705
+ return None, causes
706
+ return {
707
+ "state": "current",
708
+ "cost_usd": float(cost),
709
+ "total_tokens": int(tokens),
710
+ **({"period": period} if period is not None else {}),
711
+ }, ()
390
712
 
391
713
 
392
- def _combined_metrics(
714
+ def _combined_qualifications(
715
+ legs: Mapping[str, Mapping[str, object]],
393
716
  claude: SourceDashboardState,
394
717
  codex: SourceDashboardState,
395
- ) -> Mapping[str, object] | None:
396
- if not (_coherent_provider(claude) and _coherent_provider(codex)):
397
- return None
398
- # #359: the hero counters are backward-looking accounting actuals. A stale
399
- # but still-live quota boundary pauses projections; it does not invalidate
400
- # the retained cost/token sums that each provider already keeps visible.
401
- # Composition therefore retains the compatible number and discloses the
402
- # stale boundary through All's hero-domain freshness + local warning.
403
- for state in (claude, codex):
404
- hero_capability = state.capabilities.get("hero")
405
- if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
406
- return None
407
- try:
408
- claude_hero = claude.data["hero"]
409
- codex_hero = codex.data["hero"]
410
- if not isinstance(claude_hero, Mapping) or not isinstance(codex_hero, Mapping):
411
- return None
412
- claude_cost = claude_hero["cost_usd"]
413
- codex_cost = codex_hero["cost_usd"]
414
- claude_tokens = claude_hero["total_tokens"]
415
- codex_tokens = codex_hero["total_tokens"]
416
- if (
417
- isinstance(claude_cost, bool) or not isinstance(claude_cost, (int, float))
418
- or isinstance(codex_cost, bool) or not isinstance(codex_cost, (int, float))
419
- or isinstance(claude_tokens, bool) or not isinstance(claude_tokens, int)
420
- or isinstance(codex_tokens, bool) or not isinstance(codex_tokens, int)
421
- ):
422
- return None
423
- except (KeyError, TypeError):
718
+ ) -> tuple[Mapping[str, object], ...]:
719
+ """Notes that qualify a PUBLISHED figure (§4.3). Empty means omit the key."""
720
+ qualifications: list[Mapping[str, object]] = []
721
+ for provider in ("claude", "codex"):
722
+ if legs[provider].get("state") == "empty":
723
+ label = _PROVIDER_LABELS[provider]
724
+ qualifications.append({
725
+ "code": "provider_empty",
726
+ "message": f"{label} has no accounting in its current cycle.",
727
+ "provider": provider,
728
+ })
729
+ # public #5: the Codex ingest backlog is LIFTED here rather than read from
730
+ # the provider field by the All surfaces, so the figure and its disclosure
731
+ # cannot disagree. The provider field stays published for the Codex tab.
732
+ data = codex.data
733
+ backlog = data.get("ingest_backlog") if isinstance(data, Mapping) else None
734
+ if isinstance(backlog, Mapping) and backlog:
735
+ qualifications.append({
736
+ "code": "codex_ingest_backlog",
737
+ "message": (
738
+ "Codex has pending accounting to ingest, so its cycle total "
739
+ "may be incomplete."
740
+ ),
741
+ "provider": "codex",
742
+ })
743
+ return tuple(qualifications)
744
+
745
+
746
+ def _combined_outcome(
747
+ claude: SourceDashboardState,
748
+ codex: SourceDashboardState,
749
+ ) -> tuple[Mapping[str, object] | None, Mapping[str, object] | None]:
750
+ """Return ``(combined, combined_unavailable)`` — exactly one is not None.
751
+
752
+ Cause precedence (§3.7), first match wins, Claude before Codex at equal
753
+ precedence: provider incoherence, unresolved account scope, decoration, a
754
+ hero capability outside {supported, derived} reported as the provider's own
755
+ reason, then an invalid counter. Every cause found is listed, ordered so
756
+ that `causes[0]` is always the winner.
757
+ """
758
+ pairs: tuple[tuple[PhysicalSource, SourceDashboardState], ...] = (
759
+ ("claude", claude), ("codex", codex),
760
+ )
761
+ causes: list[_CombinedCause] = []
762
+ for provider, state in pairs:
763
+ if not _coherent_provider(state):
764
+ causes.append(_CombinedCause(1, provider, "provider_incoherent"))
765
+ for provider, state in pairs:
766
+ count = _real_account_count(state)
767
+ if count is None:
768
+ causes.append(
769
+ _CombinedCause(2, provider, "account_scope_unresolved"))
770
+ elif count > 1:
771
+ causes.append(_CombinedCause(
772
+ 3, provider, "multi_account_unsupported",
773
+ {"account_count": count},
774
+ ))
775
+ legs: dict[str, Mapping[str, object]] = {}
776
+ for provider, state in pairs:
777
+ if not _coherent_provider(state):
778
+ # An incoherent generation's data cannot be trusted to yield a leg
779
+ # OR a leg-level cause; precedence 1 already withholds the figure.
780
+ continue
781
+ leg, leg_causes = _combined_leg(state, provider)
782
+ causes.extend(leg_causes)
783
+ if leg is not None:
784
+ legs[provider] = leg
785
+ if causes:
786
+ ordered = sorted(
787
+ causes,
788
+ key=lambda cause: (
789
+ cause.precedence, 0 if cause.provider == "claude" else 1,
790
+ ),
791
+ )
792
+ return None, {
793
+ "code": ordered[0].code,
794
+ "message": _cause_message(ordered[0]),
795
+ "causes": tuple(
796
+ {
797
+ "provider": cause.provider,
798
+ "code": cause.code,
799
+ **({"detail": cause.detail} if cause.detail else {}),
800
+ }
801
+ for cause in ordered
802
+ ),
803
+ }
804
+ qualifications = _combined_qualifications(legs, claude, codex)
805
+ return {
806
+ "cost_usd": float(legs["claude"]["cost_usd"]) + float(legs["codex"]["cost_usd"]),
807
+ "total_tokens": int(legs["claude"]["total_tokens"]) + int(legs["codex"]["total_tokens"]),
808
+ "legs": legs,
809
+ **({"qualifications": qualifications} if qualifications else {}),
810
+ }, None
811
+
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:
424
852
  return None
425
853
  return {
426
- "cost_usd": float(claude_cost) + float(codex_cost),
427
- "total_tokens": claude_tokens + codex_tokens,
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
428
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
429
1061
 
430
1062
 
431
1063
  def _combined_alert_rows(
@@ -445,13 +1077,23 @@ def _combined_alert_rows(
445
1077
  if not isinstance(row, Mapping) or row.get("source") != source:
446
1078
  continue
447
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.
448
1094
  # Python's stable sort preserves declared source order, then each source's
449
- # native order, when alert timestamps tie.
450
- return tuple(sorted(
451
- ordered,
452
- key=lambda row: str(row.get("created_at") or ""),
453
- reverse=True,
454
- ))
1095
+ # native order, when firing instants tie.
1096
+ return tuple(sorted(ordered, key=_instant, reverse=True))
455
1097
 
456
1098
 
457
1099
  def compose_all_state(
@@ -461,35 +1103,23 @@ def compose_all_state(
461
1103
  """Compose provider-labeled sections without inventing blended semantics."""
462
1104
  if claude.source != "claude" or codex.source != "codex":
463
1105
  raise ValueError("all composition requires Claude and Codex provider states")
464
- combined = _combined_metrics(claude, codex)
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)
465
1112
  providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
466
- stale_cycle_providers = (
467
- _stale_cycle_providers(claude, codex) if providers_coherent else ()
468
- )
469
- # #359: the warning qualifies a retained combined actual. It stays
470
- # All-local and keeps the composed source partial so the header status also
471
- # names the caveat; provider envelopes remain independently coherent.
472
- all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
473
- if stale_cycle_providers:
474
- all_local_warnings = (SourceDashboardWarning(
475
- "combined_totals_stale",
476
- f"{' and '.join(stale_cycle_providers)} quota evidence is stale; "
477
- "combined totals use retained actuals.",
478
- "hero",
479
- ),)
480
1113
  if providers_coherent:
481
- if stale_cycle_providers:
482
- availability: Availability = "partial"
483
- else:
484
- availability = (
485
- "partial"
486
- if combined is None or "partial" in (claude.availability, codex.availability)
487
- else (
488
- "empty"
489
- if claude.availability == "empty" and codex.availability == "empty"
490
- else "ok"
491
- )
1114
+ availability: Availability = (
1115
+ "partial"
1116
+ if combined is None or "partial" in (claude.availability, codex.availability)
1117
+ else (
1118
+ "empty"
1119
+ if claude.availability == "empty" and codex.availability == "empty"
1120
+ else "ok"
492
1121
  )
1122
+ )
493
1123
  freshness: Freshness = "fresh"
494
1124
  else:
495
1125
  availability = "partial"
@@ -506,22 +1136,47 @@ def compose_all_state(
506
1136
  source_domain_freshness(codex, domain)
507
1137
  for domain in SOURCE_FRESHNESS_DOMAINS
508
1138
  ],
509
- combined is not None,
1139
+ # #556 S1: the WHOLE outcome, not merely whether one exists. The
1140
+ # legs, their periods, the qualifications and the withheld cause
1141
+ # are all published, and the account scope and hero counters that
1142
+ # produce them are not otherwise in this material — so hashing only
1143
+ # `combined is not None` would leave materially different All
1144
+ # states sharing one `data_version` (invariant 6).
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
+ ],
510
1160
  ],
511
1161
  separators=(",", ":"),
1162
+ sort_keys=True,
512
1163
  ).encode("utf-8")
513
1164
  data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
514
- successes = (item for item in (claude.last_success_at, codex.last_success_at) if item is not None)
515
- last_success_at = min(successes, default=None)
1165
+ successes = (claude.last_success_at, codex.last_success_at)
1166
+ # §4.6: `None` unless BOTH providers have one, otherwise the older.
1167
+ # Filtering `None` before `min` let one provider's success masquerade as
1168
+ # All's while the client keys "no successful snapshot yet" on null.
1169
+ last_success_at = None if None in successes else min(successes)
516
1170
  return SourceDashboardState(
517
1171
  source="all",
518
1172
  availability=availability,
519
1173
  freshness=freshness,
520
- # All-LOCAL warnings lead: `warningForSource` on the client falls back to
521
- # the FIRST warning, so a merely-partial provider warning (e.g.
522
- # `codex_metadata_incomplete`) would otherwise pre-empt the chip label
523
- # and hide the stale qualification on the combined actual.
524
- warnings=tuple((*all_local_warnings, *claude.warnings, *codex.warnings)),
1174
+ # §4.2: the All-local `combined_totals_stale` warning is retired. The
1175
+ # combined figure's own disclosure now travels in `data.combined`
1176
+ # (`qualifications`) and `data.combined_unavailable`, which is typed and
1177
+ # has provenance — All flattens both providers' warnings into one tuple
1178
+ # with no provenance field, so warning order could never carry it.
1179
+ warnings=tuple((*claude.warnings, *codex.warnings)),
525
1180
  data_version=data_version,
526
1181
  last_success_at=last_success_at,
527
1182
  capabilities={
@@ -532,7 +1187,14 @@ def compose_all_state(
532
1187
  },
533
1188
  data={
534
1189
  "combined": combined,
535
- "alerts": {"rows": _combined_alert_rows(claude, codex)},
1190
+ # Emitted iff the figure is withheld; omitted-when-inapplicable.
1191
+ **({"combined_unavailable": combined_unavailable}
1192
+ if combined is None else {}),
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,
536
1198
  "providers": {
537
1199
  "claude": claude.data,
538
1200
  "codex": codex.data,
@@ -616,25 +1278,78 @@ _CODEX_STATS_DIGEST_RELATIONS: tuple[tuple[str, str], ...] = (
616
1278
  )
617
1279
 
618
1280
 
619
- def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
620
- """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
+ )
621
1336
 
622
- A missing table is an empty relation so an older/fresh stats database has a
623
- stable digest. Other SQLite failures remain visible to the builder, which
624
- then follows the source all-or-prior failure matrix instead of publishing a
625
- guessed identity.
626
- """
627
- relations: list[list[list[object]]] = []
628
- 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:
629
1344
  try:
630
1345
  rows = stats_conn.execute(query).fetchall()
631
1346
  except sqlite3.OperationalError as exc:
632
1347
  if "no such table" not in str(exc).lower():
633
1348
  raise
634
1349
  rows = ()
635
- relations.append([list(row) for row in rows])
1350
+ relation_rows.append([list(row) for row in rows])
636
1351
  canonical = json.dumps(
637
- relations,
1352
+ relation_rows,
638
1353
  allow_nan=False,
639
1354
  ensure_ascii=False,
640
1355
  separators=(",", ":"),
@@ -642,6 +1357,26 @@ def codex_stats_digest(stats_conn: sqlite3.Connection) -> str:
642
1357
  return hashlib.sha256(canonical).hexdigest()
643
1358
 
644
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
+
645
1380
  def assess_codex_projection_coherence(
646
1381
  *,
647
1382
  active_root_keys: tuple[str, ...] | list[str] | set[str],