cctally 1.103.0 → 1.104.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 +56 -0
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,62 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.104.0] - 2026-08-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `cctally quota` reports how much of your weekly quota your usage consumes and whether Anthropic has changed the metering rate, from a budget fitted to your own history rather than a shipped constant. See `docs/commands/quota.md`.
|
|
12
|
+
- `cctally quota --json` publishes the fit, the current week's consumption and projection, the composition support radii and the detector's own bound under `schemaVersion` 1.
|
|
13
|
+
- `cctally quota --reset-calibration` discards the stored calibration for one account, and `--since` and `--watch-from` narrow or override the window the analysis uses.
|
|
14
|
+
- `bin/cctally-preflight` compiles every Python file in the repository, not only the ones beside it, so a syntax error anywhere in the tree now blocks a test run instead of surfacing during one.
|
|
15
|
+
- `cctally forecast --json` states the reading its arithmetic used and where its projection came from, through `weekly_percent_corrected`, `right_censored`, `projection_basis`, `projection_code` and `calibration_code`.
|
|
16
|
+
- `cctally forecast --json` moves to `schemaVersion` 2: its three projection fields can now be null, and they are measured from a different reading than before.
|
|
17
|
+
- `cctally project --json` moves to `schemaVersion` 2. `attributedUsedPercent` and `costPerPercent` keep their spelling and change their meaning, from a share of the window's cost to modelled weekly quota.
|
|
18
|
+
- `cctally project` reports each project's `Used %` as modelled quota units rather than as its share of the window's dollars, so a cache-heavy project no longer reads high and an output-heavy Opus one no longer reads low.
|
|
19
|
+
- `cctally doctor` reports a new Quota category: whether Anthropic changed your weekly metering rate, and whether the stored calibration is usable. Neither check can fail, so neither changes doctor's exit code.
|
|
20
|
+
- `cctally quota` records a durable event when it confirms a metering-rate change, so it and `doctor` report the change with no configuration. The desktop notification stays off until you set `alerts.rate_change_enabled`.
|
|
21
|
+
- `cctally project` states its four modelled-quota figures under the table — window total, listed-row total, filtered or unmodelled, and the meter's reading minus the modelled total — where before they were in `--json` only.
|
|
22
|
+
- The status line's 7d slot shows where the week is heading and which measurement said so, as `7d 42% (2d 3h) → 58% meter`, plus a `Δrate` marker while a metering-rate change is in force.
|
|
23
|
+
- The dashboard's Forecast panel names the basis its projection came from and shows a `Δ rate` chip while a metering-rate change is in force.
|
|
24
|
+
- The dashboard's Forecast modal adds the modelled consumption, the headroom, what the meter reading covers, and the meter's reading minus the modelled total.
|
|
25
|
+
- The dashboard's Recent Alerts modal lists metering-rate changes in their own section, with the size and direction of the change and the instant it took effect, and a rate change now raises its own toast.
|
|
26
|
+
- `alerts.rate_change_enabled` is settable with `cctally config set` and from the dashboard's Settings overlay, so turning the notification on no longer means editing `config.json` by hand.
|
|
27
|
+
- `bin/cctally-test-all` refuses to start when the recorded test estate disagrees with what this tree collects, when a recorded test disappeared without a declaration authorizing it, or when it cannot derive the estate at all.
|
|
28
|
+
- `bin/cctally-test-all --with-estate-check` runs that check on a `--harness` subset, which otherwise skips it to keep a targeted run fast.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- `cctally forecast`, the TUI and the dashboard measure your pace from what you consumed rather than from the rounded-up meter reading, so rates and projections read about half a point lower and daily budgets a little larger.
|
|
32
|
+
- At a displayed 100% the forecast shows no projection, no time-to-cap and no daily budget, because a capped meter says only that you are past 99%. `cctally quota` still models consumption there, from tokens.
|
|
33
|
+
- The projected-pace weekly alert stays quiet once `cctally quota` has fitted your budget, rather than firing on a pace the forecast no longer shows you.
|
|
34
|
+
- `cctally forecast`'s trailing four-week dollar rate divides each prior week's cost by the meter movement that week realized, not by its post-credit high-water mark, which priced one measured week at $355 per point against a true $25.
|
|
35
|
+
- A prior week whose realized movement cannot be established is left out of the forecast's four-week median rather than guessed at, and so is a week whose meter reached 100%.
|
|
36
|
+
- `cctally project` withholds its meter-versus-modelled difference when the requested range slices a subscription week, instead of stating a whole-week difference beside a three-day range.
|
|
37
|
+
- `cctally forecast` names its dollar rate's source in words — `trailing 4wk median (drift-reduced confidence)` rather than the bare code — and says when the comparability test behind that rate could not run.
|
|
38
|
+
- `rates.week_average_pct_per_hour` in `cctally forecast --json` is null at a reading of 100% or more, because a capped meter supplies no rate to average.
|
|
39
|
+
- `cctally quota` refuses to predict from a metering rate it has only just detected. It says so, and the forecast, the TUI and the dashboard fall back to the meter and state the reason, rather than projecting from a three-day fit.
|
|
40
|
+
- `bin/cctally-preflight` lists the repository once instead of once per check, so widening the Python check to the whole tree costs one pass rather than two.
|
|
41
|
+
- `docs/commands/report.md` states that `$ / 1%` falls when the provider charges more points for the same tokens, so a drop can read as improved efficiency, and points at `cctally quota` as the detector. The metric itself is unchanged.
|
|
42
|
+
- `cctally project` names the absent operand when it withholds the meter-versus-modelled difference — a modelled week with no snapshot, or a window that modelled no week — instead of reporting either as a misaligned population.
|
|
43
|
+
- `bin/cctally-test-all` builds both of its pytest phases from one recorded declaration rather than from hard-coded file names, and refuses at startup when a named target no longer exists.
|
|
44
|
+
- `bin/cctally-test-all` removes `PYTEST_ADDOPTS` and `PYTEST_PLUGINS` from both pytest phases so neither can narrow a run; set `CCTALLY_PYTEST_DURATIONS=1` for per-test timings instead.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- The dashboard keeps the full account-count value visible in the `All` hero at tablet widths instead of clipping it mid-word.
|
|
48
|
+
- A shared forecast artifact writes a ceiling more than 30 days out as `>30d` instead of printing the literal figure, which on a barely-used week read `1166.8`.
|
|
49
|
+
- The terminal forecast's explain modal says why it shows no daily budgets at a capped meter, instead of printing the heading over nothing.
|
|
50
|
+
- `bin/cctally-preflight` bounds every command it runs, including the harness-ownership check, and reports a hung system bash version probe rather than silently skipping the bash 3.2 floor check.
|
|
51
|
+
- `bin/cctally-preflight` stops its shell syntax sweep and reports what went unchecked when an interpreter hangs, rather than spending its full per-file timeout on every remaining file.
|
|
52
|
+
- `cctally project` withholds modelled quota on a multi-account install whose account registry cannot be read, instead of falling back to a cost share under the `Used %` column.
|
|
53
|
+
- `cctally forecast` leaves a prior week out of its four-week median when the recorded credit or reset rows for that week cannot be read, instead of summing the week as if the credit had zeroed the meter.
|
|
54
|
+
- `cctally forecast` keeps prior weeks from before a metering-rate change out of its four-week median even when it cannot read the entry cache, instead of admitting every one of them after a single failed read.
|
|
55
|
+
- The dashboard hero shows no week-over-week `$ / 1%` comparison when it has no `$ / 1%` to compare, instead of printing a change beside a dash.
|
|
56
|
+
- The dashboard's Forecast panel keeps its pace bar visible and its content inside the card when the source reports a degradation, where the bar collapsed to nothing and the panel overflowed.
|
|
57
|
+
- The dashboard's Forecast modal says when a published `$ / 1%` rate was measured over a reduced or unverified population, where before the qualification was only visible in `--json`.
|
|
58
|
+
- A metering-rate change desktop notification shows the effective date in your own timezone rather than in UTC.
|
|
59
|
+
- Interrupting `cctally quota` with Ctrl-C while it records a metering-rate change stops the command, rather than printing a recording failure and carrying on.
|
|
60
|
+
- `cctally quota` carries on when it cannot read your stored calibration file, rather than stopping with a Python traceback.
|
|
61
|
+
- The status line's 7d slot shows no projection once the stored reset instant has passed, rather than projecting over a week that has already ended.
|
|
62
|
+
- The dashboard's Forecast panel puts its verdict, status and `Δ rate` chips on one line instead of stacking them, giving the card back a row of height. The Forecast modal's Quota model heading is now tinted like every other section heading.
|
|
63
|
+
|
|
8
64
|
## [1.103.0] - 2026-08-26
|
|
9
65
|
|
|
10
66
|
### Added
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -140,6 +140,56 @@ def _alert_text_quota(payload: dict, _tz) -> tuple[str, str, str]:
|
|
|
140
140
|
# dashboard alert envelope, so the CLI line is the only routing it gets.
|
|
141
141
|
return title, subtitle, _with_next_step(body, payload, _tz)
|
|
142
142
|
|
|
143
|
+
|
|
144
|
+
def _alert_text_meter_rate_change(payload: dict, tz) -> tuple[str, str, str]:
|
|
145
|
+
"""#661 S2 §6.1: the non-threshold family's own copy.
|
|
146
|
+
|
|
147
|
+
It states the direction and the size of the change, and it states them as
|
|
148
|
+
a rate the provider sets rather than as anything cctally can act on. There
|
|
149
|
+
is no threshold to name, which is the whole reason this family is not an
|
|
150
|
+
`AXIS_REGISTRY` member.
|
|
151
|
+
|
|
152
|
+
Two DELIBERATE divergences from the threshold builders, both recorded in
|
|
153
|
+
`docs/alerts-gotchas.md` (#661 S2 Stage C review, F7b):
|
|
154
|
+
|
|
155
|
+
* The body does not end with `_with_next_step`. That helper derives its
|
|
156
|
+
affordance through `alert_next_step_command`, which branches on
|
|
157
|
+
`AXIS_REGISTRY` axes; this family is deliberately not one, so it has no
|
|
158
|
+
window to scope and no branch to reach. The affordance is stated
|
|
159
|
+
directly instead.
|
|
160
|
+
* This builder and `_alert_text_quota` above it live in this glue module
|
|
161
|
+
rather than in the pure `_lib_alerts_payload` kernel, which holds the
|
|
162
|
+
other five. That is the placement both non-registry families use.
|
|
163
|
+
|
|
164
|
+
`effective_from` IS routed through `format_display_dt`. It is a clock
|
|
165
|
+
instant, not a calendar day, so the carve-out `_alert_text_weekly`
|
|
166
|
+
documents for `week_start_date` does not apply: slicing its first ten
|
|
167
|
+
characters showed a user at UTC+13 or UTC-8 a date one day away from
|
|
168
|
+
their own.
|
|
169
|
+
"""
|
|
170
|
+
previous = float(payload.get("previous_units_per_point") or 0.0)
|
|
171
|
+
new = float(payload.get("new_units_per_point") or 0.0)
|
|
172
|
+
provider = str(payload.get("provider") or "claude").capitalize()
|
|
173
|
+
if previous > 0.0 and new > 0.0:
|
|
174
|
+
change = (new - previous) / previous * 100.0
|
|
175
|
+
direction = "less" if change < 0 else "more"
|
|
176
|
+
subtitle = (
|
|
177
|
+
f"each meter point now covers {abs(change):.0f}% {direction} usage")
|
|
178
|
+
else:
|
|
179
|
+
subtitle = "the rate this account is metered at changed"
|
|
180
|
+
raw = str(payload.get("effective_from") or "")
|
|
181
|
+
try:
|
|
182
|
+
effective = _lib_alerts_payload.format_display_dt(
|
|
183
|
+
raw, tz, fmt="%Y-%m-%d", suffix=False) if raw else ""
|
|
184
|
+
except ValueError:
|
|
185
|
+
effective = ""
|
|
186
|
+
body = (
|
|
187
|
+
f"Effective {effective}. Run `cctally quota` for the fitted budget "
|
|
188
|
+
f"and its evidence." if effective else
|
|
189
|
+
"Run `cctally quota` for the fitted budget and its evidence.")
|
|
190
|
+
return f"cctally - {provider} metering rate changed", subtitle, body
|
|
191
|
+
|
|
192
|
+
|
|
143
193
|
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
144
194
|
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
145
195
|
# that injects the real sys.platform / shutil.which and spawns with shell=False.
|
|
@@ -293,6 +343,8 @@ def _dispatch_alert_notification(
|
|
|
293
343
|
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
294
344
|
elif axis == "quota":
|
|
295
345
|
title, subtitle, body = _alert_text_quota(payload, tz)
|
|
346
|
+
elif axis == "meter_rate_change":
|
|
347
|
+
title, subtitle, body = _alert_text_meter_rate_change(payload, tz)
|
|
296
348
|
else:
|
|
297
349
|
title, subtitle, body = (
|
|
298
350
|
"cctally - alert",
|
|
@@ -310,10 +362,19 @@ def _dispatch_alert_notification(
|
|
|
310
362
|
# trailing log column. A missing threshold (defensive — shouldn't happen for
|
|
311
363
|
# a real crossing) floors at "info".
|
|
312
364
|
threshold = payload.get("threshold")
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
365
|
+
if axis == "meter_rate_change":
|
|
366
|
+
# #661 S2 §6.1: this family carries an EXPLICIT severity, because a
|
|
367
|
+
# rate transition has no percentage threshold for `severity_for` to
|
|
368
|
+
# map. Reading it verbatim is what keeps the three tiers meaningful
|
|
369
|
+
# here; falling through would floor every transition at `info`.
|
|
370
|
+
severity = str(payload.get("severity") or "info")
|
|
371
|
+
if severity not in ("info", "warn", "alarm"):
|
|
372
|
+
severity = "info"
|
|
373
|
+
else:
|
|
374
|
+
try:
|
|
375
|
+
severity = severity_for(int(threshold)) if threshold is not None else "info"
|
|
376
|
+
except (TypeError, ValueError):
|
|
377
|
+
severity = "info"
|
|
317
378
|
urgency = severity_to_urgency(severity)
|
|
318
379
|
|
|
319
380
|
if platform is None:
|
package/bin/_cctally_config.py
CHANGED
|
@@ -300,6 +300,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
300
300
|
"display.tz",
|
|
301
301
|
"alerts.enabled",
|
|
302
302
|
"alerts.projected_enabled",
|
|
303
|
+
"alerts.rate_change_enabled",
|
|
303
304
|
"alerts.notifier",
|
|
304
305
|
"alerts.command_template",
|
|
305
306
|
"alerts.quota",
|
|
@@ -1006,6 +1007,16 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
1006
1007
|
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
1007
1008
|
except c._AlertsConfigError:
|
|
1008
1009
|
return False
|
|
1010
|
+
if key == "alerts.rate_change_enabled":
|
|
1011
|
+
# #661 S2 section 6.2's PUSH gate. Recording a metering-rate change is
|
|
1012
|
+
# unconditional; this key gates only the notification. Same posture as
|
|
1013
|
+
# alerts.projected_enabled — validated boolean, default False, and a
|
|
1014
|
+
# corrupt alerts block surfaces the default rather than erroring out of
|
|
1015
|
+
# a plain `config get`.
|
|
1016
|
+
try:
|
|
1017
|
+
return bool(_get_alerts_config(config)["rate_change_enabled"])
|
|
1018
|
+
except c._AlertsConfigError:
|
|
1019
|
+
return False
|
|
1009
1020
|
if key == "alerts.notifier":
|
|
1010
1021
|
# Validated dispatch backend (defaults to 'auto' when unset). A corrupt
|
|
1011
1022
|
# alerts block surfaces the default — mirrors alerts.enabled.
|
|
@@ -1468,6 +1479,53 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1468
1479
|
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
1469
1480
|
)
|
|
1470
1481
|
return 0
|
|
1482
|
+
if key == "alerts.rate_change_enabled":
|
|
1483
|
+
# #661 S2 section 6.2. Identical posture to alerts.projected_enabled
|
|
1484
|
+
# above, including the re-message: _normalize_alerts_enabled_value
|
|
1485
|
+
# hardcodes "alerts.enabled" in its ValueError text, so the branch
|
|
1486
|
+
# names the key the user actually typed.
|
|
1487
|
+
try:
|
|
1488
|
+
normalized = c._normalize_alerts_enabled_value(raw)
|
|
1489
|
+
except ValueError:
|
|
1490
|
+
print(
|
|
1491
|
+
f"cctally: invalid boolean value for "
|
|
1492
|
+
f"alerts.rate_change_enabled: "
|
|
1493
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
1494
|
+
file=sys.stderr,
|
|
1495
|
+
)
|
|
1496
|
+
return 2
|
|
1497
|
+
with config_writer_lock():
|
|
1498
|
+
config = _load_config_unlocked()
|
|
1499
|
+
existing_alerts = config.get("alerts")
|
|
1500
|
+
if existing_alerts is not None and not isinstance(
|
|
1501
|
+
existing_alerts, dict
|
|
1502
|
+
):
|
|
1503
|
+
print(
|
|
1504
|
+
"cctally: alerts config error: alerts must be an object",
|
|
1505
|
+
file=sys.stderr,
|
|
1506
|
+
)
|
|
1507
|
+
return 2
|
|
1508
|
+
alerts_block = dict(existing_alerts or {})
|
|
1509
|
+
alerts_block["rate_change_enabled"] = normalized
|
|
1510
|
+
try:
|
|
1511
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
1512
|
+
except _AlertsConfigError as exc:
|
|
1513
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
1514
|
+
return 2
|
|
1515
|
+
config["alerts"] = alerts_block
|
|
1516
|
+
save_config(config)
|
|
1517
|
+
if getattr(args, "emit_json", False):
|
|
1518
|
+
print(
|
|
1519
|
+
json.dumps(
|
|
1520
|
+
{"alerts": {"rate_change_enabled": normalized}}, indent=2
|
|
1521
|
+
)
|
|
1522
|
+
)
|
|
1523
|
+
else:
|
|
1524
|
+
print(
|
|
1525
|
+
f"alerts.rate_change_enabled="
|
|
1526
|
+
f"{'true' if normalized else 'false'}"
|
|
1527
|
+
)
|
|
1528
|
+
return 0
|
|
1471
1529
|
if key == "alerts.notifier":
|
|
1472
1530
|
# Dispatch backend (Phase B). Plain string; the enum constraint is
|
|
1473
1531
|
# enforced by the pre-persist _get_alerts_config validation (so we never
|
|
@@ -2296,6 +2354,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2296
2354
|
if key in (
|
|
2297
2355
|
"alerts.enabled",
|
|
2298
2356
|
"alerts.projected_enabled",
|
|
2357
|
+
"alerts.rate_change_enabled",
|
|
2299
2358
|
"alerts.notifier",
|
|
2300
2359
|
"alerts.command_template",
|
|
2301
2360
|
"alerts.quota",
|
|
@@ -2305,8 +2364,9 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2305
2364
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
2306
2365
|
# named key preserves any user-customized threshold lists
|
|
2307
2366
|
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
2308
|
-
# enabled/projected_enabled/notifier/
|
|
2309
|
-
# enabled/projected_enabled/
|
|
2367
|
+
# enabled/projected_enabled/rate_change_enabled/notifier/
|
|
2368
|
+
# command_template keys. For enabled/projected_enabled/
|
|
2369
|
+
# rate_change_enabled/notifier the read-time validator
|
|
2310
2370
|
# (`_get_alerts_config`) re-applies the canonical default
|
|
2311
2371
|
# (`False` / `"auto"`) for the missing key on next get. NOT so for
|
|
2312
2372
|
# command_template when notifier == "command": the cross-field
|
package/bin/_cctally_core.py
CHANGED
|
@@ -400,7 +400,17 @@ _init_paths_from_env()
|
|
|
400
400
|
# removing the shared-memory WAL page map implicated by the retained corruption
|
|
401
401
|
# bundles. The one-time rebuild is the mode transition; no stats migration is
|
|
402
402
|
# added and cache/conversations remain WAL/NORMAL.
|
|
403
|
-
|
|
403
|
+
# 1010 -> 1011 (#661 S2 §6.4): the meter-rate-change event family. Adds
|
|
404
|
+
# `meter_rate_change_events`, a durable forward-only record that one provider
|
|
405
|
+
# metering-rate transition was observed, keyed
|
|
406
|
+
# `(provider, account_key, effective_from)`. It is an epoch bump and NOT a
|
|
407
|
+
# stats migration for the two reasons every bump since 1005 carries: the
|
|
408
|
+
# 13-entry registry is frozen, AND an epoch-current open returns before any
|
|
409
|
+
# schema work, so a `@stats_migration` handler would never run on an upgraded
|
|
410
|
+
# install and the table would simply never appear. `quota_alert_arming` is the
|
|
411
|
+
# precedent the family follows — a journaled STATE record whose boundary
|
|
412
|
+
# survives a rebuild, so history cannot re-fire.
|
|
413
|
+
STATS_INDEX_EPOCH = 1011
|
|
404
414
|
LEGACY_STATS_HEAD = 13
|
|
405
415
|
|
|
406
416
|
#: #496 S1 F1. A NEW branch, for a state that cannot occur before the
|
|
@@ -965,6 +975,12 @@ _ALERTS_CONFIG_VALID_KEYS = {
|
|
|
965
975
|
"weekly_thresholds",
|
|
966
976
|
"five_hour_thresholds",
|
|
967
977
|
"projected_enabled",
|
|
978
|
+
# #661 S2 §6.2. The metering-rate-change family's PUSH toggle, and only
|
|
979
|
+
# its push toggle: events are recorded from the first upgrade whatever
|
|
980
|
+
# this says, so every pull surface shows the state with no configuration.
|
|
981
|
+
# It defaults OFF like every other alert toggle, so an upgrade never
|
|
982
|
+
# produces a surprise notification.
|
|
983
|
+
"rate_change_enabled",
|
|
968
984
|
"notifier",
|
|
969
985
|
"command_template",
|
|
970
986
|
}
|
|
@@ -1062,6 +1078,17 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
1062
1078
|
f"{type(projected_enabled).__name__}: {projected_enabled!r}",
|
|
1063
1079
|
field="alerts.projected_enabled",
|
|
1064
1080
|
)
|
|
1081
|
+
# #661 S2 §6.2: the rate-change PUSH toggle, default OFF. Recording is
|
|
1082
|
+
# unconditional and does not consult this — the split is what lets a user
|
|
1083
|
+
# learn that the rate changed without opting in while nothing arrives
|
|
1084
|
+
# unbidden. Bool-validated rather than coerced, like `projected_enabled`.
|
|
1085
|
+
rate_change_enabled = block.get("rate_change_enabled", False)
|
|
1086
|
+
if not isinstance(rate_change_enabled, bool):
|
|
1087
|
+
raise _AlertsConfigError(
|
|
1088
|
+
f"alerts.rate_change_enabled must be a JSON boolean, got "
|
|
1089
|
+
f"{type(rate_change_enabled).__name__}: {rate_change_enabled!r}",
|
|
1090
|
+
field="alerts.rate_change_enabled",
|
|
1091
|
+
)
|
|
1065
1092
|
# Dispatch-global keys (Phase B). `notifier` selects the backend;
|
|
1066
1093
|
# `command_template` is an argv list for the `command` backend (and may be
|
|
1067
1094
|
# set ahead of switching the backend). The cross-field constraint
|
|
@@ -1109,6 +1136,7 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
1109
1136
|
"weekly_thresholds": weekly,
|
|
1110
1137
|
"five_hour_thresholds": five_hour,
|
|
1111
1138
|
"projected_enabled": projected_enabled,
|
|
1139
|
+
"rate_change_enabled": rate_change_enabled,
|
|
1112
1140
|
"notifier": notifier,
|
|
1113
1141
|
"command_template": command_template,
|
|
1114
1142
|
}
|
|
@@ -1637,6 +1665,39 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
|
|
|
1637
1665
|
PRIMARY KEY(source)
|
|
1638
1666
|
);
|
|
1639
1667
|
|
|
1668
|
+
-- #661 S2 §6: a provider metering-rate transition, recorded from the
|
|
1669
|
+
-- first upgrade whether or not notification is enabled (§6.2). The
|
|
1670
|
+
-- row is the durable forward-only latch: its presence is what stops
|
|
1671
|
+
-- the same transition alerting twice, and it survives a stats.db
|
|
1672
|
+
-- rebuild because the journal event that created it carries a
|
|
1673
|
+
-- self-sufficient payload (§6.5).
|
|
1674
|
+
--
|
|
1675
|
+
-- The identity is `(provider, account_key, effective_from)` and the
|
|
1676
|
+
-- promise is stated at exactly that precision — once per exact key,
|
|
1677
|
+
-- not "once per provider regime". `reduce_state` closes and
|
|
1678
|
+
-- re-creates regimes on a fingerprint change and a revised detector
|
|
1679
|
+
-- can select a different `effective_from` for the same underlying
|
|
1680
|
+
-- transition; separately, R8 can turn a formerly merged identity into
|
|
1681
|
+
-- a real account key. Both produce a new key for a transition a human
|
|
1682
|
+
-- would call the same one. The FINGERPRINT stays out of the key
|
|
1683
|
+
-- deliberately: including it would re-alert on our own algorithm
|
|
1684
|
+
-- revisions.
|
|
1685
|
+
CREATE TABLE IF NOT EXISTS meter_rate_change_events (
|
|
1686
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1687
|
+
provider TEXT NOT NULL,
|
|
1688
|
+
account_key TEXT NOT NULL DEFAULT 'unattributed',
|
|
1689
|
+
effective_from TEXT NOT NULL,
|
|
1690
|
+
previous_units_per_point REAL NOT NULL,
|
|
1691
|
+
new_units_per_point REAL NOT NULL,
|
|
1692
|
+
severity TEXT NOT NULL,
|
|
1693
|
+
detected_at_utc TEXT NOT NULL,
|
|
1694
|
+
created_at_utc TEXT NOT NULL,
|
|
1695
|
+
notified_at TEXT,
|
|
1696
|
+
UNIQUE(provider, account_key, effective_from)
|
|
1697
|
+
);
|
|
1698
|
+
CREATE INDEX IF NOT EXISTS idx_meter_rate_change_events_key
|
|
1699
|
+
ON meter_rate_change_events(provider, account_key, effective_from);
|
|
1700
|
+
|
|
1640
1701
|
CREATE TABLE IF NOT EXISTS quota_alert_arming (
|
|
1641
1702
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1642
1703
|
source TEXT NOT NULL,
|
|
@@ -581,6 +581,7 @@ from _cctally_dashboard_envelope import (
|
|
|
581
581
|
_envelope_rows_project_budget,
|
|
582
582
|
_ENVELOPE_AXIS_MAPPERS,
|
|
583
583
|
_build_alerts_envelope_array,
|
|
584
|
+
_build_meter_rate_change_array,
|
|
584
585
|
_model_breakdowns_to_models,
|
|
585
586
|
)
|
|
586
587
|
|
|
@@ -8282,6 +8283,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
8282
8283
|
merged_alerts["projected_enabled"] = (
|
|
8283
8284
|
alerts_in["projected_enabled"]
|
|
8284
8285
|
)
|
|
8286
|
+
# #661 S2 section 6.2's PUSH gate. Assigned VERBATIM, like
|
|
8287
|
+
# every sibling here: the boolean rule lives in
|
|
8288
|
+
# `_get_alerts_config`, which runs against the merged block
|
|
8289
|
+
# below, so a `bool()` here would coerce "yes" to True and
|
|
8290
|
+
# destroy the evidence before the canonical validator could
|
|
8291
|
+
# refuse it.
|
|
8292
|
+
if "rate_change_enabled" in alerts_in:
|
|
8293
|
+
merged_alerts["rate_change_enabled"] = (
|
|
8294
|
+
alerts_in["rate_change_enabled"]
|
|
8295
|
+
)
|
|
8285
8296
|
if "notifier" in alerts_in:
|
|
8286
8297
|
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
8287
8298
|
merged["alerts"] = merged_alerts
|