cctally 1.107.0 → 1.108.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/bin/_cctally_alerts.py +147 -6
  3. package/bin/_cctally_core.py +298 -2
  4. package/bin/_cctally_dashboard.py +8 -20
  5. package/bin/_cctally_dashboard_envelope.py +103 -12
  6. package/bin/_cctally_diff.py +15 -8
  7. package/bin/_cctally_doctor.py +71 -3
  8. package/bin/_cctally_forecast.py +31 -6
  9. package/bin/_cctally_journal.py +280 -28
  10. package/bin/_cctally_parser.py +9 -5
  11. package/bin/_cctally_percent_breakdown.py +278 -12
  12. package/bin/_cctally_quota.py +16 -20
  13. package/bin/_cctally_quota_model.py +156 -15
  14. package/bin/_cctally_record.py +416 -217
  15. package/bin/_cctally_rederive.py +0 -2
  16. package/bin/_cctally_setup.py +272 -110
  17. package/bin/_cctally_statusline.py +30 -1
  18. package/bin/_cctally_tui.py +28 -25
  19. package/bin/_cctally_weekrefs.py +315 -102
  20. package/bin/_lib_alerts_payload.py +48 -1
  21. package/bin/_lib_codex_hooks.py +694 -83
  22. package/bin/_lib_diff_kernel.py +33 -10
  23. package/bin/_lib_doctor.py +172 -24
  24. package/bin/_lib_journal.py +64 -0
  25. package/bin/_lib_meter_rate_change.py +132 -3
  26. package/bin/_lib_pricing.py +22 -5
  27. package/bin/_lib_pricing_check.py +5 -4
  28. package/bin/_lib_subscription_weeks.py +140 -63
  29. package/bin/cctally +14 -3
  30. package/dashboard/static/assets/{ConversationsView-BHbw2W1l.js → ConversationsView-BOSaBRtu.js} +12 -12
  31. package/dashboard/static/assets/{DoctorModal-DcyMPwvn.js → DoctorModal-CWn3U4Wl.js} +1 -1
  32. package/dashboard/static/assets/ModalRoot-SR070V6c.js +1 -0
  33. package/dashboard/static/assets/{ProjectsDrillPanel-ecN8oCwv.js → ProjectsDrillPanel-QLI9i5mZ.js} +1 -1
  34. package/dashboard/static/assets/{SourceDetailModal-CUwdD7_v.js → SourceDetailModal-pv0wlFxl.js} +1 -1
  35. package/dashboard/static/assets/{UpdateModal-CKlFE0ER.js → UpdateModal-D3m8GG6V.js} +3 -3
  36. package/dashboard/static/assets/index-BHu4mxd8.css +1 -0
  37. package/dashboard/static/assets/index-CIWsbux3.js +13 -0
  38. package/dashboard/static/assets/{outlineNavigation-CVse0Hj9.js → outlineNavigation-CJvKqmLV.js} +1 -1
  39. package/dashboard/static/assets/useKeymap-ffqsS5G0.js +1 -0
  40. package/dashboard/static/dashboard.html +3 -3
  41. package/package.json +1 -1
  42. package/dashboard/static/assets/ModalRoot-BYV-99Rq.js +0 -1
  43. package/dashboard/static/assets/index-D8svRv_9.js +0 -13
  44. package/dashboard/static/assets/index-klO46NcU.css +0 -1
  45. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.108.0] - 2026-09-05
