HIPsHanzo Proposals
Back to HIPs
HIP-0105FinalStandards TrackInfrastructure

In-Process Extension Runtime Standard

Hanzo AI Team
Created: 2026-05-19
Requires: HIP-0026

HIP-0105: In-Process Extension Runtime Standard

Abstract

This proposal defines how user-supplied code is loaded and executed inside a running Hanzo Go service — distinct from the K8s-scale FaaS surface in HIP-0060. The same service binary can host extensions in four engines — native Go, goja (pure-Go JS interpreter), wazero (pure-Go WASM runtime), and v8go (V8 via cgo) — and pick the right one per extension via an extension.json manifest. The Runtime interface is one type with four implementations; switching engines is a one-line manifest change.

Reference implementation: ~/work/hanzo/base/plugins/{extruntime,gojavm,wasmvm,v8vm} (hanzoai/base/plugins, landed 2026-05-19). Full benchmark write-up at ~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md.

Motivation

Hanzo services regularly need to run user-supplied code without spinning up a Knative pod or shelling out to a sidecar:

  • Per-record validators and computed fields in Hanzo Base
  • Per-org policy / authz rules in Hanzo IAM and Gateway
  • Custom prompt transformers in the LLM Gateway
  • Tool implementations for agents (MCP-side tool functions)
  • Webhook body transformations
  • Custom CRDT merge resolvers
  • Per-tenant feature flags evaluated against context

The naïve options are all bad:

  1. Run user code in the host Go process directly — no sandbox, one panic kills the service for every tenant.
  2. Shell out to a Node.js / Python subprocess — process spawn cost is milliseconds, pipes are bytes, memory floor is tens of megabytes per tenant.
  3. Knative FaaS (HIP-0060) — right answer for bursty inference workloads with seconds of work; wrong answer for a hot-path validator that runs 1000 times per request.

The right answer is in-process with a sandbox boundary that scales to microseconds of overhead. WebAssembly provides this; modern JS engines provide it for JS specifically; pure-Go interpreters give a softer sandbox with zero binary cost. Each is correct for a different workload. This HIP codifies which is which.

Specification

Runtime interface

Every backing engine implements the same Go interface:

package extruntime

type Runtime interface {
    Name() string
    Capabilities() Capabilities
    Load(ctx context.Context, dir string) (Module, error)
    Close() error
}

type Module interface {
    Name() string
    Runtime() string
    Exports() []string
    Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
    Close() error
}

type Capabilities struct {
    AcceptsLanguages []string // ["go"] | ["js"] | ["wasm"]
    HardSandbox      bool     // wazero, v8go: true; goja, native: false
    Cgo              bool     // v8go: true; others: false
    SupportsAbort    bool     // wazero, v8go: hard; goja: cooperative; native: false
}

payload and the Invoke result are JSON bytes. JSON is the wire format because (a) every backing engine can marshal it natively, (b) it's language-agnostic so AssemblyScript / Rust / Go / JS guests all see the same payload, and (c) the marshal cost is comparable across runtimes.

Manifest

Every extension carries a sibling extension.json:

{
  "name":    "validate-email",
  "version": "0.1.0",
  "runtime": "wazero",
  "module":  "validate.wasm",
  "exports": ["validate", "onUpdate"]
}

Fields:

FieldRequiredNotes
nameyesUnique within a host (e.g. <org>-<purpose>)
versionyesSemVer per global policy (HIP-0xxx semver-only)
runtimeyesOne of native, goja, wazero, v8go
moduleconditionalPath to compiled artifact (*.wasm for wazero, *.js for goja/v8go). Not required for native runtime.
exportsyesList of function names callable via Invoke

Calling conventions

Go-native

A native extension is a Go file that imports github.com/hanzoai/base/plugins/extruntime and registers itself at init():

extruntime.RegisterNative("validate-email", "validate", func(ctx context.Context, payload []byte) ([]byte, error) {
    // ... return JSON bytes
})

