` inside the response body of a mutation. That pattern pre-dates `showSuccessToast` and got mixed into the codebase before the toast primitive was wired. Migrate to toast on next touch.
---
## 4. Error feedback
`internal/embeds/static/error-handler.js` is the **single funnel** for HTTP error feedback. It listens for `htmx:beforeSwap`, intercepts:
- 403 (CSRF expired) → red toast + preserve user input (no swap)
- 4xx with body → allow swap (server returns rendered error context) + backup toast
- 5xx → red toast + preserve user input (no swap)
- network failure → red toast + preserve user input
**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 + form partial with `FieldErrors` populated (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).
---
## 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`).
**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 |
| `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.
---
## 6. Server-side validation
**The rule:** every form that mutates revalidates server-side and renders field-level errors. HTML5 `required` / `type=` stays as belt-and-suspenders (good UX for the happy path) but isn't trusted as the validation contract.
**Infrastructure** (to be built in M7e Slice C):
- `internal/web/formerrors.go` — `FieldErrors map[string]string` keyed by form input name
- A form-field template partial that renders `is-invalid` + `` from the map
- Handler pattern: validate → on fail, return 422 with the form partial + populated `FieldErrors`
**Field-level error rendering:**
```html
Reason
{{ with index .FieldErrors `reason` }}
{{ . }}
{{ end }}
```
Keep messages concrete and actionable: "Reason is required" 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.
**Single exception:** `internal/embeds/static/grant-toggle.js` — a toggle switch for grant active/inactive that flips immediately and reconciles on server response. Justified because the toggle is high-frequency and the misfire cost is low (server reconciles). New optimistic updates should not be added without explicit precedent + writeup.
---
## 8. Mutation status codes
| Outcome | Status | Body |
|---------|--------|------|
| Success | 200 | partial HTML for the swap target (plus `HX-Trigger: showSuccessToast` header) |
| Success + redirect | 200 (HTMX) | empty body + `HX-Redirect: /path` header |
| Validation failure | 422 | form partial with `FieldErrors` populated |
| 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:** `error-handler.js` lets 4xx-with-body swap (so the operator sees the form re-rendered with field errors) while 403/5xx do not (the original input must be preserved). 422 is the validation-failure signal that opts into the swap path.
---
## 9. Anti-patterns
| Don't | Do instead |
|-------|------------|
| `