Files

304 lines
26 KiB
Markdown

---
phase: 13-aiui-functional-conversational-node-control-and-content-surf
plan: 05
type: execute
wave: 2
depends_on: ["13-01"]
files_modified:
- core/archipelago/src/assistant/tools.rs
- core/archipelago/src/assistant/grants.rs
- core/archipelago/src/assistant/mod.rs
- core/archipelago/src/api/rpc/assistant_chat.rs
autonomous: true
requirements: [AIUI-01, AIUI-02]
must_haves:
truths:
- "An operator can change system settings by conversation, and only within the permission categories they granted (AIUI-02, D-09, D-16)"
- "No ToolDef exists anywhere in the registry whose effect touches keys, seeds, wallet spends, federation trust or factory reset — the D-09 ceiling is the absence of a tool, not a runtime filter (S-04)"
- "A fresh node grants nothing: all ten permission categories are closed until the operator opens them (D-16, S-06)"
- "An ungranted category is refused at execute_tool even when the tool was somehow proposed — the system prompt omitting it is defense in depth, not the gate (S-05)"
- "A read tool never raises a confirmation dialog (S-07)"
- "The model never sees a tool it cannot use: the system prompt lists only currently-granted-category tools (D-16)"
artifacts:
- path: "core/archipelago/src/assistant/tools.rs"
provides: "The full D-06 curated allowlist with per-tool JSON Schema, category and destructive flag"
contains: "fn registry()"
- path: "core/archipelago/src/assistant/grants.rs"
provides: "D-16 default-closed category grants, persisted under data_dir"
contains: "default_closed"
key_links:
- from: "core/archipelago/src/assistant/tools.rs"
to: "core/archipelago/src/api/rpc/dispatcher.rs"
via: "each ToolDef's execute dispatches to an existing authenticated RPC handler — never a parallel AI-only path"
pattern: "handle_(container|system|bitcoin|network|content)_"
- from: "core/archipelago/src/assistant/grants.rs"
to: "core/archipelago/src/assistant/mod.rs"
via: "CallerScope::granted_categories reads the persisted grants store instead of 13-01's hardcoded default"
pattern: "granted_categories"
---
<objective>
Expand the tracer's one-tool registry into the full curated allowlist, and make D-09's authority
ceiling and D-16's default-closed grants real and asserted.
D-06 is explicit: tools are **hand-written**, never auto-generated from `dispatcher.rs`. That is
the only way "the model never sees the full RPC surface" stays true rather than becoming an
implementation detail nobody re-checks. D-09's ceiling — reads within granted categories, app
lifecycle (start/stop/restart), settings writes; keys, seeds, wallet spends, federation trust
and factory reset permanently excluded — is enforced by **not writing those ToolDefs**, and by a
test that asserts over the whole registry so adding an out-of-bounds tool later fails CI rather
than review.
This plan also delivers AIUI-02. A finding worth stating plainly: `system.settings.set` today
accepts exactly one key, `claude_api_key` (verified, `api/rpc/system/handlers.rs:1026-1071`) —
and that key is *excluded* from chat reach by D-09. So conversational settings are built from a
hand-picked allowlist of setting keys drawn from the surfaces that actually exist
(`network.set-visibility`, `system.kiosk-display.set`, `network.set-wifi-radio`,
`bitcoin.relay-update-settings`), with `claude_api_key` explicitly and permanently absent.
Purpose: make the sandbox claim checkable. After this plan, "what can the chat reach" is a
grep over one file and a passing test, not an argument.
Output: the curated registry, the grants store, and `assistant.list-tools` / `assistant.grants-get` / `assistant.grants-set`.
</objective>
<flagged_assumptions>
**FLAGGED — unresolved edge probe, AIUI-02, category `unclassified`.** The deterministic edge
probe returned `unclassified — review manually` for AIUI-02 and it is NOT auto-resolved and NOT
auto-backstopped. Surfaced here for a human read during execution: the requirement text
("system settings reachable by conversation, scoped to what the user granted") does not say what
happens when the operator asks to change a setting that *exists in neode-ui* but is deliberately
absent from the tool allowlist — refuse plainly, refuse and name the UI path, or silently omit.
Task 1 chooses "refuse plainly **and** name the real UI path", which is the E-03 rubric's PASS
behaviour, but the requirement itself does not mandate it. Raise it if that reading is wrong.
</flagged_assumptions>
<artifacts_this_phase_produces>
Symbols created by **this plan**:
- `core/archipelago/src/assistant/tools.rs`: `fn registry()` expanded; per-tool constructors
`apps_list_tool`, `app_logs_tool`, `app_start_tool`, `app_stop_tool`, `app_restart_tool`,
`bitcoin_status_tool`, `network_status_tool`, `mesh_status_tool`, `content_list_tool`,
`settings_get_tool`, `settings_set_tool`; their args structs `AppIdArgs`, `AppLogsArgs`,
`SettingsGetArgs`, `SettingsSetArgs`; `const SETTABLE_KEYS`, `const EXCLUDED_AUTHORITY_TERMS`
- `core/archipelago/src/assistant/grants.rs`: `pub struct Grants`, `pub fn default_closed`,
`Grants::load`, `Grants::save`, `Grants::allows`, `Grants::set`
- `core/archipelago/src/api/rpc/assistant_chat.rs`: `handle_assistant_list_tools`,
`handle_assistant_grants_get`, `handle_assistant_grants_set`
- New RPC method names: `assistant.list-tools`, `assistant.grants-get`, `assistant.grants-set`
(all routed through 13-01's single `assistant.` dispatcher arm — `dispatcher.rs` is not
touched again)
</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/13-AI-SPEC.md
@.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: The curated allowlist — every tool a decision someone made</name>
<files>core/archipelago/src/assistant/tools.rs</files>
<behavior>
- `registry()` returns exactly the hand-written tools listed in the action below, and no others.
- Each tool's `parameters` is a JSON Schema object whose `required` keys all deserialize into its args struct — schema and deserialization target cannot drift.
- `settings_set` refuses any key not in `SETTABLE_KEYS`, with an error naming which keys are settable.
- `settings_set` refuses `claude_api_key` specifically, and the refusal names the neode-ui Settings path as the real way to do it.
- `app_restart` refuses an `app_id` that is not an exact installed app id — no fuzzy match, no nearest-neighbour.
- Every tool whose effect changes node state has `destructive: true`; every read tool has `destructive: false`.
</behavior>
<read_first>
- `core/archipelago/src/assistant/tools.rs` — the tracer's `ToolDef`, `ToolRegistry`, `ToolDef::validate` and the single `system_disk_status` tool. **Extend this file's existing conventions; do not restructure them.**
- `core/archipelago/src/api/rpc/dispatcher.rs` lines 42-50, 107-119, 205-241, 394, 465-477 — the verified handler names each tool dispatches to: `container-list`, `container-start`, `container-stop`, `container-restart`, `container-logs`, `bitcoin.getinfo`, `network.get-visibility`, `network.diagnostics`, `mesh.status`, `content.list-mine`, `system.disk-status`, `system.stats`, `system.settings.get`, `system.settings.set`, `system.kiosk-display.get`, `system.kiosk-display.set`, `network.set-visibility`, `network.set-wifi-radio`, `bitcoin.relay-update-settings`.
- `core/archipelago/src/api/rpc/system/handlers.rs` lines 994-1072 — `handle_system_settings_get`/`_set`. **Confirm for yourself that `_set`'s `match key` accepts only `claude_api_key` today**; that fact drives the `SETTABLE_KEYS` design below.
- `.planning/phases/13-.../13-CONTEXT.md` D-06, D-07, D-09, D-16.
- `.planning/phases/13-.../13-AI-SPEC.md` §4 "Tool Use" and §4b.1 (validate-then-refuse, never coerce, ≤ 2 consecutive validation failures per tool name).
</read_first>
<action>
Expand `registry()` to the curated allowlist. Every entry is hand-written with its own description, its own JSON Schema literal built with `serde_json::json!`, its own `PermissionCategory`, and its own `destructive` flag. **Do not derive anything from `dispatcher.rs`'s method table** — D-06 rejects that outright, and it is the single change that would make the sandbox claim untrue.
Read tools (`destructive: false`):
`system_disk_status` (System, already exists), `system_stats` (System), `apps_list` (Apps → `container-list`), `app_logs` (Apps → `container-logs`, args `app_id` + `lines` capped at 200), `bitcoin_status` (Bitcoin → `bitcoin.getinfo`), `network_status` (Network → `network.get-visibility` + `network.diagnostics`), `mesh_status` (Network → `mesh.status`), `content_list` (Media → `content.list-mine`), `settings_get` (System → `system.settings.get`, `network.get-visibility`, `system.kiosk-display.get` behind a hand-picked key allowlist).
Write tools (`destructive: true`):
`app_start`, `app_stop`, `app_restart` (Apps → `container-start`/`-stop`/`-restart`) and
`settings_set` (System → the setting-specific handler for the requested key).
D-09's ceiling is enforced by **absence**: there is no `wallet_send`, no `seed_reveal`, no
`federation_trust`, no `factory_reset`, no `system_reboot`, no `container_install`, no
`container_remove` ToolDef, and none may be added. Record the excluded set as
`const EXCLUDED_AUTHORITY_TERMS: &[&str]` so Task 3's registry-wide assertion has something
concrete to assert over.
`settings_set` is the AIUI-02 surface and needs care. Define `const SETTABLE_KEYS: &[&str]`
containing only setting keys that (a) have a real handler today and (b) are not key material:
network visibility, kiosk display preset, wifi radio on/off, and the bitcoin relay settings.
`claude_api_key` is **excluded** — it is key material, D-09 puts keys permanently outside chat
reach, and the fact that it is the *only* key `system.settings.set` accepts today is not a
reason to include it. On a request for an unlisted key, return an `is_error: true` ToolResult
whose text names the settable keys and points at the neode-ui Settings screen as the real path
(this is the E-03 PASS behaviour: refuse plainly, do not fabricate, redirect to the real UI).
`app_start`/`app_stop`/`app_restart` take an exact installed `app_id`. Their descriptions must
state that the id is exact and never fuzzy-matched, and include one inline example call (AI-SPEC
§4b.3: few-shot inline, not retrieved). Validation resolves the id against `container-list` and
refuses an unknown id with an error listing installed ids — EV-08's "restart the node" case must
ask which app rather than guessing.
Per AI-SPEC §4b.1: `validate` deserializes and refuses; never coerce, never guess, never panic.
Add the ≤ 2-consecutive-validation-failures-per-tool-name counter to the ToolExecCtx so a model
looping on malformed args aborts the turn with an apology rather than spinning.
Write the tests FIRST, one per `<behavior>` bullet, using the tracer's `ScriptedBackend`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools:: 2>&amp;1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test --package archipelago assistant::tools::` exits 0
- `grep -q 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` and `grep -q 'EXCLUDED_AUTHORITY_TERMS' core/archipelago/src/assistant/tools.rs`
- `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -ciE 'wallet_send|seed_reveal|factory_reset|system_reboot|container_install|container_remove'` returns 0 — the excluded authority has no ToolDef in non-comment source
- `grep -vE '^\s*//' core/archipelago/src/assistant/tools.rs | grep -c '"claude_api_key"'` returns 0 outside the `SETTABLE_KEYS` refusal message path — verify by reading, then assert `grep -c 'SETTABLE_KEYS' core/archipelago/src/assistant/tools.rs` ≥ 1 and that `claude_api_key` is not one of its elements
- `grep -c 'destructive: true' core/archipelago/src/assistant/tools.rs` returns 4 — `app_start`, `app_stop`, `app_restart`, `settings_set` and nothing else
- Every `ToolDef` literal in the file has an explicit `category:` and `destructive:` field (no `..Default::default()`)
- `grep -ci 'dispatcher' core/archipelago/src/assistant/tools.rs` returns 0 — nothing is generated from the method table
</acceptance_criteria>
<reversibility rating="costly">D-09's first-cut authority is rated costly in CONTEXT.md: widening later is safe, but any capability shipped and then withdrawn breaks a behaviour users will have learned. Flagged, not gated — the ceiling here is deliberately conservative.</reversibility>
<done>The registry is a readable list of hand-written decisions; a settings key outside the allowlist and an app id that does not exist are both refused with a message that names the real path.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Default-closed grants, and a system prompt that only shows what is granted</name>
<files>core/archipelago/src/assistant/grants.rs, core/archipelago/src/assistant/mod.rs, core/archipelago/src/api/rpc/assistant_chat.rs</files>
<behavior>
- A fresh node with no grants file returns an empty granted set for every caller variant.
- `assistant.grants-set` opens a named category; `assistant.grants-get` reflects it; the change survives a daemon restart.
- The system prompt built for a caller lists only tools whose category is currently granted — an ungranted tool's name does not appear in the prompt string at all.
- `assistant.list-tools` returns only granted-category tools, with each tool's category and destructive flag, so neode-ui can render an honest capability list.
- `execute_tool` still refuses an ungranted category even when the tool was proposed anyway — the prompt filter is defense in depth, not the gate.
- Revoking a category takes effect on the next turn, not only on the next session.
</behavior>
<read_first>
- `core/archipelago/src/assistant/mod.rs` — the tracer's `CallerScope::granted_categories`, which currently returns a hardcoded `{System}` for `LocalOperator`. **This task replaces the source of that set, not its shape** — the `<assumption_delta_decision>` promote in 13-01 is what makes that a data change rather than an architectural one.
- `neode-ui/src/stores/aiPermissions.ts` — the ten user-toggled categories and their labels, already shipped in the browser. The node-side names must match these exactly or the two consent surfaces will disagree.
- `core/archipelago/src/streaming/session.rs``13-PATTERNS.md`'s role-match analog for `data_dir`-scoped persisted state. Follow its load/save/permissions convention.
- `core/archipelago/src/api/rpc/mesh/assistant.rs` — the handler shape for the three new `assistant.*` methods, and `trusted_only`/`allowed_contacts`/`denied_askers`, which stay the resolution inputs for the `Mesh` variant.
- `.planning/phases/13-.../13-AI-SPEC.md` §4b.3 "Prompt Engineering Discipline" — one static, phase-authored system prompt, never assembled from prior model output, listing only granted-category tools and stating the confirm-gate contract explicitly.
</read_first>
<action>
Create `core/archipelago/src/assistant/grants.rs` with `pub struct Grants(BTreeSet<PermissionCategory>)`, `pub fn default_closed() -> Grants` returning an empty set, and `load`/`save` against a JSON file under `data_dir` (0600, following `streaming/session.rs`'s convention). A missing file is `default_closed()`, never an error and never a permissive default — D-16 accepts that the assistant looks unconfigured on a fresh node.
Rewire `CallerScope::granted_categories` in `mod.rs`: `LocalOperator` reads the persisted `Grants`; `Mesh` resolves from the existing `trusted_only`/`allowed_contacts`/`denied_askers` inputs intersected with the persisted `Grants`, so a mesh peer can never exceed what the operator opened. Both variants resolve through the same method — that is the promoted-primary contract from 13-01, and the suggested invariant test
`every_caller_variant_resolves_authority_through_caller_scope` belongs here now that there are two real sources.
Add the system-prompt builder to `mod.rs`: one static, phase-authored string that states the operator-control persona, appends **only** the granted-category tools' names and descriptions, and states the confirm-gate contract verbatim — that every write requires a human confirmation the model cannot bypass or pre-approve on the user's behalf. It is never assembled from prior model output and never editable by AIUI.
Add `handle_assistant_list_tools`, `handle_assistant_grants_get` and `handle_assistant_grants_set` to `assistant_chat.rs`, routed through 13-01's existing `assistant.` prefix arm. **Do not touch `dispatcher.rs`** — that is the whole point of the prefix arm, and it keeps this plan's `files_modified` free of a file three other plans also want.
Write the tests FIRST, one per `<behavior>` bullet. Name them
`assistant::tests::fresh_node_grants_are_empty` (S-06),
`assistant::tools::tests::settings_tool_respects_category_grant` (S-05),
`assistant::tests::ungranted_tool_absent_from_system_prompt`,
`assistant::tests::grant_revocation_takes_effect_next_turn`,
`assistant::tests::every_caller_variant_resolves_authority_through_caller_scope`.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&amp;1 | tail -25</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago fresh_node_grants_are_empty</automated>
<automated>cd core &amp;&amp; git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs</automated>
</verify>
<acceptance_criteria>
- `grep -q 'pub fn default_closed' core/archipelago/src/assistant/grants.rs` and the function body returns an empty set
- `cd core && cargo test --package archipelago assistant::` exits 0 with `fresh_node_grants_are_empty`, `settings_tool_respects_category_grant`, `ungranted_tool_absent_from_system_prompt`, `grant_revocation_takes_effect_next_turn` and `every_caller_variant_resolves_authority_through_caller_scope` all passing
- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0 — the prefix arm absorbed all three new methods
- The ten `PermissionCategory` variant names in `mod.rs` match the ten category ids in `neode-ui/src/stores/aiPermissions.ts` one-for-one (diff the two lists by hand and record the result in the summary)
- `grep -c '0o600\|from_mode' core/archipelago/src/assistant/grants.rs` ≥ 1 — the grants file is not world-readable
</acceptance_criteria>
<done>A fresh node's assistant can do nothing until a category is opened; opening one survives a restart; and an ungranted tool is invisible to the model *and* refused at the gate.</done>
</task>
<task type="auto">
<name>Task 3: Assert the ceiling over the whole registry, so a future tool fails CI not review</name>
<files>core/archipelago/src/assistant/tools.rs</files>
<read_first>
- `core/archipelago/src/assistant/tools.rs` — the registry and `EXCLUDED_AUTHORITY_TERMS` from Task 1.
- `.planning/phases/13-.../13-AI-SPEC.md` §5 structural invariants **S-04** and **S-07**, and §1b's "Regulatory / Compliance Context" — D-09's exclusion of wallet spends/keys/seeds is what keeps the software inside the MiCA/GENIUS non-custodial carve-out, so this is a regulatory-adjacent invariant, not only a security one.
- `.planning/phases/13-.../13-AI-SPEC.md` §6 guardrail **G-S5** — "the D-09 ceiling is the absence of tools".
</read_first>
<action>
Add the registry-wide structural assertions to `tools.rs`'s test module. These iterate the **whole registry** rather than checking named tools, so a tool added in a later phase that crosses the ceiling fails CI rather than depending on a reviewer noticing.
`registry_never_exposes_excluded_authority` (S-04): for every `ToolDef` in `registry()`, assert that neither its `name` nor its `description` contains any term in `EXCLUDED_AUTHORITY_TERMS` (seed, mnemonic, private key, macaroon, spend, send sats, pay invoice, federation trust, factory reset, wipe), and that no tool's category is one this phase does not use for writes. Include a comment naming §1b's regulatory rationale so a future maintainer relaxing this assertion knows what they are relaxing.
`read_tools_never_confirm` (S-07): for every `ToolDef` with `destructive: false`, run a scripted turn that calls it and assert **zero** confirmation requests were raised. Habituation is a real failure mode here — every unnecessary dialog spends the confirm gate's signal value (AI-SPEC §1b, Bravo-Lillo et al.), so this is a consent property, not a tidiness one.
`loop_is_bounded` (S-13): assert `MAX_TURNS` is enforced and that a model emitting malformed args for the same tool three times in a row aborts the turn rather than continuing.
`every_tool_has_explicit_category_and_destructive`: assert by construction that no `ToolDef` in the registry was built with a defaulted field — a tool that silently defaults to `destructive: false` is the exact bug this whole gate exists to prevent.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::tools::tests:: 2>&amp;1 | tail -20</automated>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test --package archipelago registry_never_exposes_excluded_authority</automated>
</verify>
<acceptance_criteria>
- `cd core && cargo test --package archipelago assistant::tools::tests::` exits 0 with `registry_never_exposes_excluded_authority`, `read_tools_never_confirm`, `loop_is_bounded` and `every_tool_has_explicit_category_and_destructive` all passing
- The S-04 test iterates `registry()` rather than a hardcoded list of tool names — confirm by reading; a test that names tools individually does not catch a tool added later
- Temporarily adding a `ToolDef` named `wallet_send_sats` to `registry()` makes `registry_never_exposes_excluded_authority` fail; remove it afterwards and record the observed failure message in the summary
</acceptance_criteria>
<done>The D-09 ceiling is a passing test over the whole registry, and it demonstrably goes red when a tool crosses it.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| model output → `execute_tool` | Tool name and arguments are model-chosen; both are validated before anything runs |
| operator grants → tool authority | The only source of authority. Peer-supplied content is not a source (D-10, enforced in 13-12) |
| tool → existing RPC handler | Tools call the same handlers every other authenticated caller uses; there is no AI-only backdoor |
| `settings_set` → node configuration | The one write surface AIUI-02 opens; bounded by `SETTABLE_KEYS` |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-13-24 | Elevation of Privilege | A tool for excluded authority (seed, spend, federation trust, factory reset) | **critical** | mitigate | G-S5: no such `ToolDef` exists. Asserted registry-wide by `registry_never_exposes_excluded_authority`, and demonstrated to go red by the negative-case criterion |
| T-13-25 | Elevation of Privilege | `settings_set` reaching `claude_api_key` | high | mitigate | `SETTABLE_KEYS` excludes it; the refusal names the neode-ui Settings path. Key material is UI-only per D-09 |
| T-13-26 | Elevation of Privilege | Ungranted category reached because the prompt filter was the only gate | high | mitigate | G-S6: two independent layers — prompt filtering **and** the `execute_tool` grant check. Asserted by `settings_tool_respects_category_grant` with a tool the prompt omitted |
| T-13-27 | Tampering | Fuzzy-matched `app_id` restarts the wrong container | medium | mitigate | Exact-id validation against `container-list`; an unknown id lists the installed ids instead of guessing (EV-08) |
| T-13-28 | Denial of Service | Model loops on malformed arguments | medium | mitigate | ≤ 2 consecutive validation failures per tool name, then abort the turn; plus `MAX_TURNS`. Asserted by `loop_is_bounded` |
| T-13-29 | Information Disclosure | A permissive grants default on a fresh node | high | mitigate | `default_closed()` returns empty; a missing file is not an error and not permissive. Asserted by `fresh_node_grants_are_empty` |
| T-13-30 | Spoofing | Node-side and browser-side category vocabularies drift, so consent shown ≠ consent enforced | medium | mitigate | Acceptance criterion diffs the ten `PermissionCategory` variants against `neode-ui/src/stores/aiPermissions.ts` |
| T-13-31 | Repudiation | A confirmation raised for a read action trains click-through | medium | mitigate | S-07 `read_tools_never_confirm` over every non-destructive tool. Habituation research (AI-SPEC §1b) treats this as a consent failure, not a UX nit |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | **Zero** packages added; JSON Schema stays hand-written (`schemars` remains rejected as un-audited). No install task, so no legitimacy checkpoint required |
</threat_model>
<verification>
- `cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant::` green
- `cd core && git diff --exit-code -- archipelago/src/api/rpc/dispatcher.rs` exits 0
- The negative case is demonstrated: adding `wallet_send_sats` to the registry turns S-04 red
- The ten node-side categories match the ten browser-side categories exactly
</verification>
<success_criteria>
"What can the chat reach" is answerable by reading one file, and "what it can never reach" is a
test that iterates the whole registry and goes red when crossed. Conversational settings work
within a hand-picked key allowlist that deliberately excludes key material.
</success_criteria>
<output>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-05-SUMMARY.md` when done
</output>