Compile-time linked. Zero abstraction cost beyond function pointer.

Goja and V8go (JavaScript)

The guest defines globalThis.<fn> for each export:

globalThis.validate = function(payload) {
    return { ok: true, normalized: payload.email.toLowerCase() };
};

Host unmarshals the JSON payload to a JS value, calls the function, and JSON.stringifys the result. No memory dance — JS engines own their heaps.

Wazero (WebAssembly)

The guest exports three functions in addition to its named exports:

(func $__base_alloc (param i32) (result i32))           // ptr = alloc(len)
(func $__base_free  (param i32 i32))                    // free(ptr, len)
(func $<fn>         (param i32 i32) (result i64))       // ret = fn(ptr, len)

<fn>(ptr, len) returns an i64 where the high 32 bits are the result pointer and the low 32 bits are the result length in the module's linear memory. The host calls __base_free on both the input buffer and the result buffer.

This convention is language-agnostic — AssemblyScript, Rust, Go (via TinyGo), Zig, and C can all target it. The ~/work/hanzo/base/plugins/extbench/fixtures/wazero-as/ sample shows the AssemblyScript implementation.

Default behavior when ctx is cancelled

RuntimeCancellation semantics
nativeCooperative — user code MUST check ctx.Err()
gojaCooperative — vm.Interrupt() fires only at function-call opcodes; tight numeric loops without yield points can resist abort
wazeroHard — module.Close() destroys the instance immediately; the runtime replenishes its pool from a background context
v8goHard — Isolate.TerminateExecution() aborts within ~ms

Pool sizing

Every runtime maintains a per-module pool of pre-warmed instances / contexts so per-invocation cost stays microsecond-scale. Each runtime exposes an env override. Anything <=0 or unparseable falls back to the default.

RuntimeEnvDefaultNotes
gojaBASE_GOJAVM_POOL_SIZE8Cheap per-pool-item (~9 KB Go heap); 8 is safe.
wazeroBASE_WASMVM_POOL_SIZE8Wasm modules amortize compilation, so the pool exists to soak bursty hook traffic rather than to save instantiation.
v8goBASE_V8VM_POOL_SIZE8Sized for context pool, but see scale findings — v8go is NOT recommended for production at meaningful concurrency.
pyvmBASE_PYVM_POOL_SIZE4Each Python sub-interpreter is ~4.6 MB. Pool of 4 = ~18 MB baseline. Default tuned to balance per-module memory vs cold-start cost.
starlarkBASE_STARKVM_POOL_SIZE8Pre-warmed *starlark.Thread instances.

Native doesn't pool — functions are stateless Go calls.

Benchmark data

Run on Apple M1 Max, Go 1.26.3, median of 3 runs at -benchtime=2s. Workload: validate(email, age) — JSON in, JSON out, ~20 lines of logic.

Throughput (lower ns/op is faster)

RuntimeSerial ns/opParallel ns/opRatio vs native (serial)Ratio vs native (parallel)
native1,1976941.0×1.0×
pyvm (CPython 3.13)4,0892,7653.4×4.0×
goja4,5131,6523.8×2.4×
wazero (AssemblyScript)9,9563,2718.3×4.7×
v8go11,89512,6679.9×18.3× (degrades)

Cold start (per Load())

Runtimens/opRatio vs native
native16,8471.0×
goja78,2824.6×
v8go906,03353.8×
wazero (AS)3,936,863233.7× (JIT compile)

Per-invocation memory

RuntimeB/invokeallocs/invoke
pyvm (CPython 3.13)49610 (LOWEST allocs of any non-native)
v8go673low
native1,450minimal
goja3,433JS → Go marshalling
wazero (AS)44,425JSON ptr/len + linear-memory copy

Binary size delta

Runtime addedBinary growth
native (registry only)+89 KB
wazero+620 KB
goja+10.7 MB
v8go+36.3 MB (cgo + V8 statics)

