← stitchkitDocumentation

ADR 0261 — An ingress attaches request facts through a key the request cannot carry

Invariants: I3, I6, I8, I12, I13.

Context

Code that builds a Request and calls the fetch handler of the core — a local socket ingress, a relay, a test harness — often knows something about the caller that must reach an endpoint: who the authenticated peer is, which node sent it. Putting it in a header lets any other client forge it. Keeping it in a side table keyed by the Request works only while the core hands the endpoint that same object, and nothing promised that. A later change that wraps or copies the request would make the value vanish for every application at once, silently.

Decision

defineRequestIngress<T>(name) returns a key with attach(request, value) and from(source). The values live in a WeakMap<Request, T> owned by the key, so the key is the only way in and the only way out: a header, a body, clone() and new Request(request) are other channels or other objects and carry nothing, and two keys never meet even under one name. A second attach to the same request is refused. from takes anything with a req, so a handler, authorize, beforeHandle and onError all read it from their ctx.

The guarantee that makes the lookup sound is written down and tested: on every HTTP context, ctx.req is the Request the handler of the core received. It holds for an endpoint, for the hooks, and for the error contexts of an unknown path, a refused method, a validation failure and a refusing onRequest. A change that breaks it fails the ingress tests.

No field is added to RuntimeContext or HandlerContext, so there is no new reserved key and no cast between the two. The key is owned by stitchkit/server, mirrored by stitchkit/node.

Consequences

An ingress owner attaches before handing the request over and an endpoint reads with the same key; two installed copies of the framework do not matter, because the key object is shared and the table is per request. A raw route, streamingRoute and onRequest receive (req, …) without a context and read with from({ req }); the MCP, agent and CLI lanes carry no Request. proxyOrigin normalizes before wrapFetch, so a value attached in wrapFetch belongs to the final request.