Skip to content

feat(wit,kernel)!: pre-1.0 ABI hardening — split astrid:capsule, multi-version registration, frozen-WIT discipline #750

Description

@joshuajbouw

Summary

Three coordinated changes that have to land together before 1.0 to make the WASM ABI evolvable without breaking every capsule in the ecosystem on every WIT touch. They reinforce each other — landing one without the others is wasted work.

  1. Split astrid:capsule@0.1.0 into per-domain packages.
  2. Multi-version kernel registration (add_to_linker per supported version).
  3. Frozen-WIT-file-per-version discipline (never mutate a published WIT file in place).

Motivation

Hit this concretely tonight trying to run sphere-capsule locally. PR #4 (canonical wit) added principal: option<string> to the ipc-message record. PR #5 (and PR #746 syncing it into core) added shutdown-how enum + 14 new functions to net. Both were treated as "additive" but kept the same package version string astrid:capsule@0.1.0. wasmtime's Component Model linker is strict structural typing — any record-field add or function-add is a shape change, so:

  • Every installed capsule built against the prior astrid:capsule@0.1.0 shape fails to instantiate against the new kernel.
  • The single mega-package means EVERY capsule that imports ANY interface from astrid:capsule is affected on EVERY change anywhere in the file.
  • Today the only recovery path is rebuild the entire ecosystem against a fresh sdk-rust release. Not viable post-1.0.

The pattern in the linker error (component imports instance 'astrid:capsule/ipc@0.1.0', but a matching implementation was not found in the linker) shipped on day one of any release is a brand-killer.

Scope

1. Split astrid:capsule into per-domain packages

Replace the single astrid:capsule@0.1.0 package containing 11 interfaces (ipc, net, kv, http, fs, process, sys, elicit, approval, uplink, identity) with 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: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. Keep this package as small as humanly possible; it's the hot path for cross-domain breakage. Every type that lives here ripples across every interface that imports it.

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-interface types isolate per-interface evolution.

Cross-cutting concerns like the principal field on ipc-message need rethinking: either accept that astrid:ipc itself bumps when principal handling changes, OR push principal out of the payload into the kernel's caller-context (resource-typed, more permissive evolution).

Capsules opt into exactly the subset they need:

world my-capsule {
    import astrid:ipc/host@1.0.0;
    import astrid:kv/host@1.0.0;
    // not net, not http — capsule doesn't use them
}

Result: a kv-only capsule is unaffected when astrid:net adds a function.

2. Multi-version kernel registration

wasmtime CM has no version negotiation. The kernel must explicitly register every version it claims to support. Today bindings::Capsule::add_to_linker is called once with one set of bindings; needs to be called once per (package, version) pair the kernel supports:

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

Implementation requires:

  • Build-time codegen: one binding module per (package, version) pair.
  • Host-side handlers that convert between version shapes (e.g. v1.0 ipc-message lacks principal — the v1.0 handler ignores it when emitting events to v1.0 capsules and synthesizes it from caller-context when receiving from v1.0 capsules).
  • A registered support window per package (e.g. kernel supports astrid:ipc@{1.0.0, 1.1.0, 2.0.0} simultaneously).
  • A deprecation policy: when the kernel drops a version from the support window, capsules built against it stop loading. Drop dates communicated in advance.

3. Frozen-WIT-file-per-version discipline

Once an astrid:ipc@1.0.0 is shipped, 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/active

When a shape change is needed: copy the latest frozen file to a new version, modify, register the new version in the kernel, leave the old files alone. The current unicity-astrid/wit pattern of editing the file in place dies.

Enforced by CI lint: 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.

Why-uniquely-Astrid framing

Post-1.0 Astrid is a platform that third-party capsule authors target. Every WIT change is a contract break for those authors. The pre-1.0 "rebuild the whole ecosystem" cost is acceptable from us; it is not acceptable from a third party who shipped their capsule six months ago and didn't touch it since. The fix is the three pieces above; without them, 1.0 is a marketing version number, not a stable contract.