Scale findings (added 2026-05-19 after the second benchmark pass)

The throughput benchmark above measured a single hot module. The scale benchmark measured fan-out — many modules, many tenants, many concurrent invocations — and materially changes the recommendation for two runtimes.

Per-loaded-module Go-heap cost (steady state, 1000 modules loaded):

RuntimePer-module heapTotal at N=1000Total at N=10000
native~16 B<1 MB~1 MB
goja~9 KB~9 MB~90 MB
wazero (pool=8)~635 KB~635 MB(extrapolated 6.4 GB)
wazero (pool=4)~320 KB~320 MB(extrapolated 3.2 GB)
v8gocrashed before reaching N=500 (SIGSEGV in libv8)

Sustained concurrent invocations on one module (throughput at M concurrent):

RuntimeM=10 ops/sM=100 ops/sM=1000 ops/sM=10000 ops/s
nativescales linearly with Mlinearlinearlinear
gojascalesscales188,000scales
wazero (pool=4)scalesscalespool exhaustion above pool size; queueingqueueing
v8goOK at M=10SIGSEGV in libv8(process crash)(process crash)

Pool-size sweep (1000 tenants, wazero):

Pool sizeLatencyMemory
1high (queueing)~80 MB
4good~320 MB
16same as 4~1.3 GB
64same as 4~5.1 GB
256same as 410.8 GB

No latency improvement beyond pool=4. Anything higher is pure waste.

Concrete corrections this forces:

  1. v8go is removed from the production decision tree. v8go v0.9 on darwin/arm64 SIGSEGVs inside libv8 at ~100 concurrent invocations on a shared isolate. This is not "slow" — it is process death. Production-disqualifier for any service expecting real concurrency. plugins/v8vm ships but is for benchmarking and experimental use only. Tracked upstream — revisit if v8go ships per-context isolation and resolves the crash.

  2. goja is the production winner for multi-tenant JS at scale. ~9 KB per loaded module, 188K ops/s on a single module at M=1000 concurrent, no cgo, no crash modes. For a SaaS deployment hosting thousands of tenants each with a few JS extensions, goja is the right call. The cooperative-interrupt caveat remains, but the stability-at-scale story dwarfs it.

  3. wazero default pool is 4, not 8. No throughput gain beyond that for the workloads measured; halving the pool halves the per-module memory floor.

  4. wazero scales to ~10K modules at pool=4 (~3.2 GB). Beyond that, compile-cache miss + linear-memory floor compounds; see Open Questions below for a future test.

  5. native scales to any reasonable N. ~16 B per loaded function pointer; the goroutine cost (~8 KB) dominates only at >100K concurrent invocations of any single module.

Full numbers and methodology in ~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md under "## Scale Study".

The new standard

Go-native is the default. Every metric in the benchmark — serial perf, parallel scaling, cold start, memory, binary size — favors native by a factor of 2-50×. There is no scenario in this benchmark where any other runtime beats native on raw performance. If the code is Hanzo-authored and the language constraint allows Go, write it in Go.

Pick another runtime only when one of these four exceptional conditions holds:

How a host loads extensions

Loader.LoadDir(ctx, dir) scans dir for subdirectories holding an extension.json, resolves each manifest's runtime against the runtimes linked into the binary, and returns the loaded modules by name. A manifest naming a runtime the binary does not carry is logged and skipped, not fatal — which is what lets one binary serve hosts built with different runtime sets.

That is the whole loading contract. WHERE a host points it — a hooks directory, a per-service directory, a path from config — is the host's decision and belongs to HIP-0106, not here. This HIP specifies what a runtime is and how a module is loaded; it does not name directories on anyone's disk.

A host that exposes extensions to more than one tenant is responsible for admitting only runtimes whose isolation it accepts. pyvm and v8vm share process memory and have no hard sandbox.

