` inside the response body of a mutation. That pattern pre-dated `showSuccessToast`; the forms library's outcome contract (`form-library`, `form-conventions`; `docs/design-system.md` §8) names the two mechanisms above as the only ones a mutation answers with, and the two remaining alert-in-body successes (org types, workspaces) were converted to the toast when their forms moved onto the library.
---
## 4. Error feedback
`internal/embeds/static/error-handler.js` is the **single funnel** for HTTP error feedback. Under htmx 4 every response swaps by default; suppression is declared once in each page's `htmx-config` meta tag (`"noSwap": [204, 304, 403, "5xx"]`), not scripted. The funnel listens for `htmx:response:error` and `htmx:error`:
- 422 → silent (the body is the re-rendered form and swaps inline; see §6)
- 403 (CSRF expired) → red toast; the swap is suppressed by `noSwap`, so user input is preserved
- other 4xx with body → the body swaps (server returns rendered error context) + backup toast
- 5xx → red toast; suppressed by `noSwap`, user input preserved
- network failure / timeout → red toast
**Handler responsibility:** return proper HTTP status codes. Never return a 200 with an `.alert-danger` banner for a failure — the error handler keys off status, not body content.
| Failure type | Status to return |
|--------------|------------------|
| Validation failure | 422; the declared form re-rendered in submission mode (see §6) |
| Auth failure | 401 / 403 |
| Resource not found | 404 |
| Conflict (e.g., concurrent edit) | 409 |
| Server bug | 500 (let the error handler show a generic toast; details go to logs) |
**Database write failures never render `err.Error()`.** Raw driver text ("duplicate key value violates unique constraint … SQLSTATE 23505") is not operator-facing copy — it leaks schema internals and reads as a crash. The contract for a failed write:
1. **Constraint violation** → translate with `web.FieldErrorsFromDB(err, web.ConstraintMessages{…})` (`internal/web/dberrors.go`) and route the result through the page's 422 + `FieldErrors` path (§6/§8). Name the constraints the form can plausibly hit in a map next to the handler — constraint names live beside the form they belong to, not in a global registry — each mapped to the form field to flag and a friendly, actionable message. Violations without a named entry fall back to a per-SQLSTATE-class message on the form-level `""` key. Surfaces without FieldErrors machinery (member partials, formless actions like revoke/delete buttons) render the translated message through their existing banner/toast shape instead — friendly text, never driver text.
2. **Everything else** (`ok == false`) → `slog.Error` the real error and render a *generic* message ("Failed to update the product. Details are in the server logs."). The operator can't act on driver text; the log line is where the details belong.
`member-console lint` enforces this with the `raw-error-render` rule: any line under `internal/server` that passes `err.Error()` into a `render*` helper, `fireErrorToast`/`fireSuccessToast`, or `http.Error` is flagged (lines containing `slog.` are exempt; `_test.go` files are skipped).
**A collection whose backing query failed renders an error state, never the empty state.** The empty state asserts that no rows exist, so falling back to it after a failed load tells the person their data is gone when it is only unreadable — and it takes away the actions those rows carried. A handler that loads a list renders the failure and leaves the rows it already holds in place.
---
## 5. Confirmation modals
**The rule:** require the modal for actions that (a) delete data, or (b) revoke user-visible access via the composite path (the revoke handler: decree + `end_conferral`). The same modal and contract serve the member surface (plan cancel, domain release and cancel-verification, FedWiki archive and delete); `hx-confirm` is banned everywhere and lint-caught.
**Not required** for: grant issuance, grant extend, create, update, save. These are forward-only — mistakes are revocable. Adding friction to high-frequency operator actions doesn't earn its weight.
**Special case, retired:** the org-type backfill's confirmation modal is gone with the action itself. High blast-radius mass mutations now follow the org-type default-change pattern instead: the selection immediately renders a **server-side read-only preview** (classified affected-population counts, named where a disposition is required), and the commit button lives inside that preview — the operator cannot reach the mutation without having seen its projection. Prefer this preview-then-commit shape over a modal for any future mass mutation: a modal interrupts with a question; the preview answers it. Tier drag-to-reorder and tier removal follow the same shape: the action renders the preview and the commit lives inside it — pending state is server-rendered, never browser-only, so no swap can silently discard it. Affected-population lists inside previews render **count-first**: the bucket line carries only the count and one sentence of consequence; the org names live behind a `show all N` `
` disclosure (`.org-disclosure-list` — one org per row, scrollable), so a hundred-org preview reads as one line until the operator asks for names.
**Trigger contract** (from `internal/embeds/static/confirm-action-modal.js`):
```html
Delete
```
| Attribute | Required | Default | Notes |
|-----------|----------|---------|-------|
| `data-action-url` | yes | — | endpoint hit on confirm |
| `data-action-method` | yes | `post` | `post` or `delete` |
| `data-action-target` | yes | — | HTMX swap target; `closest ` resolves against the trigger element |
| `data-action-swap` | no | `innerHTML` | HTMX swap style |
| `data-action-title` | no | `Confirm` | modal heading |
| `data-action-body` | no | empty | body copy; name the resource being affected |
| `data-action-confirm-label` | no | `Confirm` | submit-button label |
| `data-action-style` | no | `danger` | `danger` / `primary` / `warning` |
| `data-action-fields` | no | — | JSON map of hidden form fields |
**Failed requests leave the modal open** so the operator sees the error in the swap target and can retry. Successful requests close the modal automatically.
**Body copy convention:** name the affected resource. "Delete 'Standard tier'?" beats "Are you sure you want to delete this?" by a wide margin — operators routinely manage many similarly-named entities. State the effect the primitive actually has: when confirmation or preview copy and the handler disagree, the disagreement is a finding against the copy rather than a wording preference, and it is settled by correcting the copy or by changing the primitive, never by leaving the two apart.
---
## 6. Server-side validation
**The rule:** every form that mutates is a declared `forms.FormSpec`
(spec `form-library`, `form-conventions`; `docs/design-system.md` §8),
parsed through `spec.Parse(r)` (never `r.FormValue`), and revalidated
server-side regardless of what the browser already checked. This section
used to describe a hand-built `FieldErrors` map and a form-field partial
written per form; the forms library replaced both, and this doc no
longer prescribes either.
The ban on `r.FormValue` holds for a handler reading a request outside
the library too: `r.FormValue` merges the query string into the posted
body, so a URL parameter can shadow a submitted field, and it returns
only the first of a repeated field's values. Read `r.PostForm` instead.
**The outcome contract** (design D9, restated here; the full statement
is `docs/design-system.md` §8 "The outcome contract"):
- A refusal for any reason, field-level or not, answers **422** with the
same declaration re-rendered in submission mode: every submitted value
carried back (checkbox and radio state included), each field's error
under its control, a refusal that names no field in the form-level slot
every declared form renders, and `autofocus` on the first errored
control.
- Every mutation form carries `novalidate`, so a cleared required field's
submission always reaches the server, and the refusal a person sees is
the server's own message under the control, not the browser's transient
bubble. The constraint attributes (`required`, `maxlength`, `min`,
`max`, `pattern`) stay on the control regardless, generated from the
same declaration `Parse` reads, so the browser's rule and the server's
rule cannot differ.
- A handler adds its own errors by field name (uniqueness, a domain rule)
or at the form level, on the `forms.Errors` value `Parse` returns, and
builds the domain object only once that value is empty.
**A constraint-violation refusal from the database** still translates
through `web.FieldErrorsFromDB` (§4 above); its result is copied onto the
same `forms.Errors` value (`applyWebErrors` in the packages that need it)
so a DB-constraint refusal renders through the identical 422 path as a
field-validation one, never a second mechanism.
Keep messages concrete and actionable: "Enter a name." not "Invalid
input."
---
## 7. Optimistic updates
**Rule:** don't.
Every mutation waits for server confirmation before the UI updates. HTMX's request-response cycle is the contract.
**No exception currently in the codebase.** `grant-toggle.js`, the one prior exception (a grant active/inactive switch that flipped immediately and reconciled on server response), is deleted; grant issuance and extension are declared forms now (`operator.enrollment.grant.issue`, `.extend`) and wait for the server like every other mutation (design D15). A future optimistic update should not be added without explicit precedent and a writeup.
---
## 8. Mutation status codes
| Outcome | Status | Body |
|---------|--------|------|
| Success, stays | 200 | the region's re-render (plus `HX-Trigger: showSuccessToast` header) |
| Success, navigates | 200 (htmx) or 303 (native) | `redirectToRecord` (`internal/server/redirect.go`): `HX-Redirect` + empty body for an htmx submit, `Location` for a native one, both to `?flash=` on the landing page, rendered as a success toast there (design D9) |
| Validation failure | 422 | the same declared form re-rendered in submission mode, every field's error under its control |
| CSRF expired | 403 | `error-handler.js` handles the toast |
| Not found | 404 | error-handler funnel |
| Conflict | 409 | error-handler funnel |
| Server error | 500 | error-handler funnel |
**The 422 distinction matters:** 422 swaps natively under htmx 4 (the operator sees the form re-rendered with field errors) while 403/5xx sit in the declared `noSwap` list (the original input must be preserved). 422 is the validation-failure signal; nothing in JavaScript steers the swap.
---
## 9. Anti-patterns
| Don't | Do instead |
|-------|------------|
| `