Files
archy/.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-13-PLAN.md
T

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-aiui-functional-conversational-node-control-and-content-surf 13 execute 6
13-12
13-03
core/archipelago/src/assistant/backends/routstr.rs
core/archipelago/src/assistant/backends/mod.rs
core/archipelago/src/assistant/mod.rs
core/archipelago/src/api/rpc/assistant_chat.rs
false
AIUI-01
truths artifacts key_links
Routstr is the third leg of D-04's chain: local Ollama first, Claude second, Routstr third — reached only when the first two are unavailable
Routstr spending is authorized by a prepaid budget the operator sets; inference spends silently within the allowance, then stops and asks (D-05)
The ceiling is hard and arithmetic: a prompt-injected model cannot exceed it, because the cap sits in PaymentPolicy upstream of anything the model influences (D-05)
Budget exhaustion stops the loop with a plain-language explanation — no retry, no re-price, no partial spend (S-12)
Cashu token construction is not hand-rolled: the existing budget-capped auto_pay_token primitive is reused verbatim
Provider discovery routes through the node's existing Tor-proxy-aware Nostr client, not a second relay client
Generation length is capped explicitly on every Routstr request — an unbounded generation on a paid backend is a budget-cap violation, not a latency concern
path provides contains
core/archipelago/src/assistant/backends/routstr.rs Nostr provider discovery, OpenAI-shape chat client, Cashu payment attach impl Backend for RoutstrBackend
from to via pattern
core/archipelago/src/assistant/backends/routstr.rs core/archipelago/src/swarm/payment.rs auto_pay_token(data_dir, policy, accepted_mints, price_sats) — reused verbatim, already budget-capped and already degrades to None auto_pay_token
from to via pattern
core/archipelago/src/assistant/backends/routstr.rs core/archipelago/src/nostr_discovery.rs build_nostr_client for the kind-38421 provider subscription build_nostr_client
Add the third leg of D-04's chain. Routstr was named by the operator directly, with the repo link, and asked to be planned in as part of the backend work — not treated as a future option.

The phase is not starting from zero on the payment side. crate::swarm::payment::auto_pay_token already does exactly D-05's job: build a Cashu token for a given price against a set of accepted mints, hard-capped by a PaymentPolicy budget, degrading to None rather than erroring when unaffordable — with existing tests covering the over-budget and zero-budget cases. nostr-sdk is already a dependency with a Tor-proxy-aware client builder. The net-new work is one OpenAI-compatible HTTP client and the wiring that makes None mean stop and ask rather than try something else.

The entry gate. 13-03 probed a live provider and rewrote COVERAGE.md from what it observed. Its ## Gate section states whether this plan may proceed directly or must open with a decision. Task 1 reads that section; the plan does not begin by trusting documentation the spike may have contradicted.

Output: backends/routstr.rs, the D-04 chain completed, and the operator-set budget with a hard stop.

<flagged_assumptions> Routstr's wire contract is only as good as 13-03's findings. RESEARCH rated it MEDIUM and 13-ROUTSTR-FINDINGS.md is the authority this plan is written against. Where the findings say NOT OBSERVED, Task 1's decision governs — the implementation does not fall back to the docs without that decision being taken and recorded. </flagged_assumptions>

