ADR 0078 — The scope map is derived from the auth hook
- Status: Accepted — extends ADR 0075
- Date: 2026-08-17
Context
ADR 0075 shipped createScopedImplement with a hand-written scope→context map
and named the consequence honestly: the map is an application claim the
framework does not verify. Both first adopters hit that consequence on day one.
One mistyped the map and the compiler punished innocent handlers; the other
compared his draft against the hook’s actual inject and found it wrong in
four scopes out of six — “one big superset lie traded for six small unverifiable
ones”.
The root: the application already has a place where the contribution is
declared by being performed — createAuthHook’s inject. The map was a
second, parallel description of the same fact, and two hand-written
descriptions of one fact drift.
A second, related report: the documented public: object pattern is wrong in
substance. A 'public' rule admits the anonymous caller — it does not refuse
to know the logged-in one. resolve and inject still run, and public
endpoints legitimately read the identity (init / me / logout). Typing
public as “no fields” pushes those handlers toward casts.
Decision
A rule may take an object form, ScopedAuthRule: { rule, inject? },
where inject(identity, ctx) returns the fields this scope contributes and the
hook merges them into the context. The return type is the declaration:
RuleScopes<TRules> derives the scope→context map from the rules object,
createAuthHook returns a hook carrying that map at the type level
(ScopedAuthHook, a type-only marker property), and AuthScopes<typeof hook> feeds it to createScopedImplement unchanged:
const hook = createAuthHook({ resolve, rules })
export const implementFor = createScopedImplement<AuthScopes<typeof hook>>()
Derivation rules:
- a rule whose type admits
'public'yieldsPartialfields — injected when an identity is present, absent otherwise, and the type says exactly that. Membership, not the exact literal:flag ? 'public' : 'authenticated'may skip the inject at runtime, so a union containing'public'must derive optional too; - any other rule’s fields are required — the rule rejected the request before the handler if no identity resolved;
- a bare rule (no object form) contributes
object— a scope with no declared fields; - a scope with no rule at all is no key of the map, so a contract using it
fails to compile through the derived map — mirroring the hook’s own
fail-closed
no rule for scopeat the type level.
At runtime the per-rule inject runs after the shared one, only when an
identity resolved, and before the rule check — the contribution is not the
gate, so it may run for an identity the rule then rejects and must stay pure.
It must also be synchronous: the type forbids a thenable and the runtime
throws on one, because Object.assign(ctx, promise) merges nothing — the exact
silent drift this decision exists to remove. Fields computed inside an async
rule (a DB-looked-up role) are outside the derived map’s reach; those scopes
stay on the hand-written map, which remains the fallback for applications
without an auth hook, with an explicit generic, or with context enrichment
outside the hook.
Runtime drift-checking: rejected
A dev-mode warning comparing “fields actually injected” against “fields
declared for the scope” (the warnOnOutputStrip analogue requested alongside)
is rejected. The runtime cannot see the declared map — types erase — so the
check would need a runtime restatement of the map, recreating the exact
two-descriptions problem this decision removes. Derivation eliminates the drift
class by construction wherever the hook is the source; a hand-written map
remains an unverified claim, now documented as the fallback’s price.
Consequences
- One declaration fills the context and types the handlers; the map cannot drift from the hook because it is computed from it.
AuthHookConfiggains a second type parameter with a default, andcreateAuthHookaconstrules parameter. Existing callers keep compiling; callers who pass an explicit identity generic forgo derivation (TypeScript has no partial inference) and keep the hand-written map.- The derived map cannot express fields injected outside the hook (a
beforeHandlethat enriches the context). Those remain the hand-written map’s territory, or a manual intersection with the derived map. - The shared
injectstays untyped and contributes nothing to the map — it predates this decision and remains for identity attachment that no scope owns.