← stitchkitDocumentation

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 / HandlerContext carry [key: string]: unknown because transports and :param spreading 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 an unknown field 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 yield undefined at runtime — the same failure the decision removes elsewhere. The scope map, the auth rules and scopePrefixes remain three declarations the application keeps in agreement.
  • const inference has a boundary: an endpoint hoisted out of the contract literal widens scope to string, 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, createScopedImplementRegistry for the registry form, and createScopedImplement(...).stream(scope, …) for streaming multipart. The streaming builder requires the endpoint to declare its own scope and 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. defineMultipartStream keeps the loose RuntimeContext, and createMultipartStream<Ctx>() covers applications on a single context type.
  • The context base is RuntimeContext, not HandlerContext: the latter pins params/input to undefined, and intersecting that with an endpoint’s inferred shapes reduces the whole context to never — silently, because never is assignable everywhere. A type-test with params and input pins 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.
  • createImplement is untouched; single-scope applications keep the simpler primitive.

Alternatives considered

  • Split RuntimeContext into 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.