# Aivyx PA Design — Phase 0 Started 2026-04-13 as an agent-first rebuild following the archival of the pre-rebuild codebase (`~/_archive/aivyx-2026-04-13/`). Agent-first means the turn loop is the core of `aivyx-core` from commit one; every other crate justifies its existence relative to what a running turn loop needs. --- ## Deliverable 1 — The Turn Loop Contract (LOCKED 2026-04-13) This paragraph is the north star. Every subsequent deliverable is measured against it: does this make the paragraph easier or harder to say? > A turn begins when a `ChannelContext` delivers an inbound `Message` to an > `Agent`. The agent resolves the channel's trust tier and loads the > corresponding `CapabilitySet`, then enters a tool-calling loop with its > `LlmProvider`. On each LLM step, the agent either receives streamed text > to relay back through the channel or receives a structured tool call. > Each tool call — including memory recall, which is itself a tool — is > scope-checked against the agent's capability set before execution, and > on completion returns a `ToolOutcome` that distinguishes "the call > returned `Ok`" from "the intended effect was observed." Every tool call, > scope check, and outcome is appended to an HMAC-chained audit log > synchronously as it happens. The loop terminates when the LLM emits a > final assistant message, when a tool call returns `RequiresEscalation`, > when a timeout fires, or when the channel cancels. The turn ends by > flushing any pending audit entries, returning a `TurnOutcome` to the > channel, and yielding control. ### What this commits us to 1. **No entry point bypasses a channel.** CLI, desktop, REST, Telegram — all are channels. No "internal" turn with no channel context. This closes the `aivyx-pa/src/api.rs:526` class of bug by construction. 2. **Trust tier resolution happens first.** Before any LLM call, before any tool call, the agent knows what it's allowed to do. Capability attenuation is not retrofitted onto a running agent. 3. **Memory is a tool, not an ambient system.** Lazy recall via `memory.recall(query)` with scope `memory.read`. Writes via `memory.write` with scope `memory.write`. Every memory access is audited because it's just a tool call. 4. **Tool success ≠ intent completed.** `ToolOutcome::Completed` carries a `verified: bool` flag. Tool authors must think about verification at the type level. Closes `feedback_tool_success_vs_intent.md`. 5. **Audit is synchronous, inline, HMAC-chained.** If the process crashes mid-turn, the audit log still tells the truth about what got executed. The cost is microseconds per tool call; the trustworthiness gain is large. 6. **Four termination conditions, named explicitly.** Final assistant message, `RequiresEscalation`, timeout, channel cancel. A fifth is a design conversation. 7. **Turn ends by yielding control to the channel.** The agent doesn't spin waiting for "next message" — control returns to the channel adapter between turns. ### What the paragraph deliberately does not say - Streaming protocol details — deferred to Phase 1 trait design - Single-shot vs. multi-step tool loop structure — covered by "tool-calling loop" - Concurrency model (sequential? parallel tools?) — **resolved by [Amendment A6](docs/amendments/2026-04-21-parallel-tool-execution.md)**: batch dispatch via `join_all` when planner returns `NextStep::ToolCalls` - Multi-agent coordination — out of scope for v1; **shipped post-v1 as Chapter J (the Nonagon)** — a lead convening ≤9 *attenuated, in-process* specialists on the one daemon/chain, which preserves (not breaks) the single-agent ethos. See [`docs/NONAGON.md`](docs/NONAGON.md). - Federation / remote agents — out of scope for v1 (still deferred) ### Scenario tests Four scenarios the paragraph should make easy to reason about: 1. **"Tell me what I worked on yesterday"** — LLM emits `memory.recall` tool call, scope-checked, returns recalled entries, LLM summarizes. ✓ Paragraph holds. 2. **"Close the terminal window"** — LLM emits `window.close_focused`, tool executes and verifies the window is gone, `ToolOutcome::Completed { verified: true }`. ✓ Paragraph holds; closes the Alfred window-close bug class. 3. **"Run rm -rf ~ from Telegram"** — Trust tier 2 excludes `shell.exec`. Scope check denies. `ToolOutcome::Denied`. LLM sees denial and either explains or escalates. ✓ Paragraph holds. 4. **"User hits Ctrl-C mid-tool-call"** — In-flight tool finishes, loop checks cancellation before next LLM step, terminates with `TurnOutcome::Cancelled`. ✓ Paragraph holds; open question is *when* the cancellation check fires (between LLM steps, not mid-tool-call). Defer to Phase 1. ### Concepts named but not yet defined (Phase 1 work) - **`ChannelContext`** — delivers messages, carries trust tier, supports cancellation. Likely `&dyn ChannelContext` at trait boundaries. - **`TurnOutcome`** — at minimum: `Completed`, `Cancelled`, `Escalated`, `TimedOut`, `Failed(AivyxError)`. Possibly `AwaitingInput`. - **`ToolOutcome`** — at minimum: `Completed { verified: bool }`, `Denied { scope: Scope }`, `RequiresEscalation { reason: String }`, `Failed(AivyxError)`. > **Amendment (2026-04-17):** The turn loop described above now runs > inside a daemon process. Channel frontends deliver messages over a > Unix-domain-socket IPC protocol rather than by direct trait call. > See amendment > [`docs/amendments/2026-04-17-daemon-ipc-protocol.md`](docs/amendments/2026-04-17-daemon-ipc-protocol.md) > for the daemon execution topology, IPC envelope types, and > multi-connection model that implement this contract in production. > See also amendment > [`docs/amendments/2026-04-17-mission-state-machine.md`](docs/amendments/2026-04-17-mission-state-machine.md) > for the fifth termination condition (`TurnOutcome::Escalated` as > mission gate suspension). > > **Amendment (2026-04-21):** The IPC protocol now supports optional > version negotiation via `ProtocolNegotiation`/`ProtocolAccepted`/ > `ProtocolRejected` messages. For v0.1, the daemon always accepts. > The daemon also writes a `daemon.state` file for crash recovery > and emits `RecoveryNotice` lifecycle events on restart after > unclean shutdown. See amendment > [`docs/amendments/2026-04-21-protocol-negotiation.md`](docs/amendments/2026-04-21-protocol-negotiation.md). --- --- ## Deliverable 2 — The Open-Core Line (LOCKED 2026-04-13) > **Amendment A14 (2026-06-19) — relicense MIT → BUSL-1.1.** Chapter Charter > ([`docs/LICENSING.md`](docs/LICENSING.md), > [Amendment A14](docs/amendments/2026-06-19-busl-relicense.md)) supersedes the > *mechanism* of this Deliverable. Every "MIT" entry in the rule and tables below > now reads **"BUSL-1.1 (→ MIT after 4 years)"**: the whole public workspace is > **source-available under BUSL-1.1** — free for personal/non-commercial use, > paid for commercial use, auto-reverting to MIT four years after each release. > The *spirit* (auditable free core + sustainable commercial side) is preserved > and strengthened — the core now carries its own monetization hook, not just the > separate products below. v0.2.0 and prior remain MIT in perpetuity. The > branding exception is unchanged in substance (`aivyx-pa` is now "BUSL-1.1 + > branded"). Read the original text below as historical-but-superseded. ### The Rule > **Protocol and security surface are MIT. Products are commercial. > Ambiguous cases default to MIT if they would otherwise weaken the > agent's auditability.** ### How to apply it For any new crate or component, ask in order: 1. **Is it a protocol?** (Trait, interface, wire format, namespace.) → MIT 2. **Is it a security surface?** (Crypto, capability, audit, auth.) → MIT 3. **Is it a product?** (Runtime, marketplace, hosted service.) → Commercial 4. **If ambiguous:** Would closing it weaken auditability or block third-party integration? → MIT. Otherwise → Commercial. ### The v1 table | Crate / Artifact | License | Rationale | |---|---|---| | `aivyx-core` | MIT | The protocol itself — traits, IDs, turn loop. | | `aivyx-crypto` | MIT | Commodity crypto primitives. No moat. | | `aivyx-capability` | MIT | Security surface. Must be readable to be trustable. | | `aivyx-audit` | MIT | Security surface. Closed audit is worth less. | | `aivyx-config` | MIT | Commodity config loader. | | `aivyx-storage` | MIT | redb + HKDF wrapper. Security surface. | | `aivyx-channel` | MIT | Trait + `LocalChannel` reference impl. Protocol. | | `aivyx-llm` | MIT | `LlmProvider` trait + reference impls. Protocol. | | `aivyx-memory` | MIT | `Memory` trait + default impl. Protocol + commodity. | | `aivyx-pa` (rebuilt PA) | **MIT + branded** | Code MIT, name/distribution trademarked. | | Channel adapters (Telegram/Discord/Slack/Matrix/Email) | MIT | Reference impls of `aivyx-channel`. | **11 crates in v1. All MIT.** The commercial side of the open-core line is composed entirely of Phase 7+ products built on top of v1. ### Deferred to later phases (not in v1) | Component | License | Notes | |---|---|---| | `aivyx-engine` / factory | Commercial | Multi-tenant runtime. The product. | | `aivyx-hub` | Commercial | Marketplace + network effects. | | `aivyx-federation` | Commercial *(tentative)* | Revisit when built. | | `aivyx-mcp` | MIT *(when built)* | Bridge to an open protocol. | ### The branding exception (aivyx-pa) `aivyx-pa` is MIT + branded. This means: - **The code** is MIT-licensed. Anyone can fork, modify, redistribute, commercialize. - **The name, logo, and official distribution channels** are trademarked. A fork cannot call itself "Aivyx PA" or ship via Aivyx-branded app store listings, official installers, or aivyx.ai domains. - Enforcement: trademark, not license. `LICENSE` says MIT; `TRADEMARK.md` documents the brand restriction. - Precedents: Code-OSS / VSCode, Chromium / Chrome, Firefox / Iceweasel. --- ## Deliverable 3 — Agent Trait + Outcome Types (LOCKED 2026-04-13) Pseudo-code sketches, not compilable Rust. The goal is pinning down shapes and names, not producing something `cargo check` accepts. Supporting types (`TrustTier`, `CapabilitySet`, `Scope`, `AivyxError`) are referenced here and defined in later deliverables. ### Message — the inbound unit ```rust pub struct Message { pub id: MessageId, pub session_id: SessionId, pub content: MessageContent, pub received_at: SystemTime, } pub enum MessageContent { Text(String), // Phase 2: Image, Audio, File, StructuredData } ``` > **Multimodal extension** — the actual `MessageContent` / > `ContentPart` enums in `aivyx-core` carry `Image` and > `Document` variants alongside `Text`. The `Image` variant > landed during the multimodal MVP work; the `Document` > variant is governed by > [`docs/amendments/2026-06-04-content-part-document.md`](docs/amendments/2026-06-04-content-part-document.md) > (A13) so PDFs route to provider-specific document content > blocks rather than image blocks. ### ChannelContext — the delivery surface ```rust #[async_trait] pub trait ChannelContext: Send + Sync { fn channel_name(&self) -> &str; fn platform(&self) -> ChannelPlatform; fn trust_tier(&self) -> TrustTier; fn session_id(&self) -> SessionId; async fn stream_event(&self, event: StreamEvent<'_>) -> Result<(), ChannelError>; async fn finalize(&self, outcome: &TurnOutcome) -> Result<(), ChannelError>; fn cancellation_token(&self) -> CancellationToken; } pub enum ChannelPlatform { Local, // CLI, desktop app Telegram, Discord, Slack, Matrix, Email, Rest, // HTTP API // extensible } pub enum StreamEvent<'a> { /// LLM token stream — the 95% case. Text(&'a str), /// Status signal for long-running tools: "Searching…", "Downloading…". Status(&'a str), /// A tool call is about to execute. Channels may render (e.g., GUI) /// or ignore (e.g., minimal REST channels). ToolCallStarted { tool: ToolId, input: &'a serde_json::Value, }, /// A tool call finished. Outcome summary is a human-readable /// one-liner, not a structured result. ToolCallFinished { tool: ToolId, outcome_summary: &'a str, }, /// File, image, or audio attachment. Matrix/Email channels use /// this on day one when they're rebuilt. Attachment { kind: AttachmentKind, data: &'a [u8], filename: Option<&'a str>, }, } pub enum AttachmentKind { Image { mime: &'static str }, // e.g., "image/png" Audio { mime: &'static str }, File { mime: &'static str }, } ``` **Key commitments:** - `&dyn ChannelContext` at trait boundaries (dynamic dispatch — swapping channels at runtime is worth the cost) - `trust_tier()` is *read* from the channel, not set on it - **`stream_event` is a rich enum from day one** (decision: "start how we mean to go on"). Text is the 95% case; Status / ToolCallStarted / ToolCallFinished / Attachment cover the rest. - `StreamEvent<'a>` is borrowed — agents build events that reference their own buffers without allocating. Channels that need ownership can serialize or clone. - Channels are free to **ignore** any variant they don't care about. `StreamEvent` is a superset of what a rich channel *can* do, not a minimum every channel must honor. - MIME types are `&'static str` for now — small, finite, auditable set. Promote to `Cow<'static, str>` if user-supplied MIMEs become necessary. - `cancellation_token` resolves scenario-4 ambiguity: cancellation is checked between LLM steps, not mid-tool-call ### ToolOutcome — with typed verification ```rust pub enum ToolOutcome { Completed { output: serde_json::Value, verified: Verification, }, Denied { scope: Scope, held: CapabilitySet, }, RequiresEscalation { reason: String, }, Failed(AivyxError), } pub enum Verification { /// Tool queried the system and confirmed its effect happened. Verified, /// Tool returned Ok but did not verify. Unverified, /// Verification not meaningful (e.g., pure read-only query). NotApplicable, } ``` **Why `Verification` is an enum, not a bool:** `NotApplicable` captures the "calculator doesn't need to verify 2+2=4" case explicitly, so tool authors don't lie by marking everything `Verified`. Encodes `feedback_tool_success_vs_intent.md` at the type level. ### TurnOutcome — the five termination paths ```rust pub enum TurnOutcome { Completed { final_message: String, tool_calls_made: usize, duration: Duration, }, Escalated { reason: String, pending_tool: ToolId, tool_calls_made: usize, }, TimedOut { tool_calls_made: usize, elapsed: Duration, }, Cancelled { tool_calls_made: usize, }, Failed(AivyxError), } ``` Five variants matching the four paragraph-named termination conditions plus failure. No `AwaitingInput` — speculative, not needed yet. ### Agent — four lines ```rust #[async_trait] pub trait Agent: Send + Sync { fn id(&self) -> AgentId; fn capabilities(&self) -> &CapabilitySet; async fn turn( &self, message: Message, channel: &dyn ChannelContext, ) -> TurnOutcome; } ``` **Key commitments:** - `&self`, not `&mut self` — agents shared across concurrent turns via `Arc` - Returns `TurnOutcome` directly, **not** `Result`. Any error is part of the turn's history (`Failed(AivyxError)` variant). A turn always *completes in some way*. - No explicit timeout parameter — budget lives in the agent's config - No session parameter — `session_id` lives inside `Message` **The unusual choice:** returning `TurnOutcome` rather than `Result` is contrarian. The Rust instinct is to surface errors in the type. But for `turn`, errors are *part of what happened*, audited inline, and need to be rendered by the channel. Burying them inside `TurnOutcome::Failed` keeps the return type honest about "every turn completes." ### Tool — with ToolContext ```rust #[async_trait] pub trait Tool: Send + Sync { fn id(&self) -> ToolId; fn name(&self) -> &str; fn description(&self) -> &str; fn input_schema(&self) -> &serde_json::Value; // JSON Schema fn required_scope(&self) -> Scope; async fn execute( &self, input: serde_json::Value, context: &ToolContext<'_>, ) -> ToolOutcome; } pub struct ToolContext<'a> { pub agent_id: AgentId, pub session_id: SessionId, pub channel: &'a dyn ChannelContext, pub audit: &'a dyn AuditWriter, pub cancellation: &'a CancellationToken, } ``` **Key commitments:** - `execute` returns `ToolOutcome` directly, same pattern as `Agent::turn` - `ToolContext` gives tools access to channel (progress streaming), audit writer (structured entries), and cancellation - `required_scope` returns single `Scope` — composite-scope handling deferred to Deliverable 4 ### Paragraph-to-type traceability Every clause in the Deliverable 1 paragraph traces to a concrete type operation: | Paragraph clause | Type operation | |---|---| | "ChannelContext delivers Message to Agent" | `agent.turn(message, channel)` | | "resolves the channel's trust tier" | `channel.trust_tier()` | | "loads the corresponding CapabilitySet" | Agent attenuates `self.capabilities()` by trust tier | | "tool-calling loop with its LlmProvider" | Internal `llm.chat_stream(...)` | | "streamed text to relay back through the channel" | `channel.stream_event(StreamEvent::Text(chunk)).await` | | "scope-checked before execution" | `if !caps.grants(tool.required_scope())` → `ToolOutcome::Denied` | | "on completion returns a ToolOutcome" | `tool.execute(input, &ctx).await` | | "distinguishes Ok from intended effect" | `ToolOutcome::Completed { verified: Verification }` | | "HMAC-chained audit synchronously as it happens" | `ctx.audit.append(entry).await` (blocking) | | "LLM emits a final assistant message" | Stream end → `TurnOutcome::Completed` | | "tool call returns RequiresEscalation" | Match on `ToolOutcome::RequiresEscalation` → `TurnOutcome::Escalated` | | "timeout fires" | Elapsed > budget → `TurnOutcome::TimedOut` | | "channel cancels" | `cancellation.is_cancelled()` → `TurnOutcome::Cancelled` | | "returning TurnOutcome to the channel" | `channel.finalize(&outcome).await; outcome` | | "yielding control" | Function returns, tokio yields to runtime | The paragraph compiles. ### Deliberately omitted from this sketch - **`LlmProvider`** — already stable in the archived codebase, re-derived in Phase 1 without design changes - **`AuditWriter` full definition** — referenced in `ToolContext`, fully defined in Deliverable 6 (error contract) or a separate audit deliverable - **`SessionStore` / `MemoryStore`** — implementation details of concrete agents, not core contracts. Memory is a tool (Deliverable 1 commitment A2); underlying storage is private to the concrete agent - **Agent construction / builder** — the sketch shows what an agent *is*, not how to build one. Builder lives in concrete impl > **Amendment (2026-04-17):** The `ChannelContext` trait is now > implemented twice per channel: once for in-process mode (the > original adapter) and once for daemon mode (`IpcChannelBridge` > on the server side). `StreamEvent<'a>` has an owned IPC mirror > (`StreamEventPayload`) for serialization over the wire. See > amendment > [`docs/amendments/2026-04-17-daemon-ipc-protocol.md`](docs/amendments/2026-04-17-daemon-ipc-protocol.md). --- ## Deliverable 4 — Capability Taxonomy (LOCKED 2026-04-13) Four foundational decisions (locked): - **Scope shape**: hierarchical strings with a type wrapper (`Scope(String)`) - **Attenuation**: prefix-based (glob / URL-prefix / allowlist / simple-glob) - **Taxonomy completeness**: start with ~20 active scopes, reserve the rest - **Audit events**: per-tool for grants, per-scope for denials, with dedicated `MemoryAccess` for queryability ### The Scope type ```rust /// Hierarchical string form: `base` or `base:qualifier`. /// Base is a dotted identifier; qualifier is an optional attenuation string. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Scope(String); impl Scope { /// Returns None if the base is not a known scope in v1 or any registered extension. pub fn parse(s: &str) -> Option; pub fn base(&self) -> &str; pub fn qualifier(&self) -> Option<&str>; /// Prefix-attenuation check: true iff `self` is granted by `other`. pub fn is_granted_by(&self, other: &Scope) -> bool; } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CapabilitySet { scopes: Vec, // deduplicated, sorted for deterministic serialization } impl CapabilitySet { pub fn empty() -> Self; pub fn from_scopes(scopes: impl IntoIterator) -> Self; pub fn grants(&self, needed: &Scope) -> bool; pub fn intersect(&self, other: &CapabilitySet) -> CapabilitySet; pub fn iter(&self) -> impl Iterator; } ``` **Key commitments:** - `Scope::parse` returns `Option` — unknown scopes fail at parse time, not check time. Registration lives in `aivyx-capability`. - `is_granted_by` is the *only* authoritative check function. No scattered string comparisons. - `CapabilitySet::intersect` is how trust tiers cap agent caps: `effective = agent_caps.intersect(&tier_ceiling)`. - Sorted vec, not HashSet — deterministic serialization is required for HMAC-chained audit. ### Attenuation rules When a scope has a qualifier, `is_granted_by` applies: 1. **Bases must match exactly.** `fs.read:/foo` is not granted by `fs.write:/foo`. 2. **Unqualified held grants any qualified needed** with the same base. 3. **Qualified held grants qualified needed** iff the qualifier matches: - Path-like (`/` or contains `/`): glob semantics - URL-like (contains `://`): URL prefix match - Allowlist (comma-separated idents): superset check - Simple glob: glob match on the string 4. **Qualified held does NOT grant unqualified needed.** A tool requesting bare `fs.read` wants unrestricted access; a restricted capability cannot grant that. Rule 4 is intentional: tools must be specific. Unrestricted requests are visible to users at the capability-set level and require justification. ### The v1 active namespace (21 scopes) **`fs` — filesystem** | Scope | Qualifier | Description | |---|---|---| | `fs.read` | path glob | Read file contents or list directory | | `fs.write` | path glob | Create or overwrite file | | `fs.delete` | path glob | Remove file or directory | | `fs.metadata` | path glob | Stat — size, mtime, perms. Weaker than fs.read. | **`net` — network** | Scope | Qualifier | Description | |---|---|---| | `net.fetch` | URL prefix | HTTP GET / HEAD | | `net.post` | URL prefix | HTTP POST / PUT / PATCH / DELETE | | `net.dns` | domain glob | Hostname resolution | **`shell` — subprocess** | Scope | Qualifier | Description | |---|---|---| | `shell.exec` | command allowlist | Run a command, capture output | | `shell.spawn` | command allowlist | Spawn long-lived subprocess | **`git` — version control** *(read added in Amendment A12, Phase 109; write added in Amendment A13b, Chapter Forge)* | Scope | Qualifier | Description | |---|---|---| | `git.read` | repo path glob | Read repo state — `git.status` and `git.diff` share this base | | `git.write` | repo path glob | Write repo history — `git.commit` (stage + commit); Trusted-tier only, confirm-first | > **Amendment (2026-05-28):** `git.read` joins the substrate > base table as the qualifier-by-repo-path gate for the > `git.status` + `git.diff` tools added in Phase 109. See > amendment > [`docs/amendments/2026-05-28-substrate-tool-count-thirteen.md`](docs/amendments/2026-05-28-substrate-tool-count-thirteen.md) > for the P10 count update from ten to thirteen and the > shared-scope-base rationale (one `git.read` rather than > separate `git.status` / `git.diff` bases, mirroring the > read-invariant grouping). > > **Amendment (2026-06-19):** `git.write` joins the table as the > destructive sibling A12 anticipated — the qualifier-by-repo- > path gate for the `git.commit` tool (Chapter Forge). It reuses > `git.read`'s `[git] repos` allow-set, sits at `CEILING_TRUSTED` > only (writing history is as sensitive as `shell.exec` / > `fs.delete`), and is confirm-first when `confirm_destructive` > is on. See amendment > [`docs/amendments/2026-06-19-substrate-tool-count-fifteen.md`](docs/amendments/2026-06-19-substrate-tool-count-fifteen.md) > for the P10 count update from thirteen to fifteen (also adding > `web.extract` over the existing `net.fetch` base). **`skills` — learned procedural pattern surface** *(added in Phase 110)* | Scope | Qualifier | Description | |---|---|---| | `skills.propose` | — | Propose a `LearnedSkill` delta through `reflection.propose` | | `skills.list` | — | Enumerate operator-approved skills | | `skills.invoke` | — | Render the full procedure body of one approved skill | > **Phase 110 — Skills Auto-Creation (Reflection Staging).** > Three new scope bases gate the skills primitive: > `skills.propose` is the sibling capability to > `persona.propose` for proposals whose `persona_deltas` array > contains any `PersonaDeltaCategory::LearnedSkill` entry > (Q2b at Phase 110 sign-off — operator picked per-category > granularity over extending `persona.propose`). > `skills.list` and `skills.invoke` gate the two substrate > tools that let agents read the approved skill set > (enumeration vs. on-demand procedure-body render). All three > bases live in `CEILING_TRUSTED` only; SemiTrusted remote > adapters do not get skills.* by default. **No amendment > filed** — Phase 110 stays inside PRODUCT.md P8's > outcome-driven-audited-reflection envelope; the > propose-approve-apply shape P8 commits to is unchanged, and > the LearnedSkill extension is a new category inside that > shape rather than a new commitment. P10's substrate tool > count was last set to thirteen at A12 (Phase 109); skills.* > tools are **infrastructure tools** per P10's > substrate/infrastructure/third-party taxonomy (the agent > uses them to manage itself's identity layer) so they are > not counted against P10's substrate-tool cap. **`graph` — typed knowledge graph** *(added in Chapter Lattice)* | Scope | Qualifier | Description | |---|---|---| | `graph.read` | — | Query the agent's typed knowledge graph — a read-only multi-hop directed traversal (`graph.query`) | > **Chapter Lattice — typed knowledge graph.** `graph.read` > gates `graph.query`, a read-only multi-hop traversal of the > agent's own knowledge graph (entities + directed > `(subject)-[predicate]->(object)` relations extracted from > memory). Like `skills.*`, it is an **infrastructure tool** > per P10's taxonomy — the agent querying its own *derived* > self-knowledge, not a new operator-owned resource primitive — > so it grows `KNOWN_BASES` (86 → 87) **without a P10 substrate- > count amendment**, exactly the `skills.*` / `loop.*` / > `reflection.*` precedent. Bare base (like `skills.list`), > `CEILING_TRUSTED` only; SemiTrusted does not get it by > default. See > [`docs/amendments/2026-04-17-capability-taxonomy-growth.md`](docs/amendments/2026-04-17-capability-taxonomy-growth.md) > (Lattice addendum). **`llm` — language model** | Scope | Qualifier | Description | |---|---|---| | `llm.call` | model name glob | Chat endpoint call | | `llm.embed` | model name glob | Embedding endpoint call | **`memory` — agent memory** | Scope | Qualifier | Description | |---|---|---| | `memory.read` | session/topic glob | Recall entries matching a query | | `memory.write` | session/topic glob | Store entry | | `memory.forget` | session/topic glob | Delete entries matching filter | **`channel` — communication** | Scope | Qualifier | Description | |---|---|---| | `channel.send` | channel name glob | Send on an outbound channel (not necessarily the inbound one) | | `channel.receive` | channel name glob | Listen on a channel | The turn's own inbound channel is **implicitly granted** — you don't need `channel.send` to reply where the message came from. **`audit` — introspection** | Scope | Qualifier | Description | |---|---|---| | `audit.read` | event type glob | Read audit log entries | There is no `audit.write`. Only the kernel writes audit entries. **`config` — configuration** | Scope | Qualifier | Description | |---|---|---| | `config.read` | key glob | Read config value | | `config.write` | key glob | Update config value | ### Reserved — named but not defined in v1 These are part of the public namespace but have no implementation. Any tool using them fails at `Scope::parse` time until promoted. - **Agent coordination**: `agent.spawn`, `agent.delegate`, `agent.terminate` - **Scheduling**: `schedule.create`, `schedule.read`, `schedule.delete`, `schedule.run` - **System**: `system.shutdown`, `system.notify`, `system.clipboard_read`, `system.clipboard_write` - **Display / window**: `display.window_focus`, `display.window_close`, `display.screenshot` - **Federation** (Phase 7+): `federation.discover`, `federation.call`, `federation.accept` ### Audit event naming (mixed model) ```rust pub enum AuditEvent { /// A tool executed. Primary key: tool_id. ToolCall { tool_id: ToolId, scope_used: Scope, input_hash: [u8; 32], // hash, not raw input — secrets safety outcome: ToolOutcomeSummary, duration: Duration, }, /// A scope check denied a tool call. Primary key: scope. ScopeDenied { scope_requested: Scope, scope_qualifier: Option, tool_attempted: ToolId, held_capabilities: CapabilitySet, // snapshot, not reference }, /// Turn started — for correlation via turn_id. TurnStarted { turn_id: TurnId, session_id: SessionId, channel: ChannelPlatform, trust_tier: TrustTier, effective_capabilities: CapabilitySet, }, /// Turn ended — paired with TurnStarted. TurnEnded { turn_id: TurnId, outcome: TurnOutcomeSummary, tool_calls_made: usize, duration: Duration, }, /// Dedicated view of memory operations for queryability. /// Redundant with ToolCall (every memory op is also a tool call). MemoryAccess { turn_id: TurnId, operation: MemoryOperation, scope: Scope, query_or_key: String, }, } pub enum MemoryOperation { Read, Write, Forget } ``` **Commitments:** - Every event is self-contained — readable without cross-referencing others. - `input_hash` (not raw input) prevents secrets leaking into the audit log. - `held_capabilities` on denials is a snapshot — the set at denial time is preserved even if caps change later. - `MemoryAccess` is redundant with `ToolCall` but indexed for fast memory-specific queries. This is the one deviation from the strict mixed model. ### Findings from sanity-checking against D1 scenarios **A. `required_scope()` needs to be computable from input, not static.** A tool may need different scopes depending on its arguments. The `Tool` trait's `required_scope()` should become: ```rust fn required_scope(&self, input: &serde_json::Value) -> Scope; ``` This lets a memory-recall tool request `memory.read:session:` when called with a specific session ID, or bare `memory.read` when called with no filter. **Update to Deliverable 3 pending** — will fold into the final D3 skeleton during Phase 1 trait design. **B. Scenario 2 (window close) requires scopes in the Reserved section.** The v1 PA does not handle window control. Window-control tools and their scopes (`display.window_close`, etc.) are deferred to Phase 1, when the tool crate is built. The Reserved section already lists them; they'll be promoted to active when the tool lands. > **Amendment (2026-04-17):** The capability taxonomy has grown > from 12 to 23 known bases across Phases 1–21. Two bases > added by Phase 21 (`mission.create`, `mission.gate`) are > infrastructure tools per P10's taxonomy. See amendment > [`docs/amendments/2026-04-17-mission-state-machine.md`](docs/amendments/2026-04-17-mission-state-machine.md) > for the mission-specific bases and amendment > [`docs/amendments/2026-04-17-capability-taxonomy-growth.md`](docs/amendments/2026-04-17-capability-taxonomy-growth.md) > for the full 23-base inventory. --- ## Deliverable 5 — Trust Tier Model (LOCKED 2026-04-13) ### The TrustTier type ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TrustTier { /// Tier 3 — Untrusted. Public webhooks, anonymous HTTP, unknown senders. Untrusted, /// Tier 2 — SemiTrusted. Authenticated user on a remote channel. SemiTrusted, /// Tier 1 — Trusted. Authenticated user on an owned channel. Trusted, /// Tier 0 — Kernel. Unconditional. Never assigned to a user-facing channel. Kernel, } impl TrustTier { pub fn default_ceiling(self) -> &'static CapabilitySet; pub fn is_more_trusted_than(self, other: TrustTier) -> bool; } ``` **Note on ordering:** the variants are declared in *ascending trust order* so the derived `Ord` gives `Kernel > Trusted > SemiTrusted > Untrusted`. The display-label numbers (Tier 0 = Kernel, Tier 3 = Untrusted) run opposite to `Ord`. The numeric labels are a *naming convention*; the ordering used in code is the enum's `Ord`. ### Tier definitions **Tier 0 — Kernel.** Unconditional. The turn loop itself, audit writer, capability enforcer, storage encryption. Never held by a user-facing channel — a channel returning `Kernel` from `trust_tier()` is a bug. Default ceiling: unlimited. Exists so internal operations pass capability checks without being special-cased as "exempt." **Tier 1 — Trusted.** Authenticated user on an owned channel: - Local CLI - Desktop GUI (Tauri app on own machine) - Local REST API bound to 127.0.0.1 only The user is presumed present; the channel cannot be reached from the network without explicit local access. Default ceiling: near-total, with extra audit on `fs.delete` / `shell.exec` / `shell.spawn`. **Tier 2 — SemiTrusted.** Authenticated user on a remote channel: - Telegram DMs from allowlisted user - Slack DMs from allowlisted user - Matrix rooms where sender is authenticated - Email from SPF/DKIM-verified, allowlisted sender - Discord DMs from allowlisted user The user is identified but the transport is out of our control; identity verification is best-effort. Default ceiling: substantially narrower than Tier 1 — no unqualified shell, no `fs.delete`, no `config.write`, remote-write/remote-post require explicit qualifiers. **Tier 3 — Untrusted.** Anonymous or unverified contexts: - WhatsApp webhooks (public, HMAC-verified but sender spoofable) - Anonymous HTTP endpoints (if they exist) - Email from non-allowlisted senders - Any context where identity cannot be trusted Default ceiling: near-empty. Channels at this tier can *deliver* a message but the agent cannot exercise meaningful capabilities — only canned responses or public-safe reads. ### Per-tier ceiling table Notation: ✓ = granted unqualified, ▲ = granted only with explicit qualifier, ⊘ = denied entirely. | Scope | Tier 1 (Trusted) | Tier 2 (SemiTrusted) | Tier 3 (Untrusted) | |---|---|---|---| | `fs.read` | ✓ | ▲ path qualifier required | ⊘ | | `fs.write` | ✓ | ▲ path qualifier required | ⊘ | | `fs.delete` | ✓ (audited) | ⊘ | ⊘ | | `fs.metadata` | ✓ | ✓ | ⊘ | | `net.fetch` | ✓ | ✓ | ⊘ | | `net.post` | ✓ | ▲ URL prefix required | ⊘ | | `net.dns` | ✓ | ✓ | ⊘ | | `shell.exec` | ✓ (audited) | ⊘ | ⊘ | | `shell.spawn` | ✓ (audited) | ⊘ | ⊘ | | `llm.call` | ✓ | ✓ | ⊘ | | `llm.embed` | ✓ | ✓ | ⊘ | | `memory.read` | ✓ | ✓ | ▲ `memory.read:scope:public:*` only | | `memory.write` | ✓ | ✓ | ⊘ | | `memory.forget` | ✓ | ▲ scope qualifier required | ⊘ | | `channel.send` | ✓ | ▲ channel allowlist | ⊘ | | `channel.receive` | ✓ | ▲ channel allowlist | ⊘ | | `audit.read` | ✓ | ▲ event type qualifier | ▲ `audit.read:public` only | | `config.read` | ✓ | ✓ | ⊘ | | `config.write` | ✓ | ⊘ | ⊘ | **The ▲ semantic:** when a tier grants a scope "only with qualifier," held capability must be a *qualified* form — holding bare `fs.write` does NOT satisfy `▲` at Tier 2. Remote channels must request narrowly scoped capabilities, not broad ones. ### The effective-capabilities computation ```rust // Inside Agent::turn, exactly once before the tool-calling loop: let tier = channel.trust_tier(); let ceiling = tier.default_ceiling(); let effective = self.capabilities().intersect(ceiling); ``` **Commitments:** - Computed **once per turn**, before any LLM call. Not recomputed mid-turn. - Recorded as `effective_capabilities` in the `TurnStarted` audit event. - This snapshot is authoritative for the entire turn. - Mid-turn capability changes (e.g., user-confirmed escalation) are NOT supported in v1 — they're a dedicated escalation mechanism, out of scope. ### Design principle — "build narrower tools, don't loosen ceilings" When a tier can't execute a scope and you want that capability from that tier, the right answer is **a dedicated narrower-scope tool**, not a ceiling relaxation. Example: - Problem: "I want `git status` to work from Telegram" - Wrong fix: grant `shell.exec` at Tier 2 - Right fix: build a `git.status` tool that holds a narrower scope (e.g., `git.read:`) which is grantable at Tier 2 This keeps the ceilings stable and the attack surface understandable. Every loosened ceiling is a new class of attack; every narrower tool is a new controlled primitive. ### Sanity check against D1 scenarios | Scenario | Tier | Outcome | |---|---|---| | "What did I work on yesterday" (CLI) | Trusted | ✓ `memory.read:session:*` granted, tool runs | | "Close terminal window" | Trusted | Deferred — window scopes are Reserved | | "Run rm -rf from Telegram" | SemiTrusted | ⊘ `shell.exec` denied by ceiling, `ToolOutcome::Denied` | | "git status from Telegram" | SemiTrusted | ⊘ Same denial — use a dedicated `git.status` tool | | User cancellation mid-turn | Any | Tier-independent, handled by `cancellation_token` | --- ## Deliverable 6 — Error Contract (LOCKED 2026-04-13) ### The variants (14 of a 15-variant cap) ```rust #[derive(Debug, thiserror::Error)] pub enum AivyxError { // Configuration & Startup #[error("configuration error: {0}")] Config(String), #[error("storage error: {0}")] Storage(#[from] StorageError), #[error("crypto error: {0}")] Crypto(#[from] CryptoError), // Capability & Trust #[error("capability denied: scope {scope} not held")] CapabilityDenied { scope: Scope, held: CapabilitySet }, #[error("invalid scope: {0}")] InvalidScope(String), // Agent Turn Execution #[error("LLM provider error: {0}")] Llm(#[from] LlmError), #[error("tool error in {tool}: {source}")] Tool { tool: ToolId, #[source] source: Box, }, #[error("tool {tool} requires escalation: {reason}")] ToolEscalation { tool: ToolId, reason: String }, // Channel & Transport #[error("channel error: {0}")] Channel(#[from] ChannelError), // Audit & Integrity #[error("audit integrity error: {0}")] Audit(String), // Time & Cancellation #[error("operation timed out after {0:?}")] Timeout(std::time::Duration), #[error("operation cancelled")] Cancelled, // Lookup & Identity #[error("not found: {kind} {id}")] NotFound { kind: &'static str, id: String }, // Catch-all #[error("internal error: {0}")] Internal(String), } ``` ### Why each variant exists | Variant | Source in D1–D5 | Why not another variant | |---|---|---| | `Config` | D7 config loading | Startup failures, before any tool/channel exists | | `Storage` | D7 redb wrapping | Rich nested failure modes (corruption, locking, crypto) | | `Crypto` | D2, D7 | Rich nested failure modes (AEAD, HKDF, Argon2) | | `CapabilityDenied` | D4, D5 | Carries scope + held set for diagnostics | | `InvalidScope` | D4 `Scope::parse` | Parse failure is distinct from check failure | | `Llm` | D1 tool loop | Rich nested: rate limits, context overflow, auth | | `Tool` | D1, D3 `ToolOutcome::Failed` | Plugin surface — source is erased | | `ToolEscalation` | D1, D3 `RequiresEscalation` | Not a failure — a structured pause request | | `Channel` | D1, D3 `stream_event` | Platform-specific failures preserved in nested type | | `Audit` | D1 HMAC-chained audit | Integrity failures are their own class — not `Storage` | | `Timeout` | D1, D3 `TimedOut` | Unambiguous, carries budget | | `Cancelled` | D1, D3 `Cancelled` | User-initiated, not a real error — separable in match | | `NotFound` | D3 lookups | `kind` field avoids needing 4 variants | | `Internal` | Last resort | Should be rare; each usage tagged TODO for promotion | ### Nested error types (not counted against the cap) Four variants wrap nested types that live in their own crates and can be as rich as needed: `StorageError`, `CryptoError`, `LlmError`, `ChannelError`. Top-level `AivyxError` captures **kind for a caller's purposes** (retry? escalate? give up?); nested enums capture **detail for a debugger's purposes**. ### The design rule > **Adding a 15th variant requires a design note in DESIGN.md explaining > why no existing variant fits. Adding a 16th requires a conversation.** This is the forcing function against drift. The archived codebase had 22 variants; the cap keeps future-us honest. ### Match rule at call sites > **Match specifically what you can handle; propagate the rest with `?`.** Don't catch `AivyxError` wholesale. Catch `CapabilityDenied` to trigger escalation, `Timeout` to retry, `Cancelled` to clean up. Let everything else propagate. The turn loop is the outermost matcher and converts each to the appropriate `TurnOutcome`. --- ## Deliverable 7 — Storage Decision (LOCKED 2026-04-13) ### The stack (carried over from the archive, unchanged) - **redb** — embedded pure-Rust ACID KV store, single file, cross-platform - **ChaCha20-Poly1305** — at-rest encryption (AEAD, constant-time) - **Argon2id** — passphrase → master key (m=64MB, t=3, p=4 starting params, tunable) - **HKDF-SHA256** — domain-separated subkey derivation from master - **Single-writer, single-process** — explicit non-goal to support otherwise ### HKDF domain list ```rust pub enum KeyDomain { Sessions, // session metadata, turn history Memory, // memory.read / memory.write substrate Audit, // HMAC-chained audit log entries Secrets, // encrypted config values (API keys, tokens) ChannelState, // per-channel persistent state (Matrix sync tokens, IMAP UIDs, etc.) } ``` Subkey derivation: ``` subkey_{domain} = HKDF-SHA256( ikm = master_key, salt = "aivyx-v1-storage", // versioned, allows clean key rotation info = domain.as_bytes(), // "sessions" | "memory" | ... length = 32 // ChaCha20-Poly1305 key size ) ``` The **versioned salt** is new in the rebuild. Bumping to `"aivyx-v2-storage"` later produces entirely different subkeys from the same master, enabling clean migration. ### Three changes from the archive 1. **Versioned HKDF salt** (above) — enables clean key-schedule migration. 2. **Passphrase prompt lives in the channel adapter, not the storage layer.** In the archive, `aivyx serve` prompted directly at startup, mixing concerns. In the rebuild, the **local CLI channel** obtains the passphrase (terminal prompt, OS keyring, env var, test fixture) and passes the *derived master key* to the agent at construction. Storage never sees raw passphrases. Different channels can use different passphrase flows. 3. **Storage is opened once per process.** The archive had paths that re-opened redb per-request in the HTTP layer. The rebuild commits to **one open redb handle per running process**, held in the concrete agent impl. `StorageHandle` is `Clone + Send + Sync`, wrapping `Arc`. ### Storage trait (high-level sketch) ```rust #[async_trait] pub trait Storage: Send + Sync { async fn open(config: &StorageConfig, master_key: &MasterKey) -> Result where Self: Sized; fn domain(&self, domain: KeyDomain) -> DomainHandle; async fn flush(&self) -> Result<(), StorageError>; } pub struct DomainHandle { /* wraps a redb table + subkey */ } impl DomainHandle { pub async fn get(&self, key: &[u8]) -> Result>, StorageError>; pub async fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StorageError>; pub async fn delete(&self, key: &[u8]) -> Result<(), StorageError>; pub async fn scan(&self, prefix: &[u8]) -> Result, Vec), StorageError>>, StorageError>; } ``` **Commitments:** - Single `Storage` trait + single concrete impl (`RedbStorage`) in `aivyx-storage` - `DomainHandle` is per-domain so callers can't leak across domains - All methods async; redb sync calls wrapped in `spawn_blocking` - `scan` returns a stream — memory recalls iterate without loading everything ### Non-goals (explicit) - **No multi-writer.** One process opens the store at a time. Enforced by redb file lock. - **No distributed replication.** No sharding, no read replicas, no sync-between-devices. - **No schema migrations framework.** Schema changes are one-off scripts + HKDF salt bump. --- ## Deliverable 8 — Repo Skeleton (LOCKED) - [x] **Deliverable 8** — Repo skeleton (9-crate workspace, `cargo check` green) ### Workspace layout ``` ~/Projects/aivyx/ ├── Cargo.toml workspace manifest (resolver = "2", edition 2024) ├── Cargo.lock committed — this workspace produces binaries ├── rust-toolchain.toml pinned stable + rustfmt + clippy ├── DESIGN.md this document ├── LICENSE MIT, © 2026 Julian (Aivyx) ├── TRADEMARK.md MIT + branded usage rule ├── README.md stub pointing at DESIGN.md ├── .gitignore standard Rust ignores (Cargo.lock NOT ignored) └── crates/ ├── aivyx-core/ turn loop, Agent/Tool traits, TurnOutcome ├── aivyx-crypto/ HKDF, ChaCha20-Poly1305, Argon2id ├── aivyx-capability/ Scope, CapabilitySet, TrustTier ├── aivyx-audit/ HMAC-chained audit log ├── aivyx-config/ config loading, secret-field resolution ├── aivyx-storage/ redb-backed, KeyDomain, Storage trait ├── aivyx-llm/ LlmProvider trait + reference impls ├── aivyx-memory/ memory.{read,write,forget} tools ├── aivyx-channel/ ChannelContext, StreamEvent, LocalChannel └── aivyx-mcp/ MCP client adapter (Phase 23) ``` Every crate is a stub at this point: doc comments pointing at DESIGN.md deliverables, `#![allow(dead_code)]`, and the minimum placeholder types referenced by sibling crates (`Agent`, `Tool`, `Message`, `TurnOutcome`, `ToolOutcome`, `Verification`, `Scope`, `CapabilitySet`, `TrustTier`, `KeyDomain`). No runtime logic. No dependencies between crates yet — each one has an empty `[dependencies]` section. ### Verification `cargo check --workspace` compiles all 11 crates clean on rust 1.85 / edition 2024 in ~0.03s. The skeleton is the minimum artifact that proves the design is buildable — it is not an implementation. > **Amendment (2026-04-17):** The workspace has grown from 9 > to 11 crates (adding `aivyx-telegram` in Phase 8 and > `aivyx-mcp` in Phase 23), and `aivyx-channel` has expanded > into the platform's integration > hub with 15 modules. See amendment > [`docs/amendments/2026-04-17-workspace-layout.md`](docs/amendments/2026-04-17-workspace-layout.md) > for the full current layout and module map. ### Phase 1 entry criterion Phase 1 begins from this commit. The first Phase 1 task is to wire `aivyx-core` against `aivyx-capability` and `aivyx-channel` to produce the first compiling (though not yet running) turn-loop skeleton. All subsequent work happens against trait shapes already locked in Deliverables 3–7 of this document — any deviation requires a Phase 0 amendment, not a silent drift. Known Phase 1 refinements already flagged: - **D3**: `Tool::required_scope` signature should become `required_scope(&self, input: &Value) -> Scope` to express input-dependent scope requirements (e.g., `memory.read` needing different scopes based on session filter). Caught during D4 sanity check against D1 scenarios. - **D4**: `display.window_close` and related window-control scopes are Reserved in v1; promoting them to active is a Phase 1 tool-crate decision, not a Phase 0 scope taxonomy change. --- ## Status Phase 0 is closed. This document is the **locked contract** — edits require an amendment under `docs/amendments/`. For the Phase 0 exit record and the lessons carried forward, see [`docs/PHASE_0.md`](docs/archive/phases/PHASE_0.md). For the current phase and its open tasks, see [`docs/PHASE_1.md`](docs/archive/phases/PHASE_1.md). The split between this contract document and the per-phase journals is explained in [`docs/README.md`](docs/README.md).