Skip to content

RFD 0010: WASM plugin tier — wazero + TinyGo + capability gates

Summary

v2.0 shipped three documented plugin tiers (skill / WASM / OCI) but only one — skills — had end-to-end coverage. v2.2 lands the WASM tier as a first-class authoring path:

  • internal/plugin/wasm consumes a per-instance Capabilities struct that gates filesystem mounts, env keys, network hosts, memory ceiling, and per-invocation timeout at the wazero boundary.
  • The manifest gains a [runtime] block and a [capabilities.network].hosts allowlist; the JSON schema bumps to plugin.v2.2.json.
  • A samuel new plugin --kind=wasm scaffold produces a buildable TinyGo plugin tree, including a release workflow that signs with cosign keyless OIDC.
  • Cold-start performance is measured by BenchmarkColdStart_TinyGoMinimal and gated in CI at 150 ms median (3x reference-laptop budget).
  • The reference plugin samuel-go-guide-wasm ships as a TinyGo port of the existing samuel-go-guide skill; the framework benchmarks cold-start against it (BenchmarkColdStart_TinyGoReference).

Motivation

Without enforcement at the WASI boundary, the v2.0 manifest's [capabilities.filesystem] block was decorative: a buggy or malicious plugin could exfiltrate via env vars or write outside its declared sandbox. Without a cold-start budget there was no signal when a wazero or TinyGo bump regressed the tier's biggest selling point. Without a reference plugin in the registry, plugin authors who wanted to write WASM had no template and no working example.

This RFD closes all four gaps.

Decisions

1. TinyGo as the blessed toolchain

Decision: TinyGo first. Rust and AssemblyScript are documented "secondary, future" in docs/plugin-authors/wasm.md.

Why: Samuel's plugin-author base is overwhelmingly Go (the existing skill ecosystem, the samuel-go-guide skill, the framework code itself). Shipping a TinyGo scaffold means the typical author goes from samuel new plugin --kind=wasm to a buildable plugin in one command, without learning a new toolchain.

Why not Rust first: Rust would give us better binary sizes and a larger crate ecosystem, but the population that benefits is a strict subset of "people already writing Samuel plugins." The blessed toolchain decision is reversible — the framework's wazero boundary is toolchain-agnostic, so Rust plugins that compile to wasm32-wasi work today, they just don't get a scaffold yet.

2. wazero as the runtime

Decision: Embed tetratelabs/wazero v1.x.

Why: - Pure Go — no CGO, no host wasm runtime to install. - Embeddable in the samuel binary itself; one go install delivers the whole tier. - Mature WASI Preview 1 support, including the fs.FS mount API this PRD relies on for filesystem gating. - Per-module compilation cache that we layer the framework's LRU on top of without re-implementing it.

Why not wasmtime-go: CGO. The framework is a single static binary today; the wasmtime cgo dependency would push us into platform-specific release artifacts.

Why not wasmer-go: Same CGO concern, plus the project has been less actively maintained recently than wazero or wasmtime.

3. WASI Preview 1, not the Component Model

Decision: v2.2 targets WASI Preview 1. The Component Model is explicitly deferred.

Why: - Preview 1 is stable across all blessed toolchains today; the Component Model is still moving. - The host-function surface samuel exposes (samuel.fs_read, samuel.fs_write, samuel.exec, samuel.net_outbound, samuel.log, samuel.config_get, samuel.callback) is small enough that wrapping it in the Component Model's interface types buys no real ergonomics for a v2.2 plugin author. - Backwards compatibility commitment: when the framework adopts the Component Model post-v2.5, a compatibility shim keeps every v2.2 plugin loading. The shim is tracked in RFD 0012 (future).

4. Deny-by-default network with host allowlist

Decision: [capabilities.network] defaults to deny-all. Hosts opt in via hosts = ["api.example.com", "*.cdn.example.com"]. Port and protocol granularity are not enforced in v2.2.

Why: A wrong default that's tighter than necessary is recoverable (add the host you needed); a wrong default that's looser is a leak. Per-host control is sufficient for the use cases in the PRD; port + protocol can come later if a real plugin needs finer-grained control.

5. Cold-start budget: 50 ms reference / 150 ms CI

Decision: BenchmarkColdStart_TinyGoMinimal median ≤ 50 ms on a reference laptop; CI runners are allowed 3x = 150 ms. The CI gate fails any PR that regresses past 150 ms over 10 runs.

Why 50 ms: - Below human perception threshold for "felt" latency. - Achievable with the minimal TinyGo build (-no-debug -opt=2) per measurements taken during the rc cycle (reference: Apple M1 Max, ~0.65 ms cold per local bench; CI runners about 10x slower). - Honest about the gap between "first invocation" and "warm invocation": the module cache hits ~95% of invocations in a samuel run loop, so the cold budget is the conservative case.

6. Module cache LRU at 500 MiB

Decision: Per-process in-memory module cache, keyed by SHA256 of the wasm bytes, evicts oldest-first when total bytes exceed the [wasm].cache_budget (default 500 MiB).

Why: Long samuel run loops invoke the same plugin tens to hundreds of times. Recompiling each call wastes the entire cold-start budget. 500 MiB is generous — typical TinyGo plugins are 200 KB to 2 MB — but the eviction guard prevents an unbounded growth pathology when a user installs hundreds of WASM plugins.

Open questions

  • Streaming I/O between host and guest. A few plugin use cases (incremental linting, progress reporting) want streaming. Defer to v2.3 RFD with explicit demand from at least 2 plugin authors.
  • Per-plugin memory accounting in doctor. Surface under samuel doctor --verbose so operators can right-size budgets without making the default output noisier.

Outcome

Landed in v2.2.0 (PRD 0009). Updates and post-implementation revisions tracked here; the related PRD has the implementation ledger.