ADR 0075 — Per-scope handler context (createScopedImplement)
- Status: Accepted — supersedes the deferred scope→context clause of ADR 0024
- Date: 2026-08-17
Context
createImplement<TCtx>() fixes one handler-context type for a whole application.
When each scope guarantees different injected fields, that single type is a
superset: a public handler is typed with a userId that the runtime never
injects into an anonymous call. The type states something concrete and the
runtime hands back undefined.
ADR 0024 named this gap and deferred a scope-keyed factory, judging a
typed-by-meta binding speculative and proposing an existing-primitive workaround:
call createImplement<Ctx>() once per scope and implement each contract with the
matching factory.
Real code invalidated the workaround. The effective scope is chosen per endpoint, not per contract — one contract routinely carries endpoints of two or three scopes, and per-endpoint overrides outnumber contract-level ones. Splitting by scope to satisfy the workaround means splitting the contracts themselves, i.e. reshaping an application’s public API to work around a typing limitation.
Note the difference from what 0024 deferred: that clause described a factory
keyed on the contract’s meta.scope. This decision binds the effective scope
of each endpoint — endpoint.scope ?? contract.meta.scope ?? 'public', the
same resolution bindContract already performs at runtime.
Decision
Add createScopedImplement<TScopes>() to stitchkit/server. TScopes maps
each scope key to the extra context fields that scope guarantees. The returned
factory infers the contract’s scope literal and types every non-streaming handler
by its endpoint’s effective scope.
The map is type-only, mirroring createImplement<TCtx>(): context fields are
types, and a runtime map would force {} as UserFields at the call site. A scope
outside the map is a compile error in the handler map’s value position, so the
compiler names the offending scope rather than reporting not assignable to never.
createContractFactory<Scope>() is tightened in the same pass: a per-endpoint
scope override must now belong to the factory’s union. The contract-level scope
was already held to it; the override — where typos are densest — was not.
Consequences
- The superset lie is gone: a field of another scope no longer types as
string. - It degrades to
unknown, not to a compile error on access.RuntimeContext/HandlerContextcarry[key: string]: unknownbecause transports and:paramspreading write through it (→ ADR 0024, ADR 0003). Removing it to obtain a hard error would be a far larger break than this decision buys. Using anunknownfield in a typed position still fails, which is what catches the bug class in practice. - The map is an application claim the framework does not verify. Injection is
done by the consumer’s
beforeHandle/createAuthHook.inject, which is one function for all scopes. A scope map that promises a field nobody injects will type-check and yieldundefinedat runtime — the same failure the decision removes elsewhere. The scope map, the auth rules andscopePrefixesremain three declarations the application keeps in agreement. constinference has a boundary: an endpoint hoisted out of the contract literal widensscopetostring, which is no key of any map, so such an endpoint reports instead of silently inheriting the group scope. Documented in the server guide and pinned by a type-test.- The scope map is offered in all three shapes an application already uses, so
adopting one primitive never costs the typing of another: the single-contract
factory,
createScopedImplementRegistryfor the registry form, andcreateScopedImplement(...).stream(scope, …)for streaming multipart. The streaming builder requires the endpoint to declare its ownscopeand accepts only that literal: an endpoint inheriting the contract’s scope is not visible from that call site, and typing it against a guessed scope would rebuild the superset.defineMultipartStreamkeeps the looseRuntimeContext, andcreateMultipartStream<Ctx>()covers applications on a single context type. - The context base is
RuntimeContext, notHandlerContext: the latter pinsparams/inputtoundefined, and intersecting that with an endpoint’s inferred shapes reduces the whole context tonever— silently, becauseneveris assignable everywhere. A type-test withparamsandinputpins this. - Scope keys must be string literals. A map declared as an index signature makes the guard vacuous, and a numeric key is unreachable; both are documented rather than defended against, since the map is written once per application.
createImplementis untouched; single-scope applications keep the simpler primitive.
Alternatives considered
- Split
RuntimeContextinto a field interface plus an index-signature variant, so a foreign field becomes a hard error. Rejected for now — it changes the public context types every consumer and every transport names, for a strictly better error on one axis. - A runtime scope map. Rejected — it buys nothing the type system needs and forces a cast on the application side.
- Keeping the ADR 0024 workaround. Rejected — it is unusable wherever the effective scope varies per endpoint, which is the normal case.