cctally 1.103.0 → 1.105.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 (66) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_alerts.py +104 -10
  4. package/bin/_cctally_cache.py +193 -71
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +694 -70
  8. package/bin/_cctally_dashboard_conversation.py +400 -6
  9. package/bin/_cctally_dashboard_envelope.py +336 -14
  10. package/bin/_cctally_dashboard_perf.py +93 -0
  11. package/bin/_cctally_dashboard_share.py +56 -25
  12. package/bin/_cctally_dashboard_sources.py +988 -94
  13. package/bin/_cctally_db.py +30 -3
  14. package/bin/_cctally_diagnosis_sources.py +699 -186
  15. package/bin/_cctally_doctor.py +77 -0
  16. package/bin/_cctally_forecast.py +932 -51
  17. package/bin/_cctally_journal.py +430 -24
  18. package/bin/_cctally_parser.py +66 -0
  19. package/bin/_cctally_project.py +535 -10
  20. package/bin/_cctally_quota.py +28 -0
  21. package/bin/_cctally_quota_calibration.py +146 -0
  22. package/bin/_cctally_quota_model.py +2142 -0
  23. package/bin/_cctally_record.py +204 -30
  24. package/bin/_cctally_share.py +16 -8
  25. package/bin/_cctally_statusline.py +34 -0
  26. package/bin/_cctally_tui.py +531 -115
  27. package/bin/_lib_codex_conversation_query.py +258 -12
  28. package/bin/_lib_codex_hooks.py +26 -0
  29. package/bin/_lib_conversation_query.py +145 -15
  30. package/bin/_lib_dashboard_json.py +105 -0
  31. package/bin/_lib_dashboard_settings_contract.py +2 -0
  32. package/bin/_lib_diagnosis.py +21 -2
  33. package/bin/_lib_doctor.py +215 -1
  34. package/bin/_lib_forecast.py +337 -43
  35. package/bin/_lib_ingest_frontier.py +889 -0
  36. package/bin/_lib_meter_rate_change.py +360 -0
  37. package/bin/_lib_perf.py +22 -0
  38. package/bin/_lib_pricing.py +30 -3
  39. package/bin/_lib_quota_calibration.py +311 -0
  40. package/bin/_lib_quota_copy.py +157 -0
  41. package/bin/_lib_quota_model.py +2520 -0
  42. package/bin/_lib_rate_change_delivery.py +119 -0
  43. package/bin/_lib_record.py +50 -0
  44. package/bin/_lib_rederive.py +10 -0
  45. package/bin/_lib_render.py +6 -0
  46. package/bin/_lib_retained_size.py +187 -0
  47. package/bin/_lib_share_templates.py +37 -5
  48. package/bin/_lib_snapshot_cache.py +504 -17
  49. package/bin/_lib_statusline.py +200 -2
  50. package/bin/_lib_tick_stats.py +28 -4
  51. package/bin/_lib_view_models.py +30 -12
  52. package/bin/cctally +32 -0
  53. package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
  54. package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
  55. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
  56. package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
  57. package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
  58. package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
  59. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  60. package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
  61. package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
  62. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
  63. package/dashboard/static/dashboard.html +3 -2
  64. package/package.json +10 -1
  65. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  66. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
