Authoritative wire contract between vaned and external WASM plugins. This document is the single source of truth for the WIT shape, host-function surface, error model, and lifecycle obligations that plugin authors depend on.
Runtime behavior, instance pool model, observability, dedup, and policy concerns live in crates/engine-wasm.md. This file specifies the contract.
- Package:
vane:plugin@<major>.<minor>.<patch>. Current:vane:plugin@0.1.0. - Plugins declare the package version they target via
metadata.abi-version. The host rejects loading any component whoseabi-versionmajor differs from the host's. - Additive minor bump (host accepts plugins built against the previous minor): adding optional record fields, adding
context-valuevariants, adding host functions, widening acceptedon-error-hintvalues. - Major bump (recompilation required): renaming or removing fields, narrowing variants, changing function signatures, tightening trap conditions.
A plugin's world imports the host interface and exports registry plus zero or more kind-specific handler interfaces — exactly the kinds the plugin implements.
// jwt-validator.wit (plugin author writes this)
package my-plugins:jwt@1.0.0;
world jwt-validator {
import vane:host/host@0.1.0;
export vane:plugin/registry@0.1.0;
export vane:plugin/handler-l7-request@0.1.0;
}The host introspects which vane:plugin/handler-* interfaces the component exports and cross-checks against metadata.exports.
Single function called once per component load. Returns static metadata describing every middleware exported by the component.
package vane:plugin@0.1.0;
interface registry {
use types.{metadata};
get-metadata: func() -> metadata;
}
interface types {
enum middleware-kind {
l4-peek,
l4-bytes,
l7-request,
l7-response,
}
record middleware-export {
// Export name within the component (e.g. "jwt-validator").
// Appears in rule config as "<module>:<name>".
name: string,
kind: middleware-kind,
// Pool model: stateless instances are reused via PoolingAllocator;
// stateful instances are pre-allocated per call site.
stateless: bool,
// Drives LazyBuffer activation at compile time. For l7-response
// middleware, this refers to response body.
needs-body: bool,
// Capability declaration. The host packs ONLY paths declared here
// into `context` on each call; reading other paths is impossible.
// Path grammar: see § Context exposure.
inspects: list<string>,
// Reserved for forward compatibility. Must be false in 0.1.0;
// the host rejects components whose any export sets this true.
needs-streaming-body: bool,
}
record metadata {
// Logical plugin name (informational; metric / log label).
name: string,
// Plugin semver (informational).
version: string,
// ABI version this component targets. Must equal "0.1.0" or the
// host rejects the component.
abi-version: string,
exports: list<middleware-export>,
}
}The host rejects load if:
abi-versionmajor differs from the host's.- Any
middleware-export.kind = Klacks the correspondinghandler-Kinterface export. - Any
middleware-export.needs-streaming-body = true. - Two
middleware-exportentries share the samename.
One interface per middleware-kind. A plugin exports only the interfaces matching the kinds it implements. Within an interface, handle takes a name parameter selecting which export within that kind handles the call — this lets a single component export multiple middlewares of the same kind.
package vane:plugin@0.1.0;
interface handler-l4-peek {
use types.{plugin-error, context-entry};
record l4-peek-input {
// Bytes peeked from the connection; up to the host's peek-prefix
// limit (default 8 KiB).
peek: list<u8>,
// Field paths declared in `inspects`, packed by host.
context: list<context-entry>,
}
variant l4-peek-decision {
continue,
close,
}
handle: func(name: string, input: l4-peek-input)
-> result<l4-peek-decision, plugin-error>;
}interface handler-l4-bytes {
use types.{plugin-error, bytes-view, context-entry};
record l4-bytes-input {
bytes: bytes-view,
context: list<context-entry>,
}
variant l4-bytes-decision {
continue,
tunnel,
close,
}
handle: func(name: string, input: l4-bytes-input)
-> result<l4-bytes-decision, plugin-error>;
}interface handler-l7-request {
use types.{plugin-error, header, bytes-view, context-entry};
record l7-request-input {
// Upper-case ASCII (e.g. "GET", "POST").
method: string,
// Request-target as on the wire (origin-form for proxied requests).
uri: string,
// Names are ASCII-lowercase; values are UTF-8 (see § Headers).
headers: list<header>,
// Present iff the export's `needs-body` is true.
body: option<bytes-view>,
context: list<context-entry>,
}
record synth-response {
status: u16, // [100, 599]
headers: list<header>, // host normalizes names on emit
body: list<u8>,
}
variant l7-request-decision {
continue,
short(synth-response),
close,
}
handle: func(name: string, input: l7-request-input)
-> result<l7-request-decision, plugin-error>;
}l7-request-decision deliberately lacks any "route to node X" variant. Plugins decide; the FlowGraph routes. Plugin reasoning stays local to its own input.
interface handler-l7-response {
use types.{plugin-error, header, bytes-view, context-entry};
record l7-response-input {
status: u16,
headers: list<header>,
// Present iff the export's `needs-body` is true.
body: option<bytes-view>,
context: list<context-entry>,
}
record modified-response {
// none = leave unchanged; some = full replacement.
status: option<u16>,
headers: option<list<header>>,
body: option<list<u8>>,
}
variant l7-response-decision {
continue,
modify(modified-response),
abort,
}
handle: func(name: string, input: l7-response-input)
-> result<l7-response-decision, plugin-error>;
}abort causes the response delivery to fail with the connection closed; the rule's on_error does not apply (response middleware runs after the response was committed in the abstract sense, so retry-or-recover routing is ill-defined).
Per-rule plugin args (the args JSON in rule config) are delivered once per instance lifetime, not on every call. The plugin retrieves them via the host import:
get-args: func() -> string;- The returned string is always JSON; minimum value is
"{}". - Values are stable for the instance's lifetime: stateless pool instances see the args of whichever rule rented them (since stateless dedup is keyed on
(module_id, export_name, args_canonical_json), all rentals through oneMiddlewareIdshare one args value); stateful pool instances see the args of the call site they belong to. - Re-reading
get-argsreturns the same value. Plugins typically cache + parse it once during construction.
Args are configuration, not request data. Per-call repetition would waste serialization on every invocation.
record bytes-view {
data: list<u8>,
truncated: bool,
}datacarries up to the kind's body limit. Defaults: 1 MiB request, 1 MiB response, 64 KiB l4-bytes. Per-plugin override via plugin config.truncated: truemeans the actual body exceeded the limit;dataholds the prefix.- The plugin chooses fail-closed (return
plugin-error) or proceed-with-prefix based ontruncated. body: option<bytes-view>isnonewhenever the export'sneeds-body = false— plugins that did not declare body need do not see body data.
Streaming bodies are not supported in 0.1.0. The needs-streaming-body reserved field is the forward-compatibility hook; setting it true today causes load rejection.
record header {
// Host guarantees ASCII-lowercase.
name: string,
// UTF-8. Non-UTF-8 byte values are escaped as `\x{HH}`.
value: string,
}- Inbound headers (in
*-input) have names lowercased by the host. - Multiple headers with the same name preserve their wire order in the list.
- Outbound headers (in
synth-response,modified-response) need not be lowercased; the host normalizes before emission. - Header values containing CR, LF, or null bytes in plugin output trap (see § Trap conditions).
context: list<context-entry> carries connection and request fields the middleware declared in inspects.
record context-entry {
path: string,
value: context-value,
}
variant context-value {
text(string),
bytes(list<u8>),
int64(s64),
uint64(u64),
boolean(bool),
list-text(list<string>),
}The host packs only paths declared in inspects. Reading any other field is impossible — the data is not delivered. Path declarations are validated at plugin load: unknown paths cause load rejection. This makes inspects a real capability declaration and lets FlowGraph compile-time analysis (LazyBuffer activation, predicate sharing, mTLS gating) be sound.
| Path | context-value |
Notes |
|---|---|---|
conn.peer_ip |
text |
Textual representation (e.g. "192.0.2.5"). |
conn.peer_port |
uint64 |
0–65535. |
conn.local_ip |
text |
|
conn.local_port |
uint64 |
|
conn.transport |
text |
"tcp" | "udp" | "quic". |
conn.alpn |
text |
Empty string if no ALPN. |
conn.id |
text |
ConnId hex. |
conn.accept_unix_ms |
uint64 |
|
conn.tls.version |
text |
"1.2" | "1.3" | "" if not TLS. |
conn.tls.sni |
text |
ASCII-lowercase. Empty if no SNI. |
conn.tls.peer_cert |
bytes |
DER-encoded leaf cert. Empty if no client cert. |
conn.tls.peer_cert.present |
boolean |
true iff a verified peer cert is attached. |
conn.tls.peer_cert.subject_cn |
text |
Empty when present == false. |
conn.tls.peer_cert.san_dns |
list-text |
DNS-type SAN list. Empty when present == false. |
conn.tls.peer_cert.fingerprint_sha256 |
text |
Hex (lowercase). SHA-256 of the full leaf DER. |
conn.tls.peer_cert.spki_sha256 |
text |
Hex (lowercase). SHA-256 of SubjectPublicKeyInfo. Rotation-stable. |
conn.tls.peer_cert.issuer_cn |
text |
|
conn.tls.peer_cert.serial |
text |
Hex (lowercase). Big-endian, no leading-zero stripping. |
Request / response paths are also declarable; declare them only when the middleware needs the value via the context channel (e.g. for predicate-style sharing) rather than reading the corresponding field on *-input. The path table mirrors the predicate field-path grammar in crates/core.md § Predicate.
record plugin-error {
// Short stable identifier (e.g. "policy.denied", "input.malformed").
code: string,
// Operator-facing description.
message: string,
// Routing hint for the host's error channel.
on-error-hint: option<string>,
}on-error-hint interpretation:
| Value | Meaning |
|---|---|
none |
Default. Use the rule's on_error config (see flow-model.md § Two error channels). |
"force-close" |
Ignore on_error; close connection (L4) or send 500 + close (L7). Reserved for unrecoverable plugin-internal errors. |
"internal" |
Treat as internal anomaly: log + emit metric + apply on_error tombstone. Routine errors should not use this. |
Other hint values trap (treated as malformed plugin output).
plugin-error is distinct from a trap. Returning plugin-error is an in-band, plugin-designed outcome and does not surface as a wasmtime trap. See crates/engine-wasm.md § Trap and error handling for the dual-channel semantics.
A single import: vane:host/host@0.1.0. All functions are sync from the plugin's perspective; the host's wasmtime async-bridge handles concurrency.
package vane:plugin@0.1.0;
interface host {
use types.{plugin-error};
get-args: func() -> string;
enum log-level { trace, debug, info, warn, error }
record log-field {
key: string,
// Stringified value: numeric → decimal, bool → "true"/"false".
// UTF-8 required (see § Trap conditions).
value: string,
}
log: func(level: log-level, message: string, fields: list<log-field>);
now-unix-ms: func() -> u64;
random: func(buf-len: u32) -> list<u8>;
record metric-label {
key: string,
value: string,
}
// The host enforces a per-plugin cardinality cap (default 1000 series).
// Emissions exceeding the cap are dropped and a single warn-level
// log is emitted per cap event per plugin.
metric-counter: func(name: string, delta: u64, labels: list<metric-label>);
metric-gauge: func(name: string, value: s64, labels: list<metric-label>);
record http-fetch-request {
method: string, // upper-case ASCII
url: string, // absolute URI per RFC 3986
headers: list<tuple<string, string>>,
body: list<u8>,
// Per-call timeout. Falls back to plugin config default,
// then daemon default (30 s).
timeout-ms: option<u32>,
// 0 disables redirects. Falls back to plugin config default
// (default 5).
follow-redirects: option<u32>,
// Per-call insecure flag. Honored only when plugin config has
// `allow-insecure: true`; otherwise ignored and TLS is verified.
verify-tls: option<bool>,
}
record http-fetch-response {
status: u16,
headers: list<tuple<string, string>>,
// Truncated to plugin config max-body-size (default 1 MiB).
body: list<u8>,
}
variant net-error {
dns-failure(string),
connection-refused,
timeout,
tls-error(string),
pool-exhausted,
body-too-large,
not-allowed(string), // outside `allowed_hosts`
insecure-rejected, // verify-tls=false but allow-insecure=false
internal(string),
}
http-fetch: func(req: http-fetch-request) -> result<http-fetch-response, net-error>;
}http-fetch shares the daemon's TcpPool (same fingerprint, same observability) via the HttpFetchBackend trait declared in vane-core. Policy detail (allowed_hosts default, default ClientConfig, mTLS overrides) lives in crates/engine-wasm.md § http-fetch policy.
module_id is the canonical absolute filesystem path of the .wasm file (e.g. /etc/vaned/wasm/jwt-validator.wasm).
On hot reload of a path:
- Compute content hash; deserialize or compile per
crates/engine-wasm.md§ Boot. - Invoke
registry.get-metadata()on the new component. - Compare new metadata to cached for that
module_id:- If
(kind, stateless, needs-body, inspects)matches per export and the export-name set is identical — module-only swap. The FlowGraph is not recompiled; theMiddlewareInst::Wasmcontinues to refer tomodule_id, and instances rented after the swap construct against the new component. - Otherwise — metadata-changed reload. Triggers full FlowGraph recompile.
- If
metadata.nameandmetadata.versionchanges alone do not affect routing — they only annotate metric and log labels.
Renaming or moving a .wasm file is treated as deletion + addition: the old module_id drops with its FlowGraph generation; the new one compiles into the next graph generation.
The ABI does not propagate cancellation signals. Plugin invocations run to completion or hit the per-call epoch deadline (default 10 ms; configurable per plugin). Client disconnect mid-invocation is not signaled to the plugin; the plugin's eventual return is discarded by the host.
The 10 ms ceiling makes proactive cancellation a marginal optimization.
// TODO(host-is-cancelled): a future minor ABI version may add
// `host.is-cancelled() -> bool` if profiling justifies it.The host increments the wasmtime engine's epoch counter every 1 ms. Combined with the default 10 ms per-call deadline, plugin invocations are preempted within 10 ms ± 1 ms. Tick frequency is fixed (not configurable per plugin) so host-side overhead stays constant regardless of plugin count.
Reserved fields and values, intentionally unused in 0.1.0, that future minor versions may activate without a major bump:
middleware-export.needs-streaming-body— when true (rejected today), enables resource-handle body streaming.plugin-error.on-error-hint— additional string values may be added.- Keys starting with
vane.inlog-fieldandmetric-labelare reserved for host-injected fields; plugins must not emit them.
Conditions that trap (the host's bindgen! shim returns Err to the engine, treated as internal anomaly per crates/engine-wasm.md § Trap and error handling):
- Returning a
plugin-error.on-error-hintvalue not in{none, "force-close", "internal"}. - Returning a
synth-response,modified-response, orheaderwhosenameorvaluecontains CR, LF, or null bytes. - Returning
synth-response.statusormodified-response.statusoutside[100, 599]. - Calling
host.logwith non-UTF-8 bytes inmessageor anylog-field.value. - Calling
host.http-fetchwith aurlthat fails RFC 3986 absolute-URI validation. - Calling
host.metric-counterorhost.metric-gaugewith anameoutside[a-zA-Z_][a-zA-Z0-9_]*. - Returning a WIT-decoded value the wasmtime host bindings cannot deserialize. Listed for completeness; the bindings already trap these.
plugin-error returned via result.err is not a trap — it flows through the regular middleware error channel.