launch-quickly
Decisions

0004 — The action builder is a type-state machine

.mutation() does not exist until you have validated and chosen an auth posture. Not a lint rule — the code does not compile.

Context

Every Server Action is a public POST endpoint. That is true whether or not the person writing it is thinking about it, and it stays true for the action written in a hurry at the end of a Friday.

The usual mitigation is a lint rule or a review checklist. Both are rung four and six of ADR-0001, and both fail the same way: they catch the violation after it is written, by someone who has to be paying attention.

Decision

The builder's stages are separate types, not runtime checks. .mutation() does not exist on the object until .input(schema) has been called and either .authed() or .public() has been chosen — and .public() additionally requires .rateLimit(bucket).

You cannot ship an unvalidated, unauthenticated, unthrottled action by forgetting a line — the code will not compile.

The consequence stated positively: if it compiles, it is validated, authenticated (or explicitly public) and throttled. As writing-server-actions puts it, that is "stronger than any lint rule could be."

The runtime builder is deliberately untyped — every stage returns the same object, and the types are the entire state machine.

Rejected

A lint rule. Named explicitly as the weaker option. A lint rule can be disabled, can be missing from a new file's config, and reports after the fact.

Runtime checks. Same objection, one step later still.

The previous builder shape, which typed data as unknown unconditionally and forced a cast at every audit site — in a codebase that bans both any and !. Rejected on friction rather than a bug, and worth recording because the replacement is generic and slightly harder to read; that trade was made knowingly.

Consequences

Actions stay thin — validate, authorize, call mutations.ts, revalidate — and actions.ts may not import drizzle.

Actions return a discriminated union rather than throwing, because an action's failure is usually something the UI should render: a field error, a permission message. Queries in Server Components still throw, and error.tsx catches them.

.audit() runs after the mutation, not inside it. An audit-write failure must not turn a succeeded action into a reported failure. If a compliance posture needs the two to be atomic, the escape hatch is documented: move recordAuditEvent inside the mutation's own withTenant transaction and accept the coupling.

A dedicated type-level regression test guards the state machine, because a type that stops being enforced fails silently and no runtime test would notice.

On this page