Transport-agnostic dispatch core shared by HTTP RPC and WebSocket
action dispatchers.
perform_action runs the post-parse pipeline that every action-spec
handler must traverse:
1. Pre-validation auth (401) — short-circuits unauthenticated callers
on 'required' axes before input validation runs, so callers never
see invalid_params for methods with required input.
2. Validate params (400) — spec.input.safeParse(raw_params) with
z.void() / ?? {} rules. The validated input lands inside the
function so the authorization phase reads acting as a typed Zod
field.
3. Authorization phase — when auth.actor !== 'none' (or
auth.account !== 'none' && actor === 'none'), resolves the actor
via apply_authorization_phase against the supplied account_id
plus validated_input.acting. Failures fold into a JSON-RPC error
envelope. The test-harness escape hatch lives in the caller — pass
preset.request_context to skip the live phase and use a pre-baked
context instead.
4. Post-authorization auth (403) — gates auth.credential_types and
auth.roles against the resolved context.
5. Rate limit (429) — per-action IP / account throttling, throttle-
requests semantics (every invocation records, regardless of outcome).
6. Dispatch + DEV-only output validation + error normalization —
spec.side_effects picks transaction (deps.db.transaction) vs
pool. Handler throws roll back the transaction; the catch sits
outside the transaction boundary. Handler outputs are validated
against spec.output under DEV (logs an error on mismatch, never
throws, never mutates the result).
The function is pure data — it never touches a Hono context, so HTTP
RPC, REST bridge (when on the action surface), and WS dispatch all
call into it the same way and bind the discriminated
PerformActionResult to their wire shape.