package/CHANGELOG.md CHANGED
@@ -5,6 +5,100 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.105.0] - 2026-09-02
9
+
10
+ ### Added
11
+ - `cctally` prices Claude Fable 5.1 and Claude Mythos 5.1, including their cache reads at $0.25 per million tokens, so usage on either model is costed instead of contributing nothing with only a warning.
12
+
13
+ ### Changed
14
+ - `cctally quota` counts Claude Fable 5.1 and Claude Mythos 5.1 as part of the Fable 5 and Mythos 5 families, so a week containing that usage no longer has its weekly-quota fit withheld as an unsupported model mix.
15
+ - `cctally quota` refits your stored calibration once after this upgrade, because the set of model names it recognises has changed.
16
+ - `cctally quota` excludes fast-mode requests that Anthropic bills to usage credits, and widens its family-composition support floor to the corrected empirical bound.
17
+ - The dashboard's Forecast basis and `cctally statusline` use one vocabulary; the status line remains a meter-only hot path, while Forecast can name its calibrated model.
18
+ - The dashboard's Conversation viewer opens long histories from a bounded, integrity-checked outline ticket, hydrates the exact account-scoped outline automatically, and skips unchanged transcript estates between updates.
19
+ - `cctally dashboard` compresses and caches content-hashed assets and loads conversations and detail views only when opened, cutting cold JavaScript transfer while warm reloads reuse unchanged code.
20
+
21
+ ### Fixed
22
+ - `cctally dashboard` shares a progressive outline's first-chunk build, cancels abandoned transfers, and rejects an oversized outline before allocating its encoded body.
23
+ - `cctally dashboard` applies failed or over-limit background memory checks as soon as they finish, including while sync is paused, and its soak gate now distinguishes measured growth from noisy flat samples.
24
+ - `cctally dashboard` keeps production-size cache admission checks inside the whole-process memory ceiling without shrinking or evicting the retained caches.
25
+ - `cctally dashboard` stops loading a previous conversation's outline after you switch sessions, so the new reader stays current and avoids duplicate outline requests.
26
+ - Concurrent dashboard Explain requests share identical in-flight work and admit only one diagnosis process tree at a time, so multiple tabs or LAN clients no longer multiply provider workers and transient memory.
27
+ - `cctally dashboard` bounds retained source, snapshot, conversation, ingest, and SSE work; invalidates conversation caches after same-path database replacement; keeps the newest slow-tab update; and releases owners without slowing refreshes.
28
+ - `cctally quota` now records a metering-rate change it detected earlier but could not store at the time, so a busy or rebuilding database no longer costs you that entry in your rate-change history permanently.
29
+ - `cctally quota` delivers a metering-rate change notification that an interrupted write recorded but never announced, and remembers which ones it has announced, so upgrading never replays your whole rate-change history at once.
30
+ - `cctally quota` retries a metering-rate notification that was recorded without an account attribution, including on a run narrowed with `--account`, so an install that later adds a second Claude account no longer loses that announcement.
31
+ - A metering-rate change notification names the account whose rate changed, so two accounts changing rate no longer produce two identical popups. An install with one account, or none, sees exactly the notification it saw before.
32
+ - `cctally explain` and the dashboard's Explain modal build current, baseline and conversation-derived evidence within their documented warm and cold latency budgets without caching a stale report or slowing dashboard updates.
33
+ - `cctally doctor` reports `not assessed` rather than `no change detected` when no quota calibration has been fitted, so a withheld fit no longer reads as a confirmed all-clear about your metering rate.
34
+ - `cctally quota` records the durable metering-rate history whenever its detector confirms a change, even when the fitted budget is withheld because this week's composition sits outside the calibration's support.
35
+ - The metering-rate notification says the calibration was withheld, and names why, instead of promising a fitted budget it cannot supply.
36
+ - `cctally doctor` stops telling you to run `cctally quota` for a fitted budget when the calibration behind the change has none, and names the withholding instead.
37
+ - The dashboard's rate-change toast points at the evidence for a metering-rate change rather than promising a fitted budget.
38
+ - `cctally dashboard` skips both provider file estates on caught-up ticks, falls back to a full pass when hook activity cannot be recorded, and reports the main refresh loop's measured CPU duty.
39
+ - `cctally dashboard` reuses unchanged Codex accounting and account-card populations across quota and one-file updates, shortening active refreshes without changing cadence or freshness.
40
+ - `cctally quota` withholds malformed retained timestamps without a traceback, filters offset-bearing credit times by their instant, and emits every JSON timestamp with a trailing `Z`.
41
+ - Historical forecast replays ignore mid-week reset events until their recorded detection instant, so a future event can no longer erase the samples that were available at the replayed time.
42
+ - Weekly projected-pace alerts use the same model-backed value as `cctally forecast` when a quota calibration applies, instead of silently stopping for calibrated users.
43
+ - The dashboard's Forecast modal keeps an extreme projection label inside the card instead of clipping it at the 110% track edge.
44
+ - `cctally record-usage` no longer fabricates a percent milestone from a stale pre-credit reading after Anthropic zeroes your weekly counter, so a fresh week's milestone ladder no longer opens at a percentage you never reached.
45
+
46
+ ## [1.104.0] - 2026-08-29
47
+
48
+ ### Added
49
+ - `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`.
50
+ - `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.
51
+ - `cctally quota --reset-calibration` discards the stored calibration for one account, and `--since` and `--watch-from` narrow or override the window the analysis uses.
52
+ - `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.
53
+ - `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`.
54
+ - `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.
55
+ - `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.
56
+ - `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.
57
+ - `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.
58
+ - `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`.
59
+ - `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.
60
+ - 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.
61
+ - 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.
62
+ - 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.
63
+ - 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.
64
+ - `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.
65
+ - `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.
66
+ - `bin/cctally-test-all --with-estate-check` runs that check on a `--harness` subset, which otherwise skips it to keep a targeted run fast.
67
+
68
+ ### Changed
69
+ - `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.
70
+ - 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.
71
+ - 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.
72
+ - `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.
73
+ - 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%.
74
+ - `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.
75
+ - `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.
76
+ - `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.
77
+ - `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.
78
+ - `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.
79
+ - `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.
80
+ - `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.
81
+ - `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.
82
+ - `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.
83
+
84
+ ### Fixed
85
+ - The dashboard keeps the full account-count value visible in the `All` hero at tablet widths instead of clipping it mid-word.
86
+ - 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`.
87
+ - The terminal forecast's explain modal says why it shows no daily budgets at a capped meter, instead of printing the heading over nothing.
88
+ - `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.
89
+ - `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.
90
+ - `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.
91
+ - `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.
92
+ - `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.
93
+ - 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.
94
+ - 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.
95
+ - 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`.
96
+ - A metering-rate change desktop notification shows the effective date in your own timezone rather than in UTC.
97
+ - 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.
98
+ - `cctally quota` carries on when it cannot read your stored calibration file, rather than stopping with a Python traceback.
99
+ - 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.
100
+ - 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.
101
+
8
102
  ## [1.103.0] - 2026-08-26
9
103
 
10
104
  ### Added
package/README.md CHANGED
@@ -30,15 +30,15 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
30
30
  </p>
31
31
 
32
32
  <!-- cctally:latest-stable:begin -->
33
- **Latest stable: v1.101.0** (2026-08-19)
33
+ **Latest stable: v1.104.0** (2026-08-29)
34
34
 
35
- Highlights from the `v1.95.5` to `v1.101.0` stable upgrade:
35
+ Highlights from the `v1.101.0` to `v1.104.0` stable upgrade:
36
36
 
37
- - Dashboard refreshes release their read lock on `cache.db` sooner, cutting the measured median hold from 3.40 seconds to 1.04. The published data is unchanged.
38
- - Every dashboard warning carries a button that opens the surface explaining it, the week, the five-hour block, the month, the project, or the forecast. A warning whose window has closed says so and opens nothing.
39
- - The dashboard's Projects table writes out what its two percentages mean under the week selector, where a phone can read it. `Used pp` is relabelled `Used pp (sum)`, and the caption names the denominator of `Cost share`.
37
+ - `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`.
38
+ - `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.
39
+ - `cctally quota --reset-calibration` discards the stored calibration for one account, and `--since` and `--watch-from` narrow or override the window the analysis uses.
40
40
 