9
+
10
+ ### Added
11
+ - Codex reporting prices `gpt-6-astra` from OpenAI's published standard, cached-input, long-context and Fast-mode rates instead of the legacy `gpt-5` fallback.
12
+ - `cctally alerts test` gains `--axis quota` and `--axis meter-rate-change`, so you can rehearse those two notifications instead of waiting for a real crossing or rate change. `--threshold` does not apply to `meter-rate-change`.
13
+ - `cctally percent-breakdown` and `cctally report --detail` name a run of percent thresholds that one observation recorded, and print `observation_gap` where the marginal cost showed a bare `n/a`. `--json` adds `marginalCostWithheldCause`.
14
+ - On an install with more than one account for a provider, every alert toast names the account it belongs to, as the dashboard's Recent Alerts list already did.
15
+ - `cctally doctor` gains `hooks.codex_liveness_7d`, which FAILs when an enabled Codex hook has not succeeded in seven days. It reads the per-root success markers, so log rotation cannot hide a hook that stopped firing.
16
+
17
+ ### Changed
18
+ - `cctally weekly`, `cctally report` and `cctally project` render a week credited twice as three rows, one per billing cycle. A dictionary keyed on the week alone now keeps only the last cycle; key on the week and its start instant together.
19
+ - `cctally weekly`, `cctally report` and `cctally project` start the first cycle of a week whose boundary moved and was then credited at the moved boundary. The previous week keeps its own spend, which was counted into the credited week.
20
+ - Upgrading from 1.107.0 or earlier rebuilds the disposable stats index once, because a weekly reset now records the observation it came from and a rate change records its evidence. Commands report the rebuild until it finishes.
21
+ - A metering-rate change recorded before this upgrade reports no evidence rather than inventing any.
22
+ - A weekly credit is recorded at the exact second Anthropic issued it rather than the top of that hour, so a reading captured earlier in the same hour stays in the cycle it belongs to.
23
+ - A pre-credit reading captured before a second weekly credit now survives and is shown inside the earlier cycle, because each credit's cleanup is scoped to its own instant instead of reaching back past the previous credit.
24
+ - `cctally doctor` now reads Codex's own hook trust record and FAILs when the cctally handler is disabled or was never trusted in Codex `/hooks`, instead of reporting it as installed.
25
+ - `cctally doctor` warns when the Codex handler changed after Codex last recorded a trust decision about it, and when that record cannot be read at all.
26
+ - `cctally setup` refuses at exit 1 when reconciling the Codex handler would land it on a trust decision Codex recorded about a different handler. The message names the file, the slots and the fix.
27
+ - When Codex's trust record changes mid-run, the `cctally setup` refusal names each Codex hooks file it had already rewritten and the dated backup of its previous contents. `--json` adds `changed_hooks_backups`.
28
+ - `cctally doctor` states how many Codex roots are installed beside how many are enabled, so two installed-but-untrusted handlers no longer read as `0/2 root(s) enabled` alone.
29
+ - `cctally setup` recognizes a Codex handler written by a different install channel and collapses it into one canonical handler, instead of adding a second one beside it.
30
+ - `cctally setup --json` moves to `schema_version: 2`: the `installed_review_required` state is retired, and per-root `changes` becomes a per-event object with separate added, removed and unchanged counts.
31
+ - `cctally setup --dry-run` and the applied run now report the same added, removed and unchanged Codex handler counts for the same input.
32
+ - The dashboard and the TUI stop treating a Codex handler as trusted merely because it is present, and re-check that trust when `config.toml` changes, so a hook disabled while either was running no longer certifies stale conversation cost.
33
+ - A freshly installed Codex handler reports as untrusted, and `cctally doctor` FAILs, until you approve it in Codex `/hooks`. Until then the dashboard and the TUI walk every Codex session on each refresh.
34
+ - Two `cctally setup` messages read as ordinary English again: the orphaned `[hooks.state]` remedy says to remove the entry from the file by hand, and a moved trust slot says the handler would move from one key to the other.
35
+
36
+ ### Fixed
37
+ - A percent milestone crossed on a week that holds two recorded resets is listed again. It was filed against one cycle while `cctally percent-breakdown` listed another, so the crossing appeared under neither.
38
+ - Every view of a credited week picks the cycle the week is currently in, not the reset written last. `cctally diff`, the TUI's per-percent modal and the dashboard's milestone list could each name an earlier cycle.
39
+ - A weekly threshold notification names the billing cycle that crossed. On a week credited more than once it read `Week starting Jun 05` for every cycle, naming all of them at once; an uncredited week reads exactly as before.
40
+ - A second Anthropic usage reset inside one subscription week now registers. Every reset in a week shared one slot, so the first held it and later ones were discarded, leaving the reported percentage at the pre-reset high-water mark.
41
+ - A weekly reset observed while cctally is interrupted is no longer lost, and one zero reading can no longer confirm itself into a credit. The pending-reset state moved into the database and commits or rolls back with the reset it fired.
42
+ - A stale pre-credit reading that a credit removed stays removed after `cctally db rebuild --db stats`. The removal is recorded, so a rebuild no longer restores the reading and holds the reported percentage at the pre-credit high.
43
+ - An idle session no longer holds the reported weekly percentage at a stale value. It re-renders its status line from a cached rate-limit block, and that block is now ignored in full once its five-hour window has closed.
44
+ - A severe metering-rate drop now raises a red alert toast instead of the amber that made it look like a smaller drop, and the toast's rate chip matches the colour Recent Alerts already showed for the same event.
45
+ - A metering-rate change detected while the quota calibration was withheld now records which calibration was withheld and how many baseline days were missing, instead of pointing you at a fitted budget that may never have been produced.
46
+ - A metering-rate change whose notification failed and is retried later now carries that same disclosure, because it is read back from the recorded change rather than re-derived from a later run.
47
+ - A metering-rate toast now names the calibration that was withheld and why, instead of pointing you at a fitted budget that may not exist. An ordinary rate change reads exactly as it did before.
48
+ - Recent Alerts shows the evidence behind a metering-rate change: the withheld calibration, the detector's inputs, the composition provenance, and how many baseline days were withheld.
49
+ - An alert toast on a multi-account install states how to dismiss it again, on its own line below the alert's own text rather than above it, so a screen reader reads the alert before it reads how to close it.
50
+ - Alert toasts are usable from the keyboard: press Enter or Space to dismiss one, or to activate its own button and open the window it names. A focused toast states the alert before how to close it, and dismissing one restores focus.
51
+ - The dashboard's Daily panel reads `loading` while it hydrates, instead of reporting the 30-day window as `withheld` over a loading skeleton.
52
+ - The dashboard's Trend panel states its title in full on a phone. The week and cycle counts moved to the wrapping sub-line the other cards use, so the title is no longer cut off mid-word.
53
+ - The dashboard's Daily panel prints its total and peak-day amounts in full on a narrow window. Below about 400px the two summary columns stack instead of cutting the dollar figures off.
54
+ - An install upgrading from a version that predates the append-only journal no longer records a second copy of a reset it already had, so a credited week keeps its own cycles instead of gaining an extra one.
55
+
8
56
  ## [1.107.0] - 2026-09-04
9
57
 
10
58
  ### Changed
@@ -252,9 +252,16 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
252
252
  install's alert text stays byte-identical to today.
253
253
 
254
254
  Delegates the R8 gate + label precedence to the single-definition helpers
