Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
51 changes: 51 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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 WIT file
run: |
fail=0
for f in $(find host interfaces -name '*.wit' 2>/dev/null); do
if ! wasm-tools component wit "$f" >/dev/null 2>&1; then
echo "FAIL: $f"
wasm-tools component wit "$f" 2>&1 | head -20
fail=1
else
echo "OK: $f"
fi
done
exit $fail
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,70 @@ 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. |
| `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. |
| `host/[email protected]` | `astrid:[email protected]` | Unix-domain sockets and gated outbound TCP. |
| `host/[email protected]` | `astrid:[email protected]` | HTTP client with SSRF protection and streaming. |
| `host/[email protected]` | `astrid:[email protected]` | Logging, config, time, caller context, capability introspection. |
| `host/[email protected]` | `astrid:[email protected]` | OS-sandboxed host process spawn / kill / read-logs. |
| `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.

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.

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/`)

Expand Down Expand Up @@ -52,13 +113,13 @@ The kernel validates at boot that every required import has a matching export fr

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
37 changes: 37 additions & 0 deletions host/[email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/// Human-in-the-loop approval for sensitive actions.
///
/// Checks the AllowanceStore first (instant path for pre-approved
/// patterns), then publishes an ApprovalRequired IPC event and blocks
/// until the frontend user responds or the request times out (60s).
///
/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes
/// ship as a new file at a new version path; never edit this file.

package astrid:[email protected];

interface host {
/// Approval request from a capsule to the host.
///
/// The capsule declares the action and resource. The kernel classifies
/// risk and manages approval policy — the capsule sees only approved/denied.
record approval-request {
/// The action being requested (e.g. "git push").
action: string,
/// Full resource description (e.g. "git push origin main").
target-resource: string,
}

/// Approval response from the host.
record approval-response {
/// Whether the action was approved.
approved: bool,
}

/// Request human approval for a sensitive action.
///
/// Decision values: "approve" (once), "approve_session", "approve_always",
/// "deny", "allowance" (auto-granted via existing allowance).
/// Action strings are sanitized (control chars stripped, max 256 chars).
/// Resource strings are sanitized (max 1024 chars).
request-approval: func(request: approval-request) -> result<approval-response, string>;
}
Loading
Loading