Decision tree

  1. Is the code Hanzo-authored and Go-permissible? → native. Default; no further questions.

  2. Is the code user-supplied AND threat model requires HARD sandbox (e.g. tenants could be actively malicious)?

    • Language flexibility matters? (Rust / TinyGo / Zig / AssemblyScript / C plugin authors) → wazero. Pool default 4.
    • JS authoring only? → wazero with Javy (once the WASI-stdio shim lands — see Open Questions). Until then, accept the soft sandbox and use goja (see next item).
  3. Is the code user-supplied JS where the threat model is "ordinary customers, not adversaries"? → goja. Per the scale findings, this is the right answer for multi-tenant SaaS hosting thousands of JS extensions — ~9 KB per loaded module, 188K ops/s at high concurrency, no crash modes, no cgo. The cooperative-interrupt caveat applies; pair with a ctx deadline and resource limits at the host boundary.

  4. Is the code an existing .base.js hook from the legacy plugins/jsvm path? → goja (back-compat).

  5. Are you tempted to use v8go for "modern JS perf"? → Don't, for production. v8go v0.9 SIGSEGVs inside libv8 at modest concurrency (~100 invokers). The plugins/v8vm runtime ships but is for benchmarking and experimental use only until upstream ships per-context isolation that resolves the crash.

  6. Do you need REAL CPython (with the full ecosystem including numpy, pandas, cryptography) inside the Go binary, and is your deployment SINGLE-TENANT? → pyvm. CPython 3.13 embedded via a small direct cgo bridge: plugins/pyvm/pyvm_bridge.{c,h} includes <Python.h> and calls Py_InitializeEx(0), leaving signal handling to Go. There is no intermediate binding library. The bridge consolidates a whole invocation into one cgo call (pyvm_invoke), which is the point: per-call cgo crossings, not CPython, dominate the JSON-pipe embedding contract.

    Beats wazero AS by ~2× serial and has the lowest per-invocation memory (496 B/op) of any sandboxed runtime measured. Sub-interpreter pool gives per-tenant-style isolation within the single-process deployment.

    DO NOT use pyvm in multi-tenant builds. A C extension segfault in any tenant's code kills the entire host process and every other tenant. Multi-tenant operators must policy-reject runtime: pyvm in extension manifests. Gate with -tags pyvm at build time; default off.

    Python 3.13 free-threading (PEP 703, python3.13t) is detected at runtime via pyvm.GilDisabled(); when present, the GIL is a no-op and OWN_GIL sub-interpreters become unnecessary for parallelism. Measured 2026-05-18 on Apple M1 Max, default BASE_PYVM_POOL_SIZE=4: free-threading is a non-event for pyvm's embedding workload.

    BuildSerial ns/opParallel ns/op
    3.13 default-GIL4,0735,733
    3.13t free-threaded4,4785,805

    PEP 684 OWN_GIL sub-interpreters already deliver per-OS-thread parallelism on default-GIL; PEP 703 doesn't add headroom unless you abandon the sub-interp pool and run many threads against one interpreter — which we don't. The pool size is the lever, not the GIL mode. Revisit when CPython 3.14+ ships free-threading as default with closed serial-perf gap. Full measurement: ~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md.

  7. Are you serving a workload where seconds of cold start is fine and GPU is needed? → That's HIP-0060 Functions, not this. Wrong HIP.

What we are NOT recommending

  • Don't use v8go for hot-path parallel workloads. The single-isolate mutex in v8go v0.9 serializes everything; parallel scaling is worse than serial. We hit this in the benchmark and have no workaround until v8go ships per-context isolation (upstream issue tracked).

  • Don't use wazero for latency-sensitive code that is loaded per-request. 234× native coldstart eats your tail latency budget. Pre-load at boot and reuse modules.

  • Don't rewrite working Go code to wasm under the assumption that "wasm is faster". It is 4-10× slower in this benchmark for equivalent CPU-bound work. Wasm wins on sandbox, not speed.

  • Don't put Hanzo-internal service logic behind any non-native runtime. Per HIP-0014 every Hanzo service ships as a single Go binary. Service logic is native code; extension points are where user code lives.

