From 32078f7dacba026baaa577a8c3c1cf71d0c66bd5 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sun, 22 Mar 2026 19:09:09 +0400 Subject: [PATCH 01/16] RFC: Host ABI 55 host functions across 13 domain interfaces, 4 guest exports. Capability-gated, audited, per-principal scoped. Canonical WIT spec at core/wit/astrid-capsule.wit. --- text/0000-host-abi.md | 328 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 text/0000-host-abi.md diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md new file mode 100644 index 0000000..eaa7e08 --- /dev/null +++ b/text/0000-host-abi.md @@ -0,0 +1,328 @@ +- Feature Name: `host_abi` +- Start Date: 2026-03-22 +- RFC PR: [rfcs#0000](https://github.com/unicity-astrid/rfcs/pull/0000) +- Tracking Issue: [astrid#573](https://github.com/unicity-astrid/astrid/issues/573) + +# Summary +[summary]: #summary + +Define the host ABI — the syscall-like interface between the Astrid kernel and +WASM capsule guests. 55 host functions across 13 domain interfaces, plus 4 +guest exports. All operations are capability-gated, audited, and per-principal +scoped. + +# Motivation +[motivation]: #motivation + +The host ABI is the most critical contract surface in Astrid. Every capsule — +whether it handles LLM requests, manages sessions, or runs shell commands — +interacts with the kernel exclusively through these functions. A capsule cannot +make a system call, open a file, or send a network request without going +through the host ABI. + +This is by design. The WASM sandbox provides memory isolation, but the host ABI +provides *semantic* isolation. A capsule can only do what its capabilities +allow, and every action is recorded in the audit chain. + +Formalizing the host ABI as an RFC ensures: + +1. **SDK authors** can implement language bindings (Rust, Python, Go) against + a stable spec, not against Rust function signatures that change between + releases. +2. **Capsule authors** can understand what operations are available without + reading kernel source code. +3. **Security auditors** can review the complete privilege surface in one + document. + +# Guide-level explanation +[guide-level-explanation]: #guide-level-explanation + +## Architecture + +``` +┌─────────────────────────────────────┐ +│ Capsule (WASM guest) │ +│ │ +│ SDK: astrid_sdk::fs::read_file() │ +│ │ │ +│ ▼ │ +│ astrid-sys: astrid_fs_read() │ ← FFI boundary +├─────────────────────────────────────┤ +│ Host ABI (this spec) │ ← Kernel enforces here +│ │ │ +│ ▼ │ +│ Capability check → VFS resolve → │ +│ Sandbox boundary → Audit log → │ +│ Actual I/O │ +└─────────────────────────────────────┘ +``` + +The SDK wraps host functions in ergonomic Rust APIs. `astrid-sys` is the raw +FFI layer. The host ABI defines what happens at the boundary. + +## Wire format + +The current transport is Extism (not Component Model). All structured arguments +are passed as JSON-encoded byte buffers, and all structured returns are +JSON-encoded byte buffers. The WIT types in the canonical spec describe the +*logical* contract. Once the kernel migrates to the WASM Component Model, these +become actual typed parameters. + +## Capability gating + +Every host function checks the calling capsule's declared capabilities before +executing. A capsule without `fs_read` capability cannot call `astrid_fs_read`. +Violations are logged to the audit chain and return an error to the guest. + +## Per-principal scoping + +Host functions that access stateful resources (KV, filesystem, logging) are +automatically scoped to the calling principal. A capsule handling a request +from user "alice" reads from alice's KV namespace and writes to alice's log +directory, transparently. + +# Reference-level explanation +[reference-level-explanation]: #reference-level-explanation + +## Host interfaces + +The canonical WIT spec lives at +[`core/wit/astrid-capsule.wit`](https://github.com/unicity-astrid/astrid/blob/main/wit/astrid-capsule.wit). +This section summarizes each interface. + +### `types` — Shared types + +Common types used across interfaces: `log-level` enum, `key-value-pair` record, +`capsule-context` and `capsule-result` records for hook execution. + +### `fs` — Virtual filesystem (7 functions) + +| Function | Description | Capability | +|---|---|---| +| `read` | Read file contents | `fs_read` | +| `write` | Write file contents | `fs_write` | +| `readdir` | List directory entries | `fs_read` | +| `stat` | File metadata (size, type, mtime) | `fs_read` | +| `mkdir` | Create directory | `fs_write` | +| `remove` | Delete file | `fs_write` | +| `exists` | Check if path exists | `fs_read` | + +VFS paths use scheme prefixes: `workspace://` (project sandbox), `home://` +(principal home), `tmp://` (per-principal temp). The security gate resolves +schemes to physical paths and enforces capability boundaries. + +### `ipc` — Inter-process communication (3 functions) + +| Function | Description | Capability | +|---|---|---| +| `publish` | Publish message to IPC bus | `ipc_publish` | +| `subscribe` | Subscribe to topic pattern | `ipc_subscribe` | +| `recv` | Receive next message from subscription | `ipc_subscribe` | + +Topic patterns use dot-separated segments with single-segment wildcards (`*`). +Messages carry `IpcPayload` variants (LLM request, tool result, custom JSON, +etc.) and are assigned monotonic sequence numbers at publish time. + +### `uplink` — Frontend connection (3 functions) + +| Function | Description | Capability | +|---|---|---| +| `send` | Send response to connected client | `uplink` | +| `recv` | Receive input from client | `uplink` | +| `ready` | Signal that the capsule is ready for input | `uplink` | + +Only capsules with `uplink = true` capability can use these functions. + +### `kv` — Key-value store (3 functions) + +| Function | Description | Capability | +|---|---|---| +| `get` | Read value by key | (always allowed) | +| `set` | Write value by key | (always allowed) | +| `delete` | Remove key | (always allowed) | + +KV is always available — no capability needed. Namespace is automatically +scoped: `{principal}:capsule:{capsule_name}`. Capsules cannot access each +other's KV data. + +### `net` — Raw TCP networking (5 functions) + +| Function | Description | Capability | +|---|---|---| +| `connect` | Open TCP connection | `net` | +| `read` | Read from stream | `net` | +| `write` | Write to stream | `net` | +| `close` | Close connection | `net` | +| `dns-resolve` | DNS lookup | `net` | + +### `http` — HTTP client (4 functions) + +| Function | Description | Capability | +|---|---|---| +| `fetch` | Blocking HTTP request | `net` | +| `stream-start` | Begin streaming HTTP request (SSE) | `net` | +| `stream-read` | Read next chunk from stream | `net` | +| `stream-close` | Close streaming connection | `net` | + +### `sys` — System operations (8 functions) + +| Function | Description | Capability | +|---|---|---| +| `log` | Structured logging with level | (always allowed) | +| `get-config` | Read capsule configuration | (always allowed) | +| `get-env` | Read environment variable | (always allowed) | +| `get-caller` | Get calling principal info | (always allowed) | +| `get-time` | Current UTC timestamp | (always allowed) | +| `random-bytes` | Cryptographic random bytes | (always allowed) | +| `trigger-hook` | Fan-out hook to matching interceptors | (always allowed) | +| `sleep` | Suspend execution for duration | (always allowed) | + +### `cron` — Scheduled tasks (3 functions) + +| Function | Description | Capability | +|---|---|---| +| `schedule` | Register a recurring task | `cron` | +| `cancel` | Cancel a scheduled task | `cron` | +| `list` | List active scheduled tasks | `cron` | + +### `process` — Host process spawning (5 functions) + +| Function | Description | Capability | +|---|---|---| +| `spawn` | Run a command and wait for exit | `host_process` | +| `spawn-background` | Start a background process | `host_process` | +| `read-logs` | Read buffered output from background process | `host_process` | +| `kill` | Terminate a background process | `host_process` | +| `list-processes` | List active background processes | `host_process` | + +All processes run inside the platform sandbox (Seatbelt on macOS, bwrap on +Linux). The `host_process` capability lists allowed program names. + +### `elicit` — Interactive user input (3 functions) + +| Function | Description | Capability | +|---|---|---| +| `prompt` | Request text input from user | `uplink` | +| `select` | Present selection choices | `uplink` | +| `confirm` | Yes/no confirmation | `uplink` | + +### `approval` — Human-in-the-loop gates (2 functions) + +| Function | Description | Capability | +|---|---|---| +| `request` | Request human approval for an action | (always allowed) | +| `check` | Check if an action is pre-approved | (always allowed) | + +### `identity` — User identity (4 functions) + +| Function | Description | Capability | +|---|---|---| +| `create-user` | Create a new Astrid user | `identity` | +| `resolve` | Look up user by platform link | `identity` | +| `link` | Link a platform identity to an Astrid user | `identity` | +| `get-user` | Get user details by ID | `identity` | + +## Guest exports + +Capsules export up to 4 entry points: + +| Export | Description | Required | +|---|---|---| +| `astrid-hook-trigger` | Interceptor handler — receives action + payload, returns `InterceptResult` bytes | No | +| `run` | Background task entry point — capsules with run loops (IPC subscribers) | No | +| `astrid-install` | Called once after first installation | No | +| `astrid-upgrade` | Called after version upgrade (receives previous version) | No | + +## Error handling + +Host functions return errors as JSON: `{ "error": "message" }`. The SDK +converts these to `Result`. Capability violations include the +missing capability name in the error message. + +# Drawbacks +[drawbacks]: #drawbacks + +- **Large surface area.** 55 functions is a lot to maintain stable. Each one + is a compatibility commitment. +- **JSON wire format overhead.** Serializing/deserializing JSON for every + syscall is measurably slower than Component Model typed parameters. Acceptable + pre-1.0; the migration path (WS-8) is planned. +- **No versioning on individual functions.** The package version (`0.1.0`) + covers the entire ABI. Adding a function is a minor bump; changing a + function signature is a major bump. + +# Rationale and alternatives +[rationale-and-alternatives]: #rationale-and-alternatives + +## Why WIT as the spec format? + +WIT is the WASM ecosystem's canonical interface definition language. When +Astrid migrates to the Component Model, the WIT spec becomes the actual +import/export declarations — no rewrite needed. + +## Why not fewer, coarser interfaces? + +The original design had 7 functions in a single `host` interface. This was +split into 13 domain interfaces because: +- Capability gating maps to interfaces (grant `fs` without granting `net`) +- Documentation is clearer per domain +- Future SDK can expose per-domain modules + +## Why JSON and not protobuf or msgpack? + +JSON is human-readable (debugging), universally supported (every SDK language), +and matches the IPC bus payload format. The performance cost is acceptable for +the current scale. The Component Model migration eliminates serialization +entirely. + +## Why are KV and sys functions ungated? + +KV is scoped per-capsule and per-principal — a capsule can only access its own +data. System functions (logging, config, time) are read-only or side-effect-free. +Gating these would add friction without security benefit. + +# Prior art +[prior-art]: #prior-art + +- **POSIX**: The syscall interface between kernel and user-space. 400+ syscalls + organized by domain (file, process, memory, network). Astrid's host ABI is + the capsule equivalent. + +- **WASI** (WebAssembly System Interface): Standardized host functions for WASM + modules. `wasi_snapshot_preview1` provides filesystem, clock, random. Astrid + extends beyond WASI with IPC, approval gates, identity, and capsule-specific + operations. + +- **Extism**: Plugin framework providing the current host function transport. + Astrid builds on Extism's plugin model but defines its own semantic layer + (capabilities, audit, principal scoping) on top. + +- **Envoy WASM ABI**: Host functions for proxy filters (get/set headers, + send HTTP, log). Similar pattern: domain-specific host APIs for sandboxed + extensions. + +# Unresolved questions +[unresolved-questions]: #unresolved-questions + +- Should the ABI version be semver-independent from the kernel version? + Currently they share the same version number. +- Should `astrid_system_stats` be added for runtime metrics (per-capsule + memory, invocation counts, event bus throughput)? Planned for v0.6.0. +- Should there be a `capabilities` host function that lets a capsule query + its own granted capabilities at runtime? + +# Future possibilities +[future-possibilities]: #future-possibilities + +- **Component Model migration.** Replace Extism transport with native WASM + Component Model imports/exports. WIT spec becomes the actual ABI, not just + documentation. +- **`astrid_system_stats` host function.** Runtime observability for the system + capsule — per-capsule WASM heap, invocation counts, event bus metrics. +- **Capability delegation.** A capsule grants a subset of its capabilities to + a child capsule it spawns. Recursive restriction — children can only get + *more restricted*, never *more permissive*. +- **Batch KV operations.** `kv_get_many`, `kv_set_many` for reducing + round-trips in capsules that manage complex state. +- **File watching.** `fs_watch` for capsules that need to react to workspace + file changes. From cca72623e708e0a710e8feecc3cdf6a66877ac17 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sun, 22 Mar 2026 19:59:53 +0400 Subject: [PATCH 02/16] fix: correct host ABI function counts and names against actual WIT spec 51 host functions across 12 interfaces (not 55/13). All function names and per-interface counts verified against astrid-capsule.wit. --- text/0000-host-abi.md | 111 +++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index eaa7e08..5641f22 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -7,7 +7,7 @@ [summary]: #summary Define the host ABI — the syscall-like interface between the Astrid kernel and -WASM capsule guests. 55 host functions across 13 domain interfaces, plus 4 +WASM capsule guests. 51 host functions across 12 domain interfaces, plus 4 guest exports. All operations are capability-gated, audited, and per-principal scoped. @@ -99,93 +99,96 @@ Common types used across interfaces: `log-level` enum, `key-value-pair` record, | Function | Description | Capability | |---|---|---| -| `read` | Read file contents | `fs_read` | -| `write` | Write file contents | `fs_write` | -| `readdir` | List directory entries | `fs_read` | -| `stat` | File metadata (size, type, mtime) | `fs_read` | -| `mkdir` | Create directory | `fs_write` | -| `remove` | Delete file | `fs_write` | -| `exists` | Check if path exists | `fs_read` | +| `read-file` | Read file contents | `fs_read` | +| `write-file` | Write file contents | `fs_write` | +| `fs-readdir` | List directory entry names | `fs_read` | +| `fs-stat` | File metadata (size, type, mtime) | `fs_read` | +| `fs-mkdir` | Create directory | `fs_write` | +| `fs-unlink` | Delete file | `fs_write` | +| `fs-exists` | Check if path exists | `fs_read` | VFS paths use scheme prefixes: `workspace://` (project sandbox), `home://` (principal home), `tmp://` (per-principal temp). The security gate resolves schemes to physical paths and enforces capability boundaries. -### `ipc` — Inter-process communication (3 functions) +### `ipc` — Inter-process communication (6 functions) | Function | Description | Capability | |---|---|---| -| `publish` | Publish message to IPC bus | `ipc_publish` | -| `subscribe` | Subscribe to topic pattern | `ipc_subscribe` | -| `recv` | Receive next message from subscription | `ipc_subscribe` | +| `ipc-publish` | Publish message to IPC bus | `ipc_publish` | +| `ipc-subscribe` | Subscribe to topic pattern | `ipc_subscribe` | +| `ipc-unsubscribe` | Unsubscribe from topic | `ipc_subscribe` | +| `ipc-poll` | Check if messages are available | `ipc_subscribe` | +| `ipc-recv` | Receive next message from subscription | `ipc_subscribe` | +| `get-interceptor-handles` | Get handles for registered interceptors | `ipc_subscribe` | Topic patterns use dot-separated segments with single-segment wildcards (`*`). Messages carry `IpcPayload` variants (LLM request, tool result, custom JSON, etc.) and are assigned monotonic sequence numbers at publish time. -### `uplink` — Frontend connection (3 functions) +### `uplink` — Frontend connection (2 functions) | Function | Description | Capability | |---|---|---| -| `send` | Send response to connected client | `uplink` | -| `recv` | Receive input from client | `uplink` | -| `ready` | Signal that the capsule is ready for input | `uplink` | +| `uplink-register` | Register as a frontend uplink | `uplink` | +| `uplink-send` | Send response to connected client | `uplink` | Only capsules with `uplink = true` capability can use these functions. -### `kv` — Key-value store (3 functions) +### `kv` — Key-value store (5 functions) | Function | Description | Capability | |---|---|---| -| `get` | Read value by key | (always allowed) | -| `set` | Write value by key | (always allowed) | -| `delete` | Remove key | (always allowed) | +| `kv-get` | Read value by key | (always allowed) | +| `kv-set` | Write value by key | (always allowed) | +| `kv-delete` | Remove key | (always allowed) | +| `kv-list-keys` | List keys matching a prefix | (always allowed) | +| `kv-clear-prefix` | Delete all keys matching a prefix | (always allowed) | KV is always available — no capability needed. Namespace is automatically scoped: `{principal}:capsule:{capsule_name}`. Capsules cannot access each other's KV data. -### `net` — Raw TCP networking (5 functions) +### `net` — Raw networking (6 functions) | Function | Description | Capability | |---|---|---| -| `connect` | Open TCP connection | `net` | -| `read` | Read from stream | `net` | -| `write` | Write to stream | `net` | -| `close` | Close connection | `net` | -| `dns-resolve` | DNS lookup | `net` | +| `net-bind-unix` | Bind a Unix domain socket | `net` | +| `net-accept` | Accept connection on bound socket | `net` | +| `net-poll-accept` | Non-blocking accept check | `net` | +| `net-read` | Read from stream | `net` | +| `net-write` | Write to stream | `net` | +| `net-close-stream` | Close stream | `net` | ### `http` — HTTP client (4 functions) | Function | Description | Capability | |---|---|---| -| `fetch` | Blocking HTTP request | `net` | -| `stream-start` | Begin streaming HTTP request (SSE) | `net` | -| `stream-read` | Read next chunk from stream | `net` | -| `stream-close` | Close streaming connection | `net` | +| `http-request` | Blocking HTTP request | `net` | +| `http-stream-start` | Begin streaming HTTP request (SSE) | `net` | +| `http-stream-read` | Read next chunk from stream | `net` | +| `http-stream-close` | Close streaming connection | `net` | -### `sys` — System operations (8 functions) +### `sys` — System operations (7 functions) | Function | Description | Capability | |---|---|---| | `log` | Structured logging with level | (always allowed) | | `get-config` | Read capsule configuration | (always allowed) | -| `get-env` | Read environment variable | (always allowed) | | `get-caller` | Get calling principal info | (always allowed) | -| `get-time` | Current UTC timestamp | (always allowed) | -| `random-bytes` | Cryptographic random bytes | (always allowed) | | `trigger-hook` | Fan-out hook to matching interceptors | (always allowed) | -| `sleep` | Suspend execution for duration | (always allowed) | +| `signal-ready` | Signal capsule is ready | (always allowed) | +| `clock-ms` | Current time in milliseconds | (always allowed) | +| `check-capsule-capability` | Query own capability grants | (always allowed) | -### `cron` — Scheduled tasks (3 functions) +### `cron` — Scheduled tasks (2 functions) | Function | Description | Capability | |---|---|---| -| `schedule` | Register a recurring task | `cron` | -| `cancel` | Cancel a scheduled task | `cron` | -| `list` | List active scheduled tasks | `cron` | +| `cron-schedule` | Register a recurring task | `cron` | +| `cron-cancel` | Cancel a scheduled task | `cron` | -### `process` — Host process spawning (5 functions) +### `process` — Host process spawning (4 functions) | Function | Description | Capability | |---|---|---| @@ -193,34 +196,32 @@ other's KV data. | `spawn-background` | Start a background process | `host_process` | | `read-logs` | Read buffered output from background process | `host_process` | | `kill` | Terminate a background process | `host_process` | -| `list-processes` | List active background processes | `host_process` | All processes run inside the platform sandbox (Seatbelt on macOS, bwrap on Linux). The `host_process` capability lists allowed program names. -### `elicit` — Interactive user input (3 functions) +### `elicit` — Interactive user input (2 functions) | Function | Description | Capability | |---|---|---| -| `prompt` | Request text input from user | `uplink` | -| `select` | Present selection choices | `uplink` | -| `confirm` | Yes/no confirmation | `uplink` | +| `elicit` | Request structured input from user (text, select, confirm) | `uplink` | +| `has-secret` | Check if a secret is configured | (always allowed) | -### `approval` — Human-in-the-loop gates (2 functions) +### `approval` — Human-in-the-loop gates (1 function) | Function | Description | Capability | |---|---|---| -| `request` | Request human approval for an action | (always allowed) | -| `check` | Check if an action is pre-approved | (always allowed) | +| `request-approval` | Request human approval for an action | (always allowed) | -### `identity` — User identity (4 functions) +### `identity` — User identity (5 functions) | Function | Description | Capability | |---|---|---| -| `create-user` | Create a new Astrid user | `identity` | -| `resolve` | Look up user by platform link | `identity` | -| `link` | Link a platform identity to an Astrid user | `identity` | -| `get-user` | Get user details by ID | `identity` | +| `identity-create-user` | Create a new Astrid user | `identity` | +| `identity-resolve` | Look up user by platform link | `identity` | +| `identity-link` | Link a platform identity to an Astrid user | `identity` | +| `identity-unlink` | Remove a platform identity link | `identity` | +| `identity-list-links` | List all platform links for a user | `identity` | ## Guest exports @@ -242,8 +243,8 @@ missing capability name in the error message. # Drawbacks [drawbacks]: #drawbacks -- **Large surface area.** 55 functions is a lot to maintain stable. Each one - is a compatibility commitment. +- **Large surface area.** 51 host functions is a lot to maintain stable. Each + one is a compatibility commitment. - **JSON wire format overhead.** Serializing/deserializing JSON for every syscall is measurably slower than Component Model typed parameters. Acceptable pre-1.0; the migration path (WS-8) is planned. From d256726916a63af2dce203c1a5cff21663b93dee Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sun, 22 Mar 2026 21:24:15 +0400 Subject: [PATCH 03/16] refactor: replace function tables with WIT reference, strengthen unresolved questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WIT file is the canonical spec — the RFC now references it instead of duplicating function tables that go stale. Added: capability model summary, VFS scheme table, KV scoping explanation, 5 substantive unresolved questions. --- text/0000-host-abi.md | 209 ++++++++++++++++-------------------------- 1 file changed, 79 insertions(+), 130 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 5641f22..7ad6601 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -84,144 +84,70 @@ directory, transparently. # Reference-level explanation [reference-level-explanation]: #reference-level-explanation -## Host interfaces - -The canonical WIT spec lives at -[`core/wit/astrid-capsule.wit`](https://github.com/unicity-astrid/astrid/blob/main/wit/astrid-capsule.wit). -This section summarizes each interface. - -### `types` — Shared types - -Common types used across interfaces: `log-level` enum, `key-value-pair` record, -`capsule-context` and `capsule-result` records for hook execution. - -### `fs` — Virtual filesystem (7 functions) - -| Function | Description | Capability | -|---|---|---| -| `read-file` | Read file contents | `fs_read` | -| `write-file` | Write file contents | `fs_write` | -| `fs-readdir` | List directory entry names | `fs_read` | -| `fs-stat` | File metadata (size, type, mtime) | `fs_read` | -| `fs-mkdir` | Create directory | `fs_write` | -| `fs-unlink` | Delete file | `fs_write` | -| `fs-exists` | Check if path exists | `fs_read` | - -VFS paths use scheme prefixes: `workspace://` (project sandbox), `home://` -(principal home), `tmp://` (per-principal temp). The security gate resolves -schemes to physical paths and enforces capability boundaries. - -### `ipc` — Inter-process communication (6 functions) - -| Function | Description | Capability | -|---|---|---| -| `ipc-publish` | Publish message to IPC bus | `ipc_publish` | -| `ipc-subscribe` | Subscribe to topic pattern | `ipc_subscribe` | -| `ipc-unsubscribe` | Unsubscribe from topic | `ipc_subscribe` | -| `ipc-poll` | Check if messages are available | `ipc_subscribe` | -| `ipc-recv` | Receive next message from subscription | `ipc_subscribe` | -| `get-interceptor-handles` | Get handles for registered interceptors | `ipc_subscribe` | - -Topic patterns use dot-separated segments with single-segment wildcards (`*`). -Messages carry `IpcPayload` variants (LLM request, tool result, custom JSON, -etc.) and are assigned monotonic sequence numbers at publish time. - -### `uplink` — Frontend connection (2 functions) - -| Function | Description | Capability | -|---|---|---| -| `uplink-register` | Register as a frontend uplink | `uplink` | -| `uplink-send` | Send response to connected client | `uplink` | +## Canonical spec -Only capsules with `uplink = true` capability can use these functions. +The authoritative function-level specification is the WIT file: +[`core/wit/astrid-capsule.wit`](https://github.com/unicity-astrid/astrid/blob/main/wit/astrid-capsule.wit) -### `kv` — Key-value store (5 functions) +This RFC documents the design rationale, capability model, and scoping +semantics. The WIT file is the reference for function signatures and types. +If this RFC and the WIT file disagree, the WIT file is correct. -| Function | Description | Capability | -|---|---|---| -| `kv-get` | Read value by key | (always allowed) | -| `kv-set` | Write value by key | (always allowed) | -| `kv-delete` | Remove key | (always allowed) | -| `kv-list-keys` | List keys matching a prefix | (always allowed) | -| `kv-clear-prefix` | Delete all keys matching a prefix | (always allowed) | - -KV is always available — no capability needed. Namespace is automatically -scoped: `{principal}:capsule:{capsule_name}`. Capsules cannot access each -other's KV data. +## Host interfaces -### `net` — Raw networking (6 functions) +51 host functions organized into 12 domain interfaces: -| Function | Description | Capability | -|---|---|---| -| `net-bind-unix` | Bind a Unix domain socket | `net` | -| `net-accept` | Accept connection on bound socket | `net` | -| `net-poll-accept` | Non-blocking accept check | `net` | -| `net-read` | Read from stream | `net` | -| `net-write` | Write to stream | `net` | -| `net-close-stream` | Close stream | `net` | +| Interface | Functions | Capability | Purpose | +|---|---|---|---| +| `fs` | 7 | `fs_read`, `fs_write` | Virtual filesystem (workspace://, home://, tmp://) | +| `ipc` | 6 | `ipc_publish`, `ipc_subscribe` | IPC event bus: publish, subscribe, receive | +| `uplink` | 2 | `uplink` | Frontend connection registration and response sending | +| `kv` | 5 | (ungated) | Per-capsule key-value store, auto-scoped per principal | +| `net` | 6 | `net` | Unix socket I/O for capsule-to-daemon communication | +| `http` | 4 | `net` | HTTP client with streaming (SSE) support | +| `sys` | 7 | (ungated) | Logging, config, time, hooks, capability introspection | +| `cron` | 2 | `cron` | Scheduled recurring tasks | +| `process` | 4 | `host_process` | Sandboxed host process spawning (Seatbelt/bwrap) | +| `elicit` | 2 | `uplink` | Interactive user input (prompts, selections) | +| `approval` | 1 | (ungated) | Human-in-the-loop approval gates | +| `identity` | 5 | `identity` | User identity CRUD and platform linking | -### `http` — HTTP client (4 functions) +A 13th block, `types`, defines shared types (`log-level`, `key-value-pair`, +`capsule-context`, `capsule-result`) used across interfaces. It has no functions. -| Function | Description | Capability | -|---|---|---| -| `http-request` | Blocking HTTP request | `net` | -| `http-stream-start` | Begin streaming HTTP request (SSE) | `net` | -| `http-stream-read` | Read next chunk from stream | `net` | -| `http-stream-close` | Close streaming connection | `net` | +### Capability model -### `sys` — System operations (7 functions) +Each host function checks the calling capsule's declared capabilities: -| Function | Description | Capability | -|---|---|---| -| `log` | Structured logging with level | (always allowed) | -| `get-config` | Read capsule configuration | (always allowed) | -| `get-caller` | Get calling principal info | (always allowed) | -| `trigger-hook` | Fan-out hook to matching interceptors | (always allowed) | -| `signal-ready` | Signal capsule is ready | (always allowed) | -| `clock-ms` | Current time in milliseconds | (always allowed) | -| `check-capsule-capability` | Query own capability grants | (always allowed) | +- **Gated:** `fs_read`, `fs_write`, `ipc_publish`, `ipc_subscribe`, `uplink`, + `net`, `cron`, `host_process`, `identity`. Capsules must declare these in + `[capabilities]` in Capsule.toml. +- **Ungated:** `kv`, `sys`, `approval`. Always available. KV is safe because + it's namespace-scoped per capsule and principal. Sys functions are read-only + or side-effect-free. Approval is a request, not an action. -### `cron` — Scheduled tasks (2 functions) +Violations return an error to the guest and are logged to the audit chain. -| Function | Description | Capability | -|---|---|---| -| `cron-schedule` | Register a recurring task | `cron` | -| `cron-cancel` | Cancel a scheduled task | `cron` | +### VFS scheme resolution -### `process` — Host process spawning (4 functions) +The `fs` interface resolves paths through VFS schemes: -| Function | Description | Capability | +| Scheme | Resolves to | Capability | |---|---|---| -| `spawn` | Run a command and wait for exit | `host_process` | -| `spawn-background` | Start a background process | `host_process` | -| `read-logs` | Read buffered output from background process | `host_process` | -| `kill` | Terminate a background process | `host_process` | +| `workspace://` | Project sandbox root (CWD) | `fs_read` / `fs_write` | +| `home://` | `~/.astrid/home/{principal}/` | `fs_read` / `fs_write` | +| `tmp://` | `~/.astrid/home/{principal}/.local/tmp/` | `fs_write` | -All processes run inside the platform sandbox (Seatbelt on macOS, bwrap on -Linux). The `host_process` capability lists allowed program names. +The security gate resolves schemes to physical paths at capsule load time. +Cross-scheme access is denied. A capsule with `fs_read = ["workspace://"]` +cannot read `home://`. -### `elicit` — Interactive user input (2 functions) +### Per-principal KV scoping -| Function | Description | Capability | -|---|---|---| -| `elicit` | Request structured input from user (text, select, confirm) | `uplink` | -| `has-secret` | Check if a secret is configured | (always allowed) | - -### `approval` — Human-in-the-loop gates (1 function) - -| Function | Description | Capability | -|---|---|---| -| `request-approval` | Request human approval for an action | (always allowed) | - -### `identity` — User identity (5 functions) - -| Function | Description | Capability | -|---|---|---| -| `identity-create-user` | Create a new Astrid user | `identity` | -| `identity-resolve` | Look up user by platform link | `identity` | -| `identity-link` | Link a platform identity to an Astrid user | `identity` | -| `identity-unlink` | Remove a platform identity link | `identity` | -| `identity-list-links` | List all platform links for a user | `identity` | +KV namespace: `{principal}:capsule:{capsule_name}`. The principal is resolved +from the invocation context (IPC message principal field), not the capsule's +static configuration. This means the same capsule serves different KV +namespaces depending on who is calling — transparent to the capsule author. ## Guest exports @@ -229,10 +155,14 @@ Capsules export up to 4 entry points: | Export | Description | Required | |---|---|---| -| `astrid-hook-trigger` | Interceptor handler — receives action + payload, returns `InterceptResult` bytes | No | +| `astrid-hook-trigger` | Interceptor handler — receives action + payload, returns `InterceptResult` bytes (see [interceptor chain RFC](0000-interceptor-chain.md)) | No | | `run` | Background task entry point — capsules with run loops (IPC subscribers) | No | -| `astrid-install` | Called once after first installation | No | -| `astrid-upgrade` | Called after version upgrade (receives previous version) | No | +| `astrid-install` | Called once after first installation — setup KV state, validate config | No | +| `astrid-upgrade` | Called after version upgrade — receives previous version for migrations | No | + +Capsules without `run` are "on-demand" — they only execute when an interceptor +or tool is invoked. Capsules with `run` start a background task that subscribes +to IPC topics and processes events in a loop. ## Error handling @@ -305,12 +235,31 @@ Gating these would add friction without security benefit. # Unresolved questions [unresolved-questions]: #unresolved-questions -- Should the ABI version be semver-independent from the kernel version? - Currently they share the same version number. -- Should `astrid_system_stats` be added for runtime metrics (per-capsule - memory, invocation counts, event bus throughput)? Planned for v0.6.0. -- Should there be a `capabilities` host function that lets a capsule query - its own granted capabilities at runtime? +- **ABI versioning independence.** Should the ABI version be semver-independent + from the kernel version? Currently the WIT package version (`0.1.0`) and the + kernel version (`0.5.0`) are decoupled but informally linked. A stable ABI + version would let capsule authors target "ABI 1.0" regardless of which kernel + version implements it. The counter-argument: two version numbers is confusing + when there's only one implementation. + +- **Capability introspection depth.** `check-capsule-capability` exists but is + limited. Should capsules be able to query the full capability set of OTHER + capsules? This enables a system capsule to display "what can each capsule do" + but leaks capability information across the sandbox boundary. + +- **Host function deprecation path.** When a host function needs to change + signature (e.g., adding a parameter), how is backward compatibility handled? + Options: versioned function names (`fs-read-v2`), optional parameters via + JSON, or a clean break with the Component Model migration. + +- **Audit chain integration.** Which host functions should produce audit + entries? Currently logging and approval are audited. Should every `fs_write` + be audited? Every `ipc_publish`? The audit chain grows linearly with calls — + full auditing could be expensive for high-throughput capsules. + +- **Resource limits.** Should host functions enforce resource limits (max file + size on `write-file`, max message size on `ipc-publish`, max KV value size)? + Currently unbounded — a capsule can write arbitrarily large values. # Future possibilities [future-possibilities]: #future-possibilities From a9e90f1714868155d0175aa725e3dd8bb841e315 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 07:06:58 +0400 Subject: [PATCH 04/16] rfc(host-abi): encode pre-1.0 ABI evolution discipline (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three coordinated rules that make the host ABI evolvable post-1.0 without breaking third-party capsules on every WIT touch: 1. Per-domain packages — replace monolithic astrid:capsule@0.1.0 with one package per domain (astrid:ipc, astrid:net, astrid:kv, ...) plus a minimal astrid:core for cross-cutting types only. 2. Multi-version kernel registration — kernel explicitly registers every supported (package, version) pair into the wasmtime CM linker, with host-side shims translating between version shapes. 3. Frozen WIT files per version — once shipped, interfaces/@X.Y.Z.wit is immutable; shape changes ship as new files at new versions, enforced by CI lint in unicity-astrid/wit. Also updates the RFC to reflect that Component Model is the current transport (Extism/JSON wire-format framing removed), points the canonical spec at unicity-astrid/wit, scopes the discipline to kernel<->capsule (capsule<->capsule is IPC-bus territory, not WIT), resolves the prior 'ABI versioning independence' and 'deprecation path' unresolved questions, and adds new open questions on minimal- core vs zero-shared and resource-typed caller-context. Tracking issue astrid#750 added alongside astrid#573. --- text/0000-host-abi.md | 318 +++++++++++++++++++++++++++++++++--------- 1 file changed, 254 insertions(+), 64 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 7ad6601..1069d62 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -1,7 +1,7 @@ - Feature Name: `host_abi` - Start Date: 2026-03-22 - RFC PR: [rfcs#0000](https://github.com/unicity-astrid/rfcs/pull/0000) -- Tracking Issue: [astrid#573](https://github.com/unicity-astrid/astrid/issues/573) +- Tracking Issues: [astrid#573](https://github.com/unicity-astrid/astrid/issues/573), [astrid#750](https://github.com/unicity-astrid/astrid/issues/750) # Summary [summary]: #summary @@ -11,6 +11,14 @@ WASM capsule guests. 51 host functions across 12 domain interfaces, plus 4 guest exports. All operations are capability-gated, audited, and per-principal scoped. +Pre-1.0, the ABI also adopts an evolution discipline to make post-1.0 changes +non-fatal for third-party capsules: the monolithic `astrid:capsule@0.1.0` +package splits into per-domain packages (`astrid:ipc@1.0.0`, `astrid:net@1.0.0`, +…) plus a minimal `astrid:core@1.0.0`; the kernel registers every supported +`(package, version)` pair into the wasmtime Component Model linker explicitly; +and once a WIT file is published it is immutable forever — shape changes ship +as new files at new versions. + # Motivation [motivation]: #motivation @@ -40,33 +48,35 @@ Formalizing the host ABI as an RFC ensures: ## Architecture ``` -┌─────────────────────────────────────┐ -│ Capsule (WASM guest) │ -│ │ -│ SDK: astrid_sdk::fs::read_file() │ -│ │ │ -│ ▼ │ -│ astrid-sys: astrid_fs_read() │ ← FFI boundary -├─────────────────────────────────────┤ -│ Host ABI (this spec) │ ← Kernel enforces here -│ │ │ -│ ▼ │ -│ Capability check → VFS resolve → │ -│ Sandbox boundary → Audit log → │ -│ Actual I/O │ -└─────────────────────────────────────┘ +┌──────────────────────────────────────────┐ +│ Capsule (WASM guest) │ +│ │ +│ SDK: astrid_sdk::fs::read_file() │ +│ │ │ +│ ▼ │ +│ WIT-bindgen import: astrid:fs/host.read │ ← Component Model boundary +├──────────────────────────────────────────┤ +│ Host ABI (this spec) │ ← Kernel enforces here +│ │ │ +│ ▼ │ +│ Capability check → VFS resolve → │ +│ Sandbox boundary → Audit log → │ +│ Actual I/O │ +└──────────────────────────────────────────┘ ``` -The SDK wraps host functions in ergonomic Rust APIs. `astrid-sys` is the raw -FFI layer. The host ABI defines what happens at the boundary. +The SDK wraps WIT-bindgen-generated imports in ergonomic Rust APIs. The +Component Model boundary is typed end-to-end — there is no separate FFI shim +layer, and there is no serialization at the call site. The host ABI defines +what happens once a call crosses the linker. ## Wire format -The current transport is Extism (not Component Model). All structured arguments -are passed as JSON-encoded byte buffers, and all structured returns are -JSON-encoded byte buffers. The WIT types in the canonical spec describe the -*logical* contract. Once the kernel migrates to the WASM Component Model, these -become actual typed parameters. +The transport is the WASM Component Model. Arguments and returns are typed +according to the WIT spec — records, lists, options, and `result` are +passed natively across the host/guest boundary by wasmtime, with no +serialization at the call site. The WIT files are the actual import/export +declarations, not merely documentation. ## Capability gating @@ -86,12 +96,16 @@ directory, transparently. ## Canonical spec -The authoritative function-level specification is the WIT file: -[`core/wit/astrid-capsule.wit`](https://github.com/unicity-astrid/astrid/blob/main/wit/astrid-capsule.wit) +The authoritative function-level specification lives in the +[`unicity-astrid/wit`](https://github.com/unicity-astrid/wit) repository as a +set of per-domain WIT files (`interfaces/@.wit`). The kernel +vendors a snapshot at `core/wit/` for its build, but the canonical source is +the WIT repo. -This RFC documents the design rationale, capability model, and scoping -semantics. The WIT file is the reference for function signatures and types. -If this RFC and the WIT file disagree, the WIT file is correct. +This RFC documents the design rationale, capability model, scoping semantics, +and evolution discipline. The WIT files are the reference for function +signatures and types. If this RFC and a WIT file disagree, the WIT file is +correct. ## Host interfaces @@ -166,30 +180,170 @@ to IPC topics and processes events in a loop. ## Error handling -Host functions return errors as JSON: `{ "error": "message" }`. The SDK -converts these to `Result`. Capability violations include the -missing capability name in the error message. +Host functions that can fail return `result` natively through the +Component Model. The error string describes the failure; the SDK converts it +to `Result`. Capability violations include the missing capability +name in the error message. Unrecoverable errors (lock poisoning, memory +exhaustion) trap — those represent kernel invariant violations, not +guest-recoverable conditions. + +## ABI evolution discipline + +The Component Model linker enforces structural typing on imports. A +record-field add, a function add, or any other shape change in a published +WIT package makes every capsule built against the prior shape fail to +instantiate with errors of the form `component imports instance +'astrid:capsule/ipc@0.1.0', but a matching implementation was not found in +the linker`. This has already been observed concretely against the current +monolithic `astrid:capsule@0.1.0` package: additive-looking changes (adding +`principal: option` to `ipc-message`, adding 14 net functions) detonate +every capsule built before the change, because the single mega-package means +any edit anywhere in the file is a shape change for everyone importing it. + +Pre-1.0, "rebuild the whole ecosystem on every change" is the recovery path. +Post-1.0 it is not: third-party capsule authors who shipped six months ago +and have not touched their code must keep loading. + +The ABI is governed by three coordinated rules. Each is load-bearing — landing +one without the others is wasted work. + +### Rule 1: per-domain packages + +The pre-1.0 monolithic `astrid:capsule@0.1.0` package containing 12 interfaces +is replaced by one package per domain: + +``` +astrid:ipc@1.0.0 +astrid:net@1.0.0 +astrid:kv@1.0.0 +astrid:http@1.0.0 +astrid:fs@1.0.0 +astrid:process@1.0.0 +astrid:sys@1.0.0 +astrid:cron@1.0.0 +astrid:elicit@1.0.0 +astrid:approval@1.0.0 +astrid:uplink@1.0.0 +astrid:identity@1.0.0 +``` + +Plus a minimal **`astrid:core@1.0.0`** for truly cross-cutting types only +(`principal`, `caller-context`, common error shapes). This package is kept as +small as humanly possible; every type that lives in it ripples across every +interface that imports it, so each entry is justified individually. + +Each domain package owns its own types. `astrid:ipc/types` (`ipc-envelope`, +`ipc-message`) lives in `astrid:ipc`, not in a shared types module. Per-domain +type ownership isolates per-domain evolution: bumping `astrid:net` does not +recompile anything that only imports `astrid:kv`. + +Capsules opt into exactly the subset they need: + +```wit +world my-capsule { + import astrid:ipc/host@1.0.0; + import astrid:kv/host@1.0.0; + // not net, not http — capsule does not use them +} +``` + +A kv-only capsule is unaffected when `astrid:net` adds a function. + +### Rule 2: multi-version kernel registration + +wasmtime's Component Model has no implicit version negotiation. A version is +either registered into the linker or it is not. The kernel therefore +explicitly registers every `(package, version)` pair it supports: + +```rust +// pseudocode +bindings::ipc_v1_0::add_to_linker(&mut linker, host_v1_0_handler)?; +bindings::ipc_v1_1::add_to_linker(&mut linker, host_v1_1_handler)?; +bindings::ipc_v2_0::add_to_linker(&mut linker, host_v2_0_handler)?; +// per package, per supported version +``` + +This requires: + +- **Build-time codegen:** one binding module per `(package, version)` pair. +- **Host-side shims:** handlers that translate between version shapes when + shared types evolved. For example, if `ipc-message` gains a `principal` + field in v1.1, the v1.0 handler ignores it when emitting to v1.0 capsules + and synthesizes it from caller-context when receiving from v1.0 capsules. + Shims are written once per evolution, not once per capsule. +- **Support window per package:** the kernel publishes which versions it + loads (e.g. `astrid:ipc@{1.0.0, 1.1.0, 2.0.0}` simultaneously). +- **Deprecation policy:** when the kernel drops a version from the support + window, capsules built against it stop loading. Drop dates are announced in + advance, and capsules importing a soon-to-be-dropped version produce a + load-time warning naming the removal date. + +### Rule 3: frozen WIT files per version + +Once `astrid:ipc@1.0.0` ships, the file at `interfaces/ipc@1.0.0.wit` is +**immutable forever**. New shapes get a new file: + +``` +interfaces/ + ipc@1.0.0.wit # frozen + ipc@1.1.0.wit # frozen + ipc@2.0.0.wit # current +``` + +When a shape change is needed: copy the latest frozen file to a new version +path, modify, register the new version in the kernel, leave the old files +alone. The current pattern of editing a WIT file in place ends. + +This is enforced by a CI lint (`scripts/lint-wit-immutability.sh`) in the +`unicity-astrid/wit` repository: any PR that touches a file matching +`*@X.Y.Z.wit` where that version is referenced in a kernel release manifest +fails the build. New shapes = new file, with no exceptions. + +### Scope boundary: capsule-to-kernel vs capsule-to-capsule + +This evolution discipline governs the **kernel ↔ capsule** boundary — the WIT +contract enforced by the wasmtime linker. It does **not** govern +**capsule ↔ capsule** communication, which travels over the IPC bus as +typed events on string topics (e.g. `tool.v1.execute.*`). The bus is not WIT- +typed; capsule-to-capsule shape changes manifest at runtime as deserialization +errors or unmatched subscriptions, not at load time as linker errors. + +Bus-event evolution is governed separately by the topic-versioning convention +(`*.v1.*` → `*.v2.*`, with producers keeping the prior topic alive until +consumers migrate). Third-party capsule authors are free to add new event +handlers without affecting other capsules; only renames or non-additive +payload changes break consumers, and only at runtime. # Drawbacks [drawbacks]: #drawbacks - **Large surface area.** 51 host functions is a lot to maintain stable. Each - one is a compatibility commitment. -- **JSON wire format overhead.** Serializing/deserializing JSON for every - syscall is measurably slower than Component Model typed parameters. Acceptable - pre-1.0; the migration path (WS-8) is planned. -- **No versioning on individual functions.** The package version (`0.1.0`) - covers the entire ABI. Adding a function is a minor bump; changing a - function signature is a major bump. + one is a compatibility commitment, and per-domain splitting multiplies the + number of independently-versioned packages the kernel must track. +- **Host-side shim code grows with versions.** Every evolution of a shared + type requires a translation shim in the kernel for each older version still + in the support window. The maintenance load scales with how many versions + the kernel pledges to support concurrently — bounded by the support window + policy, but non-zero. +- **Codegen volume scales with the support matrix.** One binding module per + `(package, version)` pair, registered explicitly into the linker, means + build time and binary size grow with the number of supported versions + rather than being constant. Mitigated by the support-window policy, but it + is a real cost. +- **Per-domain ownership of cross-cutting types is awkward.** `principal` and + `caller-context` either live in a shared `astrid:core` package (and ripple + on every cross-cutting evolution) or are duplicated per interface (more + surface to keep in sync). Neither is free; the RFC picks the smaller-`core` + trade and admits the cost. # Rationale and alternatives [rationale-and-alternatives]: #rationale-and-alternatives ## Why WIT as the spec format? -WIT is the WASM ecosystem's canonical interface definition language. When -Astrid migrates to the Component Model, the WIT spec becomes the actual -import/export declarations — no rewrite needed. +WIT is the WASM ecosystem's canonical interface definition language. The +Component Model uses WIT directly as the import/export declarations — the +spec and the implementation are the same artefact, by construction. ## Why not fewer, coarser interfaces? @@ -197,14 +351,20 @@ The original design had 7 functions in a single `host` interface. This was split into 13 domain interfaces because: - Capability gating maps to interfaces (grant `fs` without granting `net`) - Documentation is clearer per domain -- Future SDK can expose per-domain modules +- SDK exposes per-domain modules +- Per-domain ABI versioning isolates evolution blast radius (see "ABI + evolution discipline" above) -## Why JSON and not protobuf or msgpack? +## Why per-domain packages instead of a single versioned `astrid:capsule`? -JSON is human-readable (debugging), universally supported (every SDK language), -and matches the IPC bus payload format. The performance cost is acceptable for -the current scale. The Component Model migration eliminates serialization -entirely. +A single package (`astrid:capsule@X.Y.Z`) means every interface inside it +shares one version. Any change to any interface — even one a given capsule +does not import — bumps the package version, and the linker rejects the +capsule because the imported package version no longer matches. Per-domain +packages decouple this: bumping `astrid:net` has no effect on a capsule that +only imports `astrid:kv`. The cost is more package versions to track; the +benefit is that the kernel can evolve one domain at a time without recompiling +the ecosystem. ## Why are KV and sys functions ungated? @@ -224,9 +384,11 @@ Gating these would add friction without security benefit. extends beyond WASI with IPC, approval gates, identity, and capsule-specific operations. -- **Extism**: Plugin framework providing the current host function transport. - Astrid builds on Extism's plugin model but defines its own semantic layer - (capabilities, audit, principal scoping) on top. +- **wasmtime Component Model**: The actual host runtime. wasmtime's CM linker + enforces structural typing per `(package, version)` pair, which is the + mechanism this RFC's evolution discipline plays against. Astrid's per-domain + package split is a direct response to CM's strict typing — coarser packages + amplify the blast radius of any shape change, finer packages contain it. - **Envoy WASM ABI**: Host functions for proxy filters (get/set headers, send HTTP, log). Similar pattern: domain-specific host APIs for sandboxed @@ -235,23 +397,49 @@ Gating these would add friction without security benefit. # Unresolved questions [unresolved-questions]: #unresolved-questions -- **ABI versioning independence.** Should the ABI version be semver-independent - from the kernel version? Currently the WIT package version (`0.1.0`) and the - kernel version (`0.5.0`) are decoupled but informally linked. A stable ABI - version would let capsule authors target "ABI 1.0" regardless of which kernel - version implements it. The counter-argument: two version numbers is confusing - when there's only one implementation. +- **Minimal `astrid:core` vs zero-shared types.** The RFC currently picks a + minimal `astrid:core@1.0.0` carrying `principal`, `caller-context`, and + common error shapes. The strictly purer alternative is zero shared types: + every interface defines its own `principal`, accept the duplication, accept + the loss of cross-domain ergonomics, gain full per-domain evolutionary + independence. The trade-off pivots on how often cross-cutting types actually + evolve in practice — if they prove churny, zero-shared wins; if they prove + stable, the small-`core` design pays for itself in author ergonomics. + +- **Push `principal` out of payload onto a resource handle.** A resource-typed + caller-context (host-owned, opaque handle, accessor methods) would let the + host evolve principal representation without changing any WIT payload at + all. If this works, `astrid:core` shrinks further or vanishes entirely. + Worth a design pass before 1.0; not blocking, because the record-based ABI + works as long as the per-domain split + frozen-file rules hold. + +- **World naming convention.** Per-domain packages can expose `astrid:ipc/host` + vs `astrid:ipc/guest` worlds for the two directions, or a single + `astrid:ipc` world that imports/exports as needed. The first matches + wasmtime CM idiom and makes the split between host-provided imports and + guest-provided exports explicit; the second is terser. Worth checking + against current wasmtime examples before committing. + +- **Pre-1.0 cleanup approach for `astrid:capsule@0.1.0`.** Hard cut (delete + the monolithic package, force every first-party capsule to migrate before + 1.0 ships) versus deprecated alias (keep it loadable for a release or two, + log deprecation warnings). The RFC leans hard-cut: pre-1.0 we have license + to break, post-1.0 we do not, and an alias means the new model has to + coexist with the dead one in codegen forever. Decision deferred to the + implementation PR. + +- **Support-window length.** How many versions of each package does the + kernel pledge to load simultaneously? Two? Three? Indefinite? Each extra + version adds shim maintenance. A "N+2 minor, N-1 major" convention is one + option; "current and previous major" is another. Decision deferred until + the first post-1.0 minor bump, by which point we'll have real maintenance + data. - **Capability introspection depth.** `check-capsule-capability` exists but is limited. Should capsules be able to query the full capability set of OTHER capsules? This enables a system capsule to display "what can each capsule do" but leaks capability information across the sandbox boundary. -- **Host function deprecation path.** When a host function needs to change - signature (e.g., adding a parameter), how is backward compatibility handled? - Options: versioned function names (`fs-read-v2`), optional parameters via - JSON, or a clean break with the Component Model migration. - - **Audit chain integration.** Which host functions should produce audit entries? Currently logging and approval are audited. Should every `fs_write` be audited? Every `ipc_publish`? The audit chain grows linearly with calls — @@ -264,9 +452,11 @@ Gating these would add friction without security benefit. # Future possibilities [future-possibilities]: #future-possibilities -- **Component Model migration.** Replace Extism transport with native WASM - Component Model imports/exports. WIT spec becomes the actual ABI, not just - documentation. +- **Resource-typed caller-context.** Replace the payload-carried `principal` + field with a host-owned resource handle exposed via accessor methods. This + removes a cross-cutting record type from `astrid:core` entirely, letting + the host evolve principal representation without any WIT payload change. + See the corresponding open question. - **`astrid_system_stats` host function.** Runtime observability for the system capsule — per-capsule WASM heap, invocation counts, event bus metrics. - **Capability delegation.** A capsule grants a subset of its capabilities to From f463c2fff56fc35b4c055060e063163dba644796 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 17:20:28 +0400 Subject: [PATCH 05/16] =?UTF-8?q?rfc(host-abi):=20align=20with=20wit-repo?= =?UTF-8?q?=20split=20=E2=80=94=20host/=20path,=20drop=20astrid:core,=20ad?= =?UTF-8?q?d=20astrid:guest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the per-domain split in unicity-astrid/wit#feat/per-domain-split: - Frozen-file path is host/@.wit, not interfaces/@.wit. The interfaces/ directory in the wit repo is already capsule-to-capsule IPC contract territory; the host ABI lives at host/. - astrid:core is dropped. Auditing the actual types in the monolithic astrid-capsule.wit showed nothing genuinely cross-cutting: caller-context is sys-only, principal is option (a primitive), error shapes are result. Zero-shared is purer and cheaper. Open question and drawback updated to reflect the decision. - astrid:guest@1.0.0 added for the lifecycle export contract (astrid-hook-trigger, run, astrid-install, astrid-upgrade). Named 'guest' rather than 'hook' to avoid collision with the unrelated capsule-to-capsule astrid:hook@1.0.0 in interfaces/hook.wit. Symmetric with the 'host' interfaces — host is kernel-side, guest is capsule-side. - Rule 1 package list reordered to match the actual file order in the wit repo for grep-friendliness; cron removed since it does not exist in the WIT (it was an RFC table artefact, not a real interface). - Rule 3 lint description tightened: 'modifies, deletes, or renames a file matching *@X.Y.Z.wit that exists on the base ref' — new files matching the pattern are the legitimate evolution path. --- text/0000-host-abi.md | 100 +++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 1069d62..ac66c1b 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -14,10 +14,10 @@ scoped. Pre-1.0, the ABI also adopts an evolution discipline to make post-1.0 changes non-fatal for third-party capsules: the monolithic `astrid:capsule@0.1.0` package splits into per-domain packages (`astrid:ipc@1.0.0`, `astrid:net@1.0.0`, -…) plus a minimal `astrid:core@1.0.0`; the kernel registers every supported -`(package, version)` pair into the wasmtime Component Model linker explicitly; -and once a WIT file is published it is immutable forever — shape changes ship -as new files at new versions. +…) plus `astrid:guest@1.0.0` for the lifecycle export contract; the kernel +registers every supported `(package, version)` pair into the wasmtime +Component Model linker explicitly; and once a WIT file is published it is +immutable forever — shape changes ship as new files at new versions. # Motivation [motivation]: #motivation @@ -98,9 +98,12 @@ directory, transparently. The authoritative function-level specification lives in the [`unicity-astrid/wit`](https://github.com/unicity-astrid/wit) repository as a -set of per-domain WIT files (`interfaces/@.wit`). The kernel +set of per-domain WIT files (`host/@.wit`). The kernel vendors a snapshot at `core/wit/` for its build, but the canonical source is -the WIT repo. +the WIT repo. (Note: the `host/` directory holds the kernel-to-capsule ABI +covered by this RFC; the unrelated `interfaces/` directory holds +capsule-to-capsule IPC event contracts, which are governed by topic +versioning rather than the WIT discipline below.) This RFC documents the design rationale, capability model, scoping semantics, and evolution discipline. The WIT files are the reference for function @@ -209,38 +212,50 @@ one without the others is wasted work. ### Rule 1: per-domain packages -The pre-1.0 monolithic `astrid:capsule@0.1.0` package containing 12 interfaces -is replaced by one package per domain: +The pre-1.0 monolithic `astrid:capsule@0.1.0` package containing 11 functional +interfaces plus a shared `types` interface is replaced by one package per +domain: ``` +astrid:fs@1.0.0 astrid:ipc@1.0.0 -astrid:net@1.0.0 +astrid:uplink@1.0.0 astrid:kv@1.0.0 +astrid:net@1.0.0 astrid:http@1.0.0 -astrid:fs@1.0.0 -astrid:process@1.0.0 astrid:sys@1.0.0 -astrid:cron@1.0.0 +astrid:process@1.0.0 astrid:elicit@1.0.0 astrid:approval@1.0.0 -astrid:uplink@1.0.0 astrid:identity@1.0.0 ``` -Plus a minimal **`astrid:core@1.0.0`** for truly cross-cutting types only -(`principal`, `caller-context`, common error shapes). This package is kept as -small as humanly possible; every type that lives in it ripples across every -interface that imports it, so each entry is justified individually. - -Each domain package owns its own types. `astrid:ipc/types` (`ipc-envelope`, -`ipc-message`) lives in `astrid:ipc`, not in a shared types module. Per-domain -type ownership isolates per-domain evolution: bumping `astrid:net` does not -recompile anything that only imports `astrid:kv`. +Plus **`astrid:guest@1.0.0`** for the guest export contract +(`astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`) and the +`capsule-result` type those exports use. The naming is symmetric with the +per-domain `host` interfaces: `host` is kernel-side (imported by capsules), +`guest` is capsule-side (called by the kernel). + +An earlier draft of this RFC proposed a minimal `astrid:core` package for +cross-cutting types (`principal`, `caller-context`, common error shapes). +Auditing the actual types in the monolithic file showed that nothing is +genuinely cross-cutting: `caller-context` is used only by `sys.get-caller`, +`principal` is `option` everywhere (a primitive, no shared type +needed), and error shapes are `result` (also primitive). The +zero-shared alternative is purer and cheaper, so `astrid:core` is **not** +introduced. + +Each domain package owns its own types in a single `host` interface alongside +its functions. `ipc-envelope`/`ipc-message` live in `astrid:ipc/host`, +`file-stat` lives in `astrid:fs/host`, and so on. Per-domain type ownership +isolates per-domain evolution: bumping `astrid:net` does not recompile +anything that only imports `astrid:kv`. Capsules opt into exactly the subset they need: ```wit world my-capsule { + include astrid:guest/exports@1.0.0; import astrid:ipc/host@1.0.0; import astrid:kv/host@1.0.0; // not net, not http — capsule does not use them @@ -280,11 +295,11 @@ This requires: ### Rule 3: frozen WIT files per version -Once `astrid:ipc@1.0.0` ships, the file at `interfaces/ipc@1.0.0.wit` is +Once `astrid:ipc@1.0.0` ships, the file at `host/ipc@1.0.0.wit` is **immutable forever**. New shapes get a new file: ``` -interfaces/ +host/ ipc@1.0.0.wit # frozen ipc@1.1.0.wit # frozen ipc@2.0.0.wit # current @@ -295,9 +310,10 @@ path, modify, register the new version in the kernel, leave the old files alone. The current pattern of editing a WIT file in place ends. This is enforced by a CI lint (`scripts/lint-wit-immutability.sh`) in the -`unicity-astrid/wit` repository: any PR that touches a file matching -`*@X.Y.Z.wit` where that version is referenced in a kernel release manifest -fails the build. New shapes = new file, with no exceptions. +`unicity-astrid/wit` repository: any PR that modifies, deletes, or renames a +file matching `*@X.Y.Z.wit` that exists on the base ref fails the build. New +files matching the pattern are allowed — that is the legitimate evolution +path. ### Scope boundary: capsule-to-kernel vs capsule-to-capsule @@ -330,11 +346,14 @@ payload changes break consumers, and only at runtime. build time and binary size grow with the number of supported versions rather than being constant. Mitigated by the support-window policy, but it is a real cost. -- **Per-domain ownership of cross-cutting types is awkward.** `principal` and - `caller-context` either live in a shared `astrid:core` package (and ripple - on every cross-cutting evolution) or are duplicated per interface (more - surface to keep in sync). Neither is free; the RFC picks the smaller-`core` - trade and admits the cost. +- **Zero-shared means no central place for genuinely cross-cutting types if + one emerges later.** The RFC settled on zero shared types after the audit + showed nothing in the current ABI is genuinely cross-cutting. If a future + evolution introduces a type that genuinely belongs in multiple domain + packages, the choice is duplicate-and-keep-in-sync (more surface) or + introduce `astrid:core` at that point (delayed but unavoidable cost). The + current bet is that genuinely cross-cutting types are rare; if that bet is + wrong, `astrid:core` ships as a follow-up. # Rationale and alternatives [rationale-and-alternatives]: #rationale-and-alternatives @@ -397,21 +416,14 @@ Gating these would add friction without security benefit. # Unresolved questions [unresolved-questions]: #unresolved-questions -- **Minimal `astrid:core` vs zero-shared types.** The RFC currently picks a - minimal `astrid:core@1.0.0` carrying `principal`, `caller-context`, and - common error shapes. The strictly purer alternative is zero shared types: - every interface defines its own `principal`, accept the duplication, accept - the loss of cross-domain ergonomics, gain full per-domain evolutionary - independence. The trade-off pivots on how often cross-cutting types actually - evolve in practice — if they prove churny, zero-shared wins; if they prove - stable, the small-`core` design pays for itself in author ergonomics. - - **Push `principal` out of payload onto a resource handle.** A resource-typed caller-context (host-owned, opaque handle, accessor methods) would let the host evolve principal representation without changing any WIT payload at - all. If this works, `astrid:core` shrinks further or vanishes entirely. - Worth a design pass before 1.0; not blocking, because the record-based ABI - works as long as the per-domain split + frozen-file rules hold. + all. Worth a design pass before 1.0; not blocking, because the record-based + ABI works as long as the per-domain split + frozen-file rules hold. Today + `caller-context` lives in `astrid:sys/host` and is returned by `get-caller`; + if this lands, the record gets a resource counterpart and downstream + capsules migrate to the handle-based accessor over a release. - **World naming convention.** Per-domain packages can expose `astrid:ipc/host` vs `astrid:ipc/guest` worlds for the two directions, or a single From 472b470617bd08ea3a2508646da3e4a1c0d8d815 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 18:41:49 +0400 Subject: [PATCH 06/16] rfc(host-abi): record per-export world split for astrid:guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the resolution adopted in unicity-astrid/wit@feat/per-domain-split: `astrid:guest@1.0.0` exposes four per-export worlds (`interceptor`, `background`, `installable`, `upgradable`) rather than a single `exports` world bundling all four entry points. The rationale comes from a concrete kernel cost: the wasm32-wasip2 toolchain auto-stubs mandatory exports the source crate does not implement, which forced the kernel into binary-parsing stub detection to avoid treating every interceptor-only capsule as a run-loop daemon (astrid-capsule's STUB_PRONE_EXPORTS hack). Per-export worlds eliminate the cause — exports appear in the binary only when implemented. Updates the rule-1 narrative, the worked example, and folds the now- resolved open questions on world naming and `astrid:capsule@0.1.0` cleanup into a single retrospective note. --- text/0000-host-abi.md | 66 +++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index ac66c1b..acef54c 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -230,11 +230,24 @@ astrid:approval@1.0.0 astrid:identity@1.0.0 ``` -Plus **`astrid:guest@1.0.0`** for the guest export contract -(`astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`) and the -`capsule-result` type those exports use. The naming is symmetric with the -per-domain `host` interfaces: `host` is kernel-side (imported by capsules), -`guest` is capsule-side (called by the kernel). +Plus **`astrid:guest@1.0.0`** for the guest export contract — split into +four per-export worlds (`interceptor`, `background`, `installable`, +`upgradable`) so capsules `include` only the entry points they implement. +The naming is symmetric with the per-domain `host` interfaces: `host` is +kernel-side (imported by capsules), `guest` is capsule-side (called by the +kernel). + +Per-export worlds matter for a concrete reason. The wasm32-wasip2 toolchain +auto-stubs every export declared in the world a component targets. A single +world covering all four entry points (`astrid-hook-trigger`, `run`, +`astrid-install`, `astrid-upgrade`) forces stubs for the ones a given +capsule does not implement, which then pushes the kernel into parsing the +wasm binary to distinguish real implementations from toolchain stubs +(`core/crates/astrid-capsule/src/engine/wasm/mod.rs` `STUB_PRONE_EXPORTS` +carries that hack today). Per-export worlds eliminate the cause: an export +only appears in the wasm binary when the capsule actually implements it, +so the kernel can drop the parsing hack and use plain export-presence +checks. An earlier draft of this RFC proposed a minimal `astrid:core` package for cross-cutting types (`principal`, `caller-context`, common error shapes). @@ -251,18 +264,31 @@ its functions. `ipc-envelope`/`ipc-message` live in `astrid:ipc/host`, isolates per-domain evolution: bumping `astrid:net` does not recompile anything that only imports `astrid:kv`. -Capsules opt into exactly the subset they need: +Capsules opt into exactly the subset they need on both axes — which guest +exports they implement and which host imports they use: ```wit -world my-capsule { - include astrid:guest/exports@1.0.0; +// Interceptor-only capsule: +world router { + include astrid:guest/interceptor@1.0.0; import astrid:ipc/host@1.0.0; - import astrid:kv/host@1.0.0; // not net, not http — capsule does not use them } + +// Run-loop capsule with an install hook: +world cli { + include astrid:guest/interceptor@1.0.0; + include astrid:guest/background@1.0.0; + include astrid:guest/installable@1.0.0; + import astrid:ipc/host@1.0.0; + import astrid:uplink/host@1.0.0; + import astrid:net/host@1.0.0; +} ``` -A kv-only capsule is unaffected when `astrid:net` adds a function. +A kv-only capsule is unaffected when `astrid:net` adds a function. An +interceptor-only capsule produces no `run` / install / upgrade exports in +its wasm binary, so the kernel sees only what is real. ### Rule 2: multi-version kernel registration @@ -425,20 +451,12 @@ Gating these would add friction without security benefit. if this lands, the record gets a resource counterpart and downstream capsules migrate to the handle-based accessor over a release. -- **World naming convention.** Per-domain packages can expose `astrid:ipc/host` - vs `astrid:ipc/guest` worlds for the two directions, or a single - `astrid:ipc` world that imports/exports as needed. The first matches - wasmtime CM idiom and makes the split between host-provided imports and - guest-provided exports explicit; the second is terser. Worth checking - against current wasmtime examples before committing. - -- **Pre-1.0 cleanup approach for `astrid:capsule@0.1.0`.** Hard cut (delete - the monolithic package, force every first-party capsule to migrate before - 1.0 ships) versus deprecated alias (keep it loadable for a release or two, - log deprecation warnings). The RFC leans hard-cut: pre-1.0 we have license - to break, post-1.0 we do not, and an alias means the new model has to - coexist with the dead one in codegen forever. Decision deferred to the - implementation PR. +_(Previous open questions on world naming convention and the +`astrid:capsule@0.1.0` cleanup approach were resolved during the split +implementation. The per-domain packages expose a single `host` interface +each; `astrid:guest` splits its four entry points across per-export worlds +(`interceptor`, `background`, `installable`, `upgradable`); and the +monolithic `astrid:capsule@0.1.0` is hard-cut with no deprecated alias.)_ - **Support-window length.** How many versions of each package does the kernel pledge to load simultaneously? Two? Three? Indefinite? Each extra From 099ef307e03fe1f64a17be3e81d7ee59deae0ac5 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 21 May 2026 21:40:23 +0400 Subject: [PATCH 07/16] rfc(host-abi): semver rules + WASI coverage + drop retrospective scaffolding Three changes, single commit: - Add a 'Versioning rules' subsection under the evolution discipline. Minor for purely orthogonal additions (new top-level functions on existing interfaces only). Major for any record/enum shape touch, signature change, or removal. Worked table of changes and rationale. Notes Capsule.toml's Cargo-style ^constraints and how the kernel's multi-version registration reconciles them with the linker's exact- match semantics. - Add a 'WASI primitives also available' subsection. The kernel adds wasmtime_wasi::p2 to the linker, so capsule authors should reach for wasi:random, wasi:clocks/monotonic-clock, wasi:clocks/wall-clock, and wasi:io/streams directly. astrid:* exists where Astrid layers capability gating, principal scoping, or audit on top of a syscall; where no such layering is needed, WASI is the right surface. - Correct the host-interfaces table: 65 functions across 11 packages plus astrid:guest (was 51 functions across 12 with a non-existent 'cron' row). Update capability list to drop 'cron'. - Strip retrospective scaffolding ('earlier draft proposed', 'previous open questions resolved', 'STUB_PRONE_EXPORTS carries that hack today'). Leaves the doc reading as a specification rather than a changelog. --- text/0000-host-abi.md | 139 ++++++++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 54 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index acef54c..2867bed 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -112,33 +112,51 @@ correct. ## Host interfaces -51 host functions organized into 12 domain interfaces: +65 host functions organized into 11 domain packages, plus the `astrid:guest` +export contract: -| Interface | Functions | Capability | Purpose | +| Package | Functions | Capability | Purpose | |---|---|---|---| -| `fs` | 7 | `fs_read`, `fs_write` | Virtual filesystem (workspace://, home://, tmp://) | -| `ipc` | 6 | `ipc_publish`, `ipc_subscribe` | IPC event bus: publish, subscribe, receive | -| `uplink` | 2 | `uplink` | Frontend connection registration and response sending | -| `kv` | 5 | (ungated) | Per-capsule key-value store, auto-scoped per principal | -| `net` | 6 | `net` | Unix socket I/O for capsule-to-daemon communication | -| `http` | 4 | `net` | HTTP client with streaming (SSE) support | -| `sys` | 7 | (ungated) | Logging, config, time, hooks, capability introspection | -| `cron` | 2 | `cron` | Scheduled recurring tasks | -| `process` | 4 | `host_process` | Sandboxed host process spawning (Seatbelt/bwrap) | -| `elicit` | 2 | `uplink` | Interactive user input (prompts, selections) | -| `approval` | 1 | (ungated) | Human-in-the-loop approval gates | -| `identity` | 5 | `identity` | User identity CRUD and platform linking | - -A 13th block, `types`, defines shared types (`log-level`, `key-value-pair`, -`capsule-context`, `capsule-result`) used across interfaces. It has no functions. +| `astrid:fs` | 7 | `fs_read`, `fs_write` | Virtual filesystem (workspace://, home://, tmp://) | +| `astrid:ipc` | 7 | `ipc_publish`, `ipc_subscribe` | IPC event bus: publish, subscribe, receive | +| `astrid:uplink` | 2 | `uplink` | Frontend connection registration and response sending | +| `astrid:kv` | 5 | (ungated) | Per-capsule key-value store, auto-scoped per principal | +| `astrid:net` | 21 | `net`, `net_connect` | Unix-domain sockets and gated outbound TCP | +| `astrid:http` | 4 | `net` | HTTP client with streaming (SSE) support | +| `astrid:sys` | 7 | (ungated) | Logging, config, time, hooks, capability introspection | +| `astrid:process` | 4 | `host_process` | Sandboxed host process spawning (Seatbelt/bwrap) | +| `astrid:elicit` | 2 | `uplink` | Interactive user input during install/upgrade | +| `astrid:approval` | 1 | (ungated) | Human-in-the-loop approval gates | +| `astrid:identity` | 5 | `identity` | User identity CRUD and platform linking | +| `astrid:guest` | 4 exports | — | Lifecycle/interceptor entry points (capsule → kernel) | + +### WASI primitives also available + +Capsules see WASI alongside the `astrid:*` packages. The kernel adds +`wasmtime_wasi::p2` to the linker, so capsule authors should reach for the +WASI imports for general-purpose primitives rather than expecting Astrid to +re-expose them: + +| Need | WASI package | Why not in `astrid:*` | +|---|---|---| +| Cryptographically secure random bytes | `wasi:random/random` | Standard primitive; no Astrid-specific capability gating needed. | +| Monotonic clock (timeouts, latency, scheduling) | `wasi:clocks/monotonic-clock` | Standard primitive; `astrid:sys.clock-ms` exists for wall-clock only. | +| Wall clock with structured datetime | `wasi:clocks/wall-clock` | Standard primitive. | +| Stream I/O abstractions | `wasi:io/streams` | Foundation for any future Astrid streaming additions. | +| Sleep / await duration | `wasi:clocks/monotonic-clock.subscribe-duration` | Standard primitive. | + +`astrid:*` exists where Astrid layers capability gating, principal scoping, +or audit on top of what would otherwise be a syscall (`astrid:fs` for VFS- +scheme resolution, `astrid:net` for `net_connect` allowlists, etc.). Where +no such layering is needed, capsules use WASI directly. ### Capability model Each host function checks the calling capsule's declared capabilities: - **Gated:** `fs_read`, `fs_write`, `ipc_publish`, `ipc_subscribe`, `uplink`, - `net`, `cron`, `host_process`, `identity`. Capsules must declare these in - `[capabilities]` in Capsule.toml. + `net`, `net_connect`, `host_process`, `identity`. Capsules must declare + these in `[capabilities]` in Capsule.toml. - **Ungated:** `kv`, `sys`, `approval`. Always available. KV is safe because it's namespace-scoped per capsule and principal. Sys functions are read-only or side-effect-free. Approval is a request, not an action. @@ -237,32 +255,22 @@ The naming is symmetric with the per-domain `host` interfaces: `host` is kernel-side (imported by capsules), `guest` is capsule-side (called by the kernel). -Per-export worlds matter for a concrete reason. The wasm32-wasip2 toolchain -auto-stubs every export declared in the world a component targets. A single -world covering all four entry points (`astrid-hook-trigger`, `run`, -`astrid-install`, `astrid-upgrade`) forces stubs for the ones a given -capsule does not implement, which then pushes the kernel into parsing the -wasm binary to distinguish real implementations from toolchain stubs -(`core/crates/astrid-capsule/src/engine/wasm/mod.rs` `STUB_PRONE_EXPORTS` -carries that hack today). Per-export worlds eliminate the cause: an export -only appears in the wasm binary when the capsule actually implements it, -so the kernel can drop the parsing hack and use plain export-presence +Per-export worlds matter because the wasm32-wasip2 toolchain auto-stubs +every export declared in the world a component targets. A single world +covering all four entry points would force stubs for the ones a given +capsule does not implement, and the kernel would have to parse the wasm +binary to distinguish real implementations from toolchain stubs. Per-export +worlds eliminate the cause: an export appears in the binary only when the +capsule actually implements it, and the kernel uses plain export-presence checks. -An earlier draft of this RFC proposed a minimal `astrid:core` package for -cross-cutting types (`principal`, `caller-context`, common error shapes). -Auditing the actual types in the monolithic file showed that nothing is -genuinely cross-cutting: `caller-context` is used only by `sys.get-caller`, -`principal` is `option` everywhere (a primitive, no shared type -needed), and error shapes are `result` (also primitive). The -zero-shared alternative is purer and cheaper, so `astrid:core` is **not** -introduced. - Each domain package owns its own types in a single `host` interface alongside its functions. `ipc-envelope`/`ipc-message` live in `astrid:ipc/host`, `file-stat` lives in `astrid:fs/host`, and so on. Per-domain type ownership isolates per-domain evolution: bumping `astrid:net` does not recompile -anything that only imports `astrid:kv`. +anything that only imports `astrid:kv`. There is no shared cross-cutting +package — primitives (`option` for principals, `result` +for errors) cover the only types that would otherwise be candidates. Capsules opt into exactly the subset they need on both axes — which guest exports they implement and which host imports they use: @@ -341,6 +349,39 @@ file matching `*@X.Y.Z.wit` that exists on the base ref fails the build. New files matching the pattern are allowed — that is the legitimate evolution path. +### Versioning rules + +A WIT package version follows semver with one constraint imposed by the +Component Model linker: the linker is structurally typed per `(package, +version)` pair, so any change that produces a different shape is a contract +break from the linker's perspective regardless of intent. The version number +is therefore a signal to capsule authors about what they will have to do +when they bump. + +| Change | Bump | Rationale | +|---|---|---| +| Add a new top-level function to an interface | **MINOR** | Existing function and type shapes are exactly preserved; old capsules' generated bindings are unaffected. The kernel registers the new minor as a fresh `(package, version)` pair and the old minor's implementation falls out as a subset. | +| Add a field to a record | **MAJOR** | Even `option<>` fields produce a different record layout in `wit-bindgen` output. Old capsules built against the prior shape fail to instantiate. | +| Add a variant to an enum | **MAJOR** | Different shape; exhaustive matches on return-position enums in old capsules break on regenerate. | +| Change a function signature | **MAJOR** | Obvious. | +| Remove a function, field, or variant | **MAJOR** | Obvious. | + +Only purely orthogonal additions — new top-level functions on existing +interfaces — qualify as minor bumps, because they are the only change that +leaves the existing shape exactly preserved. Any touch of an existing +record, enum, or function signature is major. This is conservative relative +to the cultural "additive = minor" convention in other ecosystems and +matches the Component Model linker's actual semantics. + +`Capsule.toml` accepts Cargo-style version constraints +(`ipc = "^1.0"`) in `[imports.astrid]`. The constraint expresses the +capsule author's intent — "I tolerate any 1.x" — while the wasm binary +still pins the exact version it was built against. The kernel's +multi-version registration policy (Rule 2) is what reconciles the two: +the kernel registers every minor in the supported window, so any 1.x +capsule loads against any 1.y kernel where y ≥ x and both are inside the +window. + ### Scope boundary: capsule-to-kernel vs capsule-to-capsule This evolution discipline governs the **kernel ↔ capsule** boundary — the WIT @@ -372,14 +413,11 @@ payload changes break consumers, and only at runtime. build time and binary size grow with the number of supported versions rather than being constant. Mitigated by the support-window policy, but it is a real cost. -- **Zero-shared means no central place for genuinely cross-cutting types if - one emerges later.** The RFC settled on zero shared types after the audit - showed nothing in the current ABI is genuinely cross-cutting. If a future - evolution introduces a type that genuinely belongs in multiple domain - packages, the choice is duplicate-and-keep-in-sync (more surface) or - introduce `astrid:core` at that point (delayed but unavoidable cost). The - current bet is that genuinely cross-cutting types are rare; if that bet is - wrong, `astrid:core` ships as a follow-up. +- **No central package for genuinely cross-cutting types.** Each domain owns + its own types. If a future evolution introduces a type that genuinely + belongs in multiple domain packages, the choice is duplicate-and-keep-in- + sync (more surface) or introduce a shared package at that point. The bet + is that genuinely cross-cutting types are rare. # Rationale and alternatives [rationale-and-alternatives]: #rationale-and-alternatives @@ -451,13 +489,6 @@ Gating these would add friction without security benefit. if this lands, the record gets a resource counterpart and downstream capsules migrate to the handle-based accessor over a release. -_(Previous open questions on world naming convention and the -`astrid:capsule@0.1.0` cleanup approach were resolved during the split -implementation. The per-domain packages expose a single `host` interface -each; `astrid:guest` splits its four entry points across per-export worlds -(`interceptor`, `background`, `installable`, `upgradable`); and the -monolithic `astrid:capsule@0.1.0` is hard-cut with no deprecated alias.)_ - - **Support-window length.** How many versions of each package does the kernel pledge to load simultaneously? Two? Three? Indefinite? Each extra version adds shim maintenance. A "N+2 minor, N-1 major" convention is one From 2f3a35d751175e2178d6d11ec3c9b56449dc02da Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 00:38:03 +0400 Subject: [PATCH 08/16] rfc(host-abi): reflect final 1.0 contract shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthesizes the entire freeze-prep refactor that landed on unicity-astrid/wit#feat/per-domain-split into the spec. Summary: rewritten to introduce typed resources, per-package error variants, and the astrid-bus:* namespace split alongside the existing per-domain / multi-version / frozen-file rules. Function counts dropped in favour of describing the surface shape (resources + factories + typed errors), since the count is now meaningfully different for each package (resource methods vs flat functions). Host interfaces table rewritten: lists each package's surface character (file-handle resource + path ops, tcp-listener / tcp-stream / udp-socket resources, etc.), the per-package error story, and the capability sets including the new net_udp and net_tcp_bind allowlists. WASI subsection replaced. The 'WASI primitives also available' table that promoted wasi:random / wasi:clocks / wasi:io to capsule authors turned out to be wrong policy — those primitives bypassed Astrid's capability gates, principal scoping, and audit chain. New 'Foundation primitives — minimal WASI retention' subsection explains the narrow retention (wasi:io/poll + wasi:io/streams only) for the pollable composition story, and that random/clocks/sleep/wall-clock live under astrid:sys instead. New 'Capsule-to-capsule contracts live in a distinct astrid-bus:* namespace' subsection documents the namespace split between kernel ABI (astrid:*) and IPC bus event schemas (astrid-bus:*). Capsule.toml example covers both prefixes. Guest exports section rewritten around per-export worlds (interceptor / background / installable / upgradable). Capsules include only what they implement; toolchain emits exports only when their world is included; kernel uses plain export-presence checks (no stub detection). Error handling section rewritten around per-package error-code variants with specific arms + unknown(string) catch-all. Documents the constraint that error strings never leak host real-paths / IPs / UUIDs / capability names; the enumerated arms are the contract. Drawbacks updated — surface description matches the new resource-heavy shape; result<_, string> mentions in the rule-1 narrative corrected. Capability model gains net_udp and net_tcp_bind. No structural changes — same RFC sections, just brought current with the implementation. --- text/0000-host-abi.md | 201 +++++++++++++++++++++++++++--------------- 1 file changed, 128 insertions(+), 73 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 2867bed..ee9981d 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -6,18 +6,22 @@ # Summary [summary]: #summary -Define the host ABI — the syscall-like interface between the Astrid kernel and -WASM capsule guests. 51 host functions across 12 domain interfaces, plus 4 -guest exports. All operations are capability-gated, audited, and per-principal -scoped. - -Pre-1.0, the ABI also adopts an evolution discipline to make post-1.0 changes +Define the host ABI — the syscall-like interface between the Astrid kernel +and WASM capsule guests. 12 per-domain packages under `astrid:*`, a parallel +`astrid-bus:*` namespace for capsule-to-capsule IPC schemas. Surface uses +typed Component Model resources for long-lived handles (`file-handle`, +`tcp-stream`, `subscription`, `process-handle`, …) and typed per-package +`error-code` variants for failures. All operations are capability-gated, +audited, and per-principal scoped. + +The 1.0 ABI also adopts an evolution discipline to make post-1.0 changes non-fatal for third-party capsules: the monolithic `astrid:capsule@0.1.0` -package splits into per-domain packages (`astrid:ipc@1.0.0`, `astrid:net@1.0.0`, -…) plus `astrid:guest@1.0.0` for the lifecycle export contract; the kernel -registers every supported `(package, version)` pair into the wasmtime -Component Model linker explicitly; and once a WIT file is published it is -immutable forever — shape changes ship as new files at new versions. +package splits into per-domain packages (`astrid:ipc@1.0.0`, +`astrid:net@1.0.0`, …) plus `astrid:guest@1.0.0` for the lifecycle export +contract; the kernel registers every supported `(package, version)` pair +into the wasmtime Component Model linker explicitly; and once a WIT file +is published it is immutable forever — shape changes ship as new files at +new versions. # Motivation [motivation]: #motivation @@ -112,56 +116,78 @@ correct. ## Host interfaces -65 host functions organized into 11 domain packages, plus the `astrid:guest` -export contract: +12 per-domain packages under `astrid:*` (the kernel ABI). Each package's +surface is a mix of methods on typed resources (long-lived handles) and +top-level functions on the `host` interface. -| Package | Functions | Capability | Purpose | +| Package | Surface | Capability | Purpose | |---|---|---|---| -| `astrid:fs` | 7 | `fs_read`, `fs_write` | Virtual filesystem (workspace://, home://, tmp://) | -| `astrid:ipc` | 7 | `ipc_publish`, `ipc_subscribe` | IPC event bus: publish, subscribe, receive | -| `astrid:uplink` | 2 | `uplink` | Frontend connection registration and response sending | -| `astrid:kv` | 5 | (ungated) | Per-capsule key-value store, auto-scoped per principal | -| `astrid:net` | 21 | `net`, `net_connect` | Unix-domain sockets and gated outbound TCP | -| `astrid:http` | 4 | `net` | HTTP client with streaming (SSE) support | -| `astrid:sys` | 7 | (ungated) | Logging, config, time, hooks, capability introspection | -| `astrid:process` | 4 | `host_process` | Sandboxed host process spawning (Seatbelt/bwrap) | -| `astrid:elicit` | 2 | `uplink` | Interactive user input during install/upgrade | -| `astrid:approval` | 1 | (ungated) | Human-in-the-loop approval gates | -| `astrid:identity` | 5 | `identity` | User identity CRUD and platform linking | -| `astrid:guest` | 4 exports | — | Lifecycle/interceptor entry points (capsule → kernel) | - -### WASI primitives also available - -Capsules see WASI alongside the `astrid:*` packages. The kernel adds -`wasmtime_wasi::p2` to the linker, so capsule authors should reach for the -WASI imports for general-purpose primitives rather than expecting Astrid to -re-expose them: - -| Need | WASI package | Why not in `astrid:*` | -|---|---|---| -| Cryptographically secure random bytes | `wasi:random/random` | Standard primitive; no Astrid-specific capability gating needed. | -| Monotonic clock (timeouts, latency, scheduling) | `wasi:clocks/monotonic-clock` | Standard primitive; `astrid:sys.clock-ms` exists for wall-clock only. | -| Wall clock with structured datetime | `wasi:clocks/wall-clock` | Standard primitive. | -| Stream I/O abstractions | `wasi:io/streams` | Foundation for any future Astrid streaming additions. | -| Sleep / await duration | `wasi:clocks/monotonic-clock.subscribe-duration` | Standard primitive. | +| `astrid:fs` | `file-handle` resource + path ops | `fs_read`, `fs_write` | Virtual filesystem (workspace://, home://, tmp://); file IO, metadata, canonicalize, hard-link | +| `astrid:ipc` | `subscription` resource + publish/publish-as | `ipc_publish`, `ipc_subscribe`, `uplink` (publish-as) | IPC event bus with `principal-attribution` verified/claimed distinction | +| `astrid:uplink` | uplink-register/send | `uplink` | Frontend connection registration | +| `astrid:kv` | get/set/delete/cas/list/list-page/clear-prefix | (ungated) | Per-capsule, per-principal key-value with atomic CAS | +| `astrid:net` | `unix-listener` / `tcp-listener` / `tcp-stream` / `udp-socket` resources + factories | `net`, `net_connect`, `net_udp`, `net_tcp_bind` | Unix sockets, outbound TCP, inbound TCP, UDP (unconnected + connected), DNS resolution | +| `astrid:http` | `http-stream` resource + http-request | `net` | HTTP client with SSRF airlock, streaming responses, typed method enum | +| `astrid:sys` | log, config, get-caller, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes, check-capsule-capability | (ungated) | Logging, manifest config, time, entropy, capability introspection | +| `astrid:process` | `process-handle` resource + spawn/spawn-background | `host_process` | OS-sandboxed (Seatbelt/bwrap) spawn with stdin/env/cwd, wait/signal/kill, exit-info | +| `astrid:elicit` | elicit/has-secret | `uplink` | Install/upgrade-time user prompts with typed elicit-type and elicit-response | +| `astrid:approval` | request-approval | (ungated) | Human-in-the-loop gate with typed approval-decision | +| `astrid:identity` | resolve/link/unlink/create-user/list-links | `identity` | Multi-platform identity (per-operation typed responses) | +| `astrid:guest` | Per-export worlds: `interceptor` / `background` / `installable` / `upgradable` | — | Lifecycle entry points (kernel → capsule); capsules `include` only what they implement | + +### Capsule-to-capsule contracts live in a distinct `astrid-bus:*` namespace + +The host ABI above (`astrid:*`) covers direct kernel-mediated calls. A +parallel namespace `astrid-bus:*` lives in `interfaces/*.wit` and carries +the schemas for events flowing between capsules over the IPC bus +(`astrid-bus:llm`, `astrid-bus:session`, `astrid-bus:tool`, etc.). The two +are different layers — host calls go through the wasmtime CM linker; bus +events are typed payloads on `astrid:ipc`. The namespace split makes the +layering visible in capsule worlds and `Capsule.toml`: + +```toml +[imports.astrid] +fs = "1.0.0" +ipc = "1.0.0" + +[imports.astrid-bus] +llm = "^1.0" +session = "^1.0" +``` -`astrid:*` exists where Astrid layers capability gating, principal scoping, -or audit on top of what would otherwise be a syscall (`astrid:fs` for VFS- -scheme resolution, `astrid:net` for `net_connect` allowlists, etc.). Where -no such layering is needed, capsules use WASI directly. +### Foundation primitives — minimal WASI retention + +The kernel exposes `wasi:io/poll@0.2.0` and `wasi:io/streams@0.2.0` to +capsules — these are the Component Model's standard `pollable` / +`input-stream` / `output-stream` resource types, and dropping them would +leave capsules unable to use the CM's async-readiness composition story. +Everything else from WASI (filesystem, sockets, clocks, random, cli) is +**not** in the linker: those operations would bypass Astrid's capability +gates, principal scoping, and audit chain. Random / monotonic clock / +sleep / wall-clock live under `astrid:sys` instead, so every host call +goes through the same audit layer. + +The retention pays off through `subscribe-readiness` / `subscribe-readable` +/ `subscribe-exit` / `subscribe-logs` methods on the Astrid resources — +they return `wasi:io/poll.pollable`, so capsule code can call +`wasi:io/poll.poll([…])` to multiplex IPC + Unix sockets + TCP + UDP + +HTTP-stream chunks + child-process exits without embedding a runtime +purely for that. ### Capability model Each host function checks the calling capsule's declared capabilities: - **Gated:** `fs_read`, `fs_write`, `ipc_publish`, `ipc_subscribe`, `uplink`, - `net`, `net_connect`, `host_process`, `identity`. Capsules must declare - these in `[capabilities]` in Capsule.toml. + `net`, `net_connect`, `net_udp`, `net_tcp_bind`, `host_process`, + `identity`. Capsules must declare these in `[capabilities]` in + `Capsule.toml`. - **Ungated:** `kv`, `sys`, `approval`. Always available. KV is safe because it's namespace-scoped per capsule and principal. Sys functions are read-only or side-effect-free. Approval is a request, not an action. -Violations return an error to the guest and are logged to the audit chain. +Violations return `capability-denied` (a variant arm of the per-package +`error-code`) to the guest and are logged to the audit chain. ### VFS scheme resolution @@ -186,27 +212,52 @@ namespaces depending on who is calling — transparent to the capsule author. ## Guest exports -Capsules export up to 4 entry points: +Guest exports live in `astrid:guest@1.0.0`, split into per-export worlds so +capsules `include` only the entry points they implement. Each world matches +a single export — the toolchain only emits the export when its world is +included, so the kernel sees only real implementations (no stub-detection +parsing needed). -| Export | Description | Required | +| World | Export | Required if included | |---|---|---| -| `astrid-hook-trigger` | Interceptor handler — receives action + payload, returns `InterceptResult` bytes (see [interceptor chain RFC](0000-interceptor-chain.md)) | No | -| `run` | Background task entry point — capsules with run loops (IPC subscribers) | No | -| `astrid-install` | Called once after first installation — setup KV state, validate config | No | -| `astrid-upgrade` | Called after version upgrade — receives previous version for migrations | No | +| `interceptor` | `astrid-hook-trigger(action, payload)` returning `capsule-result` | Most capsules — handles routed events | +| `background` | `run()` | Capsules with a long-lived IPC subscriber loop | +| `installable` | `astrid-install()` | One-time install hook (may use `elicit` for secrets/config) | +| `upgradable` | `astrid-upgrade()` | Version-upgrade hook | -Capsules without `run` are "on-demand" — they only execute when an interceptor -or tool is invoked. Capsules with `run` start a background task that subscribes -to IPC topics and processes events in a loop. +A typical capsule world: + +```wit +world cli { + include astrid:guest/interceptor@1.0.0; + include astrid:guest/background@1.0.0; + include astrid:guest/installable@1.0.0; + import astrid:ipc/host@1.0.0; + import astrid:uplink/host@1.0.0; + import astrid:net/host@1.0.0; +} +``` ## Error handling -Host functions that can fail return `result` natively through the -Component Model. The error string describes the failure; the SDK converts it -to `Result`. Capability violations include the missing capability -name in the error message. Unrecoverable errors (lock poisoning, memory -exhaustion) trap — those represent kernel invariant violations, not -guest-recoverable conditions. +Host functions return `result` where `error-code` is a typed +variant per package — `astrid:fs/host.error-code`, `astrid:net/host.error-code`, +etc. Specific arms (`not-found`, `capability-denied`, `would-block`, +`is-directory`, `boundary-escape`, `airlock-rejected`, etc.) cover the +common cases; a catch-all `unknown(string)` arm carries best-effort host +detail for unforeseen errors without forcing an ABI bump. + +The SDK translates per-package `error-code` to language-idiomatic errors +(`Result` in Rust where `SysError` carries the source variant). +Capsule code pattern-matches on the specific arms. + +Error strings (the `unknown` arm payload) are constrained: no host +real-paths, IP addresses, UUIDs, or capability names leak through. The +specific-arm enumeration is the contract; the string is for human +debugging only. + +Unrecoverable errors (lock poisoning, memory exhaustion) trap — those +represent kernel invariant violations, not guest-recoverable conditions. ## ABI evolution discipline @@ -264,13 +315,15 @@ worlds eliminate the cause: an export appears in the binary only when the capsule actually implements it, and the kernel uses plain export-presence checks. -Each domain package owns its own types in a single `host` interface alongside -its functions. `ipc-envelope`/`ipc-message` live in `astrid:ipc/host`, -`file-stat` lives in `astrid:fs/host`, and so on. Per-domain type ownership -isolates per-domain evolution: bumping `astrid:net` does not recompile -anything that only imports `astrid:kv`. There is no shared cross-cutting -package — primitives (`option` for principals, `result` -for errors) cover the only types that would otherwise be candidates. +Each domain package owns its own types and resources in a single `host` +interface alongside its functions. `ipc-envelope` / `ipc-message` and the +`subscription` resource live in `astrid:ipc/host`; `file-handle` resource +and `file-stat` record live in `astrid:fs/host`; and so on. Per-domain +type ownership isolates per-domain evolution: bumping `astrid:net` does not +recompile anything that only imports `astrid:kv`. There is no shared +cross-cutting package — each package carries its own typed `error-code` +variant, and primitives (`option` for principals, etc.) cover the +remaining shared concepts. Capsules opt into exactly the subset they need on both axes — which guest exports they implement and which host imports they use: @@ -400,9 +453,11 @@ payload changes break consumers, and only at runtime. # Drawbacks [drawbacks]: #drawbacks -- **Large surface area.** 51 host functions is a lot to maintain stable. Each - one is a compatibility commitment, and per-domain splitting multiplies the - number of independently-versioned packages the kernel must track. +- **Large surface area.** Twelve per-domain packages with typed resources + and per-package error variants is a lot to maintain stable. Each public + method on every resource is a compatibility commitment, and per-domain + splitting multiplies the number of independently-versioned packages the + kernel must track. - **Host-side shim code grows with versions.** Every evolution of a shared type requires a translation shim in the kernel for each older version still in the support window. The maintenance load scales with how many versions From f8dd4e61cf4625e1792c3f1f395376e1f113ef66 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sat, 6 Jun 2026 18:52:41 +0400 Subject: [PATCH 09/16] rfc(host-abi): add persistent process tier to astrid:process@1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the reattachable-background-process design into the still-draft astrid:process@1.0.0 interface (no version evolution — nothing has launched): a persistent tier alongside the ephemeral spawn/spawn-background, where spawn-persistent returns an opaque principal-scoped process-id backed by a host-owned registry that survives instance churn (attach/read/signal/wait/ write/stop by id, list-processes/status, durable watch events). Motivated by the pooled-instance reattach gap (a background process is reaped when its spawning instance returns to the pool, so the split-tool pattern is impossible); generalized beyond shell to MCP-stdio hosts, dev-server supervisors, and log tailers. Per-principal isolated (id is a capability token re-checked against the creator on every call; spawn refuses owner-fallback principals), quota-bounded, sandbox-contained (macOS fail-closed: no PID namespace), auditable, fail-secure. watch publish-authority added as an open question (ties to topic-grammar). Updates the interfaces table row. --- text/0000-host-abi.md | 172 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 2 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index ee9981d..6fad923 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -129,7 +129,7 @@ top-level functions on the `host` interface. | `astrid:net` | `unix-listener` / `tcp-listener` / `tcp-stream` / `udp-socket` resources + factories | `net`, `net_connect`, `net_udp`, `net_tcp_bind` | Unix sockets, outbound TCP, inbound TCP, UDP (unconnected + connected), DNS resolution | | `astrid:http` | `http-stream` resource + http-request | `net` | HTTP client with SSRF airlock, streaming responses, typed method enum | | `astrid:sys` | log, config, get-caller, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes, check-capsule-capability | (ungated) | Logging, manifest config, time, entropy, capability introspection | -| `astrid:process` | `process-handle` resource + spawn/spawn-background | `host_process` | OS-sandboxed (Seatbelt/bwrap) spawn with stdin/env/cwd, wait/signal/kill, exit-info | +| `astrid:process` | `process-handle` resource + spawn/spawn-background, **plus** a persistent tier (`spawn-persistent` → `process-id`, `attach`, id-keyed read/signal/wait/write/stop, `list-processes`/`status`, `watch`) | `host_process` (+ operator `allow_persistent` for the persistent tier) | OS-sandboxed (Seatbelt/bwrap) spawn with stdin/env/cwd, wait/signal/kill, exit-info; reattachable host-owned background processes addressed by principal-scoped id (see "Process: ephemeral and persistent tiers") | | `astrid:elicit` | elicit/has-secret | `uplink` | Install/upgrade-time user prompts with typed elicit-type and elicit-response | | `astrid:approval` | request-approval | (ungated) | Human-in-the-loop gate with typed approval-decision | | `astrid:identity` | resolve/link/unlink/create-user/list-links | `identity` | Multi-platform identity (per-operation typed responses) | @@ -210,6 +210,164 @@ from the invocation context (IPC message principal field), not the capsule's static configuration. This means the same capsule serves different KV namespaces depending on who is calling — transparent to the capsule author. +### Process: ephemeral and persistent tiers + +`astrid:process` is gated on `host_process` and spawns every child through the +OS sandbox (`bwrap` on Linux, `sandbox-exec`/Seatbelt on macOS) scoped to the +workspace. It exposes **two tiers**, because a capsule's WASM instance is +pooled and stateless — reset and returned to the pool between invocations. + +- **Ephemeral** — `spawn` (synchronous) and `spawn-background` (returns a + `process-handle` resource owned by the spawning instance's resource table; + the host reaps the child when the handle drops, i.e. when the instance + resets). Correct for fire-and-forget and within-one-invocation work. +- **Persistent** — `spawn-persistent` returns an opaque, principal-scoped + `process-id` whose child lives in a **host-owned registry** (lifetime = the + capsule, not the instance). It is reattachable — read / signal / wait / + write / stop — from any *later* invocation of the same `(capsule, principal)`, + including a different pooled instance. + +The persistent tier exists because the canonical split-tool pattern — start a +process in one tool call, read its logs in a second, stop it in a third — is +structurally impossible with an instance-owned handle: the handle (and the +child) is reaped when the spawning instance returns to the pool, before the +second tool call runs. Today the only workaround is pinning such a capsule to a +single never-reset instance, forfeiting the dynamic pool. The persistent tier +generalizes beyond the shell capsule's background-process tools to MCP-stdio +subprocess hosts (a long-running child you talk to across invocations), +dev-server / build-watch supervisors, and log tailers. + +The persistent surface, alongside `spawn` / `spawn-background` / `process-handle`: + +```wit +/// Opaque, unforgeable, principal-scoped identity for a persistent process +/// that survives instance churn. A 256-bit host-minted CSPRNG token (host +/// entropy, never the guest); the host stores a keyed hash and re-checks the +/// calling principal == creator on EVERY id-keyed call, so a leaked token is +/// inert across the principal boundary. Treat as an opaque secret. +type process-id = string; + +/// Opaque, host-encoded, resumable cursor for NON-DRAINING log reads. +record log-cursor { token: option } +enum log-stream { stdout, stderr } + +/// Lifecycle phase, reported WITHOUT draining logs. +enum process-phase { starting, running, exited, reaped } + +/// Ring-overflow policy for a persistent stream. +enum overflow-policy { drop-oldest, backpressure } + +/// Non-draining log slice addressed by cursor (the polling-safe complement to +/// the DRAINING `read-logs`). `data` is bytes (children emit non-UTF-8). +record log-chunk { + data: list, next: log-cursor, bytes-dropped: u64, drained-eof: bool, +} + +/// Non-draining status snapshot; unit of `list-processes` / `status`. +record process-info { + id: process-id, label: string, command: string, os-pid: option, + phase: process-phase, exit: option, age-ms: u64, idle-ms: u64, + buffered-bytes: u64, bytes-dropped: u64, stdin-open: bool, +} + +// `spawn-request` carries persistent-only fields (label, keep-stdin-open, +// overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, +// exit-retention-ms), honored only by `spawn-persistent` and ignored by the +// ephemeral spawns. `error-code` additionally carries: no-such-process +// (unknown / released / wrong-principal / wrong-capsule — indistinguishable, +// no oracle), registry-full (retained-id cap), invalid-id (malformed, returned +// without a lookup), persist-unsupported (host cannot keep a detached child +// contained — e.g. macOS without the operator opt-in, or an owner-fallback +// principal). + +/// Spawn a persistent background process. Gated on `host_process` AND the +/// operator `allow_persistent` sub-grant. Counts against BOTH the per-principal +/// concurrent cap (shared with `spawn-background`) AND a retained-id cap. +/// Fail-closed refusals (`persist-unsupported`): owner-fallback principal +/// (a persistent process needs an unambiguous owner); sandbox disabled; macOS +/// without the operator persistence opt-in. +spawn-persistent: func(request: spawn-request) -> result; + +/// Re-materialise a `process-handle` over a persistent process for THIS +/// invocation — the composition primitive (every ephemeral method works). +/// DETACH-on-drop: dropping it (incl. on instance reset) does NOT reap. +attach: func(id: process-id) -> result; + +/// List the caller `(capsule, principal)`'s persistent processes (label-filter +/// optional). NEVER another principal's or capsule's. Empty is normal. +list-processes: func(label-filter: option) -> result, error-code>; +/// Non-draining status snapshot (the safe poll primitive). `status-many` +/// batches for a supervisor polling N children (one lock pass, one audit row). +status: func(id: process-id) -> result; +status-many: func(ids: list) -> result, error-code>; + +// id-keyed convenience fns — exact `attach(id)?.()` sugar for +// single-shot tool calls (one principal check, one error mapping): +read-logs: func(id: process-id) -> result; // DRAINS +read-since: func(id: process-id, stream: log-stream, cursor: log-cursor, max-bytes: u32) -> result; +write-stdin: func(id: process-id, data: list) -> result; +close-stdin: func(id: process-id) -> result<_, error-code>; +signal: func(id: process-id, sig: process-signal) -> result<_, error-code>; +wait: func(id: process-id, timeout-ms: u64) -> result; // bounded REQUIRED +/// Graceful terminal: SIGTERM, grace, SIGKILL, drain, remove. (Immediate kill: +/// `attach(id)?.kill()`.) +stop: func(id: process-id, grace-ms: option) -> result; +/// Free an EXITED process's retained id + log tail without signalling. +release-process: func(id: process-id) -> result<_, error-code>; + +/// Arm DURABLE lifecycle events for `id`, published by the host UNDER THE +/// CREATOR PRINCIPAL on `astrid.process.v1.{exited,log-ready,stdin-drained}.*` +/// (a pollable cannot survive instance reset, so the durable channel is the bus +/// the capsule already subscribes to). The `exited` payload carries a reason +/// (self-exit / killed / reaped-ttl / reaped-shutdown) so a supervisor +/// distinguishes a crash from a daemon-shutdown reap. +watch: func(id: process-id, suffix: option) -> result<_, error-code>; +unwatch: func(id: process-id) -> result<_, error-code>; +``` + +**Identity is a capability, not a name.** A `process-id` is a 256-bit CSPRNG +secret (host entropy, the same `astrid:sys random-bytes` source); the host +stores a keyed hash and re-resolves the live `effective-principal` + `capsule` +on **every** id-keyed call, comparing against the recorded creator *before* +reading any field of the target entry. Wrong-owner / unknown / reaped all +collapse to `no-such-process` (no existence oracle); `invalid-id` is returned +without a registry lookup (timing-oracle defense). Unguessability is defense in +depth — the per-call principal check is the boundary, so even a fully-leaked +token (the shell returns the id in a tool message the LLM sees) is inert across +principals. The **spawn boundary** is the real isolation risk, not attach: +`effective-principal` falls back to the capsule owner when there is no +authenticated caller, so `spawn-persistent` **refuses** the owner-fallback +principal (else several tenants share a `default` namespace `list-processes` +would enumerate). + +**Lifecycle.** A persistent process is principal-scoped authority that must not +leak. It is reaped on the first of: explicit `stop`/`release-process`; +`max-lifetime-ms` (omission means the host ceiling, never infinity — a guest +cannot request unbounded life); `idle-timeout-ms`; child self-exit + +`exit-retention-ms`; principal eviction; capsule full-unload (last instance, +never an instance reset); host-pressure GC; daemon shutdown. **Never** on +instance reset or `attach`-handle drop — that is the whole point. Two caps with +distinct errors: a per-principal **concurrent** cap shared with +`spawn-background` (`quota`) and a per-principal **retained-id** cap +(`registry-full`), plus an aggregate per-principal log-buffer ceiling. + +**Sandbox lifetime.** Keeping a sandboxed child alive past the spawning instance +needs no new spawn topology: the child is already spawned by the *daemon's* +runtime, and only `kill_on_drop` + the handle's `Drop` reaps it on instance +drop. Relocating ownership into the host registry before the instance resets +moves that reap to registry teardown. On **Linux**, `bwrap`'s `--unshare-all` + +`--die-with-parent` make it kernel-enforced even on a daemon `SIGKILL`. On +**macOS**, Seatbelt has no PID namespace and no parent-death tie, so a child +that escapes its process group is unreapable and a daemon `SIGKILL` leaks it — +macOS persistence is therefore **materially weaker and fail-closed by default** +(`persist-unsupported` unless the operator opts in). The registry is in-memory, +so persistence does **not** survive a daemon restart (ids then resolve to +`no-such-process`, and the recovery signal is an empty `list-processes`); a +process the daemon can no longer see is one it cannot kill, audit, or attribute, +so re-adoption across restart is deliberately out of scope. Every persistent op +is audited (op + principal + capsule + a hash of the id; never the raw id, env, +stdin, or log contents). + ## Guest exports Guest exports live in `astrid:guest@1.0.0`, split into per-export worlds so @@ -565,7 +723,17 @@ Gating these would add friction without security benefit. size on `write-file`, max message size on `ipc-publish`, max KV value size)? Currently unbounded — a capsule can write arbitrarily large values. -# Future possibilities +- **Persistent-process `watch` publish authority.** The persistent tier's + `watch` has the host publish `astrid.process.v1.*` lifecycle events under the + creator principal. Does that need a manifest `[publish]` declaration, or is it + a distinct *kernel-authored* topic class the capsule only `[subscribe]`s to + (the host, not the capsule, is the authoritative publisher)? This ties to the + topic-grammar work. If unresolved, the fallback is to drop `watch` and have + supervisors poll `status`/`status-many` + a bounded `wait` — no new publish + authority, slightly less efficient. Related: the principal-eviction reap + chokepoint, whether `allow_persistent` is a distinct operator sub-grant vs + `host_process` alone, and whether macOS persistence ships behind the operator + opt-in with a best-effort reaper or is gated on a stronger macOS reaper first. [future-possibilities]: #future-possibilities - **Resource-typed caller-context.** Replace the payload-carried `principal` From 1990a57e3c406eacce2b8e5eba86052e37f8d039 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sat, 6 Jun 2026 19:37:06 +0400 Subject: [PATCH 10/16] rfc(host-abi): add per-child resource limits, usage readback, SIGSTOP/SIGCONT, env/secret semantics to process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds out the astrid:process contract before 1.0 freezes the record/enum shapes (adding fields/variants post-launch would be a major break): - resource-limits record on spawn-request (max-memory-bytes / max-cpu-ms / max-pids / max-open-files) — declared now, enforcement NOT YET SUPPORTED. - cpu-ms / mem-bytes-peak on process-info — usage readback, NOT YET POPULATED. - process-signal gains stop (SIGSTOP) and cont (SIGCONT) for pause/resume. - Documents env/secret semantics: env is the only path in (sandbox strips the rest); secrets go via stdin, never args (audited). The remaining open question is whether env values may themselves carry secrets vs an explicit secret channel — no shape impact either way. --- text/0000-host-abi.md | 47 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 6fad923..e0e439b 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -263,22 +263,46 @@ record log-chunk { data: list, next: log-cursor, bytes-dropped: u64, drained-eof: bool, } +/// Per-child OS resource ceilings (RLIMIT / cgroup), applicable to every tier. +/// `none` per field => the principal's profile default. Declared in 1.0 so the +/// record shape is stable; ENFORCEMENT is NOT YET SUPPORTED — today the host +/// bounds only the process slot, log buffers, and lifetime, so a single child +/// can still burn unbounded CPU/RAM. Including the fields now avoids a breaking +/// record change once enforcement lands. +record resource-limits { + /// Address-space / RSS ceiling (RLIMIT_AS or cgroup memory.max). + max-memory-bytes: option, + /// Cumulative CPU-time ceiling (RLIMIT_CPU → SIGXCPU then SIGKILL). + max-cpu-ms: option, + /// Max concurrent child PIDs (RLIMIT_NPROC / cgroup pids.max) — fork-bomb fence. + max-pids: option, + /// Max open file descriptors (RLIMIT_NOFILE). + max-open-files: option, +} + /// Non-draining status snapshot; unit of `list-processes` / `status`. record process-info { id: process-id, label: string, command: string, os-pid: option, phase: process-phase, exit: option, age-ms: u64, idle-ms: u64, buffered-bytes: u64, bytes-dropped: u64, stdin-open: bool, + /// Cumulative CPU time + peak resident memory the child has consumed. The + /// observability complement to `resource-limits`. Best-effort; `none` where + /// the platform doesn't surface it or NOT YET POPULATED. + cpu-ms: option, mem-bytes-peak: option, } // `spawn-request` carries persistent-only fields (label, keep-stdin-open, // overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, // exit-retention-ms), honored only by `spawn-persistent` and ignored by the -// ephemeral spawns. `error-code` additionally carries: no-such-process -// (unknown / released / wrong-principal / wrong-capsule — indistinguishable, -// no oracle), registry-full (retained-id cap), invalid-id (malformed, returned -// without a lookup), persist-unsupported (host cannot keep a detached child -// contained — e.g. macOS without the operator opt-in, or an owner-fallback -// principal). +// ephemeral spawns, PLUS `limits: option` (every tier; +// enforcement NOT YET SUPPORTED — see the record above). `process-signal` +// gains `stop` (SIGSTOP, pause) and `cont` (SIGCONT, resume) alongside +// term/hup/usr1/usr2/int, so a supervisor can throttle a runaway child without +// killing it. `error-code` additionally carries: no-such-process (unknown / +// released / wrong-principal / wrong-capsule — indistinguishable, no oracle), +// registry-full (retained-id cap), invalid-id (malformed, returned without a +// lookup), persist-unsupported (host cannot keep a detached child contained — +// e.g. macOS without the operator opt-in, or an owner-fallback principal). /// Spawn a persistent background process. Gated on `host_process` AND the /// operator `allow_persistent` sub-grant. Counts against BOTH the per-principal @@ -368,6 +392,17 @@ so re-adoption across restart is deliberately out of scope. Every persistent op is audited (op + principal + capsule + a hash of the id; never the raw id, env, stdin, or log contents). +**Environment and secrets.** A child inherits **no** ambient host environment: +the sandbox strips everything except a small kernel passthrough allowlist, and +`spawn-request.env` is the only way variables reach the child. The intended +model is that `env` carries operator-reviewable, non-secret configuration, and +a **secret** reaches a child over `write-stdin` (or `spawn-request.stdin`) — +**not** through `args`, which are recorded verbatim in the audit log. Whether +`env` values themselves may carry secrets (and are therefore excluded from +audit, as they already are) versus requiring an explicit secret channel is the +one open semantics question on this surface; the field shape is unaffected +either way. + ## Guest exports Guest exports live in `astrid:guest@1.0.0`, split into per-export worlds so From b7e543167b5eb0427d75b2cc98430ec22f57e1a1 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sat, 6 Jun 2026 21:47:53 +0400 Subject: [PATCH 11/16] =?UTF-8?q?rfc(host-abi):=20resolve=20capability-int?= =?UTF-8?q?rospection-depth=20=E2=80=94=20self-enumerate=20as=20an=20ungat?= =?UTF-8?q?ed=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the open Unresolved Question. Adds enumerate-capabilities() to astrid:sys: argument-free, returns the calling capsule's own EFFECTIVE capabilities (what the kernel enforces, not manifest text). Ungated. Capability posture is structural metadata, not a secret: knowing capsule X holds host_process grants nothing, because capabilities are enforced, not concealed — a caller can only exercise what it was itself granted. So introspection (self-enumerate + the existing per-name check, self or other) stays a read-only VIEW, ungated on sys. It adds no capability; it is just a view. Records the rejected alternative: gating cross-capsule introspection is security-by-obscurity (the name space is small and published, UUIDs are discoverable, and posture is not sensitive in the first place) and runs against Astrid's enforce-don't-conceal stance — no gate capability, no migration. enumerate-capabilities underpins the Capability delegation future possibility. Extends astrid:sys (sys@1.0.0); WIT to follow on review. --- text/0000-host-abi.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index e0e439b..2f580ec 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -128,7 +128,7 @@ top-level functions on the `host` interface. | `astrid:kv` | get/set/delete/cas/list/list-page/clear-prefix | (ungated) | Per-capsule, per-principal key-value with atomic CAS | | `astrid:net` | `unix-listener` / `tcp-listener` / `tcp-stream` / `udp-socket` resources + factories | `net`, `net_connect`, `net_udp`, `net_tcp_bind` | Unix sockets, outbound TCP, inbound TCP, UDP (unconnected + connected), DNS resolution | | `astrid:http` | `http-stream` resource + http-request | `net` | HTTP client with SSRF airlock, streaming responses, typed method enum | -| `astrid:sys` | log, config, get-caller, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes, check-capsule-capability | (ungated) | Logging, manifest config, time, entropy, capability introspection | +| `astrid:sys` | log, config, get-caller, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes, check-capsule-capability, enumerate-capabilities | (ungated) | Logging, manifest config, time, entropy, capability introspection (self-enumeration + per-name check) | | `astrid:process` | `process-handle` resource + spawn/spawn-background, **plus** a persistent tier (`spawn-persistent` → `process-id`, `attach`, id-keyed read/signal/wait/write/stop, `list-processes`/`status`, `watch`) | `host_process` (+ operator `allow_persistent` for the persistent tier) | OS-sandboxed (Seatbelt/bwrap) spawn with stdin/env/cwd, wait/signal/kill, exit-info; reattachable host-owned background processes addressed by principal-scoped id (see "Process: ephemeral and persistent tiers") | | `astrid:elicit` | elicit/has-secret | `uplink` | Install/upgrade-time user prompts with typed elicit-type and elicit-response | | `astrid:approval` | request-approval | (ungated) | Human-in-the-loop gate with typed approval-decision | @@ -189,6 +189,36 @@ Each host function checks the calling capsule's declared capabilities: Violations return `capability-denied` (a variant arm of the per-package `error-code`) to the guest and are logged to the audit chain. +### Capability introspection + +A capsule's capability set is **structural metadata, not a secret**. Knowing +that capsule X holds `host_process` grants nothing: capabilities are +*enforced*, not concealed, so a caller can only exercise what it was itself +granted regardless of what it knows. Introspection is therefore a plain +read-only view, ungated on the `sys` surface. + +- `enumerate-capabilities() -> list` returns the **calling capsule's + own** *effective* capabilities — the set the kernel would enforce at + host-call time, not the manifest text (a runtime-revoked grant does not + appear). Argument-free: the kernel already knows the caller. This is the + primitive a capsule uses to ground its behaviour in what it can actually do + — an agent supervisor narrowing the tool surface it exposes, or refusing + work that a missing capability would only fail deep — without hard-coding an + assumption about its own manifest. +- `check-capsule-capability({source-uuid, capability}) -> {allowed}` answers + the same question for a named capsule (self or other). + +Gating cross-capsule introspection was considered and rejected as +security-by-obscurity: the capability name space is small and published here, +UUIDs are discoverable, and — decisively — posture is not sensitive, since +knowing a capability conveys no ability to use it. A gate would add a +capability and a migration to buy only marginal reconnaissance-hardening, +against Astrid's enforce-don't-conceal stance. Introspection stays a view. + +`enumerate-capabilities` also underpins the **Capability delegation** future +possibility (see Future possibilities): a parent enumerates its own effective +set to compute the strictly-smaller subset it grants a spawned child. + ### VFS scheme resolution The `fs` interface resolves paths through VFS schemes: @@ -744,11 +774,6 @@ Gating these would add friction without security benefit. the first post-1.0 minor bump, by which point we'll have real maintenance data. -- **Capability introspection depth.** `check-capsule-capability` exists but is - limited. Should capsules be able to query the full capability set of OTHER - capsules? This enables a system capsule to display "what can each capsule do" - but leaks capability information across the sandbox boundary. - - **Audit chain integration.** Which host functions should produce audit entries? Currently logging and approval are audited. Should every `fs_write` be audited? Every `ipc_publish`? The audit chain grows linearly with calls — From 34e2aa13827f3404193f11410d8cb129360ff9af Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Sat, 6 Jun 2026 23:48:35 +0400 Subject: [PATCH 12/16] rfc(host-abi): clarify capability introspection per review (names, manifest-scoped, real checker, reuse-not-blocker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1 return shape: enumerate-capabilities returns capability NAMES/categories (host_process, net_connect, fs_read), the dual of check-capsule-capability; scoped args ("claude", host:port, paths) stay kernel-enforcement detail, not in the view. Q3 semantics: at the capsule level effective == manifest-declared — capsule capabilities are not revoked at runtime; the grant/revoke model is principal-scoped, a separate axis. Dropped the misleading 'runtime-revoked grant does not appear' phrasing. Q2 scope: check-capsule-capability is brought onto the same kernel-registered capability registry enumerate reads (it answers only a subset today) so the two are consistent — one manifest-derived source of truth. Q4 motivation: clarified enumerate is a robustness/reuse primitive (a reusable supervisor deployed under varying manifests, and anti-drift), NOT a gate — a fixed-manifest capsule already knows its own caps statically, so cap-derived governance can proceed with static declaration and does not block on this. --- text/0000-host-abi.md | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 2f580ec..463146b 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -197,16 +197,23 @@ that capsule X holds `host_process` grants nothing: capabilities are granted regardless of what it knows. Introspection is therefore a plain read-only view, ungated on the `sys` surface. -- `enumerate-capabilities() -> list` returns the **calling capsule's - own** *effective* capabilities — the set the kernel would enforce at - host-call time, not the manifest text (a runtime-revoked grant does not - appear). Argument-free: the kernel already knows the caller. This is the - primitive a capsule uses to ground its behaviour in what it can actually do - — an agent supervisor narrowing the tool surface it exposes, or refusing - work that a missing capability would only fail deep — without hard-coding an - assumption about its own manifest. +- `enumerate-capabilities() -> list` returns the capability **names** + the calling capsule holds — categories such as `host_process`, `net_connect`, + `fs_read`, not the scoped arguments within them (`["claude"]`, `host:port`, + paths). It is the dual of `check-capsule-capability`: the set of names for + which a self-`check` returns true. The scoped arguments stay + kernel-enforcement detail — a capsule grounds behaviour on *which kinds* of + operation it can perform, and the kernel still enforces the scope within + each. The set is the capsule's manifest-declared capabilities as the kernel + registered them; capsule capabilities are not revoked at runtime (the + grant/revoke model is *principal*-scoped, a separate axis), so "what I + declared" and "what I can do" coincide at the capsule level. Argument-free: + the kernel already knows the caller. - `check-capsule-capability({source-uuid, capability}) -> {allowed}` answers - the same question for a named capsule (self or other). + the same question for a named capsule (self or other). Implementing + `enumerate-capabilities` requires the kernel to expose a capsule's registered + capability names; `check` is brought onto that same registry so the two stay + consistent (it answers only a subset today). Gating cross-capsule introspection was considered and rejected as security-by-obscurity: the capability name space is small and published here, @@ -215,6 +222,16 @@ knowing a capability conveys no ability to use it. A gate would add a capability and a migration to buy only marginal reconnaissance-hardening, against Astrid's enforce-don't-conceal stance. Introspection stays a view. +**When this is more than self-knowledge.** A capsule with a fixed manifest +already knows its own capabilities — it wrote them, and they are not revoked at +runtime — so for that capsule `enumerate-capabilities` merely restates static +knowledge and is not load-bearing. It earns its place where self-knowledge is +*not* static: a reusable supervisor binary deployed under different manifests or +capability grants per deployment reads its actual set instead of hard-coding +it, and any capsule avoids code-vs-manifest drift by grounding on the +registered set. It is a robustness and reuse primitive, not a gate that +statically-declared behaviour depends on. + `enumerate-capabilities` also underpins the **Capability delegation** future possibility (see Future possibilities): a parent enumerates its own effective set to compute the strictly-smaller subset it grants a spawned child. From 939c36791970bfb8516ef9bca4e46c17b19626a2 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 10 Jun 2026 20:12:09 +0400 Subject: [PATCH 13/16] rfc(host-abi): add read-only file-injection primitive to astrid:process spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional file-injections list to spawn-request: the host materializes host-verified, read-only bytes at a chosen absolute path inside the child's existing OS sandbox. Agent-neutral (host never parses the bytes or interprets target); motivating consumer is un-overridable per-spawn agent governance. Specifies the write-protection invariant (unwritable by the child AND the principal's capsule fs surface), the integrity contract (snapshot to a host-owned path outside every VFS mount, BLAKE3 content-hash pinned + verified before exposure, hash recorded in the spawn audit, never a live bind of source), and the per-OS mechanism (Linux --ro-bind; macOS Seatbelt deny file-write* with target required outside the principal's VFS surface). No new capability — injection is permitted only into the caller's own child and is strictly a restriction. --- text/0000-host-abi.md | 76 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 463146b..52e1eac 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -342,7 +342,9 @@ record process-info { // overflow, log-ring-bytes, max-lifetime-ms, idle-timeout-ms, // exit-retention-ms), honored only by `spawn-persistent` and ignored by the // ephemeral spawns, PLUS `limits: option` (every tier; -// enforcement NOT YET SUPPORTED — see the record above). `process-signal` +// enforcement NOT YET SUPPORTED — see the record above) and +// `file-injections: list` (every tier; see "Read-only file +// injection" below). `process-signal` // gains `stop` (SIGSTOP, pause) and `cont` (SIGCONT, resume) alongside // term/hup/usr1/usr2/int, so a supervisor can throttle a runaway child without // killing it. `error-code` additionally carries: no-such-process (unknown / @@ -450,6 +452,78 @@ audit, as they already are) versus requiring an explicit secret channel is the one open semantics question on this surface; the field shape is unaffected either way. +**Read-only file injection.** `spawn-request` carries an optional +`file-injections: list`, honored by every tier, that materializes +host-verified, **read-only** bytes at a chosen absolute path inside the child's +existing OS sandbox. The motivating consumer is un-overridable per-spawn +governance — handing a supervised agent a policy file it reads but a +prompt-injected session cannot rewrite — but the primitive is deliberately +**agent-neutral**: the host never parses the bytes and never interprets what +`target` means. It is "materialize these verified bytes, read-only, at this +path," the same shape as the workspace bind the sandbox already performs, so a +Codex/Gemini/other adapter reuses it unchanged with its own target. + +```wit +/// A read-only file the host materializes inside a spawned child's sandbox. +/// `source` bytes are OPAQUE to the host — it never parses, validates, or +/// filters content; any content policy (e.g. which keys an untrusted authoring +/// capsule may set) is the authoring adapter's concern, not this ABI's. +record file-injection { + /// VFS path (e.g. `home://...`) the capsule authored; read host-side at spawn. + source: string, + /// Absolute path at which the child reads the injected bytes. + target: string, + /// 1.0 supports only `true` (read-only). `false` (a writable bind) is + /// reserved — it would not satisfy the write-protection invariant below — + /// and returns `invalid-input`. Declared now so the record shape is stable. + read-only: bool, +} +// spawn-request additionally carries: file-injections: list +// (every tier; absent or empty => no-op, no injected file). +``` + +**No new capability; strictly a restriction surface.** Injection rides +`host_process` and is permitted only into the caller's *own* child. It confers no +authority the caller does not already hold: a capsule that already dictates the +child's `args` / `env` / `cwd` / `stdin` can already determine everything the +child does, so handing it an *unmodifiable* file is strictly weaker than control +of `argv`. The value is the opposite of escalation — the injected bytes bind the +child even against a session that captures it. (The host permits injection only +into the spawn the caller owns, so the "an untrusted author hands the host a +policy" question never arises at this layer; it is an adapter concern.) + +**Write-protection invariant** — the property the primitive delivers. The bytes +the child reads at `target` MUST NOT be writable by **(a)** the child or any +subprocess it spawns, **nor (b)** the spawning principal's capsule `fs` surface +(`fs_*` runs in capsule space, *outside* the child's OS sandbox; a `home://` +-spanning `fs` capability could otherwise rewrite a home-staged file between +authoring and read). A live bind of the caller-supplied `source` would violate +(b) and is therefore forbidden. + +**Integrity contract.** Per injection, at spawn, the host: (1) **snapshots** +`source` into a host-owned path outside every VFS mount — unwritable by the child +*and* by the principal's `fs` surface; (2) **content-hashes** the snapshot +(BLAKE3), pins it, and **verifies** the exposed bytes match the pin, closing the +copy→expose TOCTOU; (3) records the hash in the **spawn audit** (which policy +governed the session). Verification is the integrity leg; the audit row is the +attribution leg — recording without verifying would log a policy that may not be +the one the child actually read. + +**Per-OS mechanism.** Both satisfy the invariant; the contract ("verified +read-only bytes at `target`") is identical across platforms, so an OS-aware +caller supplies a `target` appropriate to the platform. + +- **Linux** — `--ro-bind ` in the `bwrap` namespace that + already wraps the child. `target` may be any absolute path (the namespace + creates the mount point; it need not exist on the host), so the caller can + target a path the agent reads canonically. +- **macOS** — Seatbelt has no mount namespace, so the host materializes the + verified snapshot **at `target`** and the profile denies `file-write*` on that + literal path. `target` must therefore be a host-writable path **outside the + principal's VFS surface**; a `target` resolving within `home://` / `cwd://` / + tmp is rejected (`boundary-escape`), because materializing there would leave the + bytes writable by the principal's `fs_*` surface and defeat invariant (b). + ## Guest exports Guest exports live in `astrid:guest@1.0.0`, split into per-export worlds so From 2ef5688f26bc899ed5e14d43570425275c5c4335 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 10 Jun 2026 23:15:05 +0400 Subject: [PATCH 14/16] rfc(host-abi): rework file-injection to two-mode placement Replaces the single source/target/read-only shape with content bytes plus an injection-placement variant: - env-pointer(env-var): host materializes the verified snapshot at a host-owned path, exposes it read-only, and sets the named env var to it. OS-agnostic (Linux + macOS); the capsule supplies only the env-var name. For agents whose un-overridable tier is reachable via an env-redirected file (Claude CLAUDE_CODE_MANAGED_SETTINGS_PATH, Gemini GEMINI_CLI_SYSTEM_SETTINGS_PATH). - fixed-path(path): host ro-binds the snapshot at an absolute in-sandbox path. Linux-only (bwrap remap); rejected on macOS (a host write to a caller-named path would be an escalation). For agents with a fixed enforced path and no env redirect (Codex /etc/codex/requirements.toml). content bytes (not a source VFS path) removes the host-side read, the read gate, and the home-staged intermediate file. The host owns placement in both modes, so the macOS arbitrary-write escalation is gone. Drops the always-true read-only bool; a writable bind is a separate future capability. --- text/0000-host-abi.md | 112 +++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 52e1eac..73108e6 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -453,30 +453,46 @@ one open semantics question on this surface; the field shape is unaffected either way. **Read-only file injection.** `spawn-request` carries an optional -`file-injections: list`, honored by every tier, that materializes -host-verified, **read-only** bytes at a chosen absolute path inside the child's -existing OS sandbox. The motivating consumer is un-overridable per-spawn -governance — handing a supervised agent a policy file it reads but a -prompt-injected session cannot rewrite — but the primitive is deliberately -**agent-neutral**: the host never parses the bytes and never interprets what -`target` means. It is "materialize these verified bytes, read-only, at this -path," the same shape as the workspace bind the sandbox already performs, so a -Codex/Gemini/other adapter reuses it unchanged with its own target. +`file-injections: list`, honored by every tier, that exposes +host-verified, **read-only** bytes to the spawned child. The motivating consumer +is un-overridable per-spawn governance — handing a supervised agent a policy file +it reads but a prompt-injected session cannot rewrite — but the primitive is +deliberately **agent-neutral**: the capsule hands over the bytes plus *how the +child should find them*, and the host owns placement, integrity, and exposure. It +is the read-only complement to the workspace bind the sandbox already performs. ```wit -/// A read-only file the host materializes inside a spawned child's sandbox. -/// `source` bytes are OPAQUE to the host — it never parses, validates, or -/// filters content; any content policy (e.g. which keys an untrusted authoring -/// capsule may set) is the authoring adapter's concern, not this ABI's. +/// Host-verified, read-only bytes exposed to a spawned child. `content` is +/// OPAQUE to the host — it never parses, validates, or filters the bytes; any +/// content policy (e.g. which keys an untrusted author may set) is the authoring +/// adapter's concern, not this ABI's. record file-injection { - /// VFS path (e.g. `home://...`) the capsule authored; read host-side at spawn. - source: string, - /// Absolute path at which the child reads the injected bytes. - target: string, - /// 1.0 supports only `true` (read-only). `false` (a writable bind) is - /// reserved — it would not satisfy the write-protection invariant below — - /// and returns `invalid-input`. Declared now so the record shape is stable. - read-only: bool, + /// The bytes to expose. The capsule already holds them (it authored the + /// policy), so there is no host-side file read or VFS round-trip — and no + /// home-staged intermediate file the `fs` surface could race. + content: list, + /// How the child is pointed at the bytes (see `injection-placement`). + placement: injection-placement, +} + +/// Both modes expose the SAME verified bytes read-only; they differ only in how +/// the agent finds them — chosen to match the target agent's config mechanism. +variant injection-placement { + /// The host materializes the verified snapshot at a HOST-OWNED path (outside + /// every VFS mount), exposes it read-only, and sets the named environment + /// variable on the child to that path. The host owns the path, so there is no + /// caller-chosen target and no host write to a caller-named path. Works on + /// Linux AND macOS — the OS-agnostic mode. For agents whose un-overridable + /// config tier is reachable via an env-redirected file (Claude + /// `CLAUDE_CODE_MANAGED_SETTINGS_PATH`, Gemini `GEMINI_CLI_SYSTEM_SETTINGS_PATH`). + env-pointer(string), + /// The host ro-binds the verified snapshot at this absolute in-sandbox path. + /// LINUX ONLY — it relies on the bwrap mount-namespace remap; rejected on + /// macOS with `invalid-input` (no remap; materializing at a caller-named host + /// path would be an arbitrary host write = escalation). For agents whose + /// enforced tier is a FIXED path with no env redirect (Codex + /// `/etc/codex/requirements.toml`). + fixed-path(string), } // spawn-request additionally carries: file-injections: list // (every tier; absent or empty => no-op, no injected file). @@ -487,21 +503,23 @@ record file-injection { authority the caller does not already hold: a capsule that already dictates the child's `args` / `env` / `cwd` / `stdin` can already determine everything the child does, so handing it an *unmodifiable* file is strictly weaker than control -of `argv`. The value is the opposite of escalation — the injected bytes bind the -child even against a session that captures it. (The host permits injection only -into the spawn the caller owns, so the "an untrusted author hands the host a -policy" question never arises at this layer; it is an adapter concern.) +of `argv`. The value is the opposite of escalation — the bytes bind the child even +against a session that captures it. The host owns the materialized path +(`env-pointer`) or only remaps within the child's own namespace (`fixed-path`), so +it never writes to a caller-named host path; and it injects only into the spawn the +caller owns, so the "untrusted author hands the host a policy" question never +arises at this layer. **Write-protection invariant** — the property the primitive delivers. The bytes -the child reads at `target` MUST NOT be writable by **(a)** the child or any -subprocess it spawns, **nor (b)** the spawning principal's capsule `fs` surface -(`fs_*` runs in capsule space, *outside* the child's OS sandbox; a `home://` --spanning `fs` capability could otherwise rewrite a home-staged file between -authoring and read). A live bind of the caller-supplied `source` would violate -(b) and is therefore forbidden. +the child reads MUST NOT be writable by **(a)** the child or any subprocess it +spawns, **nor (b)** the spawning principal's capsule `fs` surface (`fs_*` runs in +capsule space, *outside* the child's OS sandbox; a `home://`-spanning `fs` +capability could otherwise rewrite a home-staged file between authoring and read). +The host therefore exposes only a host-owned snapshot — never a live bind of bytes +the capsule can still reach. **Integrity contract.** Per injection, at spawn, the host: (1) **snapshots** -`source` into a host-owned path outside every VFS mount — unwritable by the child +`content` to a host-owned path outside every VFS mount — unwritable by the child *and* by the principal's `fs` surface; (2) **content-hashes** the snapshot (BLAKE3), pins it, and **verifies** the exposed bytes match the pin, closing the copy→expose TOCTOU; (3) records the hash in the **spawn audit** (which policy @@ -509,20 +527,22 @@ governed the session). Verification is the integrity leg; the audit row is the attribution leg — recording without verifying would log a policy that may not be the one the child actually read. -**Per-OS mechanism.** Both satisfy the invariant; the contract ("verified -read-only bytes at `target`") is identical across platforms, so an OS-aware -caller supplies a `target` appropriate to the platform. - -- **Linux** — `--ro-bind ` in the `bwrap` namespace that - already wraps the child. `target` may be any absolute path (the namespace - creates the mount point; it need not exist on the host), so the caller can - target a path the agent reads canonically. -- **macOS** — Seatbelt has no mount namespace, so the host materializes the - verified snapshot **at `target`** and the profile denies `file-write*` on that - literal path. `target` must therefore be a host-writable path **outside the - principal's VFS surface**; a `target` resolving within `home://` / `cwd://` / - tmp is rejected (`boundary-escape`), because materializing there would leave the - bytes writable by the principal's `fs_*` surface and defeat invariant (b). +**Per-OS mechanism.** + +- **`env-pointer` (Linux + macOS).** The host writes the verified snapshot to a + host-owned path `P` and sets the named env var to `P`, then exposes `P` + read-only: Linux `--ro-bind P P` inside the bwrap namespace; macOS Seatbelt + `(allow file-read* (literal P))` plus a trailing `(deny file-write* (literal + P))`. `P` is host-owned and outside every VFS mount, so neither the child nor + the `fs` surface can write it. The capsule supplies only the env-var name, so it + is OS-agnostic. +- **`fixed-path` (Linux only).** `--ro-bind ` in the bwrap + namespace; `path` may be any absolute path (the namespace creates the mount + point). Rejected on macOS with `invalid-input` — Seatbelt has no mount + namespace, and materializing at a caller-named host path would be an arbitrary + host write. An agent whose only enforced tier is a fixed macOS system path is + therefore not governable this way without operator/root provisioning — an + inherent platform limit, not an ABI gap. ## Guest exports From 8552aaab9c9e23596d1d3409bf21405397e79f41 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 09:24:53 +0400 Subject: [PATCH 15/16] rfc(host-abi): add astrid:http@1.1.0 per-request control + streaming uploads Document the http contract's first post-1.0 minor evolution: an all-optional request-options record (caller timeouts, redirect policy, response/decompression size caps, https-only, subresource integrity), response-meta, a streaming request body (upload), and response trailers. Records the layer split (host-internal connection reuse and an SDK-side SSE framing helper are deliberately NOT contract changes), the SSRF exclude-list plus per-hop redirect re-validation, the per-call-vs-client-resource and mTLS forks, and the minor-bump classification as the first worked example of the evolution discipline. --- text/0000-host-abi.md | 144 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index 73108e6..f7c975b 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -127,7 +127,7 @@ top-level functions on the `host` interface. | `astrid:uplink` | uplink-register/send | `uplink` | Frontend connection registration | | `astrid:kv` | get/set/delete/cas/list/list-page/clear-prefix | (ungated) | Per-capsule, per-principal key-value with atomic CAS | | `astrid:net` | `unix-listener` / `tcp-listener` / `tcp-stream` / `udp-socket` resources + factories | `net`, `net_connect`, `net_udp`, `net_tcp_bind` | Unix sockets, outbound TCP, inbound TCP, UDP (unconnected + connected), DNS resolution | -| `astrid:http` | `http-stream` resource + http-request | `net` | HTTP client with SSRF airlock, streaming responses, typed method enum | +| `astrid:http` | `http-stream` resource + `http-request`, **plus** an options-bearing `@1.1.0` tier (`http-request-opts` / `http-stream-start-opts` / `http-upload-start` taking a `request-options` record, `response-meta`, stream `trailers`) | `net` | HTTP client with SSRF airlock, streaming responses, typed method enum; `@1.1.0` adds caller-set timeouts/redirect/size-cap/integrity and streaming uploads (see "HTTP: per-request control and streaming uploads") | | `astrid:sys` | log, config, get-caller, clock-ms, clock-monotonic-ns, sleep-ns, random-bytes, check-capsule-capability, enumerate-capabilities | (ungated) | Logging, manifest config, time, entropy, capability introspection (self-enumeration + per-name check) | | `astrid:process` | `process-handle` resource + spawn/spawn-background, **plus** a persistent tier (`spawn-persistent` → `process-id`, `attach`, id-keyed read/signal/wait/write/stop, `list-processes`/`status`, `watch`) | `host_process` (+ operator `allow_persistent` for the persistent tier) | OS-sandboxed (Seatbelt/bwrap) spawn with stdin/env/cwd, wait/signal/kill, exit-info; reattachable host-owned background processes addressed by principal-scoped id (see "Process: ephemeral and persistent tiers") | | `astrid:elicit` | elicit/has-secret | `uplink` | Install/upgrade-time user prompts with typed elicit-type and elicit-response | @@ -544,6 +544,148 @@ the one the child actually read. therefore not governable this way without operator/root provisioning — an inherent platform limit, not an ABI gap. +### HTTP: per-request control and streaming uploads (`@1.1.0`) + +`astrid:http@1.0.0` is deliberately minimal: a buffered `http-request` and a +streaming `http-stream-start`, each taking a flat `http-request-data` record +(`{url, method, headers, body}`) and bound by host-fixed deadlines — 30 s +whole-request on the buffered path, 30 s connect plus 120 s per-chunk on the +streaming path. Every deadline is a host constant with no caller override, and +the request record carries no field through which to tighten or relax one. This +is too coarse at both ends: a model-discovery probe (`GET {base}/v1/models` +inside a describe interceptor) wants a sub-second fail-fast deadline so a hung +upstream cannot occupy a pooled slot, while a large download wants a deadline +well above 30 s. `astrid:http@1.1.0` adds the per-request control surface and is +the **first worked example of the evolution discipline below** (Rules 1–3): a +new frozen `host/http@1.1.0.wit` registered alongside `@1.0.0`, with the +`@1.0.0` host handler delegating to the shared backend with defaulted options. +Capsules adopt it package-by-package; nothing built against `@1.0.0` is touched. + +**What goes in the contract — and what deliberately does not.** A knob belongs +in the ABI only if it is a property of one request/response that the kernel +routes and the host enforces. Three classes fall out: + +- *In the contract (`@1.1.0`):* per-request timeouts, redirect policy, + response/decompression size caps, scheme restriction, and subresource + integrity — all carried by an all-optional `request-options` record, so an + empty value is byte-for-byte `@1.0.0` behaviour — plus response metadata + (final URL, redirect count, timing), a streaming **request** body (upload), + and response trailers. +- *Not a contract change — host-internal:* connection reuse. `@1.0.0` builds a + fresh client per call, so discovery-then-generation against one host pays two + TLS handshakes; an origin-keyed client pool inside the host removes that with + no surface change and ships independently of any version bump. +- *Not a contract change — capsule/SDK space:* SSE event framing, cookie jars, + retry/backoff. SSE parsing is framing logic, not a host privilege — it is an + SDK helper (one `Vec`-buffered chunk→event iterator that subsumes the + three hand-rolled provider parsers, one of which carries a + UTF-8-split-across-chunks bug) and needs no WIT. The kernel-is-dumb boundary + draws the line: the host owns the socket, the airlock, and the deadline; the + capsule owns what the bytes *mean*. + +```wit +/// All fields optional; an empty `request-options` is exactly `@1.0.0` +/// behaviour. The `@1.0.0` `http-request` / `http-stream-start` are the +/// options-bearing functions below called with a defaulted value. +record request-options { + /// Caller deadlines, each capped by a host ceiling; `none` per field => + /// the host default for that phase. + timeouts: option, + /// What to do with a 3xx. `follow` re-validates EVERY hop through the + /// airlock; the default for an untrusted capsule is `error`, not silent + /// follow. + redirect: option, + max-redirects: option, + /// Buffered-path response cap; `none` => host default. The host enforces a + /// hard ceiling above which `body-too-large` is returned regardless. + max-response-bytes: option, + /// Ceiling on DECOMPRESSED bytes — decompression-bomb defence. + max-decompressed-bytes: option, + /// Auto-decompress gzip/br/deflate/zstd (default true). `false` delivers the + /// raw wire bytes (binary / non-UTF-8 downloads). + auto-decompress: option, + /// Refuse any non-https scheme. + https-only: option, + /// Subresource integrity "sha256-"; host verifies, returns + /// `integrity-mismatch` on failure. + integrity: option, +} +record timeout-config { + connect-ms: option, // TCP + TLS establish + first-byte-ms: option, // request sent -> first response byte + between-bytes-ms: option, // idle gap between body chunks (streaming) + total-ms: option, // whole-request hard deadline +} +enum redirect-policy { follow, error, manual } + +/// Metadata the `@1.0.0` `{status, headers, body}` response cannot carry; rides +/// the `@1.1.0` response record (a NEW type in the new file — the `@1.0.0` +/// record stays frozen). +record response-meta { + final-url: string, // after redirects (= request url if none) + redirect-count: u32, + elapsed-ms: u64, // host already records this; now returned + wire-bytes: u64, // received, pre-decompression +} + +http-request-opts: func(request: http-request-data, options: request-options) + -> result; // @1.1.0 record carries `meta` +http-stream-start-opts: func(request: http-request-data, options: request-options) + -> result; // @1.1.0 http-stream adds `trailers` + +/// Streaming request body. `@1.0.0` `body: option>` forces the whole +/// payload into guest memory first; here the caller writes the body +/// incrementally and `finish` seals it and yields the response stream. +resource http-upload { + body-sink: func() -> output-stream; + subscribe-writable: func() -> pollable; + finish: func() -> result; +} +http-upload-start: func(request: http-request-data, options: request-options) + -> result; + +// @1.1.0 also: `http-stream.trailers() -> result>, +// error-code>` (available after EOF); `error-code` gains redirect-blocked, +// too-many-redirects, integrity-mismatch, scheme-denied, decompression-bomb. +``` + +**Security: the airlock owns name resolution and the connect path.** The SSRF +airlock's correctness depends on the host — not the capsule — deciding which IP +a name resolves to and which socket is opened. Every general-client knob that +would let a guest influence that decision is therefore **excluded by +construction**, not merely omitted: custom DNS resolvers and static +`resolve`/`resolve-to-addrs` overrides, proxies, source-address / interface +binding, `accept-invalid-certs` / `accept-invalid-hostnames`, SNI suppression, +TLS-version downgrade, custom trust roots, TLS keylog, and non-`http(s)` schemes +— none appear in `request-options`. Conversely, `@1.1.0` is the point to +*strengthen* the airlock: redirect `follow` MUST re-validate every `Location` +hop through the airlock (a `30x` to `127.0.0.1` or `169.254.169.254` is the +canonical SSRF bypass) and strip `Authorization`/`Cookie` on a cross-origin hop, +and the default policy for an untrusted capsule is `error`/`manual`. `https-only`, +scheme restriction, integrity, and the decompressed-size ceiling are +security-positive capabilities the surface *gains* rather than withholds. + +**Two design forks, resolved.** *(1) Per-request options vs. a client/session +resource.* `@1.1.0` threads options per call rather than introducing an +`http-client` handle. The airlock and audit re-validate on every call +regardless, so a stateful handle adds surface without security value; the one +thing it buys — connection reuse — is the host-internal pool above. A session +resource remains a clean later option if a genuinely stateful web-session +capsule emerges. *(2) Client certificates (mTLS).* Deferred. The key material a +capsule would present must not cross the guest boundary as raw bytes — it +belongs in daemon key custody behind a host-held identity handle, its own design +— and no current consumer needs it; `request-options` leaves room for an opaque +`tls-identity` reference later without disturbing the rest. + +**Version classification.** `@1.1.0` is a **minor** bump under the rules below: +the `@1.0.0` functions and the `http-request-data` record are preserved and stay +registered unchanged, so old capsules' bindings are unaffected; the new surface +is reachable only through new functions and new-version types in a new frozen +file. It is the first concrete exercise of the support-window question raised +under Unresolved questions — the kernel will register +`astrid:http@{1.0.0, 1.1.0}` simultaneously, and the `@1.0.0` shim is the first +per-evolution host shim Rule 2 anticipates. + ## Guest exports Guest exports live in `astrid:guest@1.0.0`, split into per-export worlds so From 28f23d580dd5fdb0add038469b2c6b5aa112a364 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 24 Jun 2026 09:31:14 +0400 Subject: [PATCH 16/16] rfc(host-abi): address Copilot review on pre-existing prose Component Model form for the fs capability-gating example (astrid:fs/host.read-file, not the legacy astrid_fs_read symbol); clarify that astrid-bus schemas are WIT-defined but not linker-enforced (not 'not WIT-typed'); add the missing '# Future possibilities' section header so its anchor resolves; fix a stale 'astrid:core' package reference to 'astrid:sys/host' where caller-context actually lives. The astrid-hook-trigger export name was left as-is: it matches the canonical astrid:guest WIT (guest@1.0.0.wit). --- text/0000-host-abi.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/text/0000-host-abi.md b/text/0000-host-abi.md index f7c975b..0712ff3 100644 --- a/text/0000-host-abi.md +++ b/text/0000-host-abi.md @@ -85,7 +85,7 @@ declarations, not merely documentation. ## Capability gating Every host function checks the calling capsule's declared capabilities before -executing. A capsule without `fs_read` capability cannot call `astrid_fs_read`. +executing. A capsule without `fs_read` capability cannot call `astrid:fs/host.read-file`. Violations are logged to the audit chain and return an error to the guest. ## Per-principal scoping @@ -916,9 +916,10 @@ window. This evolution discipline governs the **kernel ↔ capsule** boundary — the WIT contract enforced by the wasmtime linker. It does **not** govern **capsule ↔ capsule** communication, which travels over the IPC bus as -typed events on string topics (e.g. `tool.v1.execute.*`). The bus is not WIT- -typed; capsule-to-capsule shape changes manifest at runtime as deserialization -errors or unmatched subscriptions, not at load time as linker errors. +typed events on string topics (e.g. `tool.v1.execute.*`). The bus schemas are +WIT-defined (`interfaces/*.wit`) but are not enforced by the wasmtime linker, so +capsule-to-capsule shape changes manifest at runtime as deserialization errors +or unmatched subscriptions, not at load time as linker errors. Bus-event evolution is governed separately by the topic-versioning convention (`*.v1.*` → `*.v2.*`, with producers keeping the prior topic alive until @@ -1047,11 +1048,13 @@ Gating these would add friction without security benefit. chokepoint, whether `allow_persistent` is a distinct operator sub-grant vs `host_process` alone, and whether macOS persistence ships behind the operator opt-in with a best-effort reaper or is gated on a stronger macOS reaper first. + +# Future possibilities [future-possibilities]: #future-possibilities - **Resource-typed caller-context.** Replace the payload-carried `principal` field with a host-owned resource handle exposed via accessor methods. This - removes a cross-cutting record type from `astrid:core` entirely, letting + removes a cross-cutting record type from `astrid:sys/host` entirely, letting the host evolve principal representation without any WIT payload change. See the corresponding open question. - **`astrid_system_stats` host function.** Runtime observability for the system