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.
0003 — TenantContext is branded
No code outside one module can produce one, not even by writing an object literal with the right shape. And why middleware is not a security boundary.
0005 — The template is vendored, not a dependency
Three kinds of code, three upgrade policies. The interesting one is the middle: ours to improve, yours to edit.