Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: lint

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
wit-immutability:
name: WIT frozen-file immutability
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Determine base ref
id: base
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
else
echo "ref=HEAD~1" >> "$GITHUB_OUTPUT"
fi

- name: Check frozen-file immutability
run: scripts/lint-wit-immutability.sh ${{ steps.base.outputs.ref }}

wit-parses:
name: WIT files parse
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install wasm-tools
run: cargo install --locked wasm-tools

- name: Parse every host/*.wit file
# Stages each host/*.wit alongside vendored deps/ (wasi-io) +
# sibling host/ files so cross-package `use` clauses resolve.
# interfaces/*.wit files are not validated here — they cross-
# reference each other and wasm-tools 1.x can't topo-sort deps/
# in a single pass; downstream SDK builds (cargo-component / wkg)
# validate them with proper resolution.
run: scripts/validate-wit.sh
105 changes: 87 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,53 +12,122 @@ The Astrid CLI installs the capsule-to-capsule interfaces to `~/.astrid/wit/astr

The kernel-to-capsule contract. Capsules import these interfaces; the kernel provides the implementations. Lives outside `interfaces/` because the consumer set is fixed (the kernel + each SDK that wraps the imports) rather than open-ended.

Each domain is its own package, frozen at a per-file version. A capsule imports only the domains it uses; bumping one domain does not affect capsules that do not import it.

| File | Package | Description |
|------|---------|-------------|
| `host/astrid-capsule.wit` | `astrid:[email protected]` | The 49 host functions across 11 domain-specific interfaces (`fs`, `ipc`, `kv`, `net`, `http`, `sys`, `process`, `elicit`, `approval`, `identity`, `uplink`, `types`) plus the 4 guest exports (`astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`) that make up the `capsule` world. |
| `host/[email protected]` | `astrid:[email protected]` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link, hard-link. |
| `host/[email protected]` | `astrid:[email protected]` | Publish/subscribe IPC event bus. |
| `host/[email protected]` | `astrid:[email protected]` | Inbound message ingestion from external platforms. |
| `host/[email protected]` | `astrid:[email protected]` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. |
| `host/[email protected]` | `astrid:[email protected]` | Unix-domain sockets, gated outbound TCP, inbound TCP listener, gated UDP (unconnected + connected mode), gated DNS resolution. |
| `host/[email protected]` | `astrid:[email protected]` | HTTP client with SSRF protection and streaming. |
| `host/[email protected]` | `astrid:[email protected]` | Logging, config, time, caller context, entropy, sleep, capability introspection. |
| `host/[email protected]` | `astrid:[email protected]` | OS-sandboxed host process spawn (with stdin/env/cwd), wait, signal, kill, read-logs, stdin streaming. |
| `host/[email protected]` | `astrid:[email protected]` | Interactive user input during install/upgrade lifecycle. |
| `host/[email protected]` | `astrid:[email protected]` | Human-in-the-loop approval gate for sensitive actions. |
| `host/[email protected]` | `astrid:[email protected]` | Multi-platform identity resolve and link. |
| `host/[email protected]` | `astrid:[email protected]` | Guest export contract — `astrid-hook-trigger`, `run`, `astrid-install`, `astrid-upgrade`. Each entry point lives in its own world (`interceptor`, `background`, `installable`, `upgradable`) so capsules `include` only what they implement. |

A capsule's world declares only the imports it uses plus the guest-export worlds it actually implements:

```wit
// Interceptor-only capsule:
world router {
include astrid:guest/[email protected];
import astrid:ipc/[email protected];
// intentionally not importing net, http, identity, …
}

// Run-loop capsule with an install hook:
world cli {
include astrid:guest/[email protected];
include astrid:guest/[email protected];
include astrid:guest/[email protected];
import astrid:ipc/[email protected];
import astrid:uplink/[email protected];
import astrid:net/[email protected];
}
```

Per-export worlds matter: the wasm32-wasip2 toolchain auto-stubs every export declared in a world the component targets. Bundling all four entry points into one mandatory world forced stubs for the unused ones, which then required kernel-side parsing to distinguish real implementations from toolchain stubs. With per-export worlds, an export only appears in the wasm binary when the capsule actually implements it.

### Evolution discipline

Once a `host/<name>@X.Y.Z.wit` file is shipped (i.e. on `main`), it is **immutable forever**. The wasmtime Component Model linker enforces structural typing on every `(package, version)` pair, so any record-field add or function add in a published WIT file causes every capsule built against the prior shape to fail to instantiate. The fix is to never edit a published file in place.

Shape changes ship as a new file at a new version:

```
host/
[email protected] # frozen
[email protected] # frozen (additive change from 1.0.0)
[email protected] # current (breaking change from 1.x)
```

To evolve a package:

1. Copy the latest frozen file: `cp host/[email protected] host/[email protected]`.
2. Bump the package declaration inside the new file: `package astrid:[email protected];`.
3. Make your shape changes in the new file.
4. Leave the existing frozen file untouched.
5. The kernel registers both versions in its linker (`bindings::ipc_v1_0::add_to_linker` and `bindings::ipc_v1_1::add_to_linker`) so old and new capsules both load.

## Capsule interfaces (`interfaces/`)
CI enforces this via `scripts/lint-wit-immutability.sh` — any PR that modifies or deletes a published `*@X.Y.Z.wit` file fails the build.

The capsule-to-capsule contracts. Capsules declare which they import/export in `Capsule.toml`; the kernel validates at boot that every required import has a matching export.
See [RFC: Host ABI](https://github.com/unicity-astrid/rfcs/pull/22) for the full design (per-domain packages, multi-version kernel registration, frozen-file rule) and [issue #750](https://github.com/unicity-astrid/astrid/issues/750) for the motivating bug.

## Capsule interfaces (`interfaces/`) — the `astrid-bus:*` namespace

The capsule-to-capsule contracts. Distinct namespace (`astrid-bus:*`) from the host ABI (`astrid:*`) — the medium is different: host fns are direct wasmtime CM linker calls, bus interfaces are schemas for events that flow over the IPC bus between capsules. Capsules declare which they import/export in `Capsule.toml`; the kernel validates at boot that every required import has a matching export.

| File | Package | Description |
|------|---------|-------------|
| `interfaces/llm.wit` | `astrid:[email protected]` | LLM generation requests and streaming responses |
| `interfaces/session.wit` | `astrid:[email protected]` | Conversation session storage and retrieval |
| `interfaces/spark.wit` | `astrid:[email protected]` | Agent identity and system prompt construction |
| `interfaces/context.wit` | `astrid:[email protected]` | Context window compaction and management |
| `interfaces/prompt.wit` | `astrid:[email protected]` | Prompt assembly from components |
| `interfaces/tool.wit` | `astrid:[email protected]` | Tool dispatch and execution results |
| `interfaces/hook.wit` | `astrid:[email protected]` | Lifecycle hook fan-out and response collection |
| `interfaces/registry.wit` | `astrid:[email protected]` | Model registry operations |
| `interfaces/types.wit` | `astrid:[email protected]` | Shared types used across interfaces |
| `interfaces/users.wit` | `astrid:[email protected]` | Within-principal user identity store — platform-to-AstridUserId mapping |
| `interfaces/llm.wit` | `astrid-bus:[email protected]` | LLM generation requests and streaming responses |
| `interfaces/session.wit` | `astrid-bus:[email protected]` | Conversation session storage and retrieval |
| `interfaces/spark.wit` | `astrid-bus:[email protected]` | Agent identity and system prompt construction |
| `interfaces/context.wit` | `astrid-bus:[email protected]` | Context window compaction and management |
| `interfaces/prompt.wit` | `astrid-bus:[email protected]` | Prompt assembly from components |
| `interfaces/tool.wit` | `astrid-bus:[email protected]` | Tool dispatch and execution results |
| `interfaces/hook.wit` | `astrid-bus:[email protected]` | Lifecycle hook fan-out and response collection |
| `interfaces/registry.wit` | `astrid-bus:[email protected]` | Model registry operations |
| `interfaces/types.wit` | `astrid-bus:[email protected]` | Shared types used across bus interfaces |
| `interfaces/users.wit` | `astrid-bus:[email protected]` | Within-principal user identity store — platform-to-AstridUserId mapping |

(Plus `agent`, `approval`, `client`, `elicit`, `onboarding`, `system`, `user` — see `interfaces/` for the full set.)

## How capsules use these

Capsules declare which interfaces they import and export in their `Capsule.toml`:
Capsules declare which host packages and bus interfaces they import/export in their `Capsule.toml`. The two namespaces stay distinct:

```toml
# Host ABI — kernel-mediated syscalls.
[imports.astrid]
fs = "1.0.0"
ipc = "1.0.0"

# Capsule-to-capsule event schemas, on the IPC bus.
[imports.astrid-bus]
llm = "^1.0"
session = { version = "^1.0", optional = true }

[exports.astrid]
[exports.astrid-bus]
session = "1.0.0"
```

The kernel validates at boot that every required import has a matching export from another loaded capsule. The WIT files define the message schemas carried over the IPC bus.
The kernel validates at boot that every required `astrid-bus` import has a matching `astrid-bus` export from another loaded capsule. The WIT files in `interfaces/` define the message schemas carried over the IPC bus.

## How SDKs use these

SDKs use `wit-bindgen` (Rust), `ComponentizeJS` (JS/TS), or equivalent toolchains to generate typed bindings from these definitions. The generated types match the IPC payload schemas so capsule authors get compile-time type safety.

The kernel uses `wasmtime::component::bindgen!` against `host/astrid-capsule.wit` to generate the host-side trait the host implementations satisfy.
The kernel uses `wasmtime::component::bindgen!` against each `host/<name>@<version>.wit` to generate one binding module per `(package, version)` pair. The kernel's linker setup registers every supported version explicitly — there is no implicit version negotiation in the Component Model.

## Cross-repo coordination

Both kinds of WIT files change rarely but breakingly. The repos that submodule from here are:

- [`unicity-astrid/astrid`](https://github.com/unicity-astrid/astrid) -- kernel (host implementations bound to `host/astrid-capsule.wit`)
- [`unicity-astrid/astrid`](https://github.com/unicity-astrid/astrid) -- kernel (host implementations bound to each `host/<name>@<version>.wit`)
- [`unicity-astrid/sdk-rust`](https://github.com/unicity-astrid/sdk-rust) -- Rust SDK (guest bindings + capsule contracts)
- [`unicity-astrid/sdk-js`](https://github.com/unicity-astrid/sdk-js) -- JavaScript / TypeScript SDK (same)

Expand Down
34 changes: 34 additions & 0 deletions deps/wasi-io/error.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package wasi:[email protected];


interface error {
/// A resource which represents some error information.
///
/// The only method provided by this resource is `to-debug-string`,
/// which provides some human-readable information about the error.
///
/// In the `wasi:io` package, this resource is returned through the
/// `wasi:io/streams/stream-error` type.
///
/// To provide more specific error information, other interfaces may
/// provide functions to further "downcast" this error into more specific
/// error information. For example, `error`s returned in streams derived
/// from filesystem types to be described using the filesystem's own
/// error-code type, using the function
/// `wasi:filesystem/types/filesystem-error-code`, which takes a parameter
/// `borrow<error>` and returns
/// `option<wasi:filesystem/types/error-code>`.
///
/// The set of functions which can "downcast" an `error` into a more
/// concrete type is open.
resource error {
/// Returns a string that is suitable to assist humans in debugging
/// this error.
///
/// WARNING: The returned string should not be consumed mechanically!
/// It may change across platforms, hosts, or other implementation
/// details. Parsing this string is a major platform-compatibility
/// hazard.
to-debug-string: func() -> string;
}
}
41 changes: 41 additions & 0 deletions deps/wasi-io/poll.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package wasi:[email protected];

/// A poll API intended to let users wait for I/O events on multiple handles
/// at once.
interface poll {
/// `pollable` represents a single I/O event which may be ready, or not.
resource pollable {

/// Return the readiness of a pollable. This function never blocks.
///
/// Returns `true` when the pollable is ready, and `false` otherwise.
ready: func() -> bool;

/// `block` returns immediately if the pollable is ready, and otherwise
/// blocks until ready.
///
/// This function is equivalent to calling `poll.poll` on a list
/// containing only this pollable.
block: func();
}

/// Poll for completion on a set of pollables.
///
/// This function takes a list of pollables, which identify I/O sources of
/// interest, and waits until one or more of the events is ready for I/O.
///
/// The result `list<u32>` contains one or more indices of handles in the
/// argument list that is ready for I/O.
///
/// If the list contains more elements than can be indexed with a `u32`
/// value, this function traps.
///
/// A timeout can be implemented by adding a pollable from the
/// wasi-clocks API to the list.
///
/// This function does not return a `result`; polling in itself does not
/// do any I/O so it doesn't fail. If any of the I/O sources identified by
/// the pollables has an error, it is indicated by marking the source as
/// being reaedy for I/O.
poll: func(in: list<borrow<pollable>>) -> list<u32>;
}
Loading
Loading