Skip to content

What is crucible/wasm

crucible/wasm runs state behaviors as WebAssembly: polyglot guards authored in any language that compiles to WASM and evaluated by the host over a serializable JSON ABI.

A guard is normally a Go func or a CEL expression. wasm adds a third option, a guard implemented as a WebAssembly module, so behavior logic can be written in any WASM-targeting language and dropped into a machine by name. The host invokes the module over a small JSON ABI through wazero, a pure-Go, CGo-free runtime, so adopting it adds no C toolchain and no cross-compilation burden.

It lives apart from the kernel so the wazero dependency never enters the stdlib-only core: a deployment that uses only Go or CEL guards never compiles WASM in. The ABI is core WebAssembly, not the Component Model, which would require a CGo runtime.

Today, only a Go guest path is exercised. The JSON ABI is language-agnostic by design, but the only guest the test suite compiles is Go (//go:wasmexport); a guest in another language must implement the two-export ABI directly. Ready-made non-Go guest SDKs are tracked on the roadmap.

Compile a module once and reuse it across calls, then bind it as a guard by name:

mod, err := wasm.Compile(ctx, moduleBytes) // instantiate once; reuse across calls
defer mod.Close(ctx)
reg := state.NewRegistry[Order]()
guard := wasm.Guard[string](reg, "approved", mod) // a WASM-backed state.GuardBinding
def := state.ForgeFor[Order]("order").
Guard("approved", func(state.GuardCtx[Order]) bool { return false }). // stub; Provide overwrites
State("pending").
Transition("pending").On("submit").GoTo("submitted").WhenExpr(guard).
State("submitted").
Initial("pending").
Quench()
// ... ToJSON -> LoadFromJSON -> Provide(reg) -> Quench: the guard now evaluates in WASM.

The guard composes like any other: combine it with And/Or/Not, or reference it by name from a JSON-authored machine. A broken module is fail-safe: an evaluation error reports false, so the guarded transition is blocked rather than taken on a bad verdict.

A guest module exports two functions over its linear memory:

ExportSignaturePurpose
alloc(size u32) u32reserve size bytes, return the pointer the host writes the request into
eval(ptr u32, size u32) u64read the JSON request at [ptr, ptr+size), evaluate, write the JSON response, return packed (outPtr<<32 | outLen)

For a guard the request is {"context": <ctx-json>} and the response is {"ok": <bool>}. Because the payloads are JSON, the same module works for any host language. A Module serializes concurrent Eval calls behind a mutex (one linear memory per instance).

A guest can be written in any WASM-targeting language; the test suite compiles a tiny Go //go:wasmexport guest with the standard toolchain (GOOS=wasip1 GOARCH=wasm, -buildmode=c-shared), with no TinyGo and no committed binary.

Each Eval marshals the request, crosses into the guest, and reads the response back, so the cost is dominated by the JSON round-trip across the linear-memory boundary. A WASM guard is therefore heavier than an in-process Go or CEL guard and is best reserved for genuinely polyglot logic: a rule you want to author once and share with a non-Go service, or behavior shipped by a team that does not write Go. The runtime is feature-complete for guards today; services are the next behavior to land on the same ABI.