255
- ``_cctally_account.real_account_count`` / ``account_label`` (P2-CQ1) so the
256
- ">1 real account" trigger and the key->label map have exactly one home; this
257
- wrapper only opens the RO connection and stays best-effort/never-raise.
255
+ ``_cctally_account.provider_is_decorated`` / ``account_label`` (P2-CQ1) so
256
+ the ">1 real account" trigger and the key->label map have exactly one home;
257
+ this wrapper only opens the RO connection and stays best-effort/never-raise.
258
+
259
+ #702: the gate is the canonical predicate itself, negated at the call site.
260
+ This site previously inverted the underlying counter by hand
261
+ (``real_account_count(conn, vendor) <= 1``), which is a second written form
262
+ of the R8 threshold — the arrangement that lets the two spellings disagree
263
+ if the threshold ever moves. ``provider_is_decorated`` is ``> 1``, so the
264
+ suppressed branch is ``not provider_is_decorated(...)``.
258
265
 
259
266
  #697: the VENDOR comes from the payload's own ``provider`` when it carries
260
267
  one, and from ``_AXIS_VENDOR`` otherwise. The metering-rate-change family
@@ -288,7 +295,7 @@ def _alert_label_prefix(axis: str, account_key: "str | None",
288
295
  connect=lambda p: _sq.connect(f"file:{p}?mode=ro", uri=True),
289
296
  )
290
297
  try:
291
- if _cctally_account.real_account_count(conn, vendor) <= 1:
298
+ if not _cctally_account.provider_is_decorated(conn, vendor):
292
299
  return ""
293
300
  # #416 §6: population-aware, so two accounts that auto-label to
294
301
  # one email do not print the same alert prefix.