Out of scope

  • Resource-typed evolution patterns. Worth exploring as a future v2.x option but not blocking v1.0; record-based ABIs work, they just need the discipline above.
  • Cross-language type-shape diff detection (e.g. detect that adding a struct field in sdk-rust broke a JS-consuming capsule). Manual WIT review + the immutability rule covers it.
  • Tooling to auto-bump versions on shape change. Authors do the version bump explicitly; CI just enforces the rule.

Concrete deliverables

On unicity-astrid/wit:

  • Split host/astrid-capsule.wit into 11 per-domain WIT files + 1 minimal astrid:core package.
  • Move existing pre-1.0 astrid:capsule@0.1.0 to a deprecated location or delete (no consumers can rely on it post-1.0).
  • Define folder convention: interfaces/<name>@<version>.wit per frozen file.
  • CI lint: scripts/lint-wit-immutability.sh — fails if any file matching the frozen-version pattern was modified.
  • Define the cross-cutting type story for principal/caller-context: in astrid:core, or pushed out of payload entirely (resource handle on host side).

On unicity-astrid/astrid:

  • Refactor bindings::Capsule generation to per-(package, version) modules.
  • engine/wasm/mod.rs add_to_linker becomes a loop over the supported-versions table.
  • Host handler conversion shims between versions for shared types that evolved (write once per evolution).
  • Manifest field: optional supported_versions = [...] for the kernel's package support window.
  • Deprecation logging: when a capsule imports a version flagged for upcoming removal, log a warning at load time naming the removal date.

On unicity-astrid/sdk-rust and unicity-astrid/sdk-js:

  • Both SDKs source per-domain WIT files from the canonical repo.
  • Codegen produces per-package binding modules (astrid_sdk::ipc::v1_0, etc.).
  • Public re-exports default to the latest supported version, with explicit version-pinning available for capsules that need to target older.

Migration of existing first-party capsules:

  • Audit every unicity-astrid/capsule-* repo: which interfaces does each actually use?
  • Each capsule's Cargo.toml depends on per-domain SDK packages instead of monolithic astrid-sdk (or astrid-sdk re-exports stay but bind to specific versions).
  • Rebuild + publish each capsule under the new model before 1.0 ship.

Open questions

  • Single astrid:core vs no shared types at all. Strictly purer: zero shared types, each interface has its own principal definition. More duplication, less cross-cutting risk. Probably go with minimal astrid:core for sanity, document loudly which types are in it.
  • Naming: astrid:ipc/host@1.0.0 (separate host vs guest worlds) or just astrid:ipc@1.0.0. Worth checking the wasmtime CM idiom.
  • Pre-1.0 cleanup approach. Cut over hard (delete astrid:capsule@0.1.0 from canonical, force every capsule to migrate before 1.0 release) vs leave it as a deprecated alias. Recommend hard cut — pre-1.0 we have license to break, post-1.0 we don't.
  • Resource-based evolution for the principal/caller-context piece. Worth one design pass to see if it removes the cross-cutting type entirely. If it does, the astrid:core package gets even smaller (or vanishes).

Acceptance

  • Per-domain WIT files in unicity-astrid/wit, each frozen-by-version.
  • Kernel supports at least two simultaneous versions of one package (smoke test: register astrid:ipc@1.0.0 and astrid:ipc@1.1.0 together, load a capsule that imports each).
  • CI lint blocks edits to frozen WIT files.
  • First-party capsules rebuild against per-domain packages and load.
  • Documentation in core/docs/wit-evolution.md walks a capsule author through bumping a package version.
  • The current failure mode ("astrid:capsule/ipc@0.1.0 not found in linker" when sdk-rust and kernel are at different commits) cannot recur, by construction.

Thanks

Thank you BobDowns for discovering this issue, its a big one!

Metadata

Metadata

Assignees

Labels

area/kernelastrid-kernel: kernel router, event bus, socketfeatNew feature or capabilityneeds-designRequires architectural decision before implementationp1High — blocks meaningful progress

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions