A survey of the twenty notebooks on disk found general facts that no tracked page stated; the maintainer struck the weak rows and the rest are written into their owning pages in each page's own words. The design-system page gains the hidden-navigation figures that replaced a dangling survey pointer, the reason the anatomy is parts and lint, the record-table grouping and single-tint rules, and when a surface earns row editing. The first-contact process gains a code-verification phase, the transcript lane for models without vision, the three kinds of honesty-audit drift and the click-path guardrail. The operator conventions gain guards-before-writes, error state over empty state, copy that states the real effect, toasts that report counts and the PostForm rule. The Stripe, FedWiki, identifiers, IA, testing, model and environment pages each gain their facts, the integration guide stops telling authors to hand-write a page title, issues.md logs the placeholder security contact, milestones.md records the Codeberg terms clause and CONTRIBUTING.md notes that the module path is the forge URL. One claimed fact was checked against the code and not written: the mass-mutation preview does not run the commit loop in a rolled-back transaction.
22 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Operator UX Conventions |
|
The convention layer atop the design-system primitives: when and how to apply HTMX request shapes, feedback, confirmation modals, and validation when building operator forms and actions. |
Operator UX conventions
This is the convention layer that sits on top of the design-system primitives. It says when and how to use each primitive — not what they look like. Primitive definitions live in design-system.md; this doc decides which one a new form or action picks up. It exists because each operator tab had grown its own confirmation, error, and success patterns — the pain point M7a's research named — and M7e replaced them with one vocabulary.
Status: M7e deliverable. Cited by 7f's audit as the convention-drift baseline.
1. Scope & non-goals
In scope:
- HTMX request shape on mutation surfaces (forms, destructive actions)
- Success and error feedback patterns
- Confirmation modal coverage rules
- Server-side validation rendering
- HTTP status-code discipline for mutation handlers
Out of scope (covered elsewhere):
- Component primitives (modal markup, toast markup, badge styles) — see
design-system.md§2–3 - IA / routes / breadcrumbs — see
operator-ia.mdand theoperator-panel-navigationspec - A11y audit (keyboard nav, ARIA correctness, contrast checks) — M7f
- Spacing, typography, color tokens —
design-system.md§1
2. HTMX request shape
Every mutation form on the operator surface uses HTMX, not native <form method="POST">. The native path hit a brittle gorilla-CSRF failure mode: gorilla checks the Origin header against its trusted origins, so a submission that arrives with Origin: null is rejected — which is what the operator lookup form did for at least one user, and what CDP-driven form clicks do reliably. HTMX routes through the X-CSRF-Token header on body's hx-headers instead, which is the same path every other form uses successfully.
The triad every form sets:
<form hx-post="/partials/operator/<capability>/<action>"
hx-target="#<swap-target-id>"
hx-swap="innerHTML">
hx-post/hx-put/hx-delete— never use baremethod="POST"hx-target— CSS selector for the element to swap (default page-shell target is#operator-main; in-page partials use their own container ID)hx-swap—innerHTMLfor content-area swaps;outerHTMLonly when replacing the wrapper itself (rare)
For redirects: a mutation that lands somewhere new (a create page landing on the record, a settings save returning to a page) answers through one shared helper, redirectToRecord (internal/server/redirect.go, design D9): an htmx submit gets HX-Redirect to the destination and a 200 with an empty body; a native submit gets a 303 to the same destination directly. Both carry ?flash= with the toast key (§3, §6 below). A bare 303 is never the answer to an htmx submit: htmx follows it itself and swaps the destination's full page into the form's own target, nesting the operator shell inside itself. HX-Redirect on a refusal is still wrong (§9); a refusal is 422 with the form re-rendered, never a redirect of either kind.
3. Success feedback
Rule: mutation success fires a toast. Page-load durable notices use a banner.
| Pattern | When |
|---|---|
HX-Trigger: {"showSuccessToast": "<message>"} response header |
A mutation handler returns success |
<div class="alert alert-info"> rendered in the response body |
The page itself is reporting a durable in-progress state (e.g., "Backfill in progress") |
The toast container lives outside the swap target so it survives hx-boost navigations and partial swaps (see design-system.md §3 "Success feedback"). The toast driver is internal/embeds/static/success-toast.js.
Standard message framing:
| Action | Toast message |
|---|---|
| Created | <Resource> created |
| Updated | Changes saved |
| Deleted | <Resource> deleted |
| Revoked | Grant revoked |
| Backfilled | Backfill complete (<N> orgs processed) |
Keep it short. Operators read toasts in <1s of glance time — every extra word is friction.
A toast reports the outcome of the work, not that the handler reached its end. A handler that iterates rows states how many succeeded and how many failed; a run in which every row failed answers with fireErrorToast, because a green toast beside a failure banner leaves the operator with two contradictory readings of one mass mutation.
Retired: rendering <div class="alert alert-success"> 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:
- Constraint violation → translate with
web.FieldErrorsFromDB(err, web.ConstraintMessages{…})(internal/web/dberrors.go) and route the result through the page's 422 +FieldErrorspath (§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. - Everything else (
ok == false) →slog.Errorthe 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 <details> 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):
<button type="button"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/operator/<resource>/<id>"
data-action-method="delete"
data-action-target="#<swap-target-id>"
data-action-title="Delete plan ladder"
data-action-body="Delete '<resource-name>'? This cannot be undone."
data-action-confirm-label="Delete ladder"
data-action-style="danger">
Delete
</button>
| 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 <selector> 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
autofocuson 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 declarationParsereads, 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.ErrorsvalueParsereturns, 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=<key> 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 |
|---|---|
<form method="POST" action="..."> |
hx-post (CSRF Origin-check is brittle on native POST) |
<div class="alert alert-success"> in a mutation response (retired, design D9) |
HX-Trigger: {"showSuccessToast": "..."} header for a mutation that stays, or redirectToRecord's HX-Redirect/303 pair for one that navigates |
HX-Redirect on a refusal (retired, design D9; integration settings did this before this change) |
422 with the same declared form re-rendered in submission mode |
Return 200 + .alert-danger for a failure |
Return proper status code (422 / 4xx / 5xx); let error-handler.js funnel it |
hx-confirm="Are you sure?" |
The confirm-action-modal contract |
<span class="badge bg-info"> |
<span class="badge text-bg-info"> (bare bg-* fails WCAG AA contrast against Bootstrap defaults) |
Inline <script> in templates |
Put JS in internal/embeds/static/ and reference via <script defer src> (CSP script-src 'self' blocks inline) |
http.Redirect(w, r, ...) as the only answer to a navigating mutation, regardless of HX-Request |
redirectToRecord (internal/server/redirect.go, design D9): HX-Redirect + 200 with an empty body when the submit is htmx, http.Redirect(..., http.StatusSeeOther) when it is native |
Validation in the client only (HTML5 required alone) |
A declared forms.FormSpec, parsed server-side through spec.Parse(r) (form-library; docs/design-system.md §8); required stays on the control, generated from the same declaration, for assistive technology |
for _, g := range grants { if g.Status != "active" { continue } … } to build an "active grants" list |
Use a query that joins to active provisions (ListDeliveringGrantsByOrgID / ListGrantsWithDeliveryByOrgID) — see §9a |
hx-get="/operator/foo/{{ .ID }}" — interpolated URL string in any hx-* or data-action-url attribute |
hx-get="{{ routeURL "/operator/foo/{id}" .ID }}" — member-console lint cross-checks the pattern against mux.HandleFunc registrations. Helper is web.RouteURL in internal/web/route_url.go; register on each template FuncMap as "routeURL": web.RouteURL. |
"Failed to save: " + err.Error() in a render/toast/http.Error call |
web.FieldErrorsFromDB → 422 + FieldErrors for constraint violations; slog.Error + generic message otherwise (§4; enforced by the raw-error-render lint rule) |
Writing the row first and running the invariant guard after it (CreateGrant then the same-tier check) |
Every guard runs before the first write — a guard that short-circuits after the row exists leaves an orphan no later flow can reach |
| Leaving a control visible whose effect nothing implements ("until the behaviour is built") | Hide it, or relabel it to what it actually does; a rendered control is a promise, and the handler is what has to keep it |
9a. Grant lifecycle ≠ delivery state
core.grants.status is a lifecycle field with three values — active, expired, revoked — and it only changes through explicit revocation or time-based expiry. status='active' means "this commitment has never been formally retracted"; it does not mean "this grant is currently delivering entitlements to a pool."
Operational delivery lives one layer down, on pool_provisions.status and pool_provision_ladders.status — and the schema's GiST exclusion constraint guarantees at most one active provision per (pool, ladder). A grant whose provision was ended by a later conferral (supersession, extend-as-replace, or end_conferral from another flow) stays grants.status='active' (audit ledger), but it stops delivering.
These two facts get confused in code that filters grants by status='active' and treats the result as "what this org is getting right now." The result is a UI lie: 5+ rows shown as "active" for a product that the data model guarantees is delivered by exactly one of them.
Rule. Any operator or member surface that answers "what is this org/pool getting right now?" MUST use a query that joins to pool_provisions (and pool_provision_ladders when ladder-scoped):
- Members (Sources panel, Plans page, anything user-facing): use
ListDeliveringGrantsByOrgID— INNER JOIN onpool_provisions.status='active'. Returns only live grants. Audit history is noise here. - Operators (per-org composite, audit-shaped views): use
ListGrantsWithDeliveryByOrgID— LEFT JOIN, returns every grant tagged withdelivery_state ∈ {live, superseded, inactive}. Render the column. Gate destructive actions onlive. History is the point.
Don't filter on grants.status for the "currently active" question. The status field answers a different question. If you're tempted to write a third such query, name it after what it actually returns (ListGrantsCurrentlyDeliveringTo…, not ListActiveGrants…) so the trap doesn't reappear.
10. Future
- Cascade-preview pattern — for catalog edits at criticality ≥4 (product edit, entitlement-set edit, tier reorder), surface "this change will affect: N products, M orgs, K members" before confirmation. Deferred to M11 (audit log & observability; renumbered from M10 on 2026-07-03) because the projected-change set is audit-log adjacent.
- Convention-drift guard — automated lint that flags
bg-infowithouttext-, nativemethod="POST", etc. M7f.1 deliveredmember-console lintcovering dead routes, dead swap targets, stale?tab=, and interpolated URL literals; additional §9 rules can be added there as static-detectable patterns emerge. - Real-time validation — on-blur server validation pings vs. on-submit. Not pursued unless operator feedback demands it.
See also
design-system.md— primitive definitions (modal, toast, badges, error handler)operator-ia.md— IA contract that forms/actions must not breakopenspec/specs/operator-panel-navigation/spec.md— the spec these conventions implement