Completes D-04's backend chain: Ollama -> Claude -> Routstr (budget-gated),
and wires D-05's operator-set prepaid allowance as a hard, arithmetic stop
a prompt-injected model can never cross.
- assistant/mod.rs: `AssistantBudget` (allowance_sats/spent_sats,
persisted 0600 under data_dir/assistant/budget.json, mirroring
Grants::load/save exactly — a missing/corrupt file defaults to a ZERO
allowance, D-16's "default closed" applied to money). `payment_policy()`
builds a `PaymentPolicy` from ONLY these two persisted fields — no
parameter accepts anything model/tool/provider-influenced, which is what
makes the ceiling arithmetic rather than a policy an injected model
could argue with. `record_spend()` persists a successful payment and
raises a one-time 80%-threshold owner notice (AI-SPEC §7b). New typed
`BudgetExhausted` error (downcastable via anyhow) is the signal
`loop_.rs` distinguishes from an ordinary transport error.
- assistant/loop_.rs: `run_loop` downcasts a `BudgetExhausted` out of the
backend's `Err` and returns `Ok` with a plain-language stop message —
no retry, no re-price, no partial spend, no fall-through to a different
provider at a different price. Verified to actually matter: temporarily
replaced the terminating `return` with `continue` and confirmed
`zero_budget_stops_loop_without_retry` goes red (the backend gets
retried 8x to MAX_TURNS and the turn errors instead of stopping
cleanly); restored and reconfirmed green (13-13-SUMMARY.md records the
observed failure).
- assistant/backends/mod.rs: `select_backend` now takes `&RpcHandler`
(was `&Path`) to also read the Tor-proxy config; completes the D-04
chain — Routstr never selected when the operator's allowance is zero
(Claude alone instead), otherwise chained as Claude's fallback
(Ollama -> Claude -> Routstr, each leg reached only when the priors are
unavailable). New `BackendId::Routstr` variant.
- assistant/backends/routstr.rs: the payment-decline arm now returns the
typed `BudgetExhausted` (was a plain bail in Task 2's commit, per the
plan's own "handled in Task 3" note); a successful payment records spend
against the persisted budget immediately (the Cashu proofs are already
committed at that point, regardless of whether the subsequent chat HTTP
call itself succeeds).
- api/rpc/assistant_chat.rs: `assistant.budget-get`/`assistant.budget-set`
RPCs (routed through the existing single `assistant.` dispatcher arm —
dispatcher.rs untouched) and a `nostr_tor_proxy()` accessor for
select_backend's onion-preference decision.
Named tests (assistant::tests::): zero_budget_stops_loop_without_retry (S-12),
zero_allowance_never_selects_routstr, ceiling_is_not_a_function_of_model_output,
injection_loop_against_low_budget_does_not_overspend (EV-17) — all pass.
Full assistant:: suite: 91/91. Full crate suite: 1235/1235 (2 pre-existing
ignored, unrelated). dispatcher.rs and Cargo.toml untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>