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

299 lines
25 KiB
Markdown
Raw Normal View History

2026-08-12 10:55:49 +00:00
---
phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 13
type: execute
wave: 6
depends_on: ["13-12", "13-03"]
files_modified:
- 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
autonomous: false
requirements: [AIUI-01]
must_haves:
truths:
- "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"
artifacts:
- path: "core/archipelago/src/assistant/backends/routstr.rs"
provides: "Nostr provider discovery, OpenAI-shape chat client, Cashu payment attach"
contains: "impl Backend for RoutstrBackend"
key_links:
- from: "core/archipelago/src/assistant/backends/routstr.rs"
to: "core/archipelago/src/swarm/payment.rs"
via: "auto_pay_token(data_dir, policy, accepted_mints, price_sats) — reused verbatim, already budget-capped and already degrades to None"
pattern: "auto_pay_token"
- from: "core/archipelago/src/assistant/backends/routstr.rs"
to: "core/archipelago/src/nostr_discovery.rs"
via: "build_nostr_client for the kind-38421 provider subscription"
pattern: "build_nostr_client"
---
<objective>
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.
</objective>
<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>
<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
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Read the spike's verdict before writing a line of client code</name>
<decision>
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.
</decision>
<context>
`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.
</context>
<options>
<option id="proceed-observed">
<name>Proceed against the observed protocol</name>
<pros>The client is written against facts. This is the intended path and costs nothing extra.</pros>
<cons>None, if the findings are complete.</cons>
</option>
<option id="proceed-docs-with-probe-first">
<name>Proceed against the docs, but make the first live call a capability probe that fails loudly</name>
<pros>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.</pros>
<cons>Some rework is likely when a provider is finally reached. The unconfirmed rows in COVERAGE.md stay unconfirmed until then.</cons>
</option>
<option id="defer-with-residual">
<name>Defer the Routstr leg; ship D-04 as Ollama then Claude</name>
<pros>No speculative client in the tree, and no code path that spends money on an unverified contract.</pros>
<cons>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.</cons>
</option>
</options>
<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>
<resume-signal>Select `proceed-observed`, `proceed-docs-with-probe-first`, or `defer-with-residual`.</resume-signal>
</task>
<task type="auto" tdd="true">
<name>Task 2: Discover a provider, speak OpenAI, attach ecash</name>
<files>core/archipelago/src/assistant/backends/routstr.rs, core/archipelago/src/assistant/backends/mod.rs</files>
<behavior>
- 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.
</behavior>
<read_first>
- `.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`.
</read_first>
<action>
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`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::backends:: 2>&amp;1 | tail -25</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago openai_string_arguments_are_parsed_once_at_the_edge</automated>
</verify>
<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>
<reversibility rating="reversible">A backend adapter behind the existing trait; removing the leg is deleting one branch of `select_backend`.</reversibility>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: The ceiling is arithmetic — spend silently, then stop and ask</name>
<files>core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs</files>
<behavior>
- 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.
</behavior>
<read_first>
- `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.
</read_first>
<action>
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).
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&amp;1 | tail -30</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago zero_budget_stops_loop_without_retry</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago 2>&amp;1 | tail -10</automated>
<automated>cd core &amp;&amp; git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs</automated>
</verify>
<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>
<reversibility rating="reversible">D-05 rates the ceiling reversible in CONTEXT.md — it is a config value, not a contract.</reversibility>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-13-SUMMARY.md` when done
</output>