Skip to main content
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 only

1. Abstract

This proposal defines Public Asset Hosting and HTTP Routing — a unified system for serving static files (HTML, CSS, JavaScript, images, fonts) from CBFS Visibility::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 Runner target that proxies a route off-chain to a selected runner (authorized by a runner_bindings entitlement), 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 Workload target 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) with actor as the default. caller opts the route into payment gating per CIP-18.
  • An extension to the ingress.http entitlement with static_volumes and max_static_response_bytes parameters.
  • 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_root changes.
  • Runtime mutability of routes via the actor’s normal KV write path, with narrow typed SDK helpers (toggle, re-price, re-prioritize, re-target).
This CIP intentionally defers the following to future CIPs:
  • Pre-compressed asset variants (.gz, .br files 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 single http.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:
  1. 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.
  2. Consensus spent only where it’s needed: The actor’s deterministic handler and max_query_cycles budget are reserved for requests that actually compute or change state; static and runner routes don’t draw on them.
  3. CDN-like performance: Gateways cache reconstructed objects locally, serve conditional requests via ETags, and set proper Cache-Control headers — all without actor involvement.
  4. Verb-aware dispatch to named handlers: Routes target named actor handlers, not a single http.request catchall. GET /post and POST /post go to different methods. Path parameters (/users/{id}) bind to handler arguments. Actors no longer reimplement routing.
  5. Per-route payment policy: Each route declares whether the actor or the caller pays. actor is the default — preserving the open-web property that browser GETs do not need a wallet — while caller opts a route into 402/x402 payment gating per CIP-18 for paid endpoints (LLM inference, premium API access, etc.).
  6. 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.
  7. 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.
  8. Off-chain compute as a route: Endpoints that cannot run in the deterministic PVM — LLM inference, outbound API calls, streaming responses — are declared as Runner targets 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.
  9. 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 (Workload target, §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 (Runner target) 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 (Workload target), with explicit idle-timeout and connection-lifetime policy.
  • Make pays = actor the default so that browser GETs do not require a wallet; let actors opt specific routes into pays = caller for payment-gated endpoints.
  • Support SPA (single-page application) fallback patterns (index.html for all non-file paths).
  • Reuse CIP-9’s existing _meta/content_types.json and _meta/cache_config.json for 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.http entitlement — 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 (Range header) 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 to Workload-target routes: a workload-served route is exactly as trustworthy as its host (§8.15).
  • Metered billing of streamed content. A Workload route 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 a Target, with a payment policy, priority, and enabled flag.
  • Verb: An HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE) or ANY (matches any verb at lowest specificity).
  • Target: The destination of a route — a Method (named actor handler, executed via actor RPC under consensus), a Volume (CBFS public volume mount), or a Runner (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; a Runner target 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 Runner target: 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, and serving_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) or caller (Gateway requires payment proof per CIP-18; on missing/invalid proof returns 402 Payment Required).
  • Routes table: The CBOR-encoded Routes value 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 a commit_manifest for 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 a read_handler PVM 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.js might be static in one actor and dynamic in another.
KV storage avoids these problems:
  • 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/routes along 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/routes and call commit_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 to rest.
  • /about — exact match.
  • /assets/* — anonymous wildcard; matches anything under the prefix without binding.
The Gateway extracts named path parameters from the matched route and passes them to the actor handler in the 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 (the Routes 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.
The SDK lowers the decorators and mount(...) declarations to the canonical Routes value, written to __cowboy/routes at deploy time:
In this configuration:
  • GET /api/users/abc → matches /api/users/{user_id} (priority 100) → dispatched to users_get handler with path_params = {"user_id": "abc"}. Actor pays.
  • POST /api/inference → matches /api/inference → Gateway returns 402 with x402 headers until caller pays 0.05 CBY, then proxies the request off-chain to a runner selected from the eligible set (§8.14); inference_run runs on the runner, not the actor PVM.
  • GET /assets/logo.png → matches /assets/* (priority 10) → served from web-assets volume at assets/logo.png. No actor invocation.
  • GET /about → matches /* (priority 0) → looks up about in web-assets. Not found → serves index.html with status 200 (SPA fallback).
  • GET /_cowboy/health → reserved path, always Gateway-intercepted (CIP-14 §8.6), never consults the routes table.
Single-verb sugar vs. multi-verb routing. @get/@post/etc. are sugar for the underlying @route(verbs, path) decorator; both can coexist:
This compiles to two entries in the canonical table — one per verb — both targeting 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

The Routes 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:
  1. priority descending,
  2. then path descending (UTF-8 byte order),
  3. then verb ascending (alphabetical).
This canonical form ensures that two semantically identical routes tables produce the same CBOR bytes and the same Merkle leaf in the actor’s state tree.

6.6 Route Resolution Algorithm

When a Gateway receives an HTTP request for a registered actor:
  1. Reserved paths: If the path starts with /_cowboy/, handle per CIP-14 §8.6. Highest priority, not overridable by the routes table.
  2. Read cached routes for this actor. Refresh on state-root change (§8.12). Filter to routes with enabled = true.
  3. Match candidates. A route matches if:
    • Its path pattern matches the request path (literal segments + named params + wildcards).
    • Its verb matches the request method, or verb = "ANY".
  4. Select winner. Among matching routes:
    • Highest priority wins.
    • Tie: longest concrete prefix (path with wildcards/params replaced by their match length) wins.
    • Tie: more specific verb (any non-ANY) wins over ANY.
    • Tie: Method > Runner > Workload > Volume (safety: prefer the consensus handler, then per-request off-chain compute, then the long-lived proxy, over static).
  5. Volume target wins.
    • The verb MUST be GET or HEAD. Other verbs on a route whose only match is a Volume target return 405 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 fallback is set, serve the fallback object from the same volume with fallback_status.
    • If no fallback, return 404 Not Found.
  6. 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 the HttpRequestEnvelope with path_params populated.
    • pays = "caller": Per CIP-18, verify the caller’s payment proof. On missing or invalid proof, return 402 Payment Required with x402-compatible headers (X-PAYMENT-Required, X-PAYMENT-Receipt, etc., per CIP-18). On valid proof, dispatch to the named handler.
  7. 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 returns 401 Unauthorized if the runner rejects it. Identity is 401, payment is 402 — they compose.
    • If pays = "caller", apply CIP-18 payment gating exactly as for Method (return 402 Payment Required until 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 handler outside consensus, and streams the response back. The actor’s PVM is not invoked.
  8. 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 returns 401 Unauthorized when it is rejected.
    • If pays = "caller", apply CIP-18 payment gating to connection establishment (return 402 Payment Required before 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.
  9. No match. Return 404 Not Found. Explicit routes are required; there is no fallback default_behavior.

6.7 Volume Object Path Resolution

Given a Volume-target route match and a request path:
If strip_prefix is false, the full request path (minus leading /) is used:

6.8 Validation

Gateways MUST validate the routes table on load:
  • version MUST be 1. Unknown versions cause the Gateway to fall back to all-CIP-14 dispatch (no routes-driven dispatch; HTTP ingress still works for actors with an http.request handler).
  • routes MUST NOT exceed MAX_ROUTES entries.
  • Total CBOR-encoded size MUST NOT exceed MAX_ROUTES_SIZE.
  • Each path MUST start with /.
  • path MUST NOT equal /_cowboy/ or start with /_cowboy/ (reserved by CIP-14).
  • verb MUST be one of the seven valid values.
  • target.kind MUST be "method", "volume", "runner", or "workload".
  • For Method targets: name MUST be a non-empty string. (Method existence is validated at deploy time by the SDK lowering pipeline; runtime missing-method errors return 502 Bad Gateway.)
  • For Volume targets: volume_name MUST match one of the actor’s static_volumes entitlement bindings; pays MUST be "actor"; fallback_status MUST be a valid HTTP status code (100–599).
  • For Runner targets: handler MUST be a non-empty string; requires, if present, MUST be covered by some runner_bindings entry (§7.5); if omitted, the route inherits the actor’s runner_bindings — which MUST be non-empty for any Runner route; either way the eligible set MUST be non-empty; if the optional runner pin is set it MUST fall within that eligible set; affinity MUST be none / caller / session, and affinity = "caller" REQUIRES auth = "account"; auth MUST be "public" or "account"; every mounts[] with owner = "actor" MUST be authorized in that binding, with its mode and path_prefix within the bound grant; any mount with owner = "caller" REQUIRES auth = "account" (its mode / path_prefix are checked against the caller’s CIP-9 grant at dispatch, §8.14); timeout_ms MUST be ≤ MAX_RUNNER_TIMEOUT_MS (§10).
  • For Workload targets, config/manifest validation is structural: workload MUST be exactly 32 bytes (a text form MUST be canonical 64-char lowercase hex; wrong length or noncanonical encodings reject); auth MUST be "public" or "account"; idle_timeout_ms MUST be in 1..=MAX_WORKLOAD_IDLE_TIMEOUT_MS; max_connection_ms, if present, MUST be at least 1; and max_concurrent_streams, if present, MUST be in 1..=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.
  • pays MUST be "actor" or "caller".
  • When pays = "caller": price MUST be present and parseable per CIP-18.
If validation fails, the Gateway logs a warning, evicts the cached routes table, and falls back to all-CIP-14 dispatch. The actor’s HTTP ingress continues to work — only routes-driven dispatch is disabled until the next valid update.

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) — sets enabled = false for the matching route.
  • routes.enable(verb, path) — sets enabled = 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.
These mutators avoid the foot-guns of full-table rewrites (accidentally dropping all routes, breaking unrelated entries) and are the recommended runtime API. Caching impact. Each commit that changes __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 the ingress.http entitlement (CIP-14 §6.2) with two new parameters:
The existing CIP-14 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_name in static_volumes references a Visibility::Public volume owned by the deploying account. If any volume does not exist or has Visibility::Private, deployment is rejected. The volume_id is 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 than max_static_response_bytes return HTTP 413 Content Too Large. Gateways MUST respect max_cache_bytes per 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_volumes is DELETED or GARBAGE_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 updated static_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:
Mount authorization is by principal, not by runner. CIP-9’s mount-allowlist grants access to a 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 / capabilities MUST be ones the actor is entitled to (CIP-2). Each owner = "actor" mount volume MUST be owned by the deploying account (or carry an Actor-principal grant to it). A route’s requires MUST be covered by some RunnerBinding (its pool / capabilities a subset of the binding’s), and its owner = "actor" mounts MUST appear in that binding with the route’s mode and canonicalized path_prefix within 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.
The mount machinery — CIP-9 allowlist grants to a principal, dispatcher-issued per-runner CapTokens, and CBSS threshold-seal of the DEK for private volumes — already ships and is exercised by actor jobs. What this CIP adds is routing a request through that issuance path and selecting a 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 a Volume-target route:

8.2 Gateway-Side Caching

The Gateway maintains a layered cache: Layer 1a: Per-Volume Metadata Cache (always warm for active volumes)
Keyed by volume_id. Refreshed when the on-chain manifest_root changes. Layer 1b: Per-Actor Routes Cache (always warm for active actors)
Keyed by actor_address. Refreshed when the on-chain state_root changes (§8.12). Layer 2: Object Cache (LRU, bounded)
Keyed by (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-chain StorageCommitment.manifest_root. When volume contents change (new deploy via commit_manifest), the manifest root changes. Protocol:
  1. The Gateway polls the on-chain StorageCommitment.manifest_root for each actively-cached volume every MANIFEST_POLL_INTERVAL blocks (default: 6, ~6 seconds at 1 block/sec).
  2. If manifest_root has 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).
  3. If manifest_root is unchanged, all cached objects remain valid.
Per-actor routes (Layer 1b) follow the analogous protocol against 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-9 GET_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::Public volumes: served without CapToken.
  • For Visibility::Private volumes: requires a CapToken with READ_WRITE access.
  • The manifest_root in the response MUST match the on-chain StorageCommitment.manifest_root for the volume. If it does not, the Gateway MUST reject the response and try another Relay Node.
If 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)

For public volumes, no CapToken is required (CIP-9 §7.6.3). The Gateway verifies every shard against its 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 by GET_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:
  1. Fetch the manifest via GET_MANIFEST(volume_id).
  2. Deserialize the manifest into the canonical sorted list of ShardMap entries.
  3. Re-compute the Merkle root using the canonical CBFS algorithm.
  4. Compare the computed root against the on-chain StorageCommitment.manifest_root.
  5. If they match, the manifest is authentic. If not, reject the manifest and try another Relay Node.
This ensures the Gateway can verify the manifest without trusting the Relay Node — the on-chain Merkle root is the trust anchor.

8.6 Integrity Verification

The Gateway MUST verify every layer of the integrity chain:
  1. Manifest integrity: Verify the fetched manifest against the on-chain manifest_root using the Merkle verification protocol (§8.5). A manifest that does not match the on-chain root MUST be rejected.
  2. Shard integrity: For each fetched shard, verify BLAKE3(shard_bytes) == shard_hash from the ShardMap. Shards that fail verification are discarded; the Gateway fetches a replacement from an alternative Relay Node.
  3. Object integrity: After Reed-Solomon reconstruction, verify BLAKE3(reconstructed_bytes) == content_hash from the ShardMap. If verification fails, the Gateway MUST NOT serve the object and MUST return HTTP 502 Bad Gateway.

8.7 Parallel Fetch and Hedging

For latency-sensitive serving, the Gateway employs adaptive parallel fetch:
  1. Initial fetch: Request K shards from the K lowest-latency Relay Nodes (based on historical RTT for that volume’s shard assignments).
  2. 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.
  3. 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.
  4. 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’s content_hash ("b3_" + hex(content_hash), §8.8) under RFC 7232 §3.2 weak comparison. If it matches, return 304 Not Modified with no body — the decision is gated on the content-hash ETag alone. The manifest is deliberately not consulted here: byte-identical content under a changed manifest_root still returns 304, which is HTTP-correct because the representation the client holds is unchanged. Manifest-root coherence is enforced separately by the X-Cowboy-Manifest-Root request-pin → 409 mechanism (§8.8), not as a condition on the ETag match; the 304 still echoes x-cowboy-manifest-root so 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 use If-None-Match (ETag-based) for conditional requests.

8.10 Compression

Gateways SHOULD support Accept-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.
Rationale: Introducing per-read micropayments between Gateways and Relay Nodes would add significant protocol complexity (payment channels, per-request accounting) for marginal benefit. The storage fee model already compensates Relay Nodes for both storage and bandwidth — nodes that serve more popular volumes earn proportionally more storage fees because those volumes persist (the owner keeps paying). If a volume is not worth paying storage fees for, it gets garbage-collected. Relay Node abuse protection: Relay Nodes MAY enforce local rate limits on GET_SHARD and GET_MANIFEST requests to prevent bandwidth abuse:
  • Per-source-IP rate limiting (suggested: MAX_SHARD_READS_PER_SECOND = 1000 per IP).
  • Per-volume rate limiting (suggested: MAX_SHARD_READS_PER_VOLUME_PER_SECOND = 500 per volume per IP).
  • Total bandwidth throttling per connection.
These limits are locally enforced by each Relay Node and are not protocol-mandated constants. Relay Node operators MAY adjust them based on their infrastructure capacity. Future work: A follow-on CIP may introduce actor-funded bandwidth budgets — actors deposit CBY into a bandwidth pool that compensates Relay Nodes for read traffic proportional to actual serving volume. This would align incentives more precisely but requires a per-read accounting mechanism that is not justified in this initial CIP.

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)

Verification protocol:
  1. The Gateway fetches via GET_STATE(actor_address, "__cowboy/routes").
  2. The Gateway verifies state_root matches the on-chain Account.state_root for actor_address at block_height.
  3. The Gateway verifies the Merkle proof from value to state_root.
  4. If both checks pass, the Gateway deserializes value as Routes and validates per §6.8.
  5. The verified routes table is cached, keyed by (actor_address, state_root).
If the response cannot be verified, the Gateway MUST reject it and try another Runner. Cache invalidation: Identical to §8.3 but driven by 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 a Runner-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:
  1. Build the eligible set E = runners passing those filters, intersected with the route’s requires, the actor’s runner_bindings entitlement (§7.5), and current RAS health. (requires.region is checked route-side against the runner’s advertised capabilities.regions — the job filter doesn’t apply region today.) If E is empty, return 503 Service Unavailable.
  2. If the optional runner pin is set: use it if it is in E, else 503.
  3. Else if affinity ≠ none: pick argmax(r in E) of H(affinity_key, r.id)rendezvous (HRW) hashing. The same key maps to the same runner while it stays in E; when a runner leaves E, only the keys that were on it move to their next-best, leaving every other key undisturbed. The affinity_key is the caller account (caller), a client-supplied session id (session), or unused (none).
  4. Else: spread across E — least-loaded by advertised active-job count, or round-robin.
The selected runner advertises a 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:
The runner maps (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:
The preimage MUST be domain-separated (a fixed tag distinct from every other Cowboy signing context) and MUST length-prefix each variable-length field, so that no two distinct field assignments can serialize to the same bytes (otherwise e.g. 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’s runner_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 volume manifest_root) as normal, leaving the data owned by the caller.
Request body and execution. Consistent with current Gateway behavior, v0 buffers the request body (subject to the 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 a Workload-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:
The embedded 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:
Rationale: Static assets in a 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’s HttpResponseEnvelope is authoritative. If the response includes any Access-Control-* headers, the Gateway passes them through unmodified and adds nothing. Otherwise, the Gateway applies matching cors_config rules. 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 applies cors_config rules. 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 for Method. If it sets any Access-Control-* headers, the Gateway passes them through unmodified; otherwise the Gateway applies matching cors_config rules, else no CORS headers are added. The permissive Volume static-asset default is never applied to a Runner route.
  • Workload-target routes: the workload’s response is authoritative, exactly as for Method and Runner — pass-through if it sets any Access-Control-* headers, else matching cors_config rules, else none; the Volume default 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 handles OPTIONS requests for all routes directly, without dispatching to the actor or runner:
  1. Match the request path against _meta/cors.json rules. The permissive default policy applies only when the matched route’s target is a Volume; for Method, Runner, and Workload targets, preflight uses cors_config rules or, failing a match, no Access-Control-* headers — never the Volume default and never the backend’s response headers.
  2. If the Origin header matches allowed_origins and the Access-Control-Request-Method matches allowed_methods, return 204 No Content with the appropriate Access-Control-* headers.
  3. If no match, return 204 No Content with no Access-Control-* headers (the browser will block the actual request).
This prevents actors from needing to implement preflight handling in any of their handlers, which would require listing 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_manifest cycle or republishing assets.
  • Reuses the actor’s existing state-root commitment for verifiability. The Gateway reads __cowboy/routes via GET_STATE (§8.12) and verifies a Merkle proof against the on-chain Account.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.
The atomic asset+route property — useful for the cases where you do want a single-commit deploy that updates both — is recovered when needed by writing to __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 from myapp.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

For Method-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).
To poison the cache, an attacker would need to produce a BLAKE3 collision (computationally infeasible with 256-bit output) or forge the on-chain manifest_root (requires consensus compromise).

12.2 Volume Impersonation

Threat: An actor declares static_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_bytes caps individual object sizes (default 10 MiB, ceiling 100 MiB). Objects exceeding this limit return 413 Content Too Large.
  • max_cache_bytes per volume caps Gateway cache consumption.
  • MAX_GATEWAY_CACHE_BYTES caps total cache across all volumes.
  • CIP-14 rate limits apply: MAX_REQUESTS_PER_SECOND = 100 per 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 a Volume 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.
  • Volume targets can only reference volumes in the actor’s static_volumes entitlement bindings (§6.8, §7.3). Cross-account volume references remain impossible.
  • MIN_ROUTES_UPDATE_INTERVAL_BLOCKS rate-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 = caller without a price. 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 via GET_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 at DELETED 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 a Runner-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 an auth = "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-lived Workload 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 an auth = "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 a Workload 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/routes value in their KV state are unaffected. Gateways fall back to all-CIP-14 dispatch — every request reaches the http.request handler. 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 = [] (no Volume-target routes can be served) and max_static_response_bytes = 10_485_760.
  • The new runner_bindings entitlement parameter is optional and defaults to [] (no Runner-target routes can be served). Actors opt in by declaring bindings and writing Runner-target routes; existing actors are unaffected.
  • Workload targets 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::Public volumes gain no new behavior unless an actor explicitly references them in static_volumes and writes a routes table that mounts them.
  • The _meta/cors.json file 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/routes and dispatch all requests to the http.request handler — safe degradation with no data loss or protocol violations.