@@ -400,8 +407,27 @@ def _dispatch_alert_notification(
400
407
  # rate transition has no percentage threshold for `severity_for` to
401
408
  # map. Reading it verbatim is what keeps the three tiers meaningful
402
409
  # here; falling through would floor every transition at `info`.
410
+ #
411
+ # #750 S2: the vocabulary is RESOLVED from the kernel's own tuple
412
+ # rather than restated as a literal. A restated literal makes this the
413
+ # second written form of the same rule, which is exactly the
414
+ # arrangement #747 closed on the envelope: a fourth member added to
415
+ # `RATE_CHANGE_SEVERITIES`, to the TypeScript tuple and to the CSS
416
+ # passes every parity test and is still clamped to `info` here, so the
417
+ # OS notification's urgency and the trailing `alerts.log` column are
418
+ # wrong while the suite is green.
419
+ #
420
+ # Guarded the same way `_cctally_dashboard_envelope` guards its copy,
421
+ # and an unresolvable kernel degrades to NO clamp. This function's
422
+ # contract is that it never raises, and clamping against an empty
423
+ # vocabulary would send every correct severity to `info`.
403
424
  severity = str(payload.get("severity") or "info")
404
- if severity not in ("info", "warn", "alarm"):
425
+ try:
426
+ _severities = frozenset(
427
+ _load_lib("_lib_meter_rate_change").RATE_CHANGE_SEVERITIES)
428
+ except Exception: # noqa: BLE001
429
+ _severities = None
430
+ if _severities is not None and severity not in _severities:
405
431
  severity = "info"
406
432
  else:
407
433
  try:
@@ -484,6 +510,54 @@ def _dispatch_alert_notification(
484
510
  return status
485
511
 
486
512
 
513
+ def _synthetic_instant_days_ago(days: int) -> str:
514
+ """A timezone-aware ISO instant `days` before now (#699).
515
+
516
+ Negative goes forward, which is what a quota reset needs. Both non-registry
517
+ synthetics require real instants rather than placeholders: the rate-change
518
+ body renders an effective date, and the quota body renders a reset.
519
+ """
520
+ moment = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
521
+ return moment.isoformat().replace("+00:00", "Z")
522
+
523
+
524
+ def _test_alert_account_key(vendor: str) -> str:
525
+ """A REAL account key for `vendor`, else the vendor-wide sentinel (#699).
526
+
527
+ This is what makes the R8 `[<label>]` prefix observable from
528
+ `alerts test`: on a decorated install the synthetic carries a key the
529
+ registry can label, and on a single-account install it falls back to `*`,
530
+ which `_alert_label_prefix` suppresses — so that install's output stays
531
+ byte-identical, exactly as R8 requires.
532
+
533
+ Best-effort and never-raise, following `_alert_label_prefix`: a missing
534
+ database, an unreadable registry or an empty one all degrade to the
535
+ sentinel rather than turning a rehearsal into an error path.
536
+ """
537
+ try:
538
+ import sqlite3 as _sq
539
+ import _cctally_account
540
+ import _cctally_store
541
+ db_path = _cctally_core.DB_PATH
542
+ if not db_path.exists():
543
+ return _lib_accounts.VENDOR_WIDE
544
+ conn = _cctally_store.stats_open_guarded(
545
+ db_path,
546
+ connect=lambda p: _sq.connect(f"file:{p}?mode=ro", uri=True),
547
+ )
548
+ try:
549
+ for row in _cctally_account.load_accounts(conn, vendor):
550
+ key = str(row.get("account_key") or "")
551
+ if key and key not in (_lib_accounts.UNATTRIBUTED,
552
+ _lib_accounts.VENDOR_WIDE):
553
+ return key
554
+ finally:
555
+ conn.close()
556
+ except Exception: # noqa: BLE001
557
+ return _lib_accounts.VENDOR_WIDE
558
+ return _lib_accounts.VENDOR_WIDE
559
+
560
+
487
561
  def cmd_alerts_test(args: argparse.Namespace) -> int:
488
562
  """Send a synthetic test alert through the dispatch pipeline.
489
563
 
@@ -510,9 +584,28 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
510
584
  axis = "codex_budget"
511
585
  elif args.axis == "projected":
512
586
  axis = "projected"
587
+ elif args.axis == "quota":
588
+ axis = "quota"
589
+ elif args.axis == "meter-rate-change":
590
+ axis = "meter_rate_change"
513
591
  else:
514
592
  axis = "five_hour"
515
- threshold = int(args.threshold)
593
+ # #699: `--threshold` now defaults to None so that "supplied" is
594
+ # distinguishable from "defaulted". Every axis that takes one still gets 90
595
+ # when it is omitted, which keeps the existing surface byte-identical.
596
+ raw_threshold = getattr(args, "threshold", None)
597
+ if axis == "meter_rate_change" and raw_threshold is not None:
598
+ # Exit 2 is native-usage per docs/cli-contract.md. The message names
599
+ # BOTH the axis and the flag: a refusal that does not say what failed
600
+ # costs the reader more than one that gives no reason at all.
601
+ print(
602
+ "cctally: --axis meter-rate-change does not accept --threshold; "
603
+ "a metering-rate change has no percentage threshold and carries "
604
+ "an explicit severity instead",
605
+ file=sys.stderr,
606
+ )
607
+ return 2
608
+ threshold = 90 if raw_threshold is None else int(raw_threshold)
516
609
  # --threshold range stays [1, 100] (F5): the cap is axis-uniform with the
517
610
  # existing weekly/5h thresholds. Over-budget tiers (>100%) are a v2
518
611
  # deferral, not an oversight — see spec §2 (F5).
@@ -603,6 +696,54 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
603
696
  denominator=denominator,
604
697
  week_start_at=dt.date.today().isoformat(),
605
698
  )
699
+ elif axis == "meter_rate_change":
700
+ # #699: the family is deliberately NOT in `AXIS_REGISTRY`, so there is
701
+ # no `_build_alert_payload_*` helper for it. The synthetic descriptor
702
+ # is built HERE and passed through the real
703
+ # `_lib_meter_rate_change.alert_payload`, which is what keeps that
704
+ # kernel clock-, database- and policy-free while still rehearsing the
705
+ # exact payload production dispatches.
706
+ import _lib_meter_rate_change as _mrc
707
+ previous, new = 2_442_620.0, 1_665_096.0
708
+ effective = _synthetic_instant_days_ago(7)
709
+ payload = _mrc.alert_payload(_mrc.RateChangeTransition(
710
+ provider="claude",
711
+ account_key=_test_alert_account_key("claude"),
712
+ effective_from=effective,
713
+ previous_units_per_point=previous,
714
+ new_units_per_point=new,
715
+ # Computed by the real kernel rather than hardcoded, so a change
716
+ # to the severity boundaries is rehearsed here too. These rates
717
+ # are a 31.8% drop — the maintainer's own observed transition —
718
+ # which lands on `alarm`.
719
+ severity=_mrc.transition_severity(previous, new),
720
+ detected_at=now_utc_iso(),
721
+ # The #690 disclosure, so the rehearsal exercises the withheld
722
+ # copy rather than only the ordinary path.
723
+ withholding_status="unsupported-model-mix",
724
+ detector_input_causes='["unsupported-composition"]',
725
+ composition_provenance='["forecast-aggregate"]',
726
+ baseline_withheld_days=3,
727
+ ))
728
+ elif axis == "quota":
729
+ # #699: also outside `AXIS_REGISTRY`, but this one HAS a payload
730
+ # builder, so the synthetic goes through it unchanged. `source` is
731
+ # `codex` because that is the only vendor this family observes, and
732
+ # `_AXIS_VENDOR` maps the axis to the same vendor for the R8 lookup.
733
+ payload = _build_alert_payload_quota(
734
+ source="codex",
735
+ source_root_key="default",
736
+ logical_limit_key="weekly",
737
+ observed_slot=_synthetic_instant_days_ago(2),
738
+ window_minutes=10080,
739
+ resets_at_utc=_synthetic_instant_days_ago(-5),
740
+ threshold=threshold,
741
+ kind="weekly",
742
+ crossed_at_utc=now_utc_iso(),
743
+ qualifying_percent=float(threshold),
744
+ projected_percent=min(100.0, float(threshold) + 5.0),
745
+ account_key=_test_alert_account_key("codex"),
746
+ )
606
747
  else:
607
748
  payload = _build_alert_payload_five_hour(
608
749
  threshold=threshold,
@@ -410,7 +410,33 @@ _init_paths_from_env()
410
410
  # install and the table would simply never appear. `quota_alert_arming` is the
411
411
  # precedent the family follows — a journaled STATE record whose boundary
412
412
  # survives a rebuild, so history cannot re-fire.
413
- STATS_INDEX_EPOCH = 1011
413
+ # 1011 -> 1012 (#750 S2 §3.6): the rate-change disclosure evidence. Adds four
414
+ # NULLABLE columns to `meter_rate_change_events` — `withholding_status`,
415
+ # `detector_input_causes`, `composition_provenance` and
416
+ # `baseline_withheld_days` — so a transition admitted by #688's
417
+ # detection-keyed path carries the analysis's own stamp on the ROW rather than
418
+ # only on the notification that may never have been delivered. It is an epoch
419
+ # bump for the same two reasons every bump since 1005 carries: the 13-entry
420
+ # registry is frozen, AND an epoch-current open returns before any schema
421
+ # work, so neither a `@stats_migration` handler nor an
422
+ # `add_column_if_missing` would ever run on an upgraded install and the
423
+ # columns would simply never appear.
424
+ # 1012 -> 1013 (#750 S3 §1.6): reset-event origin identity plus transactional
425
+ # debounce state. Adds `week_reset_events.origin_observation_id` (the raw
426
+ # journal id of the observation that caused the reset) and the two PARTIAL
427
+ # unique indexes that carry the dual-shaped identity — a row with an origin is
428
+ # unique on `(account_key, origin_observation_id)`, a legacy origin-null row
429
+ # keeps the old `(account_key, old_week_end_at, new_week_end_at)` tuple, and
430
+ # the table-level UNIQUE is retired because a single index cannot express
431
+ # both. Also adds `weekly_reset_debounce_state`, which replaces the filesystem
432
+ # reset-to-zero marker so an ARM or a CONFIRM commits in the same stats
433
+ # transaction as the journal cursor and the reset event. The identity change
434
+ # is what lets two genuinely distinct resets share one boundary tuple while
435
+ # one physical reset detected twice stays one event. The mechanical reason it
436
+ # is a bump and not a migration is unchanged: the registry is frozen at 13,
437
+ # and an epoch-current open returns before any schema work, so a handler or an
438
+ # `add_column_if_missing` would never run on an upgraded install.
439
+ STATS_INDEX_EPOCH = 1013
414
440
  LEGACY_STATS_HEAD = 13
415
441
 
416
442
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
@@ -1682,6 +1708,34 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1682
1708
  -- would call the same one. The FINGERPRINT stays out of the key
1683
1709
  -- deliberately: including it would re-alert on our own algorithm
1684
1710
  -- revisions.
1711
+ -- #690 / #692 (epoch 1012) adds the last four columns: the disclosure
1712
+ -- evidence captured at DETECTION time, stored as four independent
1713
+ -- facts rather than one merged status taxonomy. Folding the baseline
1714
+ -- count into `withholding_status` would make an otherwise healthy
1715
+ -- successor read as currently withheld.
1716
+ --
1717
+ -- NULL, an empty array and 0 are THREE distinct states and every
1718
+ -- consumer must preserve the distinction: NULL means legacy or
1719
+ -- unrecoverable evidence, an empty typed set means assessed with no
1720
+ -- such origin, 0 means a clean baseline, and a positive count means
1721
+ -- thin baseline evidence. The #689 regime-recovery route sees stored
1722
+ -- calibration regimes and no `QuotaAnalysis` at all, and all four
1723
+ -- values are analysis-derived, so on that route all four are NULL by
1724
+ -- construction — which is why every one of them is nullable.
1725
+ --
1726
+ -- `detector_input_causes` and `composition_provenance` hold canonical
1727
+ -- JSON arrays of enum VALUES, never their `repr`, so a sixth
1728
+ -- `CompositionProvenance` origin added later cannot silently alias
1729
+ -- onto an existing one. `baseline_withheld_days` carries
1730
+ -- `baseline_fit.population["withheld"]` — the count of fenced
1731
+ -- observations carrying a cause that fall strictly BEFORE
1732
+ -- `window_start` — as a count rather than a boolean, so zero, one and
1733
+ -- many stay distinguishable and honest copy stays possible.
1734
+ --
1735
+ -- These comments sit OUTSIDE the statement deliberately: SQLite
1736
+ -- stores the CREATE TABLE text verbatim, so a comment written inside
1737
+ -- it lands in `sqlite_schema.sql`, in every `iterdump()` golden and
1738
+ -- in `_REBUILD_SCHEMA_FINGERPRINT`.
1685
1739
  CREATE TABLE IF NOT EXISTS meter_rate_change_events (
1686
1740
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1687
1741
  provider TEXT NOT NULL,
@@ -1693,6 +1747,10 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1693
1747
  detected_at_utc TEXT NOT NULL,
1694
1748
  created_at_utc TEXT NOT NULL,
1695
1749
  notified_at TEXT,
1750
+ withholding_status TEXT,
1751
+ detector_input_causes TEXT,
1752
+ composition_provenance TEXT,
1753
+ baseline_withheld_days INTEGER,
1696
1754
  UNIQUE(provider, account_key, effective_from)
1697
1755
  );
1698
1756
  CREATE INDEX IF NOT EXISTS idx_meter_rate_change_events_key
@@ -2217,6 +2275,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2217
2275
  # start — preventing the API's -7d-derived new week from overlapping
2218
2276
  # the old week. Inserted by cmd_record_usage on detection; read by
2219
2277
  # _apply_reset_events_to_weekrefs and the cost live-recompute path.
2278
+ # #750 S3 §1.1: identity is DUAL-SHAPED, carried by the two partial
2279
+ # unique indexes below rather than by a table-level UNIQUE. A row that
2280
+ # names its originating observation is unique on
2281
+ # `(account_key, origin_observation_id)`; a legacy row with no origin
2282
+ # keeps the old `(account_key, old_week_end_at, new_week_end_at)`
2283
+ # tuple. One index cannot express both, and a single index over the
2284
+ # tuple would refuse a second genuine reset that happens to share a
2285
+ # boundary pair with the first.
2220
2286
  conn.execute(
2221
2287
  """
2222
2288
  CREATE TABLE IF NOT EXISTS week_reset_events (
@@ -2227,13 +2293,55 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2227
2293
  effective_reset_at_utc TEXT NOT NULL,
2228
2294
  observed_pre_credit_pct REAL,
2229
2295
  account_key TEXT NOT NULL DEFAULT 'unattributed',
2230
- UNIQUE(account_key, old_week_end_at, new_week_end_at)
2296
+ origin_observation_id TEXT
2231
2297
  )
2232
2298
  """
2233
2299
  )
2234
2300
  add_column_if_missing(
2235
2301
  conn, "week_reset_events", "account_key",
2236
2302
  "TEXT NOT NULL DEFAULT 'unattributed'")
2303
+ add_column_if_missing(
2304
+ conn, "week_reset_events", "origin_observation_id", "TEXT")
2305
+ # #750 S3 §1.7. A pre-journal store's `week_reset_events` was created
2306
+ # with a table-level UNIQUE that epoch 1013 retires, and
2307
+ # `CREATE TABLE IF NOT EXISTS` cannot change an existing table. Left in
2308
+ # place it survives the cutover and keeps refusing a legitimate second
2309
+ # in-place credit — the very refusal this session removes. Rebuild the
2310
+ # one table into the current shape, preserving row ids, BEFORE the two
2311
+ # partial indexes are created (a DROP TABLE takes its indexes with it)
2312
+ # and before the backfill runs.
2313
+ _rebuild_retired_week_reset_uniqueness(conn)
2314
+ conn.execute(
2315
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_week_reset_events_origin "
2316
+ "ON week_reset_events(account_key, origin_observation_id) "
2317
+ "WHERE origin_observation_id IS NOT NULL"
2318
+ )
2319
+ conn.execute(
2320
+ "CREATE UNIQUE INDEX IF NOT EXISTS "
2321
+ "idx_week_reset_events_legacy_tuple "
2322
+ "ON week_reset_events(account_key, old_week_end_at, "
2323
+ "new_week_end_at) WHERE origin_observation_id IS NULL"
2324
+ )
2325
+ # #750 S3 §1.3: the transactional replacement for the filesystem
2326
+ # reset-to-zero marker. ARM upserts the row, CONFIRM / CLEAR /
2327
+ # FIRE_IMMEDIATE delete it, and every mutation commits in the same
2328
+ # stats transaction as the journal cursor and the reset event, which is
2329
+ # what closes both crash windows the filesystem marker left open. It is
2330
+ # disposable operational state rather than journal truth, so an epoch
2331
+ # rebuild legitimately loses it: a real reset simply re-arms and
2332
+ # confirms one tick later.
2333
+ conn.execute(
2334
+ """
2335
+ CREATE TABLE IF NOT EXISTS weekly_reset_debounce_state (
2336
+ account_key TEXT PRIMARY KEY,
2337
+ week_start_date TEXT NOT NULL,
2338
+ week_end_at TEXT NOT NULL,
2339
+ baseline_pct REAL NOT NULL,
2340
+ first_zero_at_utc TEXT NOT NULL,
2341
+ first_zero_observation_id TEXT
2342
+ )
2343
+ """
2344
+ )
2237
2345
  _backfill_week_reset_events(conn)
2238
2346
 
2239
2347
  # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
@@ -2974,6 +3082,77 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2974
3082
  # === WeekRef cluster ================================================
2975
3083
 
2976
3084
 
3085
+ #: The epoch-1013 column set of `week_reset_events`, in declaration order.
3086
+ #: `_rebuild_retired_week_reset_uniqueness` copies the intersection of this and
3087
+ #: whatever the existing table has, so a column added later in the schema apply
3088
+ #: (`journal_id`) is simply re-added afterwards by its own guarded ALTER.
3089
+ _WEEK_RESET_EVENT_COLUMNS = (
3090
+ "id", "detected_at_utc", "old_week_end_at", "new_week_end_at",
3091
+ "effective_reset_at_utc", "observed_pre_credit_pct", "account_key",
3092
+ "origin_observation_id",
3093
+ )
3094
+
3095
+
3096
+ def _rebuild_retired_week_reset_uniqueness(conn) -> bool:
3097
+ """Rebuild `week_reset_events` without its retired table-level UNIQUE.
3098
+
3099
+ #750 S3 §1.7. Epoch 1013 moved identity onto the originating observation
3100
+ and expresses it as two PARTIAL unique indexes, which one table-level
3101
+ constraint cannot represent. A table created by an older binary still
3102
+ carries `UNIQUE(account_key, old_week_end_at, new_week_end_at)`, and
3103
+ `CREATE TABLE IF NOT EXISTS` is a no-op against it, so the constraint would
3104
+ survive the cutover and keep refusing a second genuine in-place credit.
3105
+
3106
+ Idempotent by construction: the trigger is the presence of a UNIQUE
3107
+ CONSTRAINT index (`PRAGMA index_list` origin `'u'`), which only a
3108
+ table-level UNIQUE creates and which the rebuilt shape does not have, so a
3109
+ second call is a no-op. Row ids are preserved, because the cutover stamps
3110
+ `journal_id = b:week_reset_events:<rowid>` from them and a renumbering
3111
+ would give the same physical facts new logical identities.
3112
+
3113
+ Returns True when it rebuilt.
3114
+ """
3115
+ try:
3116
+ columns = [str(row[1]) for row in
3117
+ conn.execute("PRAGMA table_info(week_reset_events)")]
3118
+ if not columns:
3119
+ return False
3120
+ constrained = any(
3121
+ str(row[3]) == "u"
3122
+ for row in conn.execute("PRAGMA index_list(week_reset_events)")
3123
+ )
3124
+ except sqlite3.DatabaseError:
3125
+ return False
3126
+ if not constrained:
3127
+ return False
3128
+ carried = [name for name in _WEEK_RESET_EVENT_COLUMNS if name in columns]
3129
+ column_list = ", ".join(carried)
3130
+ conn.execute(
3131
+ """
3132
+ CREATE TABLE week_reset_events__rebuild_1013 (
3133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3134
+ detected_at_utc TEXT NOT NULL,
3135
+ old_week_end_at TEXT NOT NULL,
3136
+ new_week_end_at TEXT NOT NULL,
3137
+ effective_reset_at_utc TEXT NOT NULL,
3138
+ observed_pre_credit_pct REAL,
3139
+ account_key TEXT NOT NULL DEFAULT 'unattributed',
3140
+ origin_observation_id TEXT
3141
+ )
3142
+ """
3143
+ )
3144
+ conn.execute(
3145
+ f"INSERT INTO week_reset_events__rebuild_1013 ({column_list}) "
3146
+ f"SELECT {column_list} FROM week_reset_events"
3147
+ )
3148
+ conn.execute("DROP TABLE week_reset_events")
3149
+ conn.execute(
3150
+ "ALTER TABLE week_reset_events__rebuild_1013 "
3151
+ "RENAME TO week_reset_events"
3152
+ )
3153
+ return True
3154
+
3155
+
2977
3156
  def _canonicalize_optional_iso(value: str | None, label: str) -> str | None:
2978
3157
  if value is None:
2979
3158
  return None
@@ -3056,6 +3235,123 @@ def _get_latest_row_for_week(
3056
3235
  ).fetchone()
3057
3236
 
3058
3237
 
3238
+ def _latest_reset_event_for_end(
3239
+ conn: sqlite3.Connection,
3240
+ new_week_end_at: str,
3241
+ *,
3242
+ account_key: str | None,
3243
+ as_of_utc: "str | None" = None,
3244
+ ) -> "sqlite3.Row | None":
3245
+ """The ACTIVE segment's `week_reset_events` row for a week end, or None.
3246
+
3247
+ #750 S3 B3. One chokepoint for the read sites that ask "which segment
3248
+ of this week is the live one", because they disagreed with each other and
3249
+ all were wrong in the same direction.
3250
+
3251
+ The order is `unixepoch(effective_reset_at_utc) DESC, id DESC`. The
3252
+ instant is the semantic cycle boundary, so it is what decides; `id` is
3253
+ only a deterministic tie-breaker for two rows recording the same instant,
3254
+ and ordering on it ALONE answers "whichever row was written last", which
3255
+ a backfill can make the older reset. `unixepoch(...)`, never a lexical
3256
+ compare: the column carries mixed offset spellings and a textual ORDER BY
3257
+ mis-orders them on a non-UTC host, the same defence
3258
+ `_reset_aware_floor` documents.
3259
+
3260
+ ``account_key`` scopes the read; ``None`` is the explicit merged read and
3261
+ is byte-stable on a single-account install. It matters because the match
3262
+ is on the end instant alone, and two accounts hold weeks that share one.
3263
+
3264
+ ``as_of_utc`` restricts the answer to segments already in effect at that
3265
+ capture instant, which is what the milestone WRITER asks (#750 S3, Unit B
3266
+ review): a crossing belongs to the segment that was live when it was
3267
+ observed, not to one credited afterwards. It narrows the question rather
3268
+ than changing it, so the ordering is the same ordering — the writer kept
3269
+ its own `ORDER BY id DESC` copy of this query and therefore stamped
3270
+ `percent_milestones.reset_event_id` with a segment
3271
+ `cmd_percent_breakdown` does not filter on, and the milestone rendered
3272
+ nowhere.
3273
+ """
3274
+ acct_pred = "" if account_key is None else " AND account_key = ?"
3275
+ acct_params: tuple = () if account_key is None else (account_key,)
3276
+ as_of_pred = ""
3277
+ as_of_params: tuple = ()
3278
+ if as_of_utc is not None:
3279
+ as_of_pred = " AND unixepoch(effective_reset_at_utc) <= unixepoch(?)"
3280
+ as_of_params = (as_of_utc,)
3281
+ # The columns are NAMED rather than taken with `SELECT *`. `week_reset_
3282
+ # events` is an epoch-versioned table that has already gained and lost
3283
+ # columns (epoch 1013 added `origin_observation_id` and retired a
3284
+ # constraint), and under `*` a column that goes away fails at whichever
3285
+ # consumer happens to read it, one call frame away from the query. Named,
3286
+ # it fails here. This is the read set: `id` is the segment identity
3287
+ # `percent-breakdown` filters milestones on, `effective_reset_at_utc` is
3288
+ # the boundary `_diff_resolve_anchor` moves the window start to, and the
3289
+ # remaining three are the row's own identity for a caller that needs it.
3290
+ return conn.execute(
3291
+ f"""
3292
+ SELECT id, account_key, old_week_end_at, new_week_end_at,
3293
+ effective_reset_at_utc
3294
+ FROM week_reset_events
3295
+ WHERE new_week_end_at = ?{acct_pred}{as_of_pred}
3296
+ ORDER BY unixepoch(effective_reset_at_utc) DESC, id DESC
3297
+ LIMIT 1
3298
+ """,
3299
+ (new_week_end_at,) + acct_params + as_of_params,
3300
+ ).fetchone()
3301
+
3302
+
3303
+ def _ordered_in_place_cuts(
3304
+ cuts: "list[tuple[dt.datetime, str]] | None",
3305
+ week_start_at: str | None,
3306
+ week_end_dt: dt.datetime,
3307
+ ) -> "list[tuple[dt.datetime, str]]":
3308
+ """The week's in-place cuts, strictly inside it, deduplicated and ascending.
3309
+
3310
+ #750 S3 B2. Deduplication is on the parsed INSTANT rather than on the
3311
+ stored text, because two rows can spell one instant in different offsets.
3312
+ A cut on either boundary would emit a zero-width segment, which is not a
3313
+ billing cycle, so both bounds are strict.
3314
+
3315
+ The two bounds degrade INDEPENDENTLY (#750 S3, Unit B review). When the
3316
+ week's own start is absent or unparseable the lower bound cannot be
3317
+ tested, and the cut is admitted, as it was before this filter existed.
3318
+ The upper bound is a different quantity and the caller always has it, so
3319
+ it still applies: skipping it alongside the lower one admitted a cut at or
3320
+ after the week's end, and the tail `[cn, end)` it produced was inverted or
3321
+ zero-width. That asymmetry was inherited from the two per-applier copies
3322
+ rather than chosen.
3323
+
3324
+ ``week_start_at`` is the week's EFFECTIVE start — the caller passes the
3325
+ boundary shift when the week carries one, so a cut that predates the shift
3326
+ falls outside this week and is rejected rather than producing a head
3327
+ segment that runs backwards. It is a raw ISO string rather than a `WeekRef`
3328
+ or a `SubWeek`, because BOTH appliers apply this same bound and they had
3329
+ two identical copies of it, neither with a test, until the Unit B review.
3330
+ It lives here, beside `_latest_reset_event_for_end`, because the two
3331
+ together are the whole of "which reset bounds this segment".
3332
+ """
3333
+ if not cuts:
3334
+ return []
3335
+ start_dt = None
3336
+ if week_start_at:
3337
+ try:
3338
+ start_dt = parse_iso_datetime(week_start_at, "week_start_at")
3339
+ except ValueError:
3340
+ start_dt = None
3341
+ ordered: list[tuple[dt.datetime, str]] = []
3342
+ seen: set = set()
3343
+ for cut_dt, cut_raw in sorted(cuts, key=lambda c: c[0]):
3344
+ if cut_dt in seen:
3345
+ continue
3346
+ if cut_dt >= week_end_dt:
3347
+ continue
3348
+ if start_dt is not None and cut_dt <= start_dt:
3349
+ continue
3350
+ seen.add(cut_dt)
3351
+ ordered.append((cut_dt, cut_raw))
3352
+ return ordered
3353
+
3354
+
3059
3355
  def _reset_aware_floor(
3060
3356
  conn: sqlite3.Connection,
3061
3357
  week_start_date: str,
@@ -1892,7 +1892,10 @@ def _conversation_frontier_context():
1892
1892
  try:
1893
1893
  codex_hooks_mod = _cctally()._load_sibling("_lib_codex_hooks")
1894
1894
  hook_roots = codex_hooks_mod.codex_hook_roots(codex_homes)
1895
- codex_guards = tuple(root.hooks_path for root in hook_roots)
1895
+ # config.toml joins the guard set (#719 §2.6a): a certificate seeded
1896
+ # while the handler was enabled must not survive Codex flipping
1897
+ # `enabled = false` under it.
1898
+ codex_guards = codex_hooks_mod.codex_frontier_guard_paths(hook_roots)
1896
1899
  except Exception:
1897
1900
  codex_hooks_mod = None
1898
1901
  hook_roots = ()
@@ -1916,27 +1919,12 @@ def _conversation_frontier_context():
1916
1919
  return False
1917
1920
 
1918
1921
  def codex_trusted():
1922
+ # Counting owned handlers proved nothing about whether Codex would
1923
+ # run them (#719). Route through the one shared kernel, which is
1924
+ # fail-closed on every state that is not `installed_enabled`.
1919
1925
  if codex_hooks_mod is None:
1920
1926
  return False
1921
- try:
1922
- trusted = bool(hook_roots)
1923
- for hook_root in hook_roots:
1924
- document = codex_hooks_mod._read_hooks_document(
1925
- hook_root.hooks_path)
1926
- hooks = document.get("hooks", {})
1927
- for event in codex_hooks_mod.CODEX_HOOK_EVENTS:
1928
- owned = sum(
1929
- 1
1930
- for group in hooks.get(event, ())
1931
- if isinstance(group, dict)
1932
- for handler in group.get("hooks", ())
1933
- if codex_hooks_mod.is_dashboard_activity_codex_hook_handler(
1934
- handler)
1935
- )
1936
- trusted = trusted and owned >= 1
1937
- return trusted
1938
- except Exception:
1939
- return False
1927
+ return codex_hooks_mod.codex_hook_roots_all_enabled(hook_roots)
1940
1928
 
1941
1929
  return frontier_mod, {
1942
1930
  "claude": (claude_roots, claude_guards, claude_trusted()),