v0.25.0 - budget by plan, and the answer the review queue could not give
Five things, batched as agreed so one release carries them.
1. An organisation does not have one Claude subscription
It has a Teams contract and a handful of individual Max seats, on different plans, different renewal dates, often different owners. budget_subscriptions keyed on tool_id alone:
CREATE TABLE budget_subscriptions (
tool_id TEXT PRIMARY KEY, -- ← why Claude greyed out the second time
plan TEXT NOT NULL DEFAULT '',So the choice was track the contract and be blind to the seats, or the reverse. Both totals were wrong and neither said so.
Three tables move to (tool_id, plan_key). SQLite cannot alter a primary key, so each is rebuilt and copied — proven against a database in the old shape: stored vendor key intact, members and connection carried onto the plan their subscription named, no orphans, idempotent on restart.
Every card button carried the tool and every handler did find(s => s.tool_id === k), which returns whichever plan sorts first. Editing your Max seats would have opened the Teams contract; importing users into one would have replaced the other's list. They carry the subscription now.
The page's own bSubMonthly/bSpendParts, run against three plans on one tool:
per subscription : Team=1000 Max 20=270 Max 5=108
page total : GBP 1378.00
old model showed : 1000 (one plan only)
Four decisions, all about not producing a plausible wrong number:
- Ambiguity is refused, not guessed — a member write that doesn't name a plan, on a tool with two, gets
claude has 2 subscriptions (max-20, team) - say which plan this is for. Guessing moves seats between contracts and the total still looks fine. - The picker no longer treats a tool as used up. It says which plans are linked and stays selectable. Only a licence genuinely billed twice is disabled — the
coversrule still catches that, it just no longer mistakes two plans for one double-bill. - Renaming works — editing sends
plan_key, so Team → Teams updates the row instead of quietly creating a second. - Orphan adoption — a key can still be stored and synced before the subscription is written down; those rows are adopted when the tool's first subscription appears.
2. The review queue's answer went nowhere
firezone-client-tunnel turned up on a real estate — the first time the process-candidate path has fired outside a seeded demo. It's a VPN tunnel daemon: it resolves DNS for everything behind the tunnel, so naming it says "something on this device, through the VPN", the same non-answer as a stub resolver.
But the queue offered only add to registry or dismiss, and dismissing cleared the card while leaving the process on the Agentic view for ever — agentic_from never consulted candidate decisions. A question whose answer went nowhere.
There's a third disposition now: the name identifies no program. That's a fact about the name, so it reaches every view that reads one, and the receiver stops asking about it.
3. VPN clients are a category, not a one-off
Tailscale, OpenVPN, WireGuard, Cloudflare WARP, AnyConnect, GlobalProtect, Zscaler, Netskope and the rest join the floor, on the same reasoning that put svchost and taskhostw there. The queue handles the tail.
4. stable comes out of Warp's exe_names
It solved the mystery it was added for, and it is a word. Any process literally named stable was being silently excused as Warp. The queue can ask instead.
5. The overview queue emptied itself
Adding a candidate to the registry set CAND = null and called render() — but the queue is only ever fetched by load(), behind a check for whether the widget is on the overview. So it drew an empty queue and left the rest invisible until a manual reload. Three handlers had that shape; there's one reloadDerived() now.
Checked
Suites: registry 23, portal 627, scanner 210, receiver 300. Plus collector parity, build.py --check, both byte-identical invariants, chart copies, shellcheck.
Two things worth knowing before this ships:
- The migration runs on the live database at receiver start. Proven and idempotent, but it rebuilds three tables — worth a copy of the state volume first.
- I have not seen the budget page render two plans in a browser. The arithmetic is exercised directly and the suites pass, but the managed-mode UI needs a login I avoided. That click-through is still owed.