DocsReference
#ADR 0009: A module may be mounted in-process or as a sidecar Worker
Status: accepted, 2026-09-06
#Context
ADR 0003 composes a venture's modules at compile time: the venture lists module crates, Harness::build() wires them, and the wasm binary contains exactly those modules. That is right for a venture that owns its backend, and it leaves one case with no answer for a managed service that provisions one.
It is worth saying first what the case is not. Build cost was the obvious suspicion and it is unfounded: adding a module crate to a warm build takes about five seconds, and a cold build with nothing cached is under thirty (docs/BUILD-COST.md, issue #58). Nothing in this ADR is justified by avoiding a rebuild, and no documentation should claim otherwise.
The real case is confidentiality. A customer may want a module whose source they will not hand to a supplier. Linking it into a shared artifact means we hold the source. Building the whole bundle privately for that customer still means we hold the source. Neither is acceptable, and there is no arrangement of compile-time composition that fixes it, because compiling requires the code. That is the case this ADR exists for.
#Decision
A module may be mounted one of two ways, and a caller cannot tell which.
- In-process. The crate is linked into the Worker, as ADR 0003 describes. This stays the default.
- Sidecar. The module runs in its own Worker, carrying one module plus core and the runtime, reached over a Cloudflare service binding and mounted at the same
/v1/<name>prefix. The harness forwards the method, path, query and body; headers cross only the explicit allowlist of the issue #131 amendment below, and the answer is copied back through the response allowlist there.
The mount is runtime configuration, not a builder call. A .sidecar() in src/lib.rs would bake a customer-specific mount into the artifact, so the artifact would stop being a function of the module set and #59's cache could never hit for a sidecar customer. The mount table is read from configuration alongside the other bindings, so one wasm serves customers with and without sidecars. This matters for artifact identity and provenance rather than for build time, which is measured and small.
A sidecar binds the same database and its own secrets. The database is shared because a sidecar is a module of that tenant, not a separate tenant. The secrets are not shared, and this is deliberate: HARNESS_SECRET signs the confirm and unsubscribe links every module mints, so a sidecar holding it could forge them for the host's modules, and ADMIN_TOKEN would open the host's admin endpoints. Nothing in the code needs sharing, because each module signs and verifies its own tokens (ADR 0006). Each Worker is provisioned its own secrets and rotates them independently. The caller's Authorization stops at the boundary (issue #131): the host authorizes admin paths under a mount with its own token, and the gate re-materializes the sidecar's own admin token only for requests whose gateway stamp carries that verdict, so neither Worker ever holds the other's secret.
Validation moves to the first request. HarnessBuilder::build never sees Env; Runtime::provides() is a static declaration and real binding presence is discovered in Cloudflare::ports(). A missing service binding therefore cannot be a build error the way a missing port is. Sidecar mounts are validated on the first request per isolate, and a failure degrades that prefix only, returning 503, while every other module keeps serving.
The contract is checked on every response, not cached at cold start. An isolate can outlive a sidecar redeploy, so a handshake cached at startup would keep reporting a contract that no longer holds. Every harness response carries x-harness-api and x-harness-module; the host checks them on each forwarded response. This costs no extra subrequest and catches a redeploy within one request.
#Scope
This narrows two statements rather than reversing them. ARCHITECTURE §2 principle 2, "the wasm binary contains exactly those modules", becomes "exactly those modules it serves in-process". §12's non-goal of runtime plugin loading is unchanged: Cloudflare's WebAssembly.instantiate() accepts only pre-compiled modules, so nothing is loaded at request time, and a sidecar is a separately deployed Worker rather than a plugin. ADR 0008's tenant isolation is untouched: one tenant, one database, and sharing a build artifact is not sharing a deployment.
Cloudflare requires a bound Worker to be on the same account as its caller. In the customer-account deployment mode this is what makes the promise work: both Workers are the customer's, and they deploy the sidecar with their own credentials. In a hosted mode it does not hold, because Workers Scripts: Edit is account-scoped with no per-script resource, so a credential given to one customer would reach every other Worker in that account. The hosted-mode mechanism is deferred to #67 and is not decided here.
#Consequences
- A sidecar carries its own copy of core and the runtime. At a few MB against a 64 MiB uncompressed script limit, size is not a constraint.
- The host spends one subrequest per sidecar call, against 10,000 per invocation on the paid plan. The sidecar's own D1 queries count against the sidecar's invocation budget, not the host's.
- The in-process event bus does not cross the boundary; #62 decides what replaces it. ~~Until then a sidecar can neither emit events the host hears nor subscribe to the host's.~~ Decided and built since: ADR 0017 keeps the crossing inbound-only — a sidecar still cannot emit events the host hears, but the host now forwards its own emissions to each sidecar's
POST /__eventsinsidewait_until, proven live by thewrangler dev sidecar event forwardCI job (issue #258). - Every schema tool walks
harness.modules()and so cannot see a sidecar. Its migrations, the duplicate-table check andfz data exportall need explicit work before a sidecar may own tables; #66. - A sidecar cannot serve
well_known()routes, because the host nests it only under/v1/<name>. The auth service, which needs the root-level/.well-knownroutes added in #46, therefore cannot be a sidecar. - A sidecar must not be publicly routable. The host's rate limiting, captcha and request-id trust key off the
cf-connecting-ipit forwards; a directly reachable sidecar would accept a client-supplied one. Issue #131 turned that from an operational rule into an enforced one — see the amendment below.
#Amendment (issue #131): the trust boundary
The Decision above shipped a forwarder. Unfiltered headers made the mount a leak: a caller's Authorization — the venture's admin bearer — crossed to a Worker with its own secrets, a client-forged cf-connecting-ip reached the sidecar's abuse controls, a sidecar's set-cookie could plant cookies on the venture's origin, and a publicly reachable sidecar answered the open internet because nothing at the seam said otherwise. #131 writes down the boundary:
- Request allowlist. Method, path, query and body cross. Of headers, only
content-type,content-length,accept,accept-languageanduser-agent. The host then stamps its ownx-request-id, acf-connecting-ipresolved from the actual inbound request, and — whenSIDECAR_GATEWAY_SECRETis configured — anx-harness-gatewaytoken: an HMAC under the shared secret, purpose-bound (sidecar-gateway, so a confirm-link token can never pass and vice versa) and 120 s lived. A request the host authorized as admin is stamped under a second, disjoint purpose,sidecar-gateway-admin— see Admin below.authorizationandcookienever cross; neither does anything unlisted. - Response allowlist.
content-type,location,cache-control,etag,last-modified,vary,retry-after,content-disposition,x-harness-api,x-harness-moduleandx-request-idreturn.set-cookiestops at the host. - The gateway. A sidecar that sets
SIDECAR_REQUIRE_GATEWAYanswers401 sidecar-unauthorizedfor every guarded route —/v1/*and/__surface— whose token it cannot verify; requiring the gate without a usable secret fails closed with503. Probes and the sidecar's own UI stay reachable, so a directly-routable sidecar protects its API even while remaining operable. A verified token's subject (the mount name) is forensic, not checked: a sidecar cannot know the name a host chose for it. - Admin.
/adminpaths under a mount are authorized by the host, against the host's ownADMIN_TOKEN, before forwarding. The gateway token is the host's assertion that this happened, and it carries that assertion in the MAC: only then is the stamp minted undersidecar-gateway-admin, and only that purpose lets the gate re-materialize the sidecar's own admin token for the module routes behind it. The distinction is load-bearing, because every proxied request carries a stamp — were the plain one enough, a single captured stamp would be an admin credential for its whole lifetime. Each Worker keeps checking its own secret, as the Decision promised, and no bearer ever crosses in either direction. - Abuse. Forwarded writes (
POST,PUT,PATCH,DELETE) pass the host's rate limiter and fail closed. Captcha stays with the module that serves the route and verifies its single-use token once; the boundary is not a second chance to consume it. - Surface merge. A sidecar's
/__surfaceis fetched with the gateway stamp, byte-capped before parsing, contract-checked, limited to exactly the mounted module's public part (a second or foreign entry rejects the whole document), declaration-capped, validated, and stripped of captcha-demanding actions on a production host that has noCaptchaport. A bad or oversized answer contributes nothing and is logged. - One Worker.
HARNESS_ONE_WORKERtruthy alongside a non-emptyHARNESS_SIDECARSis a configuration error where the table is read: a one-Worker deployment serves everything in-process and can mount nothing.
#Amendment (issue #157): a second justification, and the language of a sidecar
A second justification now stands beside confidentiality. The Decision's case was keeping source away from a supplier. This one needs no secrecy at all: the only way to add business rules to a venture today is a crate implementing Module, and every declaration point on that trait is a Rust type — router() returns an axum::Router, requires() a &'static [Port], migrations() a Migrations, events() a Vec<(EventName, EventHandler)> — so a customer who does not write Rust has no way in at all, even one glad to show us the source. It is a reason in its own right, not a variation on confidentiality.
ADR 0000 is not disturbed. The harness, core, the runtimes and every module the project ships stay Rust. What settles here is narrower: the sidecar's language was never the harness's business, because the only channel is the Dispatcher port over a service binding and the contract is entirely HTTP-observable. docs/MOUNTING.md's A sidecar need not be written in Rust is that contract, written as an implementer's checklist.
Data crosses the Tables contract, never a raw handle. This narrows the Decision. There, a sidecar binds the host's same database because a Rust sidecar carries core, sea-query and the migration machinery and can be trusted with a handle. A sidecar carrying none of that has no safe way to hold one — no migrations, no dialect-portable SQL, no declared-table validation — so it reaches data over the Tables HTTP contract instead. The contract is live (#153); the typed client is not (#155), so today such a sidecar either hand-rolls its requests against the routes or stays confined to logic touching no data.
HARNESS_SECRET still does not cross. Unchanged from the Decision and already enforced in code; a non-Rust sidecar is no different. It provisions its own secrets, and SIDECAR_GATEWAY_SECRET remains the one secret deliberately shared.
Why a separate Worker, once customer TypeScript is running. The tempting argument here is the one this ADR forbids, so it is written down in its place. That a separate Worker redeploys in seconds rather than the minutes a wasm rebuild takes is not a reason for the sidecar to exist — the disclaimer above disqualifies it, and nothing here revives it; it is an argument about the form the sidecar takes. Bundling customer code into the harness Worker would make the artifact a function of the customer's code as well as the module set, and would put customer code inside the isolate that holds HARNESS_SECRET. A separate Worker keeps the artifact a pure function of the module set — the property that lets the build key deliberately exclude the mount table — and keeps the #131 trust boundary intact. The faster redeploy follows as a consequence, not a reason, so ## Not a reason: build time stands.
Mirrored from docs/adr/0009-sidecar-modules-over-service-bindings.md at b6f50cd. The harness is the source of truth: a correction goes there first.