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.
- package/CHANGELOG.md +48 -0
- package/bin/_cctally_alerts.py +147 -6
- package/bin/_cctally_core.py +298 -2
- package/bin/_cctally_dashboard.py +8 -20
- package/bin/_cctally_dashboard_envelope.py +103 -12
- package/bin/_cctally_diff.py +15 -8
- package/bin/_cctally_doctor.py +71 -3
- package/bin/_cctally_forecast.py +31 -6
- package/bin/_cctally_journal.py +280 -28
- package/bin/_cctally_parser.py +9 -5
- package/bin/_cctally_percent_breakdown.py +278 -12
- package/bin/_cctally_quota.py +16 -20
- package/bin/_cctally_quota_model.py +156 -15
- package/bin/_cctally_record.py +416 -217
- package/bin/_cctally_rederive.py +0 -2
- package/bin/_cctally_setup.py +272 -110
- package/bin/_cctally_statusline.py +30 -1
- package/bin/_cctally_tui.py +28 -25
- package/bin/_cctally_weekrefs.py +315 -102
- package/bin/_lib_alerts_payload.py +48 -1
- package/bin/_lib_codex_hooks.py +694 -83
- package/bin/_lib_diff_kernel.py +33 -10
- package/bin/_lib_doctor.py +172 -24
- package/bin/_lib_journal.py +64 -0
- package/bin/_lib_meter_rate_change.py +132 -3
- package/bin/_lib_pricing.py +22 -5
- package/bin/_lib_pricing_check.py +5 -4
- package/bin/_lib_subscription_weeks.py +140 -63
- package/bin/cctally +14 -3
- package/dashboard/static/assets/{ConversationsView-BHbw2W1l.js → ConversationsView-BOSaBRtu.js} +12 -12
- package/dashboard/static/assets/{DoctorModal-DcyMPwvn.js → DoctorModal-CWn3U4Wl.js} +1 -1
- package/dashboard/static/assets/ModalRoot-SR070V6c.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-ecN8oCwv.js → ProjectsDrillPanel-QLI9i5mZ.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CUwdD7_v.js → SourceDetailModal-pv0wlFxl.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CKlFE0ER.js → UpdateModal-D3m8GG6V.js} +3 -3
- package/dashboard/static/assets/index-BHu4mxd8.css +1 -0
- package/dashboard/static/assets/index-CIWsbux3.js +13 -0
- package/dashboard/static/assets/{outlineNavigation-CVse0Hj9.js → outlineNavigation-CJvKqmLV.js} +1 -1
- package/dashboard/static/assets/useKeymap-ffqsS5G0.js +1 -0
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +1 -1
- package/dashboard/static/assets/ModalRoot-BYV-99Rq.js +0 -1
- package/dashboard/static/assets/index-D8svRv_9.js +0 -13
- package/dashboard/static/assets/index-klO46NcU.css +0 -1
- 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
|
package/bin/_cctally_alerts.py
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.
|
|
256
|
-
">1 real account" trigger and the key->label map have exactly one home;
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
package/bin/_cctally_core.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()),
|