Pair a spec with a handler while preserving per-method input/output types
and selecting the narrowest ctx.auth shape the spec literal admits.
Constructing {spec, handler} literals widens handler to
ActionHandler<any, any>, so spec/handler drift (renamed Zod schema,
output field removal, input shape change) slips past the typechecker.
rpc_action(spec, handler) binds the handler signature to
(input: z.infer<spec.input>, ctx) => z.infer<spec.output> via the
generic spec parameter — drift surfaces at the call site.
The ctx.auth narrowing follows the spec's auth.account /
auth.actor literals (see HandlerForSpec): an actor-implying spec
gets ctx.auth: RequestActorContext; an account-grain spec gets
ctx.auth: RequestContext; everything else stays `ctx.auth:
RequestContext | null`. Handlers can rely on the dispatcher's
runtime guarantee without a manual narrowing call.
Fits fuz_app's factory-closure pattern (handlers close over
grantable_roles, app_settings ref, notification_sender, etc.).
zzz uses a different shape — a codegen-keyed Record<Method, Handler>
map typed via generated ActionInputs/ActionOutputs — which works when
handlers are pure (no closure state) and specs are codegen-enumerated.
fuz_app's admin + role-grant-offer actions have neither, so per-pair typing
at the registration site is the right fit.
Spec-literal preservation is load-bearing: declare specs with
satisfies RequestResponseActionSpec (canonical) so auth.actor
keeps its 'required' / 'none' literal type. A spec typed
directly as RequestResponseActionSpec widens the axes to
AuthAxisState and the handler defaults to the loosest tier — sound,
but loses the ergonomic narrowing.