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/wasmconsumes a per-instanceCapabilitiesstruct 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].hostsallowlist; the JSON schema bumps toplugin.v2.2.json. - A
samuel new plugin --kind=wasmscaffold produces a buildable TinyGo plugin tree, including a release workflow that signs with cosign keyless OIDC. - Cold-start performance is measured by
BenchmarkColdStart_TinyGoMinimaland gated in CI at 150 ms median (3x reference-laptop budget). - The reference plugin
samuel-go-guide-wasmships as a TinyGo port of the existingsamuel-go-guideskill; 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 --verboseso 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.