Honest caveats baked into the recommendation

  • The goja interrupt is cooperative. A malicious extension author can write a tight numeric loop with no function-call opcodes and resist ctx.Done(). If your threat model requires hard abort, use wazero or v8go. (SupportsAbort: true for goja is honest in the common case; caveated against adversarial guests.)

  • Wazero module.Close() for hard-abort destroys the instance permanently. The Hanzo wazero runtime auto-refills the pool from a background ctx to keep pool size stable. Memory pressure from refills under sustained cancellation is something to watch in observability.

  • V8go's TerminateExecution is isolate-wide. Because Hanzo serializes on the isolate today, the cancellation target is unambiguous. If/when v8go grows per-context isolation, the cancellation story has to be re-examined.

Reference implementation

  • Packages (hanzoai/base, on main):
    • plugins/extruntime/ — interface, manifest, native impl, loader
    • plugins/gojavm/ — goja adapter
    • plugins/wasmvm/ — wazero (pure Go)
    • plugins/pyvm/ — CPython via pyvm_bridge.{c,h} (cgo)
    • plugins/starkvm/ — Starlark
    • plugins/v8vm/ — v8go (cgo, -tags v8vm, stub for the default build)
    • plugins/extbench/ — fixtures + benchmark harness
  • Tests, on the default build: extruntime 6 passed, gojavm 7 passed, wasmvm 2 passed and 4 skipped, v8vm 0 — its tests are behind //go:build v8vm, so the default build compiles the stub and runs nothing.
  • Verification: go build ./plugins/... and go test ./plugins/... are clean. The v8vm tag is NOT verified: rogchap.com/v8go v0.9.0 is declared in go.mod but absent from the module cache, so a tagged build cannot reach the compiler without fetching it.

Backwards compatibility

The existing plugins/jsvm API (goja-based hooks for .base.js files) is untouched. Old hooks continue to work via the old path. The new extension runtime is additive — opt in by writing an extension.json.

Security considerations

  • Sandbox boundary is the runtime's responsibility. wazero and v8go enforce memory isolation; goja and native share the host heap.
  • Resource limits: wazero supports stack limits and memory caps; v8go v0.9 lacks a per-isolate heap-limit API (V8 default ~768MB-1.5GB applies). Host code MUST set a ctx deadline on every Invoke to protect against runaway CPU.
  • Path traversal: the loader resolves module relative to the manifest directory and refuses paths containing ...
  • Manifest validation: name must be alphanumeric + dash/underscore; runtime must be one of the four known values; an unknown runtime causes the extension to be skipped with a logged warning, not loaded against the wrong engine.

Open questions

  1. Javy support — Shopify's Javy (raw JS → wasm via QuickJS-in-wasm) emits a WASI command module (_start reads stdin, writes stdout). Our wazero convention is pointer-based. The two ABIs don't compose without a stdio shim. Either we ship the shim (a small wazero adapter that forwards stdio buffers as the pointer-based payload) or we publish a Javy plugin that emits our convention directly. Tracked in plugins/extbench/fixtures/wazero-javy/TODO.md.

  2. Component Model / WIT — wazero's component-model branch is experimental. Once stable, we should reconsider whether the hand-rolled pointer/len ABI is worth the simplicity or if we should adopt WIT interfaces. Likely revisit Q3 2026.

  3. Hot reload — plugins/jsvm has filesystem watching; the new extension loader does not. Add only if a real consumer asks for it.

  4. Host API surface — what can extension code call back into? DB access, HTTP fetch, KMS reads, log emission? Each runtime needs plumbing. Suggest a separate HIP for the host-API contract once we have a concrete extension that exercises it.

References