Status: Draft
Type: Standards Track
Category: Core
Created: 2026-03-07
Requires: CIP-9 (Runner Attached Storage), CIP-14 (DNS-Addressable Actors); workload registry records for
Workload-target routes only1. Abstract
This proposal defines Public Asset Hosting and HTTP Routing — a unified system for serving static files (HTML, CSS, JavaScript, images, fonts) from CBFSVisibility::Public volumes, dispatching dynamic HTTP requests to named actor handlers, proxying requests to off-chain runners, and relaying long-lived streaming connections (WebSocket / SSE) to registered workloads — all via the CIP-14 Gateway network. The core primitive is a routes table stored in the actor’s KV state that declares, for each (verb, path) pair, whether the request is served from a CBFS volume, dispatched to a named actor handler, or proxied to an off-chain runner, and whether the actor or the caller pays for the request. Workload-target routes are the one exception to that storage story in the initial operator-configured deployment: they are bound by operator static Gateway configuration (§8.15) and become actor-declarable when the v2 route-manifest rewrite lands.
This CIP specifies:
- A unified route schema (verb, path, target, payment policy, priority, enabled) stored in actor KV at the reserved key
__cowboy/routes. - A verb-aware route resolution algorithm that determines per-request dispatch (volume mount, actor handler, or off-chain runner) and payment policy.
- A
Runnertarget that proxies a route off-chain to a selected runner (authorized by arunner_bindingsentitlement), with optional caller authentication and actor- or caller-owned CBFS mounts, for endpoints that cannot run in consensus — LLM inference, outbound API calls, streaming. - A
Workloadtarget that relays a route — including WebSocket upgrades and unbuffered SSE — to a single long-lived registered workload backend (§8.15), for streaming app endpoints: inference SSE, agent chat UIs, application websockets. Initially bound by operator static Gateway configuration; actor-declared in route manifests when the v2 route-model rewrite lands. - A per-route payment policy (
pays = actor | caller) withactoras the default.calleropts the route into payment gating per CIP-18. - An extension to the
ingress.httpentitlement withstatic_volumesandmax_static_response_bytesparameters. - A Gateway-to-Relay-Node fetch protocol for retrieving, reconstructing, caching, and serving public-volume objects.
- A Gateway-to-Runner fetch protocol for reading the routes table from actor state with Merkle-proof verification.
- A CORS configuration schema (
_meta/cors.json) with sensible defaults for static assets. - Cache invalidation driven by on-chain state-root and
manifest_rootchanges. - Runtime mutability of routes via the actor’s normal KV write path, with narrow typed SDK helpers (toggle, re-price, re-prioritize, re-target).
- Pre-compressed asset variants (
.gz,.brfiles in the volume). - Small object inlining (bypassing erasure coding for tiny files).
- Image optimization or resizing at the Gateway edge.
- Range requests and chunked transfer for streaming large assets.
- Caller-pays static-asset downloads (paid public-volume content).
2. Motivation
CIP-14 makes actors reachable over HTTP through a singlehttp.request handler. Real applications, though, are heterogeneous: one actor serves static assets (bundled JavaScript, CSS, images, fonts), dynamic API endpoints, and — increasingly — work that belongs off-chain (LLM inference, outbound API calls, streaming). These have very different needs. A static file should come straight from storage. A state-changing call needs the actor’s deterministic handler under consensus. A model call cannot run in the PVM at all.
A routes table lets the actor make that explicit: for each (verb, path), it declares how the request is served — from a CBFS volume, by a named actor handler, or by an off-chain runner — and who pays. Each request then takes the cheapest correct path, and routing, asset serving, and payment logic move out of handler code into a declarative table that expresses them better.
Concretely, this gives:
- Static served straight from storage: Static assets come directly from the Gateway’s cache or are reconstructed from Relay Node shards — CDN-fast, with no actor handler involved.
- Consensus spent only where it’s needed: The actor’s deterministic handler and
max_query_cyclesbudget are reserved for requests that actually compute or change state; static and runner routes don’t draw on them. - CDN-like performance: Gateways cache reconstructed objects locally, serve conditional requests via ETags, and set proper
Cache-Controlheaders — all without actor involvement. - Verb-aware dispatch to named handlers: Routes target named actor handlers, not a single
http.requestcatchall.GET /postandPOST /postgo to different methods. Path parameters (/users/{id}) bind to handler arguments. Actors no longer reimplement routing. - Per-route payment policy: Each route declares whether the actor or the caller pays.
actoris the default — preserving the open-web property that browser GETs do not need a wallet — whilecalleropts a route into 402/x402 payment gating per CIP-18 for paid endpoints (LLM inference, premium API access, etc.). - Runtime mutability without redeploys: Toggling a route, raising a price, or activating a maintenance redirect does not require shipping new assets. The routes table is small structured state, mutated via the actor’s normal KV write path.
- Familiar developer ergonomics: Routes are declared via decorators on handler functions in the actor’s source code (FastAPI / axum / Hono style). The SDK lowers them to the canonical schema at deploy time. A developer who has shipped a Flask or Express app can ship a Cowboy actor in minutes.
- Off-chain compute as a route: Endpoints that cannot run in the deterministic PVM — LLM inference, outbound API calls, streaming responses — are declared as
Runnertargets and proxied off-chain to a runner, rather than being forced through consensus or bolted on as out-of-band jobs. The same routing table decides, per(verb, path), what runs in consensus, what serves static, and what runs off-chain. - Streaming app backends as a route: Long-lived connections — websockets for live apps, SSE for streamed inference and agent chat — terminate at a registered persistent workload behind the same URL space (
Workloadtarget, §8.15), instead of a sidecar server outside the platform. One domain serves static from a volume, consensus methods from the actor, and live streams from a workload the actor owns.
3. Design Goals
- Serve static assets without invoking any actor handler or consuming PVM cycles.
- Let actors declare routes per
(verb, path), targeting a CBFS volume mount, a named actor handler, or an off-chain runner, with explicit priority ordering. - Let a route execute off-chain on a runner (
Runnertarget) for work that cannot or should not run in consensus, with optional caller authentication and actor- or caller-owned CBFS mounts. - Relay long-lived streaming connections (WebSocket, SSE) unbuffered to a registered persistent workload (
Workloadtarget), with explicit idle-timeout and connection-lifetime policy. - Make
pays = actorthe default so that browser GETs do not require a wallet; let actors opt specific routes intopays = callerfor payment-gated endpoints. - Support SPA (single-page application) fallback patterns (
index.htmlfor all non-file paths). - Reuse CIP-9’s existing
_meta/content_types.jsonand_meta/cache_config.jsonfor HTTP header generation. - Provide CORS headers for static assets by default (browsers need them).
- Define the Gateway-to-Relay-Node fetch protocol for static assets and the Gateway-to-Runner fetch protocol for routes: retrieval, integrity verification, and caching.
- Extend the existing
ingress.httpentitlement — no new entitlement type. - Allow runtime mutation of route metadata (priority, pays/price, enabled, target handler) via narrow typed SDK helpers, without redeploying assets or actor code.
4. Non-Goals
- Replacing actor handlers for dynamic requests. Volume and method targets coexist; the routes table controls which
(verb, path)pair goes where. - Server-side rendering. Actors that need SSR use a method target.
- Pre-compressed asset variants (
.gz,.br). Compression is on-the-fly only. - Image optimization, resizing, or transformation at the Gateway edge.
- Range requests (
Rangeheader) for partial content delivery. - Verifiable or multi-operator runner serving. A
Runner-target route is trusted to the selected runner in this CIP; attested / trustless serving and on-chain settlement of off-chain work are deferred (§13). The same applies toWorkload-target routes: a workload-served route is exactly as trustworthy as its host (§8.15). - Metered billing of streamed content. A
Workloadroute can payment-gate connection establishment (pays = caller, CIP-18); usage-metered streaming settlement (per-token, per-message) is the streaming-payments protocol’s scope, not this CIP’s.
5. Definitions
- Route: An entry in the actor’s routes table that maps a
(verb, path)pattern to aTarget, with a payment policy, priority, and enabled flag. - Verb: An HTTP method (
GET,HEAD,POST,PUT,PATCH,DELETE) orANY(matches any verb at lowest specificity). - Target: The destination of a route — a
Method(named actor handler, executed via actor RPC under consensus), aVolume(CBFS public volume mount), or aRunner(the request proxied off-chain to a selected runner endpoint for non-consensus execution). - Runner target: A route whose handler runs off-chain on a selected runner (the CIP-9 / RAS execution tier), not in consensus. Distinct from the state-read runner role of §8.12 (
GET_STATE), which only serves verified reads of the actor’s KV state; aRunnertarget serves the route’s request and response itself. - Workload target: A route relayed to a single long-lived registered service — a persistent workload with a control-plane registry record (identity, owning actor, serving endpoint). Distinct from a
Runnertarget: no per-request runner selection, no handler dispatch, no route mounts — the Gateway relays bytes to one durable backend, including upgraded WebSocket connections and streamed SSE responses (§8.15). - Workload registry record: The control-plane record describing a persistent workload. This CIP consumes exactly three of its fields —
workload_id,owning_actor, andserving_endpoint.Workload-target routes are unavailable where that registry does not exist. - Pays: The party that bears the cost of a request —
actor(default; Gateway debits actor hot balance per CIP-14) orcaller(Gateway requires payment proof per CIP-18; on missing/invalid proof returns402 Payment Required). - Routes table: The CBOR-encoded
Routesvalue stored in actor KV at the reserved key__cowboy/routes. - Mount: A
Volume-target route with a wildcard path, declared at the SDK module level (e.g.,actor.mount("/assets", volume="web-assets")). - Object: A single file stored in a CBFS volume (e.g.,
assets/logo.png). Objects are erasure-coded into shards and distributed across Relay Nodes. - Shard: One piece of an erasure-coded object, stored on a single Relay Node. Any K of K+M shards are sufficient to reconstruct the original object.
6. Routes
6.1 Storage Location and Rationale
The routes table is stored in the actor’s KV state at the reserved key__cowboy/routes. It is NOT stored in the CBFS volume.
The KV location was chosen over alternatives:
- Volume
_meta/routes.json: atomic with assets but couples cadences that should be independent. Routes change far more often than assets — toggles, prices, A/B paths, abuse-mitigation flips — and forcing acommit_manifestfor every metadata change is friction. It also requires API-only actors (no static files) to provision a CBFS volume just to publish routing rules. - Actor
routes()method called by the Gateway: defeats the static-serving performance goal because every dispatch would need aread_handlerPVM execution to read the table. - Entitlement parameters (CIP-2): immutable after deployment, so changing any route would require redeploying the actor. Website route structures change too frequently.
- Convention-based (e.g.,
/static/*always from volume): too rigid. A path like/app.jsmight be static in one actor and dynamic in another.
- Routes are state, not assets. KV is the natural home for small, structured, mutable actor state.
- Verifiable by the Gateway with no actor execution. The actor’s state root is committed on-chain. The Gateway fetches the value at
__cowboy/routesalong with a Merkle proof against the state root and verifies it without invoking the actor (§8.12). - Update cadence decoupled from asset deploys. Most route changes (toggle, pays, price) don’t touch assets and don’t require
commit_manifest. - Atomicity recoverable when needed. A single block-level transaction can write to
__cowboy/routesand callcommit_manifest, restoring atomic asset+route deploys for the cases that need them.
6.2 Schema
pays = "actor" (the default) makes the Gateway debit the actor’s hot balance for the request, exactly as CIP-14 already specifies for query and command paths. The caller sees a normal HTTP response with no payment friction. This preserves the open-web property that browser GETs do not require a wallet.
pays = "caller" opts the route into payment gating per CIP-18. The Gateway returns 402 Payment Required with x402-compatible headers until the caller submits a valid payment proof. price is required and is interpreted by CIP-18 (per-request, per-byte, etc., depending on CIP-18 modes).
For Volume targets, pays MUST be "actor". Caller-pays static downloads are deferred to a follow-on CIP.
A Runner target proxies the request off-chain to a selected runner, which executes the route handler outside consensus — the body and any stream never enter a transaction — then streams the response back through the Gateway. Dispatch does not bind the route to a node: the route declares an optional requires selector — omitted, it may run on any runner in the actor’s runner_bindings (§7.5); set it to narrow — and the Gateway picks a healthy runner from the eligible set at call time, preferring the same one per affinity key (§8.14). An optional runner pin overrides selection for a route that needs one specific node. This is the path for endpoints that cannot or should not run in the deterministic PVM: LLM inference, outbound API calls, long-running or streaming work. pays = "caller" composes normally (paid compute), and auth = "account" lets the handler act on the authenticated caller’s behalf — e.g., reading or writing the caller’s own CBFS volume via a caller-owned mount. On-chain state changes still belong behind a Method target; a Runner handler’s execution is never consensus.
A Workload target relays the request to one long-lived registered workload — a persistent service (model server, agent chat backend, app relay) carrying a control-plane registry record — rather than dispatching per-request to a selected runner. This is the streaming path: the Gateway upgrades and pipes WebSocket connections, relays SSE unbuffered, and applies stream-lifetime policy (idle_timeout_ms, max_connection_ms) instead of the request-scoped timeout_ms (§8.15). pays = "caller" gates connection establishment; auth = "account" authenticates the establishing caller. There is no runner selection, no handler name, and no route mounts — the workload’s volume mounts (and any future capabilities) travel with its registry record. Until actor-declared routes land in the shipped v2 route-manifest model (§6 errata), workload routes are bound by operator static Gateway configuration with these same fields (§8.15).
6.3 Path Patterns
Route paths use the following syntax:/api/users/{user_id}— named path parameter; binds to a single segment./static/*rest— wildcard; matches zero or more segments and binds the remainder torest./about— exact match./assets/*— anonymous wildcard; matches anything under the prefix without binding.
HttpRequestEnvelope.path_params field (CIP-14 §8.5). Static (Volume-target) routes do not bind path parameters; the matched suffix is handled per §6.7.
6.4 Example: Full-Stack Application
This CIP defines the canonical wire format (theRoutes value at __cowboy/routes) and the Gateway’s resolution algorithm. SDK ergonomics are an implementer’s choice; this section shows one such surface — a Python SDK that follows the canonical @actor / @actor_method style established by CIP-6 — to illustrate how a typical full-stack actor would be authored.
mount(...) declarations to the canonical Routes value, written to __cowboy/routes at deploy time:
GET /api/users/abc→ matches/api/users/{user_id}(priority 100) → dispatched tousers_gethandler withpath_params = {"user_id": "abc"}. Actor pays.POST /api/inference→ matches/api/inference→ Gateway returns402with x402 headers until caller pays0.05 CBY, then proxies the request off-chain to a runner selected from the eligible set (§8.14);inference_runruns on the runner, not the actor PVM.GET /assets/logo.png→ matches/assets/*(priority 10) → served fromweb-assetsvolume atassets/logo.png. No actor invocation.GET /about→ matches/*(priority 0) → looks upaboutinweb-assets. Not found → servesindex.htmlwith status200(SPA fallback).GET /_cowboy/health→ reserved path, always Gateway-intercepted (CIP-14 §8.6), never consults the routes table.
@get/@post/etc. are sugar for the underlying @route(verbs, path) decorator; both can coexist:
foo.
Runner selector ergonomics (informative). requires is optional, so runner() with no selector inherits the actor’s runner_bindings — the zero-config path when the bound pool is already capable. An SDK SHOULD also offer intent-sugar that lowers to capability tags — e.g. runner(model="claude-sonnet") → requires.capabilities = ["llm", "model:claude-sonnet"], or typed helpers like runner.llm(...) / runner.mcp(...) — so common routes need no tag strings, while requires={…} stays the explicit form. A hardcoded default capability is deliberately avoided: routes vary (LLM, image generation, webhooks, plain compute), so the default is “inherit the entitlement,” not a specific capability.
6.5 Canonical Encoding
TheRoutes value is encoded as CBOR (RFC 8949) with deterministic key ordering (CBOR §4.2.1 Core Deterministic Encoding). The routes list is sorted before encoding by:
prioritydescending,- then
pathdescending (UTF-8 byte order), - then
verbascending (alphabetical).
6.6 Route Resolution Algorithm
When a Gateway receives an HTTP request for a registered actor:-
Reserved paths: If the path starts with
/_cowboy/, handle per CIP-14 §8.6. Highest priority, not overridable by the routes table. -
Read cached routes for this actor. Refresh on state-root change (§8.12). Filter to routes with
enabled = true. -
Match candidates. A route matches if:
- Its
pathpattern matches the request path (literal segments + named params + wildcards). - Its
verbmatches the request method, orverb = "ANY".
- Its
-
Select winner. Among matching routes:
- Highest
prioritywins. - Tie: longest concrete prefix (path with wildcards/params replaced by their match length) wins.
- Tie: more specific verb (any non-
ANY) wins overANY. - Tie:
Method>Runner>Workload>Volume(safety: prefer the consensus handler, then per-request off-chain compute, then the long-lived proxy, over static).
- Highest
-
Volume target wins.
- The verb MUST be
GETorHEAD. Other verbs on a route whose only match is aVolumetarget return405 Method Not Allowed. - The Gateway resolves the volume object path per §6.7.
- If the object exists, serve it from the named volume (§8).
- If the object does not exist and
fallbackis set, serve the fallback object from the same volume withfallback_status. - If no fallback, return
404 Not Found.
- The verb MUST be
-
Method target wins.
pays = "actor": Dispatch via CIP-14 query path (GET/HEAD) or command path (POST/PUT/PATCH/DELETE). The Gateway invokes the named handler, passing theHttpRequestEnvelopewithpath_paramspopulated.pays = "caller": Per CIP-18, verify the caller’s payment proof. On missing or invalid proof, return402 Payment Requiredwith x402-compatible headers (X-PAYMENT-Required,X-PAYMENT-Receipt, etc., per CIP-18). On valid proof, dispatch to the named handler.
-
Runner target wins.
- If
auth = "account", the request MUST carry the caller’s Cowboy-account signature; the runner verifies it (§8.14) and the Gateway returns401 Unauthorizedif the runner rejects it. Identity is401, payment is402— they compose. - If
pays = "caller", apply CIP-18 payment gating exactly as forMethod(return402 Payment Requireduntil a valid proof is presented). - The Gateway selects an eligible runner (§8.14) and proxies the request off-chain to it; the runner mounts the route’s authorized volumes (via dispatcher-issued CapTokens), executes the
handleroutside consensus, and streams the response back. The actor’s PVM is not invoked.
- If
-
Workload target wins.
- If
auth = "account", the establishment request MUST carry the caller’s Cowboy-account signature; the workload verifies it (§8.15) and the Gateway returns401 Unauthorizedwhen it is rejected. - If
pays = "caller", apply CIP-18 payment gating to connection establishment (return402 Payment Requiredbefore any upgrade or first streamed byte). - The Gateway resolves the workload’s serving endpoint from its registry record and relays the connection per §8.15 — WebSocket upgrade + transparent byte-pipe, or unbuffered streamed HTTP response. The actor’s PVM is not invoked.
- If
-
No match. Return
404 Not Found. Explicit routes are required; there is no fallbackdefault_behavior.
6.7 Volume Object Path Resolution
Given aVolume-target route match and a request path:
strip_prefix is false, the full request path (minus leading /) is used:
6.8 Validation
Gateways MUST validate the routes table on load:versionMUST be1. Unknown versions cause the Gateway to fall back to all-CIP-14 dispatch (no routes-driven dispatch; HTTP ingress still works for actors with anhttp.requesthandler).routesMUST NOT exceedMAX_ROUTESentries.- Total CBOR-encoded size MUST NOT exceed
MAX_ROUTES_SIZE. - Each
pathMUST start with/. pathMUST NOT equal/_cowboy/or start with/_cowboy/(reserved by CIP-14).verbMUST be one of the seven valid values.target.kindMUST be"method","volume","runner", or"workload".- For
Methodtargets:nameMUST be a non-empty string. (Method existence is validated at deploy time by the SDK lowering pipeline; runtime missing-method errors return502 Bad Gateway.) - For
Volumetargets:volume_nameMUST match one of the actor’sstatic_volumesentitlement bindings;paysMUST be"actor";fallback_statusMUST be a valid HTTP status code (100–599). - For
Runnertargets:handlerMUST be a non-empty string;requires, if present, MUST be covered by somerunner_bindingsentry (§7.5); if omitted, the route inherits the actor’srunner_bindings— which MUST be non-empty for anyRunnerroute; either way the eligible set MUST be non-empty; if the optionalrunnerpin is set it MUST fall within that eligible set;affinityMUST benone/caller/session, andaffinity = "caller"REQUIRESauth = "account";authMUST be"public"or"account"; everymounts[]withowner = "actor"MUST be authorized in that binding, with itsmodeandpath_prefixwithin the bound grant; any mount withowner = "caller"REQUIRESauth = "account"(itsmode/path_prefixare checked against the caller’s CIP-9 grant at dispatch, §8.14);timeout_msMUST be ≤MAX_RUNNER_TIMEOUT_MS(§10). - For
Workloadtargets, config/manifest validation is structural:workloadMUST be exactly 32 bytes (a text form MUST be canonical 64-char lowercase hex; wrong length or noncanonical encodings reject);authMUST be"public"or"account";idle_timeout_msMUST be in1..=MAX_WORKLOAD_IDLE_TIMEOUT_MS;max_connection_ms, if present, MUST be at least 1; andmax_concurrent_streams, if present, MUST be in1..=MAX_CONCURRENT_STREAMS_CEILING(§10). Registry existence, ownership, generation-independent route eligibility, and endpoint validity are establishment-time checks (§8.15), not config-load checks: a transiently unavailable registry MUST NOT make the Gateway discard an otherwise valid operator configuration. paysMUST be"actor"or"caller".- When
pays = "caller":priceMUST be present and parseable per CIP-18.
6.9 Runtime Mutability
The routes table can be modified at runtime via the actor’s normal KV write path. Two API levels are exposed: Wholesale rewrites. The actor can replace the entire__cowboy/routes value. This is heavyweight and intended for maintenance scenarios (e.g., swapping in a static “we’ll be back” routes table during a migration). Rewrites are subject to the same validation as deploy-time route tables (§6.8).
Narrow mutators. The SDK SHOULD expose typed helpers that compile to atomic, scoped KV writes:
routes.disable(verb, path)— setsenabled = falsefor the matching route.routes.enable(verb, path)— setsenabled = true.routes.set_pays(verb, path, pays, price?)— updates payment policy.routes.set_priority(verb, path, priority)— re-prioritizes.routes.replace_handler(verb, path, name)— points a route at a different method.
__cowboy/routes advances the actor’s state root. Gateways pick up changes within MANIFEST_POLL_INTERVAL blocks. To bound cache churn, the protocol enforces a minimum interval between routes-affecting commits: a commit that mutates __cowboy/routes more often than MIN_ROUTES_UPDATE_INTERVAL_BLOCKS (default: 6 blocks) is rejected by the runtime with a RoutesUpdateRateLimited error.
Threat model. The routes table is owner-controlled. A malicious actor owner can rewrite their own routes — that is outside the protocol’s threat model (see §12.5). Reserved /_cowboy/* paths remain Gateway-intercepted regardless of the routes table.
7. Entitlement Extension
7.1 New Parameters
This CIP extends theingress.http entitlement (CIP-14 §6.2) with two new parameters:
allowlist_methods, max_request_bytes, max_response_bytes, max_query_cycles) are unchanged and continue to govern Method-target route behavior. max_static_response_bytes is separate from max_response_bytes because static assets (images, video thumbnails, font files) are typically much larger than dynamic API responses.
7.2 Example Manifest
7.3 Enforcement
- Deployment-time: The deployment transaction verifies that each
volume_nameinstatic_volumesreferences aVisibility::Publicvolume owned by the deploying account. If any volume does not exist or hasVisibility::Private, deployment is rejected. Thevolume_idis deterministic per CIP-9 §11.1 (keccak256(account_address || volume_name)), so cross-account references are impossible. - Gateway enforcement: Gateways MUST only serve static assets from volumes listed in
static_volumes. Objects larger thanmax_static_response_bytesreturn HTTP413 Content Too Large. Gateways MUST respectmax_cache_bytesper volume when allocating cache space. - Volume lifecycle: The Gateway’s serving authority follows the volume’s CIP-9 status (§8.13). If a volume listed in
static_volumesisDELETEDorGARBAGE_COLLECTING, the Gateway returns the corresponding HTTP status without invoking the actor. The entitlement remains valid — the volume reference is stale. The actor owner must redeploy with updatedstatic_volumes(or restore the volume) to resume static serving.
7.4 Why max_cache_bytes Is in the Entitlement
Cache limits are a resource commitment by the Gateway. Entitlements are the established mechanism for declaring resource quotas that the protocol enforces. Placing cache limits in the routes table — which is actor-mutable at runtime without redeployment — would let actors arbitrarily expand their cache footprint on Gateways without any on-chain governance check.
7.5 Runner Bindings
Runner-target routes require the actor to authorize, at deploy time, which runners may serve them and which actor-owned volumes those runners may be issued CapTokens for. This CIP adds a third ingress.http parameter:
MountPrincipal (Actor or Account) — never to a runner directly. The runner holds no standing grant; at dispatch it receives a scoped CapToken (and, for a private volume, a CBSS-sealed DEK) issued by the same dispatcher path that attaches volumes to a job. runner_bindings does not create a CIP-9 grant — it authorizes the Gateway to dispatch this actor’s Runner routes to runners in the bound pool / capability set and bounds which actor-owned volumes a route may reference.
Enforcement.
- Deployment-time (actor-owned mounts). Each binding’s
pool/capabilitiesMUST be ones the actor is entitled to (CIP-2). Eachowner = "actor"mount volume MUST be owned by the deploying account (or carry anActor-principal grant to it). A route’srequiresMUST be covered by someRunnerBinding(itspool/capabilitiesa subset of the binding’s), and itsowner = "actor"mounts MUST appear in that binding with the route’smodeand canonicalizedpath_prefixwithin the grant. Runners chosen at dispatch (§8.14) must be RAS-registered, healthy, and advertise a route-serving endpoint. - Request-time (caller-owned mounts).
owner = "caller"mounts are authorized by the caller, not the actor: the caller must own the volume and have granted the agent actor (MountPrincipal::Actor) mount access via the CIP-9 allowlist. After the runner authenticates the caller (§8.14), the dispatcher issues the runner a CapToken (+ sealed DEK) for that caller’s volume under the actor principal; the authenticated caller’s identity is what selects which caller-owned volume. This lets one endpoint give every caller scoped access to their own owned storage without the actor hosting it.
caller-owned volume from the authenticated HTTP caller per request.
8. Gateway-to-Relay-Node Fetch Protocol
8.1 Request Flow
When a Gateway receives an HTTP request that resolves to aVolume-target route:
8.2 Gateway-Side Caching
The Gateway maintains a layered cache: Layer 1a: Per-Volume Metadata Cache (always warm for active volumes)volume_id. Refreshed when the on-chain manifest_root changes.
Layer 1b: Per-Actor Routes Cache (always warm for active actors)
actor_address. Refreshed when the on-chain state_root changes (§8.12).
Layer 2: Object Cache (LRU, bounded)
(volume_id, object_path). Bounded by max_cache_bytes per volume (from entitlement) and MAX_GATEWAY_CACHE_BYTES total across all volumes. Eviction follows LRU with frequency weighting — frequently accessed objects are retained longer.
8.3 Cache Invalidation
Cache invalidation is driven by changes to the on-chainStorageCommitment.manifest_root. When volume contents change (new deploy via commit_manifest), the manifest root changes.
Protocol:
- The Gateway polls the on-chain
StorageCommitment.manifest_rootfor each actively-cached volume everyMANIFEST_POLL_INTERVALblocks (default: 6, ~6 seconds at 1 block/sec). - If
manifest_roothas changed: a. Fetch the new volume manifest from any Relay Node. b. Diff old and new manifests to identify changed, added, and removed objects. c. Evict all changed and removed objects from the object cache. d. Update the per-volume metadata cache (content-type map, cache config, CORS config). - If
manifest_rootis unchanged, all cached objects remain valid.
Account.state_root; see §8.12.
Why polling, not push: Relay Nodes are dumb storage without push notification capability. Subscribing to on-chain events for every cached volume creates scaling concerns. Polling the manifest_root (a single 32-byte on-chain read) is cheap and sufficient — static asset invalidation latency of a few seconds is acceptable.
8.4 Relay Node RPC
Gateways interact with Relay Nodes using the existing CIP-9GET_SHARD RPC (CIP-9 §16.3) plus GET_MANIFEST for direct manifest retrieval.
GET_MANIFEST
Retrieves the canonical serialized volume manifest for a given volume.
- For
Visibility::Publicvolumes: served without CapToken. - For
Visibility::Privatevolumes: requires a CapToken withREAD_WRITEaccess. - The
manifest_rootin the response MUST match the on-chainStorageCommitment.manifest_rootfor the volume. If it does not, the Gateway MUST reject the response and try another Relay Node.
GET_MANIFEST is not available on a given Relay Node, the Gateway MAY fall back to indirect manifest fetch via GET_SHARD against the well-known manifest shard address (BLAKE3(volume_id || "__manifest__")), marking the Relay as outdated rather than malfunctioning.
Relay Nodes store the latest committed manifest for each volume they hold shards for. When a Runner calls commit_manifest, the updated manifest is propagated to all Relay Nodes holding shards in that volume.
GET_SHARD (existing CIP-9 §16.3)
shard_hash from the ShardMap. Shards that fail verification are discarded and replacements are fetched from alternative Relay Nodes.
8.5 Canonical Manifest Serialization and Verification
The volume manifest returned byGET_MANIFEST is the list of all ShardMap entries for the volume. Verification against the on-chain manifest_root uses the canonical CBFS serialization and Merkle scheme: CBFS bincode encoding plus an RFC-6962-style imbalanced-tree promotion (avoids the duplicate-last-leaf shape associated with CVE-2012-2459). The authoritative algorithm and reference implementation live in the CBFS manifest module; this CIP defers to that source.
Gateway verification protocol:
- Fetch the manifest via
GET_MANIFEST(volume_id). - Deserialize the manifest into the canonical sorted list of
ShardMapentries. - Re-compute the Merkle root using the canonical CBFS algorithm.
- Compare the computed root against the on-chain
StorageCommitment.manifest_root. - If they match, the manifest is authentic. If not, reject the manifest and try another Relay Node.
8.6 Integrity Verification
The Gateway MUST verify every layer of the integrity chain:- Manifest integrity: Verify the fetched manifest against the on-chain
manifest_rootusing the Merkle verification protocol (§8.5). A manifest that does not match the on-chain root MUST be rejected. - Shard integrity: For each fetched shard, verify
BLAKE3(shard_bytes) == shard_hashfrom the ShardMap. Shards that fail verification are discarded; the Gateway fetches a replacement from an alternative Relay Node. - Object integrity: After Reed-Solomon reconstruction, verify
BLAKE3(reconstructed_bytes) == content_hashfrom the ShardMap. If verification fails, the Gateway MUST NOT serve the object and MUST return HTTP502 Bad Gateway.
8.7 Parallel Fetch and Hedging
For latency-sensitive serving, the Gateway employs adaptive parallel fetch:- Initial fetch: Request K shards from the K lowest-latency Relay Nodes (based on historical RTT for that volume’s shard assignments).
- Hedged requests: If any shard request takes longer than
HEDGE_THRESHOLD_MS(default: 100ms), issue a speculative request to an alternative Relay Node for a parity shard. Use whichever response arrives first. - Shard selection: The Gateway prefers data shards (indices
0..K-1) but accepts parity shards. Any K of the K+M shards suffice for reconstruction. - Concurrency cap: No more than
MAX_CONCURRENT_SHARD_FETCHES(default: 8) outstanding shard requests per object reconstruction.
8.8 HTTP Response Headers
When serving a static asset, the Gateway sets the following headers:
Clients requiring strict consistency MAY send
X-Cowboy-Manifest-Root: <hex> as a request header; the Gateway returns 409 Conflict if its cached manifest does not match.
8.9 Conditional Requests
If-None-Match: The Gateway compares the client’s ETag against the cached object’scontent_hash("b3_" + hex(content_hash), §8.8) under RFC 7232 §3.2 weak comparison. If it matches, return304 Not Modifiedwith no body — the decision is gated on the content-hash ETag alone. The manifest is deliberately not consulted here: byte-identical content under a changedmanifest_rootstill returns304, which is HTTP-correct because the representation the client holds is unchanged. Manifest-root coherence is enforced separately by theX-Cowboy-Manifest-Rootrequest-pin →409mechanism (§8.8), not as a condition on the ETag match; the304still echoesx-cowboy-manifest-rootso a pinning client still observes a manifest change.If-Modified-Since: Not directly supported. Volume objects do not have per-file modification timestamps. Clients SHOULD useIf-None-Match(ETag-based) for conditional requests.
8.10 Compression
Gateways SHOULD supportAccept-Encoding: gzip, br and compress responses on-the-fly for compressible content types: text/html, text/css, application/javascript, application/json, image/svg+xml, text/plain, application/xml.
Binary content types (image/png, image/jpeg, font/woff2, application/octet-stream) MUST NOT be compressed — they are already optimally encoded and compression wastes CPU.
Gateways MAY cache compressed variants alongside the uncompressed object to avoid re-compressing on subsequent requests.
8.11 Relay Node Bandwidth Economics
CIP-9 specifies that Relay Nodes earn storage fees proportional to the shards they store (CIP-9 §10). This CIP introduces Gateways as a major reader class — potentially fetching shards at high volume for popular static sites. The bandwidth cost model: Who pays for shard reads:- The volume owner (actor’s account) pays for Relay Node storage via per-epoch storage fees (CIP-9 §10). These fees cover the cost of storing and serving shards.
- Gateways do NOT pay per-shard-fetch fees directly. The bandwidth cost is absorbed into the Relay Node’s storage fee revenue, analogous to how a CDN origin server absorbs bandwidth from CDN edge pulls.
GET_SHARD and GET_MANIFEST requests to prevent bandwidth abuse:
- Per-source-IP rate limiting (suggested:
MAX_SHARD_READS_PER_SECOND = 1000per IP). - Per-volume rate limiting (suggested:
MAX_SHARD_READS_PER_VOLUME_PER_SECOND = 500per volume per IP). - Total bandwidth throttling per connection.
8.12 Routes Fetching (Gateway ↔ Runner)
The routes table is fetched from the actor’s KV state, not from a Relay Node. The Gateway uses a state-read RPC against any Runner serving the actor:GET_STATE (state read with Merkle proof)
- The Gateway fetches via
GET_STATE(actor_address, "__cowboy/routes"). - The Gateway verifies
state_rootmatches the on-chainAccount.state_rootforactor_addressatblock_height. - The Gateway verifies the Merkle proof from
valuetostate_root. - If both checks pass, the Gateway deserializes
valueasRoutesand validates per §6.8. - The verified routes table is cached, keyed by
(actor_address, state_root).
Account.state_root instead of StorageCommitment.manifest_root. The Gateway polls every MANIFEST_POLL_INTERVAL blocks; on a state-root change, it refetches __cowboy/routes and replaces the cached routes table.
Empty routes: If value is empty (the actor has never written to __cowboy/routes), the Gateway falls back to all-CIP-14 dispatch. The actor’s HTTP ingress works exactly as if this CIP did not exist — every request reaches the http.request handler. This is the migration path for existing actors.
Bandwidth economics: State reads against a Runner are part of normal protocol operation (block production, RPC) and do not incur additional fees. Per-read rate limits MAY be enforced locally by Runners (suggested: same MAX_SHARD_READS_PER_SECOND as Relay Nodes).
8.13 Serving Authority by Volume Status
Gateway HTTP serving authority maps to the CBFS volume lifecycle (CIP-9 §13):ACTIVE → GRACE_PERIOD → DELETED → GARBAGE_COLLECTING.
Continuing to serve in
GRACE_PERIOD is intentional — the owner may top up storage fees at any moment, and abrupt 503s would be a worse user experience than serving with an advisory header. The hard halt happens at DELETED (intentional removal) and GARBAGE_COLLECTING (irreversible). This bounds the “free CDN” externality (Gateway caches continuing to serve after the owner stops paying) within STORAGE_GRACE_EPOCHS per CIP-9.
8.14 Runner-Target Dispatch (Gateway → Runner)
For aRunner-target route (resolution §6.6 step 7), the Gateway does not invoke the actor’s PVM. It proxies the request off-chain to a runner and relays the response.
Runner selection. The Gateway resolves the route to a runner per request. It reads runner registry state (as it already does to fetch routes) and applies the same eligibility filters the dispatcher applies to jobs (the registry’s filter_job_candidates): capability / job-type, the CIP-2 required_runner_pool entitlement, TEE, rate card, active-job capacity, and health / heartbeat (derived from last_heartbeat). It then makes the pick itself:
- Build the eligible set
E= runners passing those filters, intersected with the route’srequires, the actor’srunner_bindingsentitlement (§7.5), and current RAS health. (requires.regionis checked route-side against the runner’s advertisedcapabilities.regions— the job filter doesn’t apply region today.) IfEis empty, return503 Service Unavailable. - If the optional
runnerpin is set: use it if it is inE, else503. - Else if
affinity ≠ none: pickargmax(r in E) of H(affinity_key, r.id)— rendezvous (HRW) hashing. The same key maps to the same runner while it stays inE; when a runner leavesE, only the keys that were on it move to their next-best, leaving every other key undisturbed. Theaffinity_keyis the caller account (caller), a client-supplied session id (session), or unused (none). - Else: spread across
E— least-loaded by advertised active-job count, or round-robin.
route_serving_endpoint in its RAS registration — a new registration field this CIP requires. The Gateway reuses existing registry data and mirrors the job-dispatch eligibility filters where they exist; the route-only additions are region filtering, HRW affinity, optional route-pin validation, and the route_serving_endpoint proxy.
Request forwarding. The Gateway dispatches to the selected runner’s route_serving_endpoint:
(actor_address, handler) to deployed code; it MUST already have a handler registered for (actor_address, handler) before the Gateway may dispatch — how that handler is deployed and registered is out of scope, and the runner returns 502 if it is absent. The runner returns a normal HTTP response (status, headers, body or chunked stream); the Gateway relays it verbatim, applying only the CORS rule (§9.4), timeout_ms, and the 502/504 mapping. The Gateway is untrusted transport and asserts no caller identity.
Caller authentication (auth = "account"). This is a new signed-request scheme — NOT the existing EIP-712 SessionVoucher (which is payment-specific). The caller signs, with their Cowboy-account secp256k1 key, a canonical preimage over:
handler="a",verb="bc" and handler="ab",verb="c" would share a signature). The signature is over keccak256(preimage), recoverable to the caller’s account. The recovered signer is the caller — any Cowboy account, authenticating as itself, not the actor — so a single endpoint serves every authenticated user under their own identity (the multi-user model). The runner recovers the signer (reusing the runner’s existing secp256k1 recovery utilities), verifies chain_id and network against its own deployment (rejecting a signature minted for another chain — closing cross-deployment replay, which per-runner nonce stores would not), checks expiry (a bounded validity window) and the single-use nonce for replay, forwards the recovered caller to the handler, and binds the request to that account; on any failure it returns 401, which the Gateway relays. The Gateway never asserts caller identity. (host is gateway-forwarded and not independently verifiable by the runner under the untrusted-gateway model, so it is not bound.) The recovered account is what authorizes a caller-owned mount (§7.5).
The runner self-enforces auth from RouteContext.auth: a route with auth = "account" and no account_sig is rejected 401 even when a request reaches the runner directly — so the Gateway’s account_sig presence check is defense-in-depth, not the sole gate.
Mounts. The runner holds no standing grant; the dispatcher issues it a CapToken (and, for a private volume, a CBSS-sealed DEK) per mount, scoped to the mount’s volume, mode, and path_prefix and never broader than the relevant grant — the same issuance path that attaches volumes to a job:
owner = "actor": issued under the actor principal, bounded by the actor’srunner_bindings(§7.5).owner = "caller": issued under the actor principal for the authenticated caller’s volume — valid only because the caller granted the agent actor mount access (§7.5) and the runner verified the caller’s signature. Writes commit back to CBFS (advancing the volumemanifest_root) as normal, leaving the data owned by the caller.
ingress.http max_request_bytes cap) before forwarding — this is also what lets body_sha256 bind the signed request. The runner executes the handler off-chain (no consensus, no deterministic-PVM constraint: LLM inference, outbound HTTP, streaming) within timeout_ms, and the Gateway streams the response back as it arrives. Streaming request bodies (with a stream_session_id-bound signature) are deferred. On timeout_ms expiry the Gateway returns 504 Gateway Timeout; on a runner transport/handler error, 502 Bad Gateway.
Payment. pays = "caller" is gated exactly as for Method (§6.6): the Gateway enforces CIP-18 (402) before forwarding. Metering of the off-chain work itself (tokens, wall-time) is a runner/RAS concern, out of scope here.
CORS. Runner-target CORS follows the Method rule (§9.4): the runner’s response headers are authoritative; the Gateway fills CORS only when the runner sets none.
Trust (v0). The runner is trusted to execute the route faithfully — the same trust an actor already places in any runner it dispatches a job to. Verifiable / multi-operator runner serving (attested execution, on-chain settlement) is deferred (§13).
Reused vs. new. Reused (ships today): CIP-9 mount-allowlist grants to a principal, dispatcher-issued per-runner CapTokens, and CBSS DEK seal for private volumes. New in this CIP: the Runner route target; the Gateway→runner proxy; a route-serving endpoint advertised in RAS registration; a route caller-auth signing scheme (distinct from the payment voucher); and selecting a caller-owned volume from the authenticated HTTP caller per request.
8.15 Workload-Target Dispatch (Gateway → Workload)
For aWorkload-target route (resolution §6.6 step 8), the Gateway relays the connection to one long-lived registered workload. There is no per-request selection: the route names a workload registry record, and the record names the backend.
The registry record. A persistent workload carries a control-plane workload registry record. This CIP consumes exactly three of its fields — workload_id (what Workload.workload names), owning_actor (what the §6.8 validation binds routes to), and serving_endpoint (where the Gateway relays) — plus the record’s route eligibility rule: the Gateway relays only to a workload whose desired_state and observed_status are both Running with a fresh liveness lease; an ineligible workload returns 503. The initial record carries volume mounts only — secrets and egress capabilities are deferred there, and there is no route-binding field: route binding lives in Gateway static configuration now and the v2 manifest later. A Workload target has no mounts of its own. Workload-target routes are a hard dependency on that registry: no record, no route.
Initial route binding. Actor-declared Workload routes require target polymorphism in the shipped v2 route-manifest model (§6 errata) and land with that rewrite. Until then, workload routes are bound by operator static Gateway configuration for first-party workloads on operator infrastructure, with no chain surface. The normative initial schema:
Workload target shape is normative for the initial operator-configured deployment notwithstanding the §6 errata — the errata concerns the shipped route-manifest storage model (actor-KV vs STORAGE_MANAGER), not this target’s field set, which first ships here in operator configuration and later moves verbatim into the v2 manifest.
The configuration key is (actor, verb, path) — a static entry exists inside a specific actor’s URL space, never as a hostless global. Duplicate keys are invalid. Ownership is validated conditionally at every establishment: absent expected_workload_owner, the named record’s owning_actor MUST equal the entry’s actor; present, owning_actor MUST equal expected_workload_owner, which MUST differ from actor — the explicit cross-owner form (one actor’s URL space fronting another principal’s workload), a typed principal rather than a boolean, so a record later repointed to a different owner fails closed instead of inheriting the override (§8.15 tests require the record-swap negative).
Collisions are rejected, not shadowed, and collision is semantic overlap, not key equality: a static entry collides when its verb set intersects an actor-declared route’s verb set (ANY intersects every verb) and the two path patterns can match any common path (wildcard, parameter, and literal segments considered — /api/* overlaps /api/admin; ANY /chat overlaps GET /chat). The Gateway MUST reject such entries when loading configuration against the current manifest snapshot. Because actor manifests can change after startup, it MUST repeat the overlap check whenever that actor’s manifest refreshes. If a newly observed actor route overlaps an already loaded static workload route, the actor-declared route wins and the Gateway MUST immediately disable the conflicting static entry for that manifest generation, emit a high-severity diagnostic identifying both patterns, and keep it disabled until a later non-conflicting manifest is observed. A stale cache or lookup order MUST NOT let operator configuration override a consensus Method route or its payment policy. Both binding modes share the dispatch semantics below, so promoting a static route to a manifest-declared one changes where the route lives, not how it serves.
Request mapping and HTTP/SSE relay. The registry serving_endpoint is a base URL. The Gateway appends the matched request path (after applying strip_prefix; preserving a leading /) to the endpoint’s base-path prefix and forwards the original query string verbatim. It MUST NOT follow backend redirects. It forwards end-to-end request headers, replaces Host with the backend authority, removes the standard hop-by-hop headers and every header named by Connection, and adds X-Cowboy-Route-Context; response headers receive the same hop-by-hop filtering before relay. The original Origin is end-to-end and MUST NOT be rewritten. In particular, Authorization is an opaque end-to-end application header: the Gateway MUST forward it byte-for-byte and MUST NOT interpret, replace, or log its value.
For a plain or streamed HTTP exchange the Gateway buffers the request body per §8.14’s max_request_bytes rule, establishes the backend response within WORKLOAD_CONNECT_TIMEOUT_MS, then relays the response body unbuffered: each non-empty backend body chunk is yielded downstream as it arrives, without applying the Runner response-byte cap. SSE (Content-Type: text/event-stream) is the motivating case: events MUST NOT be accumulated into a complete body, deliberately coalesced, or compressed by the Gateway. idle_timeout_ms bounds response-body silence after headers arrive — each non-empty body chunk resets it; on expiry the Gateway drops the body stream. max_connection_ms, when set, starts when the route is admitted and hard-caps establishment plus the response body’s lifetime. Client disconnect MUST immediately drop the backend response/body, which cancels upstream reads, and release the route’s concurrency permit.
WebSocket relay. For a valid WebSocket upgrade request the Gateway applies auth / pays gating, resolves and checks the workload record, and completes the backend handshake before sending 101 Switching Protocols to the client. A backend HTTP rejection (including 401) is relayed as an HTTP response; timeout and transport failures use the mappings below. The Gateway forwards the client’s Sec-WebSocket-Protocol offers and returns only the protocol selected by the backend. The initial operator-configured deployment does not negotiate WebSocket extensions: the Gateway removes Sec-WebSocket-Extensions on both handshakes rather than claiming an extension independently on either leg.
After both upgrades, the Gateway terminates the two WebSocket legs and relays data messages and close semantics in both directions without inspecting application payloads. Message bytes and text/binary opcode MUST be preserved, but fragmentation and masking are hop-local and MAY be re-encoded. Ping and pong are handled hop-locally by each terminated leg (not forwarded, which would create duplicate automatic pong responses) and count as traffic for idle_timeout_ms; silence means no frame in either direction. max_connection_ms starts at route admission and includes both handshakes. On backend transport death without a close frame the Gateway sends the client close code 1011 and closes; a normal backend close is forwarded, and client disconnect or close closes the backend side. Streaming request bodies outside an upgraded connection remain deferred (§13).
Caller authentication. auth = "account" follows the §8.14 signing scheme’s construction — length-prefixed fields, keccak256, secp256k1 recovery, chain_id/network binding, expiry + single-use nonce — but under a distinct fixed domain tag unique to workload route auth: the literal ASCII bytes "cowboy/cip15/workload-route-auth/v1", pinned here so independent client and workload implementations can produce matching golden vectors (§8.15 tests require them, alongside the cross-target negatives). The workload record id (32 bytes) occupies the slot handler occupies in §8.14; for a body-less upgrade request body_sha256 is the hash of the empty body. The distinct tag means a signature minted for a Runner route can never validate on a Workload route or vice versa, even if a handler string collides with a workload id — the two targets have separate verifiers and nonce stores, so shared-domain reuse would replay once in each (§8.15 tests require the cross-target negative vector).
The Gateway forwards X-Cowboy-Route-Context: base64url(CBOR(WorkloadRouteContext)), where WorkloadRouteContext contains actor_address: bytes20, workload: bytes32, verb: string, the forwarded path: string, path_params: list<(string,string)>, auth, optional payment_proof, optional account_sig, optional account_expiry, optional account_nonce, chain_id: u64, and network: string. It has no handler or mounts fields. The workload verifies the signature and self-enforces auth from this context — the Gateway’s complete-field presence check is defense-in-depth, and the Gateway asserts no caller identity. Per-message authentication inside an established stream is the workload’s own concern (it holds the authenticated identity from establishment).
Payment. pays = "caller" gates establishment exactly as for Method (§6.6): 402 before the upgrade or first streamed byte. One payment authorizes one connection. Metering the stream itself — tokens generated, messages relayed, wall-time — is out of scope here: usage-metered settlement is the streaming-payments protocol, layered above this transport.
CORS. SSE and plain HTTP responses follow the Method rule (§9.4): the workload’s response headers are authoritative; the Gateway fills CORS only when the workload sets none. WebSocket is not subject to CORS — browsers do not enforce cross-origin restrictions on WS connections — so an auth = "public" WS route is reachable from any origin’s JavaScript: the workload MUST validate the Origin header itself for browser-facing endpoints (§12.12), and user-bound streams SHOULD use auth = "account".
Intermediary configuration. A Gateway deployed behind a load balancer or reverse proxy (ALB, nginx) MUST have that layer configured for long-lived streams, or the layer silently breaks what this section specifies: idle timeout above the workload’s heartbeat interval (or the LB kills healthy connections), response buffering disabled on streaming routes (or SSE events arrive in bursts after the fact), and request-body size caps reviewed (an intermediary defaulting to small body caps rejects requests far below the application’s own limits — an observed failure class on public RPC endpoints). These are deployment requirements, not protocol parameters.
Admission, resolution, and errors. The concurrency key is the static entry identity (actor, verb, path). The Gateway acquires a permit before registry resolution or backend dialing and holds it through the complete HTTP body or WebSocket lifetime; handshake attempts therefore count against the cap and cannot form an unbounded slow-establishment queue. At max_concurrent_streams (default DEFAULT_MAX_CONCURRENT_STREAMS) it returns 503 Service Unavailable immediately.
For every admitted establishment, the Gateway reads the current registry record and checks: the id exists; owning_actor satisfies the rule above; route eligibility is true at the record snapshot’s current block; and serving_endpoint passes workload-registry validation plus the Gateway’s transport policy. Missing, ineligible, owner-mismatched, or invalid records return 503 Service Unavailable without dialing. Backend DNS/connect/TLS failure or connection refusal returns 502 Bad Gateway. A backend HTTP or WebSocket handshake not completed within WORKLOAD_CONNECT_TIMEOUT_MS returns 504 Gateway Timeout. After response headers or 101 have been sent, later timeout or transport failure terminates the stream because its HTTP status can no longer be changed.
Trust. A workload-served route is exactly as trustworthy as the host running the workload — nothing about relaying through the Gateway adds verifiability. Trust-minimized serving is therefore a per-route choice, not a platform property: the same actor’s URL space can mix Volume routes (content-addressed, verifiable), Method routes (consensus), and Workload routes (host-trusted). In the initial operator-configured deployment every registered workload is first-party, so the host trust equals platform trust; verifiable workload serving is deferred (§13).
9. CORS
9.1 Why CORS Is Specified Here
This CIP is the first specification where browsers will directly consume Gateway responses. Static HTML pages served from one actor’s domain will fetch JavaScript, CSS, and API endpoints from the same or other actors. Without CORS headers, browsers block cross-origin requests. Deferring CORS any further would make this CIP unusable for real web applications.9.2 Configuration
CORS is configured via_meta/cors.json in the CBFS public volume:
9.3 Example
9.4 Default CORS Policy and Precedence
When no_meta/cors.json exists, the Gateway applies a permissive default for routes whose target is a Volume:
Visibility::Public volume are public by definition. Restricting their CORS policy by default would break most web applications. The permissive default matches CDN behavior (Cloudflare, Fastly, S3).
Precedence rules:
Method-target routes: the actor’sHttpResponseEnvelopeis authoritative. If the response includes anyAccess-Control-*headers, the Gateway passes them through unmodified and adds nothing. Otherwise, the Gateway applies matchingcors_configrules. Otherwise, no CORS headers are added. This preserves the actor’s authority over its own dynamic-response CORS — a load-bearing security posture for many applications.Volume-target routes: the Gateway appliescors_configrules. If no rule matches, the default policy above applies. The actor is not invoked.Runner-target routes: the runner’s response is authoritative, exactly as forMethod. If it sets anyAccess-Control-*headers, the Gateway passes them through unmodified; otherwise the Gateway applies matchingcors_configrules, else no CORS headers are added. The permissiveVolumestatic-asset default is never applied to aRunnerroute.Workload-target routes: the workload’s response is authoritative, exactly as forMethodandRunner— pass-through if it sets anyAccess-Control-*headers, else matchingcors_configrules, else none; theVolumedefault is never applied. WebSocket upgrades are outside CORS entirely (browsers do not enforce it on WS): Origin validation is the workload’s responsibility (§12.12).
9.5 Preflight Handling
The Gateway handlesOPTIONS requests for all routes directly, without dispatching to the actor or runner:
- Match the request path against
_meta/cors.jsonrules. The permissive default policy applies only when the matched route’s target is aVolume; forMethod,Runner, andWorkloadtargets, preflight usescors_configrules or, failing a match, noAccess-Control-*headers — never theVolumedefault and never the backend’s response headers. - If the
Originheader matchesallowed_originsand theAccess-Control-Request-Methodmatchesallowed_methods, return204 No Contentwith the appropriateAccess-Control-*headers. - If no match, return
204 No Contentwith noAccess-Control-*headers (the browser will block the actual request).
OPTIONS in the allowlist_methods entitlement parameter and waste query-path cycles on a pure CORS check.
10. Protocol Constants
11. Rationale
11.1 Why KV Storage for Routes (Not Volume _meta/)
Routes are state, not assets. They are small (under 64 KiB), structured, frequently mutated, and tied to actor logic — none of which fits the volume model, which is optimized for byte-large public objects with infrequent atomic batch updates.
Storing routes in __cowboy/routes (KV):
- Lets API-only actors function without a CBFS volume. An actor that exposes only dynamic methods should not need to provision a public volume just to publish routing rules.
- Decouples route updates from asset deploys. Toggling a path, adjusting a price, or activating a maintenance redirect does not require a
commit_manifestcycle or republishing assets. - Reuses the actor’s existing state-root commitment for verifiability. The Gateway reads
__cowboy/routesviaGET_STATE(§8.12) and verifies a Merkle proof against the on-chainAccount.state_root. No new on-chain primitive is required, and no actor execution is invoked on the dispatch path. - Permits runtime mutation via narrow typed SDK helpers (§6.9), matching how operational web apps are actually run — feature flags, gradual rollouts, abuse mitigation, dynamic pricing.
__cowboy/routes and calling commit_manifest in the same block-level transaction.
A briefly considered alternative was an actor routes() method called by the Gateway. This was rejected because every dispatch would require PVM execution to read the table, defeating the static-serving performance goal. Reading a Merkle-verified KV value has the same Gateway-side cost as reading the volume manifest does today (one RPC, one verification, cached).
11.2 Why Extend ingress.http Instead of a New Entitlement
Routes are a mode of HTTP ingress, not a fundamentally different capability. The actor still receives HTTP requests — some are dispatched to named handlers, some served from a volume. A separate ingress.static entitlement would require checking two entitlements on every request and complicate the Gateway’s dispatch logic. Extending ingress.http keeps the parameter space unified.
11.3 Why Polling-Based Cache Invalidation
Relay Nodes and Runners do not support push notifications for cached state. Subscribing to on-chain events for every cached volume and actor creates scaling concerns as the number of active actors grows. Polling each cached object’s anchor —StorageCommitment.manifest_root for volumes, Account.state_root for routes — is cheap (a single 32-byte read per anchor per MANIFEST_POLL_INTERVAL blocks) and sufficient. The resulting invalidation latency (up to ~6 seconds) is acceptable for both asset deploys and route updates.
11.4 Why Include CORS
This CIP is the first specification where browsers will directly consume Gateway responses at scale. Without CORS headers, a static HTML page served frommyapp.cowboy.network cannot load JavaScript from api.cowboy.network or fetch data from its own /api/ endpoints if they are on a different subdomain. Deferring CORS to yet another CIP would make this one impractical for real web applications.
11.5 Why Permissive CORS Default for Static Assets
Public volume assets are, by definition, publicly readable. Any party can fetch them from Relay Nodes directly without authentication. Restricting CORS origins by default would create a mismatch: the data is public, but browsers cannot access it. This matches the behavior of every major CDN and static hosting provider.11.6 Why Actor-Wins CORS Precedence on Method-Target Routes
ForMethod-target routes, the actor’s response envelope is authoritative for CORS. The Gateway only fills in CORS headers when the actor sets none. Reversing this — letting cors_config override actor-set headers — would silently strip security-critical CORS that the actor may consider load-bearing (e.g., narrowing Access-Control-Allow-Origin to a single trusted origin for a specific endpoint).
11.7 Why b3_ ETag Prefix
ETags are opaque strings in HTTP, but clients and intermediary caches may compare them. The b3_ prefix:
- Distinguishes BLAKE3 hashes from MD5, SHA-256, or other ETag formats used by other servers.
- Enables clients that understand BLAKE3 to verify content integrity end-to-end by stripping the prefix and comparing against
BLAKE3(response_body). - Avoids collision with weak ETags (which use the
W/prefix per RFC 7232).
11.8 Why a Runner Target (Off-Chain Compute Belongs Off Consensus)
The deterministic PVM cannot run an LLM, call an external API, or stream a long response — and even where it could, doing so would force non-deterministic or heavy work through consensus, metered against query cycles and bounded by block time and the RPC body cap. Actors already work around this by dispatching off-chain jobs to runners; the Runner target makes that a first-class route: the Gateway proxies straight to the runner, the body never enters a transaction, and the response streams back. It reuses the runner’s existing execution and mount machinery (and its secp256k1 recovery utilities) rather than inventing a parallel one — the route caller-auth signing scheme itself is new (§8.14) — and keeps a clean split: Method for anything that must be consensus, Runner for anything that must not.
12. Security Considerations
12.1 Cache Poisoning
Threat: A compromised Relay Node serves corrupted shards, causing the Gateway to cache and serve incorrect content. Mitigation: The Gateway verifies every layer of the integrity chain (§8.5–§8.6):- Manifest against on-chain
manifest_root(Merkle root). - Each shard against its
shard_hash(BLAKE3) from the ShardMap. - Reconstructed object against its
content_hash(BLAKE3).
manifest_root (requires consensus compromise).
12.2 Volume Impersonation
Threat: An actor declaresstatic_volumes pointing to a volume it does not own, serving another account’s data under its own domain.
Mitigation: Deployment-time validation (§7.3) ensures each volume_name references a Visibility::Public volume owned by the deploying account. The volume_id is deterministic per CIP-9 §11.1: keccak256(account_address || volume_name). Cross-account references are impossible because the account_address component differs.
12.3 Large Asset DoS
Threat: An actor stores very large objects in a public volume and drives traffic to them, exhausting Gateway resources. Mitigations:max_static_response_bytescaps individual object sizes (default 10 MiB, ceiling 100 MiB). Objects exceeding this limit return413 Content Too Large.max_cache_bytesper volume caps Gateway cache consumption.MAX_GATEWAY_CACHE_BYTEScaps total cache across all volumes.- CIP-14 rate limits apply:
MAX_REQUESTS_PER_SECOND = 100per actor per Gateway. - CIP-9 storage billing makes hosting large volumes expensive for the account owner.
12.4 Stale Manifest
Threat: A Gateway serves content from an old manifest after the volume owner has deployed an update (e.g., serving a version with a known security vulnerability). Mitigation:MANIFEST_POLL_INTERVAL (6 blocks) bounds the maximum staleness to ~6 seconds. The X-Cowboy-Block and X-Cowboy-Manifest-Root response headers tell the client which block and manifest were used. Clients requiring strict freshness can include X-Cowboy-Min-Block (CIP-14 §8.3) or X-Cowboy-Manifest-Root (§8.8) request headers; mismatch yields 409 Conflict.
12.5 Routes Table Manipulation
Threat: A malicious or compromised actor rewrites its routes table — at deploy time or at runtime — to redirect handler-served paths to aVolume target (bypassing authentication logic), to flip a route from pays = caller to pays = actor (shifting cost back to the actor wallet), or to point a route at a different handler than expected.
Mitigation: The routes table is authored and mutated by the actor owner. If the routes table is malicious, the actor owner is the attacker, which is outside the protocol’s threat model. The protocol cannot protect users from malicious actors they choose to interact with. Several invariants nonetheless hold:
/_cowboy/*paths are always Gateway-intercepted (CIP-14 §8.6) regardless of the routes table.Volumetargets can only reference volumes in the actor’sstatic_volumesentitlement bindings (§6.8, §7.3). Cross-account volume references remain impossible.MIN_ROUTES_UPDATE_INTERVAL_BLOCKSrate-limits routes-affecting commits, preventing an actor from churning gateway caches faster than ~1 update per 6 seconds.- Validation (§6.8) rejects routes tables that are empty, oversized, reference unknown volumes, or set
pays = callerwithout aprice. On rejection, the Gateway falls back to all-CIP-14 dispatch with the previously-cached table.
12.6 Routes Cache Poisoning
Threat: A compromised Runner serves a tampered routes value viaGET_STATE, causing the Gateway to dispatch requests according to attacker-controlled routes.
Mitigation: The Gateway verifies every GET_STATE response against the on-chain Account.state_root using the supplied Merkle proof (§8.12). A response that does not verify is rejected; the Gateway tries another Runner. To poison the cache, an attacker would need to forge the on-chain state root (consensus compromise) or produce a Merkle proof collision (computationally infeasible).
12.7 Free-CDN Externality on Delinquent Storage
Threat: A Gateway continues to serve cached objects for a volume whose owner has stopped paying storage fees, externalizing bandwidth onto Relay Nodes that are no longer compensated. Mitigation: §8.13 ties Gateway serving authority to the volume’s CIP-9 status. Serving terminates atDELETED and GARBAGE_COLLECTING. The GRACE_PERIOD window is bounded by STORAGE_GRACE_EPOCHS, beyond which the volume transitions to DELETED regardless of cache state.
12.8 CORS Misconfiguration
Threat: An overly permissive CORS configuration (allowed_origins: ["*"] with allow_credentials: true) could enable credential-leaking cross-origin attacks.
Mitigation: Gateways MUST reject CORS configurations where allowed_origins includes "*" and allow_credentials is true — this is explicitly forbidden by the CORS specification (the browser would reject it anyway, but the Gateway should catch it at validation time). If detected, the Gateway falls back to the default CORS policy.
12.9 Runner-Target Trust
Threat: A selected runner executes aRunner-target route dishonestly — returns a wrong result, leaks the request, or misuses a mounted volume.
Mitigation (v0): The runner is trusted to execute the route faithfully — the same trust an actor already extends to any runner it dispatches a job to. The blast radius is bounded by what the route authorizes: a runner may only mount volumes named in the actor’s runner_bindings (owner = "actor") or a volume the caller granted it (owner = "caller"), each under a scoped CIP-9 mount-allowlist grant and, for private volumes, a per-job CBSS-sealed DEK. The Gateway is untrusted transport and asserts no result authority. Verifiable runner execution (attestation, fraud proofs) and on-chain settlement of off-chain work are deferred to a follow-on CIP (§13).
12.10 Caller Authentication and the Untrusted Gateway
Threat: For anauth = "account" route, a malicious Gateway forges or strips the caller’s identity to reach a handler as the wrong account (e.g., to mount another user’s caller-owned volume).
Mitigation: Caller authentication is performed by the runner, not the Gateway. The runner verifies the caller’s secp256k1 signature (ecrecover) over the request and binds the caller-owned mount to that verified account. A Gateway cannot forge a signature it does not hold, and a stripped signature simply yields 401. Confidential request bodies SHOULD be encrypted to the runner so the Gateway sees only ciphertext (mechanism out of scope).
12.11 Streaming Connection Exhaustion
Threat: Long-livedWorkload connections are held open en masse (slow-loris on upgrade requests, idle websockets, abandoned SSE streams), exhausting Gateway or workload connection capacity that request-scoped timeouts never reclaim.
Mitigation: Every established stream is bounded by idle_timeout_ms (silence in both directions tears it down) and optionally max_connection_ms; establishment is bounded by WORKLOAD_CONNECT_TIMEOUT_MS; per-route concurrency is capped at max_concurrent_streams with 503 beyond it — no queueing, so a flood cannot build unbounded backlog. pays = "caller" establishment gating additionally prices connection attempts on abuse-prone routes.
12.12 Cross-Origin WebSocket Hijacking
Threat: Browsers do not apply CORS to WebSocket connections, so any origin’s JavaScript can open a WS to anauth = "public" Workload route and act with the visiting user’s ambient context.
Mitigation: The workload MUST validate the Origin header on browser-facing WS endpoints and reject unexpected origins. User-bound streams SHOULD require auth = "account", which binds establishment to a caller signature no foreign origin can produce. The Gateway relays Origin verbatim and never rewrites it.
12.13 Workload Endpoint Hijack
Threat: An attacker redirects aWorkload route’s traffic by tampering with the serving endpoint — repointing the registry record or the operator’s static route configuration — turning a trusted route into an attacker-controlled backend.
Mitigation: The registry record is the single binding authority for the endpoint, and writes to it are control-plane operations under the workload owner’s authority (CIP-10 family); the Gateway resolves the endpoint from the record at connection time and never accepts an endpoint from request data. Operator static configuration for first-party workloads is operator-trusted by definition. Gateway↔workload transport SHOULD be private-network or mutually authenticated so a repointed DNS name alone is not sufficient.
13. Future Work
14. Backwards Compatibility
This CIP is fully backwards compatible with CIP-14:- Actors without a
__cowboy/routesvalue in their KV state are unaffected. Gateways fall back to all-CIP-14 dispatch — every request reaches thehttp.requesthandler. This is the migration path for existing actors: they continue to work, and they opt into routes-driven dispatch by writing to__cowboy/routes(typically via the SDK’s deploy step). - The new entitlement parameters (
static_volumes,max_static_response_bytes) are optional. When absent, defaults preserve baseline behavior:static_volumes = [](noVolume-target routes can be served) andmax_static_response_bytes = 10_485_760. - The new
runner_bindingsentitlement parameter is optional and defaults to[](noRunner-target routes can be served). Actors opt in by declaring bindings and writingRunner-target routes; existing actors are unaffected. Workloadtargets are additive. Initial binding uses operator static Gateway configuration — no chain surface, no actor state, no entitlement change; actors and existing routes are unaffected. Actor-declared workload routes arrive with the v2 route-manifest rewrite and remain opt-in.- Existing
Visibility::Publicvolumes gain no new behavior unless an actor explicitly references them instatic_volumesand writes a routes table that mounts them. - The
_meta/cors.jsonfile is optional. Volumes without it use the default CORS policy (§9.4). - Gateway nodes must be upgraded to support routes-driven dispatch. Gateways that have not been upgraded will ignore
__cowboy/routesand dispatch all requests to thehttp.requesthandler — safe degradation with no data loss or protocol violations.