41
- [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.101.0)
41
+ [See every change in this stable upgrade](https://github.com/omrikais/cctally/releases/tag/v1.104.0)
42
42
  <!-- cctally:latest-stable:end -->
43
43
 
44
44
  ## Quick start
@@ -140,6 +140,73 @@ 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
+ THREE deliberate divergences from the threshold builders, all recorded
153
+ in `docs/alerts-gotchas.md` (#661 S2 Stage C review, F7b; the third
154
+ added by #688):
155
+
156
+ * The body does not end with `_with_next_step`. That helper derives its
157
+ affordance through `alert_next_step_command`, which branches on
158
+ `AXIS_REGISTRY` axes; this family is deliberately not one, so it has no
159
+ window to scope and no branch to reach. The affordance is stated
160
+ directly instead.
161
+ * This builder and `_alert_text_quota` above it live in this glue module
162
+ rather than in the pure `_lib_alerts_payload` kernel, which holds the
163
+ other six. That is the placement both non-registry families use.
164
+ * The BODY varies on a payload field, which no threshold builder does.
165
+ #688 records a transition whenever the detector qualifies, including
166
+ when the command's own verdict is withheld for a reason that bears on
167
+ predicting forward, so a recorded transition may sit beside a verdict
168
+ `cctally quota` refuses. Sending that user to a fitted budget that
169
+ does not exist is a false instruction, so the body names the
170
+ withholding instead and points at the evidence.
171
+
172
+ NOT a fourth divergence (#697): the standard R8 `[<label>] ` title prefix
173
+ is applied DOWNSTREAM by `_dispatch_alert_notification`, which resolves the
174
+ account registry from this payload's own `provider` field rather than from
175
+ `_AXIS_VENDOR`. The prefix is shared behaviour every axis takes, so this
176
+ builder keeps its signature and its return and never learns about accounts.
177
+
178
+ `effective_from` IS routed through `format_display_dt`. It is a clock
179
+ instant, not a calendar day, so the carve-out `_alert_text_weekly`
180
+ documents for `week_start_date` does not apply: slicing its first ten
181
+ characters showed a user at UTC+13 or UTC-8 a date one day away from
182
+ their own.
183
+ """
184
+ previous = float(payload.get("previous_units_per_point") or 0.0)
185
+ new = float(payload.get("new_units_per_point") or 0.0)
186
+ provider = str(payload.get("provider") or "claude").capitalize()
187
+ if previous > 0.0 and new > 0.0:
188
+ change = (new - previous) / previous * 100.0
189
+ direction = "less" if change < 0 else "more"
190
+ subtitle = (
191
+ f"each meter point now covers {abs(change):.0f}% {direction} usage")
192
+ else:
193
+ subtitle = "the rate this account is metered at changed"
194
+ raw = str(payload.get("effective_from") or "")
195
+ try:
196
+ effective = _lib_alerts_payload.format_display_dt(
197
+ raw, tz, fmt="%Y-%m-%d", suffix=False) if raw else ""
198
+ except ValueError:
199
+ effective = ""
200
+ withheld = payload.get("withholding_status")
201
+ if withheld:
202
+ tail = (f"The calibration was withheld ({withheld}), so no fitted "
203
+ f"budget is available. Run `cctally quota` for the evidence.")
204
+ else:
205
+ tail = "Run `cctally quota` for the fitted budget and its evidence."
206
+ body = f"Effective {effective}. {tail}" if effective else tail
207
+ return f"cctally - {provider} metering rate changed", subtitle, body
208
+
209
+
143
210
  # Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
144
211
  # pure (parameterized on platform + which_on_path); this module is the I/O glue
145
212
  # that injects the real sys.platform / shutil.which and spawns with shell=False.
@@ -177,7 +244,8 @@ _AXIS_VENDOR = {
177
244
  }
178
245
 
179
246
 
180
- def _alert_label_prefix(axis: str, account_key: "str | None") -> str:
247
+ def _alert_label_prefix(axis: str, account_key: "str | None",
248
+ provider: "object | None" = None) -> str:
181
249
  """Return ``"[<label>] "`` for an account-specific crossing when the vendor
182
250
  has >1 real account (R8), else ``""``. Best-effort + never-raise: a missing
183
251
  DB / read error / vendor-wide (`*`) row yields no prefix, so a ≤1-real-account
@@ -186,13 +254,26 @@ def _alert_label_prefix(axis: str, account_key: "str | None") -> str:
186
254
  Delegates the R8 gate + label precedence to the single-definition helpers
187
255
  ``_cctally_account.real_account_count`` / ``account_label`` (P2-CQ1) so the
188
256
  ">1 real account" trigger and the key->label map have exactly one home; this
189
- wrapper only opens the RO connection and stays best-effort/never-raise."""
257
+ wrapper only opens the RO connection and stays best-effort/never-raise.
258
+
259
+ #697: the VENDOR comes from the payload's own ``provider`` when it carries
260
+ one, and from ``_AXIS_VENDOR`` otherwise. The metering-rate-change family
261
+ is the only payload that carries the key, and it is authoritative there —
262
+ the family's visible title is derived from that same field, so a static map
263
+ entry could send a Codex title to Claude's registry with nothing reporting
264
+ the disagreement. The lookup is normalized because ``real_account_count``
265
+ matches ``accounts.provider`` exactly, and an unmatched provider degrades
266
+ silently to no prefix. The whole resolution sits INSIDE the guard so that a
267
+ provider whose ``__str__`` raises cannot escape the never-raise contract.
268
+ """
190
269
  if not account_key or account_key == _lib_accounts.VENDOR_WIDE:
191
270
  return ""
192
- vendor = _AXIS_VENDOR.get(axis)
193
- if vendor is None:
194
- return ""
195
271
  try:
272
+ vendor = str(provider).strip().lower() if provider is not None else ""
273
+ if not vendor:
274
+ vendor = _AXIS_VENDOR.get(axis)
275
+ if vendor is None:
276
+ return ""
196
277
  import sqlite3 as _sq
197
278
  import _cctally_account
198
279
  db_path = _cctally_core.DB_PATH
@@ -293,6 +374,8 @@ def _dispatch_alert_notification(
293
374
  title, subtitle, body = _alert_text_projected(payload, tz)
294
375
  elif axis == "quota":
295
376
  title, subtitle, body = _alert_text_quota(payload, tz)
377
+ elif axis == "meter_rate_change":
378
+ title, subtitle, body = _alert_text_meter_rate_change(payload, tz)
296
379
  else:
297
380
  title, subtitle, body = (
298
381
  "cctally - alert",
@@ -303,17 +386,28 @@ def _dispatch_alert_notification(
303
386
  # R8 label prefix (#341): an account-specific crossing gains a `[<label>] `
304
387
  # title prefix ONLY when the vendor has >1 real account. Byte-identical at
305
388
  # <=1 real account (empty prefix). Applied to the notification title only.
389
+ # #697: the vendor whose registry is counted comes from the payload's own
390
+ # `provider` when it carries one, and from `_AXIS_VENDOR` otherwise.
306
391
  account_key = payload.get("account_key")
307
- title = _alert_label_prefix(axis, account_key) + title
392
+ title = _alert_label_prefix(axis, account_key, payload.get("provider")) + title
308
393
 
309
394
  # Severity (3-tier) drives both the notify-send urgency token and the
310
395
  # trailing log column. A missing threshold (defensive — shouldn't happen for
311
396
  # a real crossing) floors at "info".
312
397
  threshold = payload.get("threshold")
313
- try:
314
- severity = severity_for(int(threshold)) if threshold is not None else "info"
315
- except (TypeError, ValueError):
316
- severity = "info"
398
+ if axis == "meter_rate_change":
399
+ # #661 S2 §6.1: this family carries an EXPLICIT severity, because a
400
+ # rate transition has no percentage threshold for `severity_for` to
401
+ # map. Reading it verbatim is what keeps the three tiers meaningful
402
+ # here; falling through would floor every transition at `info`.
403
+ severity = str(payload.get("severity") or "info")
404
+ if severity not in ("info", "warn", "alarm"):
405
+ severity = "info"
406
+ else:
407
+ try:
408
+ severity = severity_for(int(threshold)) if threshold is not None else "info"
409
+ except (TypeError, ValueError):
410
+ severity = "info"
317
411
  urgency = severity_to_urgency(severity)
318
412
 
319
413
  if platform is None: