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 |
|
|
false |
|
|
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_TIMEOUTassistant/backends/mod.rs: the Routstr leg inserted intoselect_backendassistant/mod.rs:pub struct AssistantBudget,fn payment_policyapi/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 existingassistant.arm —dispatcher.rsis 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## Gatesection was read and its verdict quoted in the summary- If
defer-with-residual:COVERAGE.mdis 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> Selectproceed-observed,proceed-docs-with-probe-first, ordefer-with-residual.
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>bulletgrep -q 'impl Backend for RoutstrBackend' core/archipelago/src/assistant/backends/routstr.rsgrep -q 'auto_pay_token' core/archipelago/src/assistant/backends/routstr.rsandgrep -ci 'build_payment_token\|bdhke\|blind' core/archipelago/src/assistant/backends/routstr.rsreturns 0 — the Cashu primitive is called, not reimplementedgrep -q 'build_nostr_client' core/archipelago/src/assistant/backends/routstr.rs— no second relay clientgrep -q 'screen_outbound' core/archipelago/src/assistant/backends/routstr.rsgrep -q 'ROUTSTR_MAX_TOKENS' core/archipelago/src/assistant/backends/routstr.rsand 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.tomlexits 0 </acceptance_criteria> A backend adapter behind the existing trait; removing the leg is deleting one branch ofselect_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.
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 passingcd core && cargo test --package archipelago(full suite) exits 0, includingswarm::payment's existing budget testsgrep -q 'pub struct AssistantBudget' core/archipelago/src/assistant/mod.rs- The
Nonebranch inloop_.rsreturns a terminating result — verify by reading that no loop-continuation or provider-reselection follows it injection_loop_against_low_budget_does_not_overspendasserts total spend is zero and the loop terminated with the stop messagecd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rsexits 0- Temporarily make the
Nonebranch continue instead of terminate and confirmzero_budget_stops_loop_without_retrygoes 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> |
<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