<artifacts_this_phase_produces> Symbols created by this plan:

  • assistant/backends/routstr.rs: pub struct RoutstrBackend, pub struct RoutstrProvider, async fn discover_providers, fn select_provider, fn attach_payment, fn parse_openai_tool_calls, const ROUTSTR_KIND, const ROUTSTR_MAX_TOKENS, const DISCOVERY_TIMEOUT
  • assistant/backends/mod.rs: the Routstr leg inserted into select_backend
  • assistant/mod.rs: pub struct AssistantBudget, fn payment_policy
  • api/rpc/assistant_chat.rs: handle_assistant_budget_get, handle_assistant_budget_set
  • New RPC method names: assistant.budget-get, assistant.budget-set (through 13-01's existing assistant. arm — dispatcher.rs is not touched) </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @CLAUDE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/COVERAGE.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-ROUTSTR-FINDINGS.md @.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-12-SUMMARY.md Task 1: Read the spike's verdict before writing a line of client code Whether to implement `backends/routstr.rs` against the observed protocol, against the documentation alone, or to defer the Routstr leg of D-04 with a named residual. `13-03` subscribed to the real relays and probed a provider, then rewrote `COVERAGE.md` from what it observed and recorded per-claim verdicts in `13-ROUTSTR-FINDINGS.md`. `COVERAGE.md`'s `## Gate` section states, in one sentence, whether this plan may proceed directly.

Three things make this a decision rather than a formality. Routstr is a young, actively-developed project, so a docs-only client is a real risk of writing the wrong header name and the wrong event filter into a security-sensitive loop. It is also the only backend that spends the operator's money, so a client built on a guess has a worse failure mode than one built on a guess elsewhere. And CONTEXT.md is unambiguous that Routstr is in scope at the operator's explicit request, so deferring it is a real cost that should be chosen deliberately, not defaulted into.

Read 13-ROUTSTR-FINDINGS.md's verdict table before choosing. If every claim is CONFIRMED, option proceed-observed is the obvious answer and this checkpoint costs a minute. Proceed against the observed protocol The client is written against facts. This is the intended path and costs nothing extra. None, if the findings are complete. Proceed against the docs, but make the first live call a capability probe that fails loudly Delivers the operator-requested feature even though no provider was reachable at spike time. The probe means a wrong guess surfaces as a clear error rather than a silent misbehaviour. Some rework is likely when a provider is finally reached. The unconfirmed rows in COVERAGE.md stay unconfirmed until then. Defer the Routstr leg; ship D-04 as Ollama then Claude No speculative client in the tree, and no code path that spends money on an unverified contract. Drops a capability the operator asked for by name. Requires recording the residual in COVERAGE.md and in the phase summary, and re-planning it later. <acceptance_criteria>

  • The chosen option id is recorded in the plan summary with one sentence of rationale
  • COVERAGE.md's ## Gate section was read and its verdict quoted in the summary
  • If defer-with-residual: COVERAGE.md is updated to mark the Routstr rows deferred with a reason, Tasks 2 and 3 are skipped, and the residual is named in the phase summary — never silently omitted
  • If proceed-docs-with-probe-first: Task 2's action gains the capability-probe requirement and the summary records which claims remain unverified </acceptance_criteria> Select proceed-observed, proceed-docs-with-probe-first, or defer-with-residual.
Task 2: Discover a provider, speak OpenAI, attach ecash core/archipelago/src/assistant/backends/routstr.rs, core/archipelago/src/assistant/backends/mod.rs - Provider discovery subscribes for the provider event kind over the node's existing Tor-aware Nostr client and returns the advertised endpoints, models and prices. - Discovery that finds nothing within its timeout returns an empty list, not an error, and `select_backend` falls through rather than failing the turn. - Provider selection picks the cheapest advertised price for the requested model that is affordable under the remaining budget, preferring an onion endpoint when Tor is up. - A chat request is OpenAI-shaped, carries a `tools` array mapped from the granted `ToolDef`s, and requests non-streaming for any turn that may emit a tool call. - Tool-call arguments arriving as a JSON-encoded **string** are parsed once at this adapter's edge, and the shared loop receives the same parsed object shape every other backend produces. - Each `tool_calls[]` entry's id is echoed back in the corresponding result turn. - The generation-length cap is set explicitly on every request. - Payment is attached using the header spelling `13-ROUTSTR-FINDINGS.md` recorded; the token comes from the existing budget-capped primitive and is never constructed here. - `screen_outbound` runs on this leg before any body is sent. - `.planning/phases/13-.../13-ROUTSTR-FINDINGS.md` — the observed event shape, header spelling, arguments encoding, and model/price fields. **This is the specification for this file.** Where a row says `NOT OBSERVED`, Task 1's decision governs. - `core/archipelago/src/swarm/payment.rs` lines 77-101 — `auto_pay_token` in full, including its `policy.affords` short-circuit and its deliberate degrade-to-`None` on any wallet or mint problem. `13-PATTERNS.md` says **copy this call verbatim**; do not reimplement Cashu token building. - `core/archipelago/src/nostr_discovery.rs` — `build_nostr_client` (Tor-proxy aware). Reuse it; do not construct a second `nostr-sdk` client. - `core/archipelago/src/assistant/backends/claude.rs` and `ollama.rs` — the `Backend` implementations to match, and the `ToolCall`/`BackendTurn` normalization contract. AI-SPEC §3 Pitfall 2 is the specific trap here: this is the one backend whose arguments arrive as a string. - `core/archipelago/src/assistant/egress.rs` (13-12) — `screen_outbound`, which must run on this leg. - `core/archipelago/src/streaming/` — the existing Cashu handling and the `streaming.list-mints` / `.configure-mints` RPCs that supply `accepted_mints`. Create `core/archipelago/src/assistant/backends/routstr.rs` implementing the `Backend` trait.

discover_providers subscribes over nostr_discovery.rs::build_nostr_client for the provider event kind recorded in the findings, with a bounded DISCOVERY_TIMEOUT, parsing endpoints, models and pricing from the observed content schema. Cache results for the process lifetime with a short TTL; a relay round trip per chat turn is not acceptable latency on the third leg of a fallback chain. Finding nothing is an empty list, never an error — select_backend falls through and the operator gets an answer from wherever it can.

select_provider picks the cheapest advertised price for the requested model that the remaining budget affords, preferring an onion endpoint when Tor is up. Treat every discovered provider as untrusted data: it is a self-published Nostr event, so nothing about it may widen what this node does beyond issuing a paid chat request to the advertised endpoint.

The HTTP half models its reqwest::Client construction on backends/claude.rs (same crate, same TLS and socks features already in Cargo.toml) but the request and response shapes are net-new. Parse tool_calls[] per the findings: this is the backend whose function.arguments arrives as a JSON-encoded string, so parse it exactly once here and hand the shared loop the same object shape Ollama and Claude produce. Echo each call id back in the result turn. Set the generation-length cap explicitly on every request — an unbounded generation on a paid backend is a direct budget-cap violation risk, not a latency concern.

attach_payment calls crate::swarm::payment::auto_pay_token(data_dir, policy, accepted_mints, price_sats) and attaches the returned token using the header spelling the findings recorded. Do not build a Cashu token here; the existing primitive is already budget-capped, already tested, and already degrades correctly. A None return is handled in Task 3, not here.

Call screen_outbound before sending any body — this is a cloud leg and G-B1/G-B2 apply exactly as they do to Claude.

Insert the Routstr leg into select_backend after Claude, completing D-04's order.

Write the tests FIRST, one per <behavior> bullet, with a local HTTP stub for the chat endpoint and a fixture event for discovery. Name the encoding case openai_string_arguments_are_parsed_once_at_the_edge and the fall-through case no_provider_found_falls_through_not_errors. cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::backends:: 2>&1 | tail -25 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago openai_string_arguments_are_parsed_once_at_the_edge <acceptance_criteria>

  • cd core && cargo test --package archipelago assistant::backends:: exits 0 with a test per <behavior> bullet
  • grep -q 'impl Backend for RoutstrBackend' core/archipelago/src/assistant/backends/routstr.rs
  • grep -q 'auto_pay_token' core/archipelago/src/assistant/backends/routstr.rs and grep -ci 'build_payment_token\|bdhke\|blind' core/archipelago/src/assistant/backends/routstr.rs returns 0 — the Cashu primitive is called, not reimplemented
  • grep -q 'build_nostr_client' core/archipelago/src/assistant/backends/routstr.rs — no second relay client
  • grep -q 'screen_outbound' core/archipelago/src/assistant/backends/routstr.rs
  • grep -q 'ROUTSTR_MAX_TOKENS' core/archipelago/src/assistant/backends/routstr.rs and the constant is used on every request path
  • The header spelling and event kind in the source match 13-ROUTSTR-FINDINGS.md — quote both in the summary
  • cd core && git diff --exit-code -- archipelago/Cargo.toml exits 0 </acceptance_criteria> A backend adapter behind the existing trait; removing the leg is deleting one branch of select_backend. A discovered provider answers an OpenAI-shaped tool-calling request paid with an ecash token built by the existing budget-capped primitive, and no provider found means falling through rather than failing.
Task 3: The ceiling is arithmetic — spend silently, then stop and ask core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs - The operator sets a prepaid allowance; `assistant.budget-get` reports the allowance, the amount spent and the remainder. - Inference within the allowance proceeds with no prompt — spending is silent by design until the ceiling. - When the quoted price exceeds the remaining allowance, the payment primitive returns nothing and the loop **stops**: no retry, no re-price, no partial spend, and a plain-language message telling the operator why and offering to top up. - A zero allowance means Routstr is never selected — not selected-and-then-failed. - The ceiling cannot be raised by anything the model emits: it is read from operator-set config at the start of the turn and is not a function of any model output. - Crossing 80% of the allowance raises an owner notice; exhaustion is an informational stop, not an error, because it is designed behaviour. - A scripted injection-driven loop against a near-exhausted allowance terminates with the stop message and zero overspend. - `core/archipelago/src/swarm/payment.rs` — `PaymentPolicy`, `policy.affords`, and its existing tests `over_budget_declines_without_touching_wallet` and `zero_budget_is_origin_only`. These are the semantics this task wires to; do not re-derive them. - `.planning/phases/13-.../13-AI-SPEC.md` §5 invariant **S-12**, dimension **E-08**, dataset row **EV-17**, and §7b's alert table (budget ≥ 80% is a warning; exhaustion is informational, because it is designed behaviour, not a failure). - `.planning/phases/13-.../13-CONTEXT.md` D-05 — the ceiling is hard; a prompt-injected model cannot exceed it. - `core/archipelago/src/assistant/loop_.rs` — where a `None` from the payment path must terminate the loop rather than fall through to another attempt. - `core/archipelago/src/assistant/mod.rs` — `AssistantCounters` from 13-12, which gains the budget-burn counter. Add `AssistantBudget` to `assistant/mod.rs`: an operator-set allowance in sats, the amount spent this period, and the accepted mints. `payment_policy()` builds a `PaymentPolicy` from it at the **start of the turn**, from operator-set config only — never from anything the model emitted. That is what makes the ceiling arithmetic rather than a policy the model could argue with: `policy.affords` is upstream of every model-influenced value.

Wire the None return from auto_pay_token in loop_.rs to terminate the loop with a user-facing message explaining that the prepaid allowance is exhausted and offering to top up. No retry, no re-price, no partial spend, and no falling through to a different provider at a different price — a retry loop against a budget ceiling is precisely the "prompt-injection-driven tool-call loop overspends" failure mode, and auto_pay_token's degrade-to-None is only a hard stop if the caller treats it as one.

A zero allowance means select_backend does not select Routstr at all, so the operator sees "no backend available" rather than a paid backend that fails at the payment step.

Add handle_assistant_budget_get and handle_assistant_budget_set to assistant_chat.rs, routed through 13-01's existing assistant. arm. Do not touch dispatcher.rs. Add the budget-burn counter and the 80% owner notice to 13-12's counters, keeping AI-SPEC §7b's framing: exhaustion is informational, not an error.

Write the tests FIRST, one per <behavior> bullet. Name them assistant::tests::zero_budget_stops_loop_without_retry (S-12), assistant::tests::zero_allowance_never_selects_routstr, assistant::tests::ceiling_is_not_a_function_of_model_output, assistant::tests::injection_loop_against_low_budget_does_not_overspend (EV-17). cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -30 cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago zero_budget_stops_loop_without_retry cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago 2>&1 | tail -10 cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs <acceptance_criteria>

  • cd core && cargo test --package archipelago assistant:: exits 0 with all four named tests passing
  • cd core && cargo test --package archipelago (full suite) exits 0, including swarm::payment's existing budget tests
  • grep -q 'pub struct AssistantBudget' core/archipelago/src/assistant/mod.rs
  • The None branch in loop_.rs returns a terminating result — verify by reading that no loop-continuation or provider-reselection follows it
  • injection_loop_against_low_budget_does_not_overspend asserts total spend is zero and the loop terminated with the stop message
  • cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs exits 0
  • Temporarily make the None branch continue instead of terminate and confirm zero_budget_stops_loop_without_retry goes red; restore it and record the observed failure in the summary </acceptance_criteria> D-05 rates the ceiling reversible in CONTEXT.md — it is a config value, not a contract. Spending is silent within the allowance and stops dead at it, with a plain-language explanation, zero overspend and no retry — and the stop demonstrably breaks when the terminating branch is removed.

<threat_model>

Trust Boundaries

Boundary Description
Nostr relays → provider list Self-published events from unknown parties; treated as untrusted data throughout
node → a discovered third-party endpoint Carries the turn's context and a bearer ecash token
operator config → PaymentPolicy The only source of the ceiling; nothing model-influenced reaches it
wallet/mint state → payment Server-side only, through the existing primitive

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-85 Denial of Service (financial) Injection-driven loop overspending the allowance critical mitigate G-S8: the cap is arithmetic in PaymentPolicy::affords, upstream of anything the model influences; None terminates the loop with no retry. Asserted by zero_budget_stops_loop_without_retry and injection_loop_against_low_budget_does_not_overspend, and demonstrated to go red when the terminating branch is removed
T-13-86 Spoofing A hostile Nostr event advertising a malicious provider endpoint high mitigate Providers are untrusted data: discovery only yields an endpoint to POST a paid chat request to. Nothing about a provider event widens tool authority, changes a grant or affects the ceiling. Selection is bounded by affordability
T-13-87 Information Disclosure Node state or a secret leaving for a third-party inference provider critical mitigate screen_outbound (G-B1/G-B2) runs on this leg exactly as on Claude's; asserted by grep and by 13-12's egress suite
T-13-88 Denial of Service (financial) Unbounded generation on a paid backend high mitigate ROUTSTR_MAX_TOKENS set explicitly on every request; asserted by grep and by the per-request test
T-13-89 Tampering Hand-rolled Cashu token construction diverging from the audited primitive high mitigate auto_pay_token reused verbatim; asserted by the no-BDHKE grep. 13-PATTERNS.md and RESEARCH both say copy, do not reimplement
T-13-90 Information Disclosure A second, non-Tor-aware Nostr client leaking the node's network position medium mitigate build_nostr_client reused; asserted by grep
T-13-91 Tampering String-encoded tool arguments mis-parsed, so the loop silently sees the wrong arguments high mitigate Parsed once at the adapter edge per AI-SPEC §3 Pitfall 2; asserted by openai_string_arguments_are_parsed_once_at_the_edge. Note the confirm gate still names the validated arguments, so a parse bug surfaces as a refusal rather than a wrong execution
T-13-92 Repudiation A docs-only client shipped as if it were verified medium mitigate Task 1's checkpoint:decision reads 13-03's findings and records which claims remain unverified; COVERAGE.md carries no unconfirmed INTEGRATE row
T-13-93 Denial of Service A relay round trip on every chat turn low mitigate Discovery results cached with a short TTL; a discovery miss is an empty list and a fall-through, not an error
T-13-SC Tampering npm/pip/cargo installs high mitigate Zero packages added — nostr-sdk and reqwest are already in-tree. Asserted by git diff --exit-code -- archipelago/Cargo.toml. No install task, so no legitimacy checkpoint required
</threat_model>
- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago` full suite green - `zero_budget_stops_loop_without_retry`, `zero_allowance_never_selects_routstr`, `ceiling_is_not_a_function_of_model_output`, `injection_loop_against_low_budget_does_not_overspend`, `openai_string_arguments_are_parsed_once_at_the_edge` and `no_provider_found_falls_through_not_errors` all pass - The header spelling and event kind in `routstr.rs` match `13-ROUTSTR-FINDINGS.md` - `cd core && git diff --exit-code -- archipelago/Cargo.toml archipelago/src/api/rpc/dispatcher.rs` exits 0

<success_criteria> D-04's chain is complete — local, then Claude, then a Nostr-discovered ecash-paid provider — and the operator's prepaid allowance is a hard arithmetic ceiling that a prompt-injected model cannot cross, demonstrated by a test that goes red when the terminating branch is removed. </success_criteria>

Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-13-SUMMARY.md` when done