32 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 | 01 | execute | 1 |
|
true |
|
|
This is the tracer slice for Phase 13 (D-01, D-02, D-06). It is production-quality, not a prototype — every later plan expands out from it: more tools (13-05), the confirm gate (13-08), more backends (13-10, 13-13), content grids (13-06). Nothing in it is a stub that would need an architectural change to fill.
Purpose: catch an architectural dead-end after one commit instead of after ten. The four layers this phase spans (Rust agent service, RPC dispatch, neode-ui broker, AIUI client) have never been wired together; if the shape is wrong, it is wrong here.
Output: core/archipelago/src/assistant/, the assistant.* RPC surface, the chat:*
postMessage message types, and AIUI's embedded-mode chat branch.
<assumption_delta_decision> Noun that is now primary: a caller scope — a caller identity carrying the permission scope its tool calls resolve authority through. "A mesh peer" and "the local operator in AIUI" are two variants of it; Pine voice will be a third.
Decision: promote.
Rationale: D-02's stated intent is "callers distinguished by permission scope", and today's
mesh/listener/assist.rs shapes its peer-facing controls (trusted_only, allowed_contacts,
denied_askers) around the single mesh caller. Adding AIUI's permissions alongside a
still-mesh-shaped model would recreate exactly the two divergent security models D-02 exists to
prevent — the seam where they diverge is the seam where a future tool gets the wrong authority.
Concretely: assistant/mod.rs defines CallerScope as the primary representation, with
variants Mesh { peer_id } and LocalOperator { session_id } (and a documented, not-yet-built
Voice slot). CallerScope::granted_categories() is the only source of authority
execute_tool reads. The mesh controls are demoted to inputs that the Mesh variant resolves
its granted set from — they keep working unchanged for mesh/LoRa callers, they just stop being
the shape everything else is bolted onto.
Suggested (not required) invariant test: assistant::tests::every_caller_variant_resolves_authority_through_caller_scope
— iterate every CallerScope variant, assert each one's tool authority comes from
granted_categories() and that no execute_tool branch reads a mesh-specific field directly.
Goes red if a future phase reintroduces the mesh-only assumption.
</assumption_delta_decision>
<flagged_assumptions> None in this plan.
Edge-probe accounting for AIUI-01. Its probe resolved covered and produced two findings,
both here: the pending-confirmation lifecycle truth tagged (edge: AIUI-01 concurrency) above,
and the two-tab nonce finding carried as a verification: backstop scalar rather than a plain
truth. The four probes that returned unclassified belong to other requirements and are
surfaced where those requirements live — AIUI-02 in 13-05, AIUI-04 and AIUI-05 in 13-09, AIUI-06
in 13-15. Six requirements probed, two covered, four unclassified, nothing dropped; the full
reconciliation with its counts is in 13-VALIDATION.md § Edge-Probe Reconciliation.
</flagged_assumptions>
<artifacts_this_phase_produces> Symbols created by this plan (excluded from drift verification — they do not exist yet):
Rust — core/archipelago/src/assistant/
mod.rs:CallerScope(enum:Mesh,LocalOperator),PermissionCategory(enum, D-16's 10 categories),ToolExecCtx(struct),pub async fn chat(...),AssistantErrortools.rs:ToolDef(struct),ToolRegistry(struct),ToolCall,ToolResult,ChatMessage,Role,fn registry(),fn system_disk_status_tool(),struct SystemDiskStatusArgs,ToolDef::validateloop_.rs:pub async fn run_loop,async fn execute_tool,const MAX_TURNSbackends/mod.rs:pub trait Backend,enum BackendTurn,fn select_backendbackends/claude.rs:struct ClaudeBackend,const CLAUDE_MODEL,const ASSISTANT_HTTP_TIMEOUT,const ASSISTANT_MAX_TOKENSbackends/scripted.rs:struct ScriptedBackend(#[cfg(test)]only)
Rust — RPC
core/archipelago/src/api/rpc/assistant_chat.rs:handle_assistant(prefix sub-dispatcher),handle_assistant_chat- New RPC method names:
assistant.chat core/archipelago/src/main.rs:mod assistant;
TypeScript — neode-ui
types/aiui-protocol.ts:AIUIChatRequest,ArchyChatResponse(added to theAIUIRequest/ArchyResponseunions)services/contextBroker.ts:handleChatRequest(private method)
TypeScript — AIUI (/home/archipelago/Projects/AIUI, branch development)
services/archyBridge.ts:sendChat(text, onToken)exported onarchyBridgecomposables/useAI.ts:streamViaArchy(embedded-mode branch) </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/ROADMAP.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-PATTERNS.md Task 1: End-to-end "how much space is left" — the Rust spine, one tool, one backend core/archipelago/src/assistant/mod.rs, core/archipelago/src/assistant/tools.rs, core/archipelago/src/assistant/loop_.rs, core/archipelago/src/assistant/backends/mod.rs, core/archipelago/src/assistant/backends/claude.rs, core/archipelago/src/assistant/backends/scripted.rs, core/archipelago/src/api/rpc/assistant_chat.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/main.rs - `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-AI-SPEC.md` §3 (the `run_loop` sketch, the `Backend` trait, `ScriptedBackend`) and §4 (the `execute_tool` sketch). **This file IS the pattern source — 13-PATTERNS.md records "no analog exists in this codebase" for `loop_.rs` and `tools.rs`.** - `core/archipelago/src/mesh/listener/assist.rs` — the analog for `backends/claude.rs`'s HTTP client construction (`call_claude`), and for the "spawned off the loop so it never blocks" concurrency discipline. Read `call_ollama`/`call_claude`/`run_assist`/`is_sender_allowed` in full. - `core/archipelago/src/api/rpc/mesh/assistant.rs` — the exact analog for `assistant_chat.rs`'s handler shape (`impl RpcHandler` + `pub(in crate::api::rpc) async fn handle_* -> Result`) and for the `data_dir/secrets/claude-api-key` availability probe at lines 27-30. - `core/archipelago/src/api/rpc/dispatcher.rs` lines 440-480 — the registration block; note `"mesh.assistant-status"` at 445 and `"system.disk-status" => self.handle_system_disk_status()` at 470. - `core/archipelago/src/api/rpc/mod.rs` lines 264-330 — the session-cookie + CSRF + `role.can_access(&method)` gate every dispatched method already passes through. - `core/archipelago/src/api/rpc/middleware.rs` — `UNAUTHENTICATED_METHODS`. Read it to confirm you are not adding to it. - `core/archipelago/src/swarm/payment.rs` — read the `#[tokio::test]` module at the bottom for this codebase's async unit-test convention. Create the `assistant` module — the D-02 shared service — following AI-SPEC §3's structure exactly, and register `mod assistant;` in `core/archipelago/src/main.rs` (this is a binary-only crate; there is no `lib.rs`, so all tests are in-crate `#[cfg(test)] mod tests`).mod.rs defines the promoted primary noun per the <assumption_delta_decision> block above: pub enum CallerScope { Mesh { peer_id: String }, LocalOperator { session_id: String } } with fn granted_categories(&self) -> BTreeSet<PermissionCategory>, plus pub enum PermissionCategory carrying D-16's ten variants (Apps, System, Network, Wallet, Files, Media, Search, AiLocal, Notes, Bitcoin), a ToolExecCtx { registry, caller: CallerScope, handler: Arc<RpcHandler> }, and the public pub async fn chat(ctx, user_text) -> Result<String> entry. For this tracer LocalOperator::granted_categories returns {System} sourced from a hardcoded default set — 13-05 replaces that source with the persisted D-16 default-closed grants store, which is a data-source change, not an architectural one. Mesh::granted_categories resolves from the existing trusted_only/allowed_contacts/denied_askers inputs so mesh callers behave exactly as today.
tools.rs defines ToolDef { name: &'static str, description: &'static str, parameters: serde_json::Value, category: PermissionCategory, destructive: bool }, the normalized ChatMessage/Role/ToolCall { id, name, arguments: Value }/ToolResult { call_id, content, is_error } types from AI-SPEC §3, a ToolRegistry wrapping a &'static [ToolDef]-backed lookup by name, and exactly ONE tool: system_disk_status — category System, destructive: false, description naming that it reports free and total disk space on this node. Do NOT add the schemars crate — it is not in Cargo.toml and is not covered by 13-RESEARCH.md's Package Legitimacy Audit, so adding it would bypass the package-legitimacy gate. Instead hand-write parameters as a serde_json::json! JSON Schema object literal adjacent to a #[derive(Deserialize)] struct SystemDiskStatusArgs (empty for this tool), and add a unit test that round-trips the schema's declared required keys through serde_json::from_value::<SystemDiskStatusArgs> so the schema and the deserialization target cannot drift apart silently. ToolDef::validate(&self, raw: &Value) deserializes-and-refuses per AI-SPEC §4b.1 — never coerce, never guess, never panic.
backends/mod.rs defines #[async_trait] pub trait Backend { async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result<BackendTurn> } and pub enum BackendTurn { Text(String), ToolCalls(Vec<ToolCall>) }, plus select_backend() returning the first available backend in D-04's order. For this tracer only the Claude leg is implemented; select_backend must be written so backends/ollama.rs (13-10) and backends/routstr.rs (13-13) slot in ahead of and behind it without changing the trait — that is the architectural commitment this tracer is proving.
backends/claude.rs implements Backend against the Anthropic Messages API: key read from self.config.data_dir.join("secrets/claude-api-key") (the SAME path mesh/rpc/mesh/assistant.rs probes — do not introduce a second key location), model claude-haiku-4-5-20251001, max_tokens: 2048, tools mapped from ToolDef.parameters into Anthropic's input_schema field, tool_choice: {"type":"auto","disable_parallel_tool_use":true} per AI-SPEC §3 Pitfall 5, stream: false. Parse content blocks of type: "tool_use" into BackendTurn::ToolCalls echoing tool_use.id into ToolCall.id; parse type: "text" into BackendTurn::Text. Define NEW module-scoped constants ASSISTANT_HTTP_TIMEOUT (180s) and ASSISTANT_MAX_TOKENS (2048) — do NOT import OLLAMA_TIMEOUT/MAX_REPLY_CHARS/CHUNK_CHARS from assist.rs, which are LoRa-airtime-tuned (AI-SPEC §3 Pitfall 6).
backends/scripted.rs is #[cfg(test)]-gated and implements Backend by replaying a canned Vec<BackendTurn>, per AI-SPEC §5. It must never compile into the shipped binary.
loop_.rs implements run_loop and execute_tool per AI-SPEC §3/§4 with const MAX_TURNS: usize = 8. execute_tool is the single choke point and, in this tracer, already enforces: unknown-tool refusal (returns an error turn naming the missing tool, never silently ignores), the ctx.caller.granted_categories() check, and ToolDef::validate before execution. The destructive branch is present and returns a not-yet-implemented error for any destructive tool — there are none in the registry yet, and 13-08 fills that branch with the real confirm gate. A tool's execute dispatches to the SAME RpcHandler method every other authenticated caller uses (handle_system_disk_status) — never a parallel AI-only code path.
api/rpc/assistant_chat.rs adds handle_assistant(&self, method: &str, params) -> Result<Value> as a prefix sub-dispatcher plus handle_assistant_chat. Register in dispatcher.rs as a SINGLE guarded arm m if m.starts_with("assistant.") => self.handle_assistant(m, params).await placed adjacent to the "mesh.assistant-*" block at ~445, so every later assistant.* method (13-05's list-tools/grants-*, 13-08's confirm-tool, 13-10's history) is added inside assistant_chat.rs and dispatcher.rs is touched exactly once in this phase. This also settles RESEARCH Open Question 4: the role.can_access(&rpc_req.method) RBAC check runs upstream in api/rpc/mod.rs on the full method string BEFORE dispatch, so assistant.* inherits it unchanged with no bespoke auth — assert this rather than assume it, with the test named below.
Add #[cfg(test)] mod tests in loop_.rs (or mod.rs) with: disk_status_tool_executes (a ScriptedBackend emitting one ToolCalls turn then one Text turn; asserts the tool ran and the real disk figures reached the final answer), unknown_tool_is_refused_not_ignored, and assistant_methods_require_session (asserts no string starting with assistant. appears in UNAUTHENTICATED_METHODS).
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant:: 2>&1 | tail -20
cd core && CARGO_INCREMENTAL=0 cargo test --package archipelago assistant_methods_require_session
grep -c 'schemars' core/archipelago/Cargo.toml | grep -qx 0
<acceptance_criteria>
core/archipelago/src/assistant/mod.rscontainspub enum CallerScopewith both aMeshand aLocalOperatorvariant, andfn granted_categoriescore/archipelago/src/assistant/tools.rscontainspub struct ToolDefwith fieldscategoryanddestructivecore/archipelago/src/assistant/backends/mod.rscontainspub trait Backendandpub enum BackendTurncore/archipelago/src/assistant/loop_.rscontainsasync fn execute_toolandconst MAX_TURNS: usize = 8core/archipelago/src/assistant/backends/scripted.rsopens with a#![cfg(test)]or is declared behind#[cfg(test)] mod scripted;inbackends/mod.rs—grep -n 'cfg(test)' core/archipelago/src/assistant/backends/mod.rsreturns a matchcd core && cargo test --package archipelago assistant::exits 0 withdisk_status_tool_executes,unknown_tool_is_refused_not_ignoredandassistant_methods_require_sessionall listed as passinggrep -n 'assistant\.' core/archipelago/src/api/rpc/middleware.rsreturns no match (UNAUTHENTICATED_METHODS not widened)grep -c 'starts_with("assistant.")' core/archipelago/src/api/rpc/dispatcher.rsreturns 1 — exactly one dispatcher arm for the wholeassistant.*surfacegrep -n 'secrets/claude-api-key' core/archipelago/src/assistant/backends/claude.rsreturns a match, andgrep -rn 'ANTHROPIC_API_KEY' core/archipelago/src/assistant/returns no match (one key ledger, D-01)grep -rnE 'OLLAMA_TIMEOUT|MAX_REPLY_CHARS|CHUNK_CHARS' core/archipelago/src/assistant/returns no match (AI-SPEC §3 Pitfall 6)grep -c 'schemars' core/archipelago/Cargo.tomlreturns 0 — no unaudited crate added </acceptance_criteria> D-01 makes theassistant.*RPC surface a contract AIUI and later the voice pipeline are written against; re-homing the loop browser-side afterwards means re-implementing every tool in TypeScript and moving key handling. Flagged per CONTEXT.md's own rating, not gated. AScriptedBackendturn namingsystem_disk_statuscauses the realhandle_system_disk_statusto run and its real figures to appear in the loop's final answer; an unknown tool name returns an error turn; noassistant.*method is reachable unauthenticated.
In aiui-protocol.ts add export interface AIUIChatRequest { type: 'chat:request'; id: string; text: string } and export interface ArchyChatResponse { type: 'chat:response'; id: string; success: boolean; text?: string; error?: string }, and add each to the AIUIRequest and ArchyResponse unions respectively. Do NOT add a tool-call member to AIActionType — tool selection is node-side by D-01/D-03 and must never be expressible as an AIUI-originated action.
In contextBroker.ts add case 'chat:request': this.handleChatRequest(msg.id, msg.text); break; to the existing handleMessage switch, and a private async handleChatRequest(id, text) that calls rpcClient.call<{ text: string }>({ method: 'assistant.chat', params: { text } }) and posts the result back through the existing postToIframe helper as a chat:response. Use the existing this.allowedOrigin transport primitive — do NOT add a second postMessage channel and do NOT relax the origin check. On RPC failure post { success: false, error } with the error message, never the raw exception object.
The broker must NOT pass a permission category through for chat: authority is resolved node-side from CallerScope (Task 1), and duplicating a browser-side gate here would create the second security model D-02 exists to prevent. Add a comment at the handler naming that reason so a future reader does not "helpfully" add one back.
cd neode-ui && npx vitest run src/services/tests/contextBroker.test.ts
cd neode-ui && npx vue-tsc --noEmit
<acceptance_criteria>
grep -q "chat:request" neode-ui/src/types/aiui-protocol.tsandgrep -q "ArchyChatResponse" neode-ui/src/types/aiui-protocol.tsgrep -q "assistant.chat" neode-ui/src/services/contextBroker.tsgrep -c "tool-call" neode-ui/src/types/aiui-protocol.tsreturns 0 —AIActionTypewas not widenedcd neode-ui && npx vitest run src/services/__tests__/contextBroker.test.tsexits 0 (existing suite still green)cd neode-ui && npx vue-tsc --noEmitexits 0grep -c "allowedOrigin" neode-ui/src/services/contextBroker.tsis unchanged or higher — the origin guard was not removed or loosened </acceptance_criteria> Achat:requestpostMessage from the allowed origin produces anassistant.chatRPC on the page's own session and achat:responseback to the iframe; a message from any other origin is still dropped.
Add sendChat(text: string): Promise<{ text: string }> to the archyBridge export object in archyBridge.ts, built on the existing postToParent + origin-validated listener pattern already used by requestContext — same request-id correlation, same allowedOrigin check, a 180s timeout matching the node's ASSISTANT_HTTP_TIMEOUT. Reject with a plain Error on timeout or on success: false.
In useAI.ts add streamViaArchy(history, onToken, onError, signal) that calls archyBridge.sendChat with the latest user turn and emits the returned text through onToken. Branch each of the three existing send sites (lines ~564, ~647, ~759) on the same __AIUI_EMBEDDED__ signal useArchy.ts already reads: when embedded, call streamViaArchy; otherwise keep streamClaude/streamOpenRouter exactly as they are. streamClaude and streamOpenRouter are NOT deleted — D-17 keeps standalone mode working with AIUI's own proxy for development and for anyone running AIUI outside a node.
Do not remove CLAUDE_PATH/OPENROUTER_PATH; plan 13-02 changes what those paths resolve to on a node (a session-gated Rust forwarder) and 13-09 retires them, in that order.
Commit and push on development with a message naming the Archy phase, per CLAUDE.md's commit-and-push-every-unit-of-work rule. Stage explicitly by path.
cd /home/archipelago/Projects/AIUI/packages/app && npx vitest run
cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmit
cd /home/archipelago/Projects/AIUI && git log --oneline -1 && git status --porcelain | grep -c . | grep -qx 0
<acceptance_criteria>
grep -q "sendChat" /home/archipelago/Projects/AIUI/packages/app/src/services/archyBridge.tsgrep -q "streamViaArchy" /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.tsgrep -c "streamClaude" /home/archipelago/Projects/AIUI/packages/app/src/composables/useAI.tsis ≥ 1 — standalone mode was not deleted (D-17)cd /home/archipelago/Projects/AIUI/packages/app && npx vitest runexits 0 — AIUI's own test command isvitest run(confirmed at plan time, resolving 13-VALIDATION.md's Wave 0 "AIUI test command UNCONFIRMED" item)cd /home/archipelago/Projects/AIUI/packages/app && npx vue-tsc --noEmitexits 0cd /home/archipelago/Projects/AIUI && git status --porcelainis empty andgit log --oneline -1shows the new commit ondevelopment</acceptance_criteria> An embedded AIUI chat send produces achat:requestpostMessage instead of a directapi/claudefetch; a standalone AIUI chat send still usesstreamClaude; both test suites are green and the AIUI commit is pushed.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| AIUI iframe → neode-ui page | Untrusted-by-design content crosses via postMessage; origin-checked, but same-origin today (no browser-enforced sandbox — see 13-09) |
neode-ui page → node /rpc |
Authenticated: session cookie + CSRF + role.can_access() (api/rpc/mod.rs:264-330) |
model output → execute_tool |
The model's output is an input to the check, never the check. This is the phase's load-bearing boundary |
| node → api.anthropic.com | The only egress in this plan; carries the system prompt, tool schemas and the turn |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-13-01 | Elevation of Privilege | assistant.chat RPC |
high | mitigate | Registered in the normal dispatcher.rs table so the existing session + CSRF + RBAC gate runs before dispatch; asserted by assistant_methods_require_session. UNAUTHENTICATED_METHODS untouched (Phase-10 hard constraint) |
| T-13-02 | Elevation of Privilege | execute_tool unknown-tool path |
high | mitigate | D-06 curated allowlist; an unregistered name returns an error turn, never a dispatch. Asserted by unknown_tool_is_refused_not_ignored |
| T-13-03 | Information Disclosure | Claude API key | critical | mitigate | Key read server-side from data_dir/secrets/claude-api-key inside backends/claude.rs; never serialized into any RPC response and never present in a browser bundle. Asserted by the no-ANTHROPIC_API_KEY-in-assistant/ grep |
| T-13-04 | Tampering | AIUI forging a chat:request from another origin |
medium | mitigate | The broker's existing event.origin !== this.allowedOrigin guard is reused unchanged; no second postMessage channel is added |
| T-13-05 | Denial of Service | Model loops without terminating | medium | mitigate | MAX_TURNS = 8 hard stop in run_loop; the loop bails with a user-facing error rather than spinning |
| T-13-06 | Spoofing | Model claims a tool ran that did not | medium | accept | Not structurally preventable — no gate constrains prose. Measured behaviourally as E-01's integrity half in 13-14; recorded as prohibition P-1 there |
| T-13-07 | Elevation of Privilege | Two live Claude credential paths (secrets/claude-api-key vs the port-3142 proxy's ANTHROPIC_API_KEY) |
high | mitigate | Out of this plan's scope by sequencing: 13-02 collapses them to one ledger in the same wave. This plan is forbidden from creating a third — asserted by the grep above |
| T-13-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan adds zero new packages. schemars was explicitly rejected because it is absent from 13-RESEARCH.md's Package Legitimacy Audit; JSON Schema is hand-written instead. Asserted by the schemars count-0 gate |
| </threat_model> |
<success_criteria> The spine is proven: typed chat in the embedded AIUI reaches a curated node tool and a real answer comes back, over authenticated transport, with the model key never leaving the node — and every later plan in this phase can be built as an expansion of this slice rather than a parallel mechanism. </success_criteria>
Create `.planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-01-SUMMARY.md` when done