From 8ba604125122bc1de00516566d090beef60630c7 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 05:33:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(13-13):=20D-05=20prepaid=20budget=20?= =?UTF-8?q?=E2=80=94=20arithmetic=20ceiling,=20hard=20stop,=20D-04=20chain?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../archipelago/src/api/rpc/assistant_chat.rs | 53 +++ .../archipelago/src/assistant/backends/mod.rs | 66 +++- .../src/assistant/backends/routstr.rs | 42 ++- core/archipelago/src/assistant/loop_.rs | 39 +- core/archipelago/src/assistant/mod.rs | 357 +++++++++++++++++- 5 files changed, 530 insertions(+), 27 deletions(-) diff --git a/core/archipelago/src/api/rpc/assistant_chat.rs b/core/archipelago/src/api/rpc/assistant_chat.rs index 3f58b93e..f958a18a 100644 --- a/core/archipelago/src/api/rpc/assistant_chat.rs +++ b/core/archipelago/src/api/rpc/assistant_chat.rs @@ -32,6 +32,8 @@ impl RpcHandler { "assistant.confirm-tool" => self.handle_assistant_confirm_tool(params).await, "assistant.history" => self.handle_assistant_history(session_token).await, "assistant.clear-history" => self.handle_assistant_clear_history(session_token).await, + "assistant.budget-get" => self.handle_assistant_budget_get().await, + "assistant.budget-set" => self.handle_assistant_budget_set(params).await, other => anyhow::bail!("no such assistant method: {other}"), } } @@ -188,6 +190,48 @@ impl RpcHandler { Ok(serde_json::json!({ "categories": grants_categories_json(&grants) })) } + /// assistant.budget-get — the operator's current Routstr allowance + /// (D-05): the total sats authorized this period, the amount spent so + /// far, and the remainder. Never touched by anything model-influenced — + /// this is purely a read of the persisted [`crate::assistant::AssistantBudget`]. + async fn handle_assistant_budget_get(self: &Arc) -> Result { + let budget = crate::assistant::AssistantBudget::load(self.data_dir()).await; + Ok(serde_json::json!({ + "allowance_sats": budget.allowance_sats, + "spent_sats": budget.spent_sats, + "remaining_sats": budget.remaining_sats(), + })) + } + + /// assistant.budget-set — the operator sets (or raises) the prepaid + /// Routstr allowance. Params: `{ "allowance_sats": u64 }`. Only the + /// allowance changes here; `spent_sats` is left untouched, so raising + /// the allowance after an exhaustion stop (D-05's "offering to top up") + /// simply widens the remainder rather than resetting the period's + /// spend history. This is the ONLY place `AssistantBudget.allowance_sats` + /// is ever written — never from a tool call, never from model output + /// (D-05: the ceiling is operator-set, full stop). + async fn handle_assistant_budget_set( + self: &Arc, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let allowance_sats = params + .get("allowance_sats") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing allowance_sats"))?; + + let mut budget = crate::assistant::AssistantBudget::load(self.data_dir()).await; + budget.allowance_sats = allowance_sats; + budget.save(self.data_dir()).await?; + + Ok(serde_json::json!({ + "allowance_sats": budget.allowance_sats, + "spent_sats": budget.spent_sats, + "remaining_sats": budget.remaining_sats(), + })) + } + /// assistant.chat — a single chat turn from the authenticated local /// operator. Params: `{ "text": string }`. Returns `{ "text": string }`. async fn handle_assistant_chat( @@ -283,6 +327,15 @@ impl RpcHandler { pub(crate) fn data_dir(&self) -> &std::path::Path { &self.config.data_dir } + + /// Read-only accessor for the node's configured Nostr Tor-proxy address + /// (`ARCHIPELAGO_NOSTR_TOR_PROXY`) — used by `assistant::backends::select_backend` + /// to decide whether the Routstr leg should prefer an onion endpoint. + /// Owned `Option` (not a borrow) so the caller can carry it + /// into a `RoutstrBackend` that outlives this borrow of `self`. + pub(crate) fn nostr_tor_proxy(&self) -> Option { + self.config.nostr_tor_proxy.clone() + } } /// Shared shape for `assistant.grants-get` and `assistant.grants-set`'s diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs index a1b37851..d8f24057 100644 --- a/core/archipelago/src/assistant/backends/mod.rs +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -3,12 +3,11 @@ //! written against this trait only; wire-format differences live entirely //! inside each adapter. -use std::path::Path; - use anyhow::Result; use async_trait::async_trait; use super::tools::{ChatMessage, ToolCall, ToolDef}; +use crate::api::rpc::RpcHandler; pub mod claude; pub mod ollama; @@ -32,14 +31,20 @@ pub trait Backend: Send + Sync { } /// D-04's identified backends — used for tracing which backend answered a -/// given turn. Grows a `Routstr` variant once 13-13 lands it. Deliberately -/// NOT carried into `ChatMessage`/`history.rs` (outside this plan's file -/// scope; per-turn backend attribution in the persisted transcript is a -/// natural follow-up, not required by any of 13-10's behaviors). +/// given turn. Deliberately NOT carried into `ChatMessage`/`history.rs` +/// (outside this plan's file scope; per-turn backend attribution in the +/// persisted transcript is a natural follow-up, not required by any of +/// 13-10's behaviors). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackendId { Ollama, Claude, + /// 13-13: the third D-04 leg. Not currently returned as the "primary" + /// id by `select_backend` (mirroring the existing convention that the + /// returned id names the primary attempt, not necessarily which leg of + /// a `FallbackChain` actually answers) — kept as its own variant for + /// future tracing/observability parity with `Ollama`/`Claude`. + Routstr, } impl std::fmt::Display for BackendId { @@ -47,6 +52,7 @@ impl std::fmt::Display for BackendId { match self { BackendId::Ollama => write!(f, "ollama"), BackendId::Claude => write!(f, "claude"), + BackendId::Routstr => write!(f, "routstr"), } } } @@ -95,18 +101,20 @@ fn ollama_is_selectable(detected: bool, tool_capable: bool) -> bool { detected && tool_capable } -/// D-04's backend chain: local Ollama first (node data never leaves the -/// node when a local model is available and tool-capable), then Claude. -/// The Routstr slot (13-13) inserts as a third leg without changing the -/// `Backend` trait or this function's shape — the architectural commitment -/// the 13-01 tracer proved and this plan fills the first real leg of. +/// D-04's complete backend chain: local Ollama first (node data never +/// leaves the node when a local model is available and tool-capable), +/// Claude second, and Routstr — a Nostr-discovered, Cashu-paid provider — +/// third, reached only when the first two are unavailable AND the operator +/// has actually authorized spending (D-05: a ZERO allowance means Routstr +/// is never even selected, not selected-and-then-declined). /// /// Reuses `detect_ollama()` — the SAME probe `mesh.assistant-status` /// reports — rather than writing a second one. Ollama being unreachable OR /// its configured model being reachable-but-not-tool-capable both fall /// through to Claude, each with a logged reason — never a silent, /// tools-free degrade. -pub async fn select_backend(data_dir: &Path) -> (Box, BackendId) { +pub async fn select_backend(handler: &RpcHandler) -> (Box, BackendId) { + let data_dir = handler.data_dir(); let (detected, _models) = crate::api::rpc::mesh::assistant::detect_ollama().await; let model = ollama::OLLAMA_DEFAULT_MODEL; let tool_capable = if detected { @@ -145,10 +153,36 @@ pub async fn select_backend(data_dir: &Path) -> (Box, BackendId) { "Ollama is reachable but its configured model ({model}) is not tool-capable" )); } - ( - Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), - BackendId::Claude, - ) + + // D-04's third leg: Routstr, reached only when Ollama isn't selectable + // — and only when the operator has actually authorized spending. The + // budget is read fresh HERE, once, at the start of the turn — the + // policy `RoutstrBackend` pays against for the WHOLE turn is fixed at + // this point, before any model output exists yet (D-05's "not a + // function of model output" property). + let budget = crate::assistant::AssistantBudget::load(data_dir).await; + if budget.allowance_sats == 0 { + tracing::info!("D-04: Routstr allowance is zero — Claude alone, Routstr not selected"); + return ( + Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), + BackendId::Claude, + ); + } + + let policy = budget.payment_policy(); + let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir) + .await + .map(|m| m.mints) + .unwrap_or_default(); + let tor_proxy = handler.nostr_tor_proxy(); + let routstr_backend = + routstr::RoutstrBackend::new(data_dir.to_path_buf(), policy, accepted_mints, tor_proxy); + let chain = FallbackChain { + primary: Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf())), + primary_id: BackendId::Claude, + secondary: Box::new(routstr_backend), + }; + (Box::new(chain), BackendId::Claude) } #[cfg(test)] diff --git a/core/archipelago/src/assistant/backends/routstr.rs b/core/archipelago/src/assistant/backends/routstr.rs index e40028e5..5bb174e2 100644 --- a/core/archipelago/src/assistant/backends/routstr.rs +++ b/core/archipelago/src/assistant/backends/routstr.rs @@ -412,10 +412,12 @@ impl RoutstrBackend { // D-05: pay via the existing budget-capped primitive — never // hand-rolled here (T-13-89). A `None` return means the price - // exceeds the remaining allowance, or the wallet/mint declined; - // Task 2's own contract stops at returning a clear `Err` here — - // Task 3 upgrades this exact arm to a typed signal `loop_.rs` - // downcasts to stop the turn cleanly instead of retrying. + // exceeds the remaining allowance, or the wallet/mint declined. + // 13-13 Task 3: this is a TYPED signal (`crate::assistant::BudgetExhausted`), + // not a plain string bail — `loop_.rs` downcasts it out of the + // generic `Err` to stop the turn cleanly (no retry, no re-price, + // no partial spend) rather than treating it like an ordinary + // transport error that might be worth another attempt. let token = match attach_payment( &self.data_dir, &self.policy, @@ -426,13 +428,32 @@ impl RoutstrBackend { { Some(t) => t, None => { - anyhow::bail!( - "Routstr payment declined for {price_sats} sats — over the remaining \ - prepaid allowance, or the wallet/mint could not pay" - ); + return Err(crate::assistant::BudgetExhausted { + remaining_sats: remaining_budget, + quoted_price_sats: price_sats, + } + .into()); } }; + // D-05/S-12: the payment already went through at this point — a + // Cashu token was built and its proofs already committed by + // `attach_payment`/`auto_pay_token`, regardless of whether the + // chat HTTP call below succeeds. Record it against the operator's + // persisted allowance NOW, before attempting the request, not + // after — reloaded fresh from disk (rather than mutating + // `self.policy`'s turn-start snapshot) so concurrent spend from + // elsewhere is never clobbered by a stale in-memory copy. A + // failure to persist is logged, never surfaced as a chat error — + // the payment already happened either way. + let mut budget = crate::assistant::AssistantBudget::load(&self.data_dir).await; + if let Err(e) = budget.record_spend(&self.data_dir, price_sats).await { + tracing::warn!( + error = %e, + "routstr: failed to persist the budget spend (the payment itself already succeeded)" + ); + } + self.send_paid_request(&model, &endpoint, &token, system, tools, history) .await } @@ -701,7 +722,10 @@ mod tests { }) } - fn backend_for(base_url: &str, budget_sats: u64) -> RoutstrBackend { + /// `_base_url` is accepted (not read) purely so call sites read as + /// "a backend pointed at this stub" even though `send_paid_request`'s + /// tests pass the endpoint explicitly as its own argument. + fn backend_for(_base_url: &str, budget_sats: u64) -> RoutstrBackend { RoutstrBackend::new( std::env::temp_dir(), PaymentPolicy::with_budget(budget_sats, 5), diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs index dcb3bbe4..b7acf65d 100644 --- a/core/archipelago/src/assistant/loop_.rs +++ b/core/archipelago/src/assistant/loop_.rs @@ -65,7 +65,44 @@ pub async fn run_loop( } for turn_idx in 0..MAX_TURNS { - match backend.send(system, tools, &history).await? { + let turn = match backend.send(system, tools, &history).await { + Ok(t) => t, + Err(e) => { + // D-05/S-12/T-13-85: the Routstr leg's payment primitive + // declined this specific price against the operator's + // remaining prepaid allowance — arithmetic, upstream of + // anything the model influenced. Downcasting out of the + // generic `Err` (rather than string-matching) is what lets + // this be distinguished from an ordinary transport error + // reliably. Stop HERE: no retry, no re-price, no partial + // spend, and no falling through to a different provider at + // a different price for this turn — a retry loop against a + // budget ceiling is exactly the "prompt-injection-driven + // tool-call loop overspends" failure mode this guards. + // Exhaustion is designed behaviour (AI-SPEC §7b), so this + // returns Ok with a plain-language stop message, never an + // Err that would read as a crash. + if let Some(exhausted) = e.downcast_ref::() { + let stop_message = format!( + "I've reached the prepaid spending limit for cloud inference this \ + period ({} sats remaining, this request needed {} sats) — stopping \ + here rather than retrying, re-pricing, or partially spending. Raise \ + the allowance in AI settings if you'd like to continue.", + exhausted.remaining_sats, exhausted.quoted_price_sats + ); + history.push(ChatMessage { + role: Role::Assistant, + text: Some(stop_message.clone()), + tool_calls: vec![], + tool_results: vec![], + }); + ctx.counters.note_turns_used((turn_idx + 1) as u64); + return Ok((stop_message, history)); + } + return Err(e); + } + }; + match turn { BackendTurn::Text(answer) => { history.push(ChatMessage { role: Role::Assistant, diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 312ce113..c5b13644 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -98,6 +98,9 @@ struct AssistantCountersInner { cloud_escalation_while_local_up: u64, blocked_egress: u64, max_turns_reached: u64, + /// 13-13 Task 3 / D-05: how many times a successful Routstr payment was + /// recorded against the operator's prepaid allowance this session. + budget_burn_events: u64, /// Grant-refusal timestamps this window, split by whether untrusted /// content was present in context at the time (T-13-83) — this is /// what tells a probing attack apart from ordinary misconfiguration. @@ -271,6 +274,29 @@ impl AssistantCounters { ); } + /// 13-13 Task 3 / D-05: a Routstr payment just succeeded and was + /// recorded against the operator's prepaid allowance. Purely a counter + /// bump — the 80%-threshold owner notice itself is raised by + /// [`AssistantBudget::record_spend`], which calls this alongside it, + /// since only that method has both the before/after spend figures the + /// notice's threshold check needs. + pub(crate) fn note_budget_burn(&self) { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .budget_burn_events += 1; + } + + /// How many Routstr payments have been recorded this session — for + /// tests/observability; not exposed over RPC by this plan. + #[cfg(test)] + pub(crate) fn budget_burn_events(&self) -> u64 { + self.inner + .lock() + .expect("assistant counters mutex poisoned") + .budget_burn_events + } + /// G-B1: an outbound cloud request was blocked before it left this /// node because it appeared to contain secret-shaped material. /// Structurally should be unreachable (G-S5/S-11); if it ever fires, @@ -290,6 +316,164 @@ impl AssistantCounters { } } +const BUDGET_FILE: &str = "assistant/budget.json"; +/// D-05's cross-mint swap-fee tolerance for the Routstr leg — mirrors +/// `swarm::payment`'s existing `max_fee_sats` semantics (ignored once the +/// wallet already holds the provider's mint). Small and fixed: nothing +/// about this figure is operator-configurable in this phase, matching +/// every other `PaymentPolicy` caller in this codebase, which each +/// hardcode their own fee tolerance rather than exposing a second knob. +const ROUTSTR_MAX_FEE_SATS: u64 = 5; +/// AI-SPEC §7b: crossing this fraction of the allowance raises an owner +/// notice (informational, not a warning of failure) — exhaustion itself is +/// a separate, informational STOP handled by `loop_.rs`'s `BudgetExhausted` +/// downcast, never an error here. +const BUDGET_NOTICE_THRESHOLD_PERCENT: u128 = 80; + +/// D-05: the operator-set prepaid allowance for the Routstr leg of D-04 — +/// an operator-set ceiling, never a function of anything the model, a +/// tool call, or a discovered provider emits. [`AssistantBudget::payment_policy`] +/// reads only these two persisted fields — nothing else — which is what +/// makes the ceiling arithmetic rather than a policy an injected model +/// could ever argue with. +/// +/// Persisted under `data_dir/assistant/budget.json`, 0600, following +/// [`grants::Grants`]'s own persistence convention exactly. A missing or +/// unreadable file is [`AssistantBudget::default_empty`] — a ZERO +/// allowance, D-16's "default closed" philosophy applied to money: Routstr +/// spends nothing until the operator deliberately sets an allowance (see +/// `backends::select_backend`'s zero-allowance check, which never even +/// selects Routstr in that case). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct AssistantBudget { + /// Total sats the operator has authorized for this period. Zero means + /// Routstr is never selected at all — not selected-and-then-declined. + pub allowance_sats: u64, + /// Sats spent against `allowance_sats` so far this period. Only ever + /// incremented by [`AssistantBudget::record_spend`], which is called + /// exclusively AFTER `auto_pay_token` already returned `Some(token)` — + /// this field never decides whether to pay, it only accounts for a + /// payment that already happened. + pub spent_sats: u64, +} + +impl AssistantBudget { + /// D-16's philosophy applied to spending: a fresh node authorizes + /// nothing. + pub fn default_empty() -> Self { + Self { + allowance_sats: 0, + spent_sats: 0, + } + } + + /// Load the persisted budget for this node. A missing or corrupt file + /// is `default_empty()` — never an error, and never anything other + /// than a zero allowance (mirrors `Grants::load`'s own fail-safe + /// default). + pub async fn load(data_dir: &Path) -> Self { + let path = data_dir.join(BUDGET_FILE); + let Ok(content) = tokio::fs::read_to_string(&path).await else { + return Self::default_empty(); + }; + serde_json::from_str(&content).unwrap_or_else(|_| Self::default_empty()) + } + + /// Persist this budget, 0600 (mirrors `Grants::save`'s convention). + pub async fn save(&self, data_dir: &Path) -> Result<()> { + let dir = data_dir.join("assistant"); + tokio::fs::create_dir_all(&dir).await?; + let path = data_dir.join(BUDGET_FILE); + let content = serde_json::to_string_pretty(self)?; + tokio::fs::write(&path, &content).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok(); + } + Ok(()) + } + + /// The arithmetic ceiling this period — `allowance_sats` minus + /// `spent_sats`, saturating (never negative, never overflows). + pub fn remaining_sats(&self) -> u64 { + self.allowance_sats.saturating_sub(self.spent_sats) + } + + /// D-05: build the `PaymentPolicy` the Routstr leg pays against, from + /// THIS persisted state alone. No parameter here accepts anything a + /// model, a tool call, or a discovered provider produced — the + /// function signature has nothing else to read, which is what makes + /// the ceiling arithmetic rather than a policy an injected model could + /// argue with (`PaymentPolicy::affords` is upstream of every + /// model-influenced value by construction). + pub fn payment_policy(&self) -> crate::swarm::payment::PaymentPolicy { + crate::swarm::payment::PaymentPolicy::with_budget( + self.remaining_sats(), + ROUTSTR_MAX_FEE_SATS, + ) + } + + /// Whether spend has crossed `BUDGET_NOTICE_THRESHOLD_PERCENT` of the + /// allowance. `u128` multiplication avoids any overflow at realistic + /// sat values while staying exact — no floating point. + fn crossed_80_percent(&self) -> bool { + self.allowance_sats > 0 + && (self.spent_sats as u128) * 100 + >= (self.allowance_sats as u128) * BUDGET_NOTICE_THRESHOLD_PERCENT + } + + /// D-05/S-12: record a successful Routstr payment against this budget + /// and persist it. Called ONLY after `auto_pay_token` returned + /// `Some(token)` (i.e. the payment already happened) — this never + /// decides whether to pay, only accounts for a payment that already + /// went through. Raises an owner notice the FIRST time this crosses + /// 80% of the allowance (AI-SPEC §7b); exhaustion itself is a + /// separate, informational stop handled entirely by `loop_.rs`'s + /// `BudgetExhausted` downcast, never an error here. + pub async fn record_spend(&mut self, data_dir: &Path, price_sats: u64) -> Result<()> { + let was_below_threshold = !self.crossed_80_percent(); + self.spent_sats = self.spent_sats.saturating_add(price_sats); + self.save(data_dir).await?; + global_counters().note_budget_burn(); + if was_below_threshold && self.crossed_80_percent() { + global_counters().owner_notice( + OwnerNoticeKind::Info, + format!( + "Routstr spending has crossed {BUDGET_NOTICE_THRESHOLD_PERCENT}% of this \ + period's prepaid allowance ({} of {} sats spent).", + self.spent_sats, self.allowance_sats + ), + ); + } + Ok(()) + } +} + +/// D-05/S-12: the Routstr payment primitive declined this specific price +/// against the operator's remaining prepaid allowance. Distinguishable +/// from an ordinary transport error via `anyhow::Error::downcast_ref` so +/// `loop_.rs` can terminate the turn with a plain-language stop message — +/// never a retry, a re-price, a partial spend, or a fall-through to a +/// different provider at a different price for this same turn (T-13-85). +#[derive(Debug)] +pub struct BudgetExhausted { + pub remaining_sats: u64, + pub quoted_price_sats: u64, +} + +impl std::fmt::Display for BudgetExhausted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Routstr quoted {} sats but only {} sats remain in this period's prepaid allowance", + self.quoted_price_sats, self.remaining_sats + ) + } +} + +impl std::error::Error for BudgetExhausted {} + /// The process-wide counters instance, shared by every production call /// site that has no `ToolExecCtx`/`AssistantCounters` handle threaded to /// it already (e.g. `backends/claude.rs`, which implements the @@ -548,7 +732,7 @@ pub async fn chat( let grants = caller.granted_categories(handler.data_dir()).await; let visible_tools = registry.visible_to(&grants); - let (backend, backend_id) = backends::select_backend(handler.data_dir()).await; + let (backend, backend_id) = backends::select_backend(&handler).await; tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn"); let system_prompt = build_system_prompt(&visible_tools); @@ -1177,4 +1361,175 @@ mod tests { let (answer, _history) = loop_task.await.expect("join").expect("run_loop"); assert_eq!(answer, "Done — restarted as requested."); } + + // ----------------------------------------------------------------- + // 13-13 Task 3: D-05's arithmetic ceiling — spend silently within the + // allowance, then stop and ask. No retry, no re-price, no partial + // spend, and the ceiling can never be a function of anything the + // model emits. + // ----------------------------------------------------------------- + + /// A backend that always errors with `BudgetExhausted` — standing in + /// for the Routstr leg's own `send()` when the quoted price exceeds + /// the remaining allowance. Counts how many times it was actually + /// called, so "no retry" is asserted directly rather than inferred. + struct BudgetExhaustedBackend { + calls: Arc, + remaining_sats: u64, + quoted_price_sats: u64, + } + + #[async_trait::async_trait] + impl backends::Backend for BudgetExhaustedBackend { + async fn send( + &self, + _system: &str, + _tools: &[tools::ToolDef], + _history: &[tools::ChatMessage], + ) -> Result { + self.calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(BudgetExhausted { + remaining_sats: self.remaining_sats, + quoted_price_sats: self.quoted_price_sats, + } + .into()) + } + } + + /// S-12: a `BudgetExhausted` error from the backend stops the loop + /// with a plain-language message — `run_loop` returns `Ok`, not an + /// `Err` that would read as a crash, and never calls `send()` a second + /// time (no retry). + #[tokio::test] + async fn zero_budget_stops_loop_without_retry() { + let (handler, _tmp) = test_rpc_handler().await; + let ctx = ToolExecCtx::new( + tools::registry(), + CallerScope::LocalOperator { + session_id: "s".to_string(), + }, + handler, + ); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let backend = BudgetExhaustedBackend { + calls: calls.clone(), + remaining_sats: 0, + quoted_price_sats: 50, + }; + let (answer, _history) = loop_::run_loop(&backend, "sys", &[], vec![], &ctx) + .await + .expect("a budget-exhausted stop must be Ok, not a crash-shaped Err"); + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the backend must be called exactly once — no retry against the ceiling" + ); + assert!( + answer.to_lowercase().contains("allowance") || answer.to_lowercase().contains("limit"), + "the stop message must explain why in plain language: {answer}" + ); + } + + /// D-05: a fresh node's `AssistantBudget` defaults to a zero + /// allowance, and `select_backend` must never select Routstr in that + /// case — the operator sees Claude alone (or Claude's own error) + /// rather than a paid backend chosen and then declined at the payment + /// step. + #[tokio::test] + async fn zero_allowance_never_selects_routstr() { + let (handler, _tmp) = test_rpc_handler().await; + let budget = AssistantBudget::load(handler.data_dir()).await; + assert_eq!( + budget.allowance_sats, 0, + "a fresh node must default to a zero allowance" + ); + let (_backend, id) = backends::select_backend(&handler).await; + assert_ne!( + id, + backends::BackendId::Routstr, + "a zero allowance must never select Routstr" + ); + } + + /// D-05: `payment_policy()`'s ceiling is computed ONLY from the + /// persisted `AssistantBudget` fields — never from anything a model, a + /// tool call, or a discovered provider could influence. Reloading + /// independently of any "turn" context produces the identical ceiling, + /// and no price a hostile provider could quote is ever judged + /// affordable beyond what was persisted before the turn began. + #[tokio::test] + async fn ceiling_is_not_a_function_of_model_output() { + let (handler, _tmp) = test_rpc_handler().await; + let budget = AssistantBudget { + allowance_sats: 1_000, + spent_sats: 300, + }; + budget.save(handler.data_dir()).await.expect("save"); + + let reloaded = AssistantBudget::load(handler.data_dir()).await; + let policy = reloaded.payment_policy(); + assert_eq!( + policy.budget_sats, 700, + "the ceiling must be allowance minus spent, computed only from operator-set state" + ); + + // No price an attacker-controlled (self-published Nostr) provider + // event could quote ever widens what `affords()` will accept — + // the function's only inputs are the persisted fields above. + assert!( + !policy.affords(u64::MAX), + "no model/provider-influenced price can ever be judged affordable beyond the persisted remaining allowance" + ); + assert!(policy.affords(700)); + assert!(!policy.affords(701)); + } + + /// EV-17: a scripted injection-driven retry loop against a + /// near-exhausted allowance terminates on the FIRST decline with zero + /// overspend — the persisted `spent_sats` figure never moves, because + /// `BudgetExhausted` only ever fires when `auto_pay_token` already + /// returned `None` (no wallet operation occurred at all), and + /// `run_loop` stops before ever asking the backend a second time. + #[tokio::test] + async fn injection_loop_against_low_budget_does_not_overspend() { + let (handler, _tmp) = test_rpc_handler().await; + let budget = AssistantBudget { + allowance_sats: 10, + spent_sats: 9, // 1 sat remaining + }; + budget.save(handler.data_dir()).await.expect("save"); + + let ctx = ToolExecCtx::new( + tools::registry(), + CallerScope::LocalOperator { + session_id: "s".to_string(), + }, + handler.clone(), + ); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let backend = BudgetExhaustedBackend { + calls: calls.clone(), + remaining_sats: 1, + quoted_price_sats: 50, + }; + let (answer, _history) = loop_::run_loop(&backend, "sys", &[], vec![], &ctx) + .await + .expect("run_loop must stop gracefully, not error"); + + assert_eq!( + calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "an injection-driven retry loop against a near-exhausted allowance must not cause repeated payment attempts" + ); + let reloaded = AssistantBudget::load(handler.data_dir()).await; + assert_eq!( + reloaded.spent_sats, 9, + "a declined payment must never touch spent_sats — zero overspend" + ); + assert!( + answer.to_lowercase().contains("allowance") || answer.to_lowercase().contains("limit"), + "the loop must terminate with the stop message: {answer}" + ); + } }