// plecto:filter — the typed contract between the native fast path (host) and
// WASM filters (extension plane). See ADR 000001 / 000002.
//
// v0.3.0 (ADR 000073): `on-response` carries the as-forwarded request snapshot and
// `response-decision` gains `replace`. Base scope (ADR 000010): sync, header-only,
// Plecto's OWN http types.
// stream<u8> bodies, async funcs, and wasi:http type reuse arrive in a later
// increment. wasmtime 46 (shipped 2026-06-22) brings Component Model async +
// WASI 0.3 by default (ADR 000003 / 000005); the committed direction is to
// CONVERGE these types onto wasi:http (proxy / middleware) in M3 (ADR 000020).
// Until that migration this world stays buildable+runnable on the stable
// toolchain with no experimental flags.
package plecto:filter@0.3.0;
interface types {
// A single HTTP header field.
record header {
name: string,
value: list<u8>,
}
// The request as seen by a filter. Header-only in v0.1.0 (no body).
record http-request {
method: string,
// path including query, e.g. "/api?x=1"
path: string,
authority: string,
scheme: string,
headers: list<header>,
}
// A response a filter synthesises when it short-circuits the chain.
record http-response {
status: u16,
headers: list<header>,
body: list<u8>,
}
// A rewrite a filter asks the host to apply to the request before continuing.
record request-edit {
set-headers: list<header>,
remove-headers: list<string>,
}
// A rewrite a filter asks the host to apply to the response before continuing.
record response-edit {
set-status: option<u16>,
set-headers: list<header>,
remove-headers: list<string>,
}
// The typed outcome of a request-side filter (Tenet 3). Never a bare flag.
variant request-decision {
// pass unchanged to the next filter
%continue,
// apply the edit, then continue
modified(request-edit),
// stop the chain and synthesise this response now (don't reach upstream)
short-circuit(http-response),
}
// The typed outcome of a response-side filter (ADR 000073).
variant response-decision {
%continue,
modified(response-edit),
// Stop the chain and send this SYNTHESISED response instead of the upstream one
// (the upstream body stream is dropped, never read — ADR 000038 stays intact).
// Distinct from the request side's `short-circuit`: nothing is short-circuited
// here — a response that already arrived is being replaced (P12). The host
// validates the returned headers fail-closed, exactly like a `short-circuit`
// output (ADR 000071 rules).
replace(http-response),
}
// The typed outcome of a request-side BODY filter (buffer-then-decide, ADR 000025). v1 carries
// the buffered body as `list<u8>`; the deferred true-streaming increment swaps it to `stream<u8>`
// (the contract keeps room for a `body` resource then). Never a bare flag (Tenet 3).
variant request-body-decision {
// continue with the (possibly transformed) body
%continue(list<u8>),
// stop the chain and synthesise this response now (don't reach upstream)
short-circuit(http-response),
}
}
// --- host-API: deny-by-default capabilities (ADR 000006). One capability per
// --- interface; a filter touches only what the host explicitly lends it.
interface host-log {
enum level { trace, debug, info, warn, error }
log: func(level: level, message: string);
}
interface host-clock {
// Wall-clock milliseconds since the Unix epoch, captured once at request
// start: a stable per-request snapshot. Repeated calls within one request
// return the same value (deterministic TTL / rate-limit logic).
now-ms: func() -> u64;
}
interface host-kv {
// host-held mutable business state (ADR 000004 / 000011): the place a stateless
// filter keeps session / cache bytes. Backed by a pluggable `KvBackend` — an
// in-memory map by default, redb when a durable store is configured (ADR 000004).
// Keys are host-namespaced per filter identity (ADR 000011); a filter cannot
// reach another filter's keyspace.
get: func(key: string) -> option<list<u8>>;
set: func(key: string, value: list<u8>);
delete: func(key: string);
}
// --- host-counter: atomic named counters (ADR 000004). Shape mirrors
// --- wasi:keyvalue/atomics so polyglot filters meet a familiar contract.
interface host-counter {
// Atomically add `delta` to the counter at `key` (host-namespaced per filter,
// ADR 000011) and return the NEW value. `delta` is signed, so decrement and
// reset-toward-zero are expressible in one call. An unset counter reads as 0.
increment: func(key: string, delta: s64) -> s64;
// Read the current value without mutating it (0 when unset).
get: func(key: string) -> s64;
}
// --- host-ratelimit: the token bucket stays HOST-NATIVE (ADR 000005). The hot-path
// --- refill + counting never cross the WASM boundary; the filter only DECIDES to
// --- consult the limiter and on what key. This is the fast-path/extension-plane
// --- split applied to rate limiting.
interface host-ratelimit {
// The outcome of a token-bucket acquire.
record acquire {
// whether `cost` tokens were granted
allowed: bool,
// tokens left in the bucket after this call
remaining: u64,
// when denied, ms until enough tokens refill to satisfy `cost` (0 when allowed)
retry-after-ms: u64,
}
// Try to take `cost` tokens from THIS filter's bucket at `key` (host-namespaced per
// filter). The bucket spec (capacity / refill) is configured HOST-SIDE in the manifest
// (`[filter.ratelimit]`, ADR 000026), NOT supplied by the filter — so an untrusted filter
// cannot neuter its own limiter by claiming a huge capacity. A filter with no host-
// configured bucket is denied (fail-closed). Refill uses the per-request clock snapshot
// (host-clock), deterministic within a request; a backend error also denies.
try-acquire: func(key: string, cost: u64) -> acquire;
}
// --- host-config: read-only, manifest-declared business config (ADR 000066). A generic
// --- string passthrough — the host does not interpret keys or values, only the filter does.
// --- Operator-owned (the filter cannot write it), same ownership model as host-ratelimit's
// --- bucket spec. Absence of a key just means it was not declared in `[filter.config]`; a
// --- filter that requires a key validates it itself (typically in `init`, trapping on a
// --- missing/invalid value — combined with `isolation = "trusted"` this surfaces as a load
// --- failure rather than a per-request one, ADR 000066 §Decision 4).
interface host-config {
get: func(key: string) -> option<string>;
}
// The base contract: header-only filters (auth, rate-limit, header rewrite) implement exactly this.
// It deliberately does NOT export `on-request-body`, so the host can tell — by the ABSENCE of that
// export (ADR 000005 mechanism 2 / ADR 000038) — that this filter never reads the request body, and
// skip buffering it entirely (zero-copy streaming passthrough). `request-body-decision` is `use`d so
// its type is available to the host for the optional body call, even though the base world itself
// declares no body export.
world filter {
use types.{http-request, http-response, request-decision, response-decision, request-body-decision};
// granted capabilities (deny-by-default: only these are importable, ADR 000006)
import host-log;
import host-clock;
import host-kv;
import host-counter;
import host-ratelimit;
import host-config;
// heavy, once-per-instance initialisation (Tenet 4)
export init: func();
// hot path — one call per request / response.
//
// `req` on `on-response` is the AS-FORWARDED request snapshot (Declared Semantics,
// ADR 000073): the header-only request as it left the request-side chain — filter
// edits applied (e.g. an auth filter's `x-authenticated-user` stamp), before the
// host's egress transforms (hop-by-hop strip, upstream path rewrite, traceparent
// injection). Untouched inbound headers such as `Origin` are still present. It is a
// value-passed view: editing it does nothing — respond with a `response-decision`.
// `resp.body` is always empty (header-only): the upstream body streams zero-copy
// past the chain (ADR 000038); `replace` SYNTHESISES a body, it never reads one.
export on-request: func(req: http-request) -> request-decision;
export on-response: func(req: http-request, resp: http-response) -> response-decision;
}
// The body-reading contract: identical to `filter` PLUS the `on-request-body` export. A filter that
// inspects or transforms the request body targets this world; the PRESENCE of `on-request-body` is
// the signal that makes the host buffer the body and run this hook (buffer-then-decide, ADR 000025).
// Absence (the base `filter` world) means the body streams straight through. The deferred
// true-streaming increment swaps the `list<u8>` for `stream<u8>`. (Spelled out rather than
// `include filter` — WIT does not propagate an included world's type `use` into a new export's
// scope, so the shared shape is duplicated here deliberately.)
world filter-body {
use types.{http-request, http-response, request-decision, response-decision, request-body-decision};
import host-log;
import host-clock;
import host-kv;
import host-counter;
import host-ratelimit;
import host-config;
export init: func();
export on-request: func(req: http-request) -> request-decision;
export on-request-body: func(body: list<u8>) -> request-body-decision;
// Same as-forwarded / replace Declared Semantics as `filter` (ADR 000073): `req` is the
// chain-output snapshot before egress transforms; `replace` is terminal and synthesises.
export on-response: func(req: http-request, resp: http-response) -> response-decision;
}