Clarify provider vs integration terminology and document the in-tree distribution model. Refresh M9 status, issue tracking, and lint/doc links to use the new language.
29 KiB
Operator UX Foundation (M7)
Working journal for the Milestone 7 effort. Research findings, IA sketches, design-system decisions, audit results, and convention drift land here as they happen. Authoritative milestone tracking lives in milestones.md; this file is the long-form companion.
Why This Milestone Exists
The operator panel grew organically across M1–M6 as new tabs were added per capability. The "six tabs" framing undercounts what is actually there — Persons, Organizations, Sites, Org Types, Products (with nested Prices), Entitlement Sets (with nested Rules), Plan Ladders (with nested Tiers), Grants, and four Billing surfaces (accounts, subscriptions, invoices, payments) are all distinct surfaces flattened into a single tab strip. The seams are visible:
- Cross-tab state synced via custom HTMX events (
productMutation,entitlementMutation,planLadderMutation) — fragile and easy to forget when adding a new tab. - Stale-dropdown bugs recur (e.g., creating a plan ladder doesn't refresh the Org Types default-plan dropdown until the page is refreshed). The symptom was patched via the
HX-Triggerevent mesh; the architectural cause (flat tab IA + DOM-cachedrevealedpartials) is what M7 retires. - URL state shoehorned into a
?tab=query param; no real route per capability. Specific entities (an org, a product, a ladder, a grant) never appear in URLs — nothing is bookmarkable, shareable, or reload-stable. data-switch-tabattribute pattern +operator-tabs.jsto make cross-tab navigation work under strict CSP.- Form, confirmation, and feedback patterns vary tab to tab — there's no shared design vocabulary.
- Accessibility has not been audited; keyboard navigation is patchy.
- FedWiki sites sit at the same IA level as catalog/runtime tabs, entrenching the assumption that FedWiki is the only integration the member-console will ever have. NextCloud and Discourse are on the horizon; the IA must not bake FedWiki in.
These are SPA-pattern symptoms in a tool whose mental model is a navigation hierarchy with three orthogonal axes — catalog (org types, products, entitlement sets, plan ladders), runtime (per-org enrollment and billing), and integrations (FedWiki today; more later). M7 reshapes the panel to match that grain. The integration contract itself is its own scope (proposed M9); M7 only needs to not entrench the FedWiki-first-class assumption.
Phase Tracker
See milestones.md for the canonical scope. Brief status here:
| Phase | Status | Notes |
|---|---|---|
| 7a — Operator surface inventory & walkthrough | Done | Three sub-tracks completed. 7a.1 Code archaeology — docs/operator-ux-research.md; surfaced four cross-cutting findings handed to 7b: catalog/runtime/integrations as the three IA axes, the per-org composite view as the missing route, integrations carve-out into proposed M9, and read-only-operator role considerations. 7a.2 Deterministic walkthrough capture — chrome-devtools-mcp single pass over every operator screen; outputs in docs/operator-ux-walkthrough-evidence/. 7a.3 Subjective walkthrough (multi-LLM) — briefing doc plus per-agent outputs against a fixed rubric. |
| 7b — Information architecture | Done | OpenSpec change operator-ia landed (archived 2026-05-12-operator-ia); modifies operator-panel-navigation. docs/operator-ia.md is the canonical IA. |
| 7c — Design system foundation | Done | docs/design-system.md documents the Bootstrap-derived primitive palette + custom toast/modal/error-handler primitives. Decisions captured in §7c below. |
| 7d — SPA → MPA conversion | Done | OpenSpec changes operator-mpa-conversion + operator-composite-expansion archived 2026-05-16. Success toast survives hx-boost navigation because .toast-container is outside the swap target. |
| 7e — Form & action patterns | Done | docs/operator-ux-conventions.md is the canonical convention layer (HTMX request shape, success/error feedback, modal coverage, validation, status codes, anti-patterns). |
| 7f — Audit & integrity guards | Done | All four sub-phases landed. 7f.1 member-console lint (Cobra subcommand at cmd/lint.go, package internal/lint/) — five rules: dead routes, dead swap targets, stale ?tab= refs, banned interpolated literals, route↔walkthrough coverage. Helper web.RouteURL registered on operator/fedwiki/workspace FuncMaps; 28 URL refs migrated. 7f.2 11 rod walkthroughs in test/e2e/operator-walkthroughs/; 10 pass against the running stack, 1 skips gracefully on a seed-data quirk (non-plan grant products with no entitlement_set_id — pre-existing). 7f.3 status/operator-a11y-baseline.md covers all 14 top-level operator routes via Lighthouse snapshot mode (scores 89–96; recurring color-contrast on the global nav lifts every route by ~1 point when fixed). 7f.4 this update + the milestones row. |
Personas (refined in 7a)
- Operator (primary): internal staff administering people, orgs, plans, entitlements, and billing. Integrations (FedWiki today) are administered through a separate axis — see "Why This Milestone Exists" above. Frequency varies sharply by task: runtime work (enrollment lookups, billing reconciliation) is potentially daily; catalog work (creating products, editing ladders) is weeks-to-months apart. Needs efficiency on hot paths and recoverability on cold paths.
- Read-only operator: someone who needs to inspect operator state without being able to mutate. Useful for support handoffs and for keeping principle-of-least-authority intact when the team grows past one person. Same screens, action affordances suppressed.
- Hard constraint on 7b: URL structure must not bake the operator role. A read-only operator viewing
/operator/organizations/{orgID}and a full operator viewing the same URL land on the same route — what differs is which actions render.
Design Principles (placeholder — fleshed out in 7c)
Working list, refined as research and design-system work proceeds:
- URL is the source of truth. Every operator capability is bookmarkable, refreshable, shareable.
- Frequency-weighted IA. High-frequency tasks get top-level placement; rare tasks live deeper without cluttering the surface.
- No surprise destruction. Destructive actions are confirmed; confirmation copy names the affected entities; nothing is hidden in dropdowns.
- Server-rendered first. HTMX enhances; JavaScript is for the small set of interactions that genuinely need it. Strict CSP stays strict.
- Accessible by default. Keyboard parity with mouse; color is not the sole channel; WCAG 2.1 AA is the floor.
- Conventions over configuration. Forms, tables, modals, and feedback follow shared patterns so operators learn the system once.
7a.1 Findings Handed to Downstream Phases
From the code-archaeology sub-track only. 7a.2 (deterministic capture) and 7a.3 (multi-LLM walkthrough) will append their own findings sections when they land. Full 7a.1 detail in docs/operator-ux-research.md.
- The IA has three axes, not one tab strip. Catalog (org types, products, entitlement sets, plan ladders) → runtime (per-org enrollment + billing) is one-way and dependency-shaped. Integrations is orthogonal to both. 7b owns the IA call but starts from this finding.
- The per-org composite view is the single biggest unmet need. "What's the state of org X?" is a daily lookup that today requires threading Organizations → click → Billing tab → scan. A bookmarkable
/operator/organizations/{orgID}route is the highest-leverage screen for 7b. - Two grant paths are intentional, not accidental. Plan grants (ladder-aware, via
IssueGrant) and non-plan grants (legacy, viaCreateGrant) handle differentproduct_typevalues. 7b should keep both but make the distinction visible — and consider co-locating them on the per-org page. - Tier rank reorder is the highest-criticality recurring catalog action. Sole-writer invariants in
entitlements.Transition; per-affected-orgpool_provision_transitionsaudit row. Worth the most-careful screen design in M7. 7e should require cascade-preview on this and on entitlement-set / product edits at Crit ≥ 4. - Product retirement, not delete. Local products are 1:1 mapped to Stripe via
stripe.product_mappings; M6a already planslifecycle_status(draft/published/retired). 7b/7c surface the Stripe mapping; 7e exposes "retire" rather than "delete." Seestatus/issues.md"Product retirement and Stripe-mapping visibility in operator UI." - Org type CRUD deferred. Schema supports it; UI deliberately doesn't expose it. Will need to grow when non-personal org types arrive. See
status/issues.md"Org type CRUD when non-personal types arrive." - Integrations carve-out (proposed M9). FedWiki must not be at the same IA level as Products / Org Types / Billing. 7b reserves the URL real estate (
/operator/integrations/...or similar); the integration contract itself is M9's job. Seestatus/issues.md"Integration architecture (and operator IA placement)" and the proposed M9 inmilestones.md. - No usage telemetry exists. All frequency / criticality annotations in 7a.1 are code-introspection estimates. An action-counter middleware spike was considered and deferred — folded into proposed M11 phase 11b (auto-instrumentation middleware) since there is no immediate consumer between now and M11. 7b should not block on telemetry; if any IA decision ends up load-bearing on a specific cell, revisit then.
7c Pre-implementation Decisions
Decisions made during pre-implementation exploration (2026-05-13). These feed directly into what 7c builds and documents.
Bootstrap foundation
The codebase is already on Bootstrap 5.3.6 (latest stable). The design system is Bootstrap-first: app.css supplements Bootstrap for the small set of things Bootstrap doesn't cover (HTMX loading indicator hook, toast container positioning, cross-browser <meter> element fixes). Everything else uses Bootstrap utilities and components directly — no custom token layer, no parallel utility system.
app.css carries a comment block at the top stating this contract. All hardcoded color values (e.g. #0d6efd) are replaced with Bootstrap CSS variable references (var(--bs-primary)) so they stay in sync if Bootstrap's defaults ever change.
Button palette — two variants retired
WCAG 2.1 AA audit surfaced two Bootstrap 5 outline variants that fail against a white background:
| Variant | Color | Contrast on white | Verdict |
|---|---|---|---|
btn-outline-info |
#0dcaf0 (cyan) |
~1.8:1 | Fails (need 4.5:1) |
btn-outline-warning |
#ffc107 (yellow) |
~1.28:1 | Fails (need 4.5:1) |
Both are retired from the approved button palette. Existing uses (e.g. "Tiers" and "Validate Structure" buttons in the plan-ladders partial) are replaced during 7c. Replacement: btn-outline-secondary for neutral secondary actions; btn-outline-primary for emphasis secondary actions. design-system.md documents the full approved palette with contrast ratios.
Success feedback — HX-Trigger toast
7c adds a success toast primitive (HTML element in the base layout + JS listener for showSuccessToast HX-Trigger event) to complement the existing error toast. Rationale: after 7d ships hx-boost, the .toast-container is outside the hx-boost swap target and survives page navigation; inline alert-success in a POST response body is replaced by the redirect target and never seen. Existing templates are not migrated in 7c — 7e owns that convention and timing.
Server-side API:
// Signal success to the client — works for both partial swaps and hx-boost navigations.
w.Header().Set("HX-Trigger", `{"showSuccessToast": "Product created"}`)
design-system.md scope
docs/design-system.md is a Bootstrap usage guide + custom primitives reference, not a standalone design system. Structure:
- Foundation — role of
app.css, Bootstrap CSS variable conventions, future theming hook (see backlog) - Approved component palette — which Bootstrap variants are sanctioned and why (buttons with contrast ratios, badge colors, alert types)
- Custom primitives — confirm modal (
data-*API + ARIA spec), error toast, success toast (HX-Trigger API), loading states, empty states - WCAG baseline —
aria-liverequirements for HTMX swap targets, focus management rules, contrast checklist
7d Landed Decisions
Decisions taken during the SPA → MPA conversion (change operator-mpa-conversion, archived alongside 7d's completion).
Runtime above catalog in sidebar render order
The 7b IA grouped capabilities into Runtime, Catalog, and Integration. 7d picked the top-to-bottom render order: Runtime → Catalog → Integration. Rationale is the same frequency-weighted IA principle behind 7a's research — runtime is the hot path, so it sits at the top. Group headings (<h3> labels above each block) were dropped on the same grounds: naming "Members and Billing" or "Catalog" visibly adds cognitive load that isn't paid back by the disambiguation it provides at this scale.
Persons is lookup-only, no flat browse
A top-level /operator/persons browse route would duplicate organization-member context, encourage "find a person" workflows that should route through the owning organization, and inflate the sidebar with a low-frequency affordance. Persons survives only as /operator/persons/{personID} (resolved-detail) reachable via the landing-surface lookup affordance or an org member-row link. The legacy ?tab=people redirects to the landing surface.
No required group headings
The sidebar renders a single flat <ul> of links in the Runtime → Catalog → Integration order without <h3> group labels. The spec language was softened from "groups SHALL appear as visible sections" to "groups govern URL grouping, conceptual grouping, and dependency direction; rendered headings are not required."
Composite expansion landed decisions (follow-up to 7d)
Decisions taken during operator-composite-expansion, the change that closed the five [~] deferrals from operator-mpa-conversion (composite billing summary, two-form issuance split, member rows, person memberships, landing activity wiring, lookup affordance).
Recent activity is one unified timeline, not two domain-partitioned sections
The original operator-ia IA specified "Recent runtime activity" and "Recent billing activity" as two separate sections on the landing surface. During implementation we collapsed them into one "Recent activity" section that interleaves grants, transitions, invoices, and payments by timestamp. The operator's mental model of "what's been happening" is temporal, not domain-partitioned — seeing an invoice payment land next to the grant transition that triggered it carries more signal than seeing them in two separate scrolling regions. The split-feed framing was IA inertia, not a researched operator workflow.
The asymmetry caught it: the polish slice renamed "Recent runtime activity" → "Recent activity" while keeping "Recent billing activity" qualified — which implicitly said "Recent activity = everything but billing," incoherent.
Spec was MODIFIED accordingly: one section, one query (ListRecentActivity), one ?limit=N knob (clamped to [1, 100], default 20). No more ?activity_limit / ?billing_limit split.
Lookup affordance uses HTMX submission, not native form POST
The lookup form (POST /operator/lookup) initially used a native <form method="POST"> with a hidden gorilla.csrf.Token field. This path failed CSRF validation under some browser submission conditions (gorilla rejected with origin=null for at least one user's submission, and chrome-devtools puppeted form clicks also send origin=null reliably). The fix was to switch to hx-post with hx-target="#lookup-result" — same X-CSRF-Token header path every other operator form uses successfully.
Why: gorilla's native-form-POST CSRF path checks the Origin header against TrustedOrigins. When the browser omits or nulls the Origin header (some cross-domain redirects, some test harnesses, some older browsers), the check fails. The HTMX path doesn't depend on Origin — it sends X-CSRF-Token from the body's hx-headers, which gorilla validates differently.
How to apply: for any new form that submits via POST/PUT/DELETE, use hx-post/hx-put/hx-delete rather than native HTML forms. This convention is already followed by every other form in the operator UI; the lookup form was the only divergence and it broke.
For HX-Redirect: when the handler needs to send the user to a different page (unique-match lookup, post-submit navigation), set the HX-Redirect response header instead of using http.Redirect. HTMX intercepts that header and does a full-page navigation.
Lookup resolution: server-side, no autocomplete
The lookup input is a single text field with a submit button. No JS-driven autocomplete dropdown. The handler runs resolution server-side (email-exact + name-substring against active persons, then org-slug-exact) and returns either a redirect or a swap fragment. This matches the rest of the operator UX (low-traffic, form-driven, CSP-clean) and keeps the surface diagnosable. An autocomplete is a 7e/M8-era enhancement if the affordance ever needs it.
7e Landed Decisions
The source of truth for form & action patterns across the operator UI is docs/operator-ux-conventions.md. The doc is convention-only — primitive definitions live in docs/design-system.md. M7e Slices A-D brought existing templates into compliance.
Toast wins for mutation success; banner for page-load notices
HX-Trigger: showSuccessToast fires on every mutation success — handlers no longer pass Success strings through to template alert banners. The banner pattern survives only for page-load durable state (e.g., the BackfillResult summary). Migrated via the fireSuccessToast helper in operator_partials.go — all 13 render helpers intercept the success string and fire the toast header before passing "" to the data loader.
Modal scope: deletes + composite revoke only
Confirmation modal required for any action that (a) deletes data or (b) revokes user-visible access via the composite path (RevokeGrantAndTransition). Grant issuance + extend remain friction-free — they're forward-only and mistakes are revocable. Adding friction to high-frequency operator actions doesn't earn its weight. BackfillOrgType is a documented special case (high blast radius justifies it).
Backfill dry-run pattern
High-blast-radius mass mutations expose a Preview button alongside the modal-guarded action. Preview runs the same loop inside a transaction that's deliberately rolled back; counts reflect what would happen if the operator commits. Currently used by BackfillOrgType — the only surface today that needs it. Reused by M11 cascade-preview when that lands.
Server-side validation via FieldErrors
Every mutation handler validates server-side. On failure: populate a web.FieldErrors map, call a per-form render*FormErrors helper that writes 422 and re-renders the partial with is-invalid + invalid-feedback on the bad fields. Templates use the fieldErr template func (registered in OperatorPartialsHandler) for per-form scoping — multi-form pages (the composite) need errors scoped to the form that triggered them. HTML5 required stays as belt-and-suspenders. The 422 status is the signal that lets error-handler.js swap the response body (4xx-with-body opts into the swap path; 5xx never does).
Composite is the sole UI entry point for grant actions
Legacy CreateGrant + RevokeGrant handler bodies retired entirely. The composite's IssueGrant, CreateNonPlanGrant, and RevokeGrantAndTransition cover every UI grant flow. The two-form split on the composite (plan vs non-plan products, partitioned by product.kind) gives the operator intent-explicit forms and lets each handler enforce its own kind validation without trying to be both. E2e test rewritten to drive the composite path.
HTMX-only forms; native form POST is banned
Native <form method="POST"> hit gorilla CSRF's Origin-check failure mode for some browser submissions. Every mutation form uses hx-post/hx-put/hx-delete — same X-CSRF-Token header path that every other form uses successfully. HX-Redirect response header replaces http.Redirect for handler-driven navigation. Documented in docs/operator-ux-conventions.md §2 + §9 anti-patterns table.
Two M7d-era bugs caught by M7e use
While migrating templates, two silent dead-reference bugs surfaced — both genres that Go's compiler can't see:
-
Stale
hx-target="#productsContent"etc. across 12 templates — SPA-era container IDs that didn't exist post-MPA conversion; HTMX silently no-op'd every form submission. Fixed by introducing#operator-bodyswap-target wrapper aroundrenderBodyand sweeping all stale targets. -
Stale
hx-get="/partials/operator/products"on "Back to " buttons — endpoints retired in M7d §5.5; Go'sServeMuxcatch-all at/served the dashboard for these requests, which then got swapped into the operator panel. Fixed by converting buttons to plain<a href>links (the body'shx-boostauto-converts to boosted nav) + adding/partials/404 guard at the server.
Both bugs went unnoticed for two milestones because manual testing happened to use working flows. M7f scope was expanded (see status/milestones.md 7f row) to add cmd/operator-lint (CI-failing static checker for dead routes, dead swap targets, anti-pattern grep) and test/e2e/operator-walkthroughs/ (scripted per-surface workflow tests) so this genre gets caught at write time.
Post-7e hotfixes (operator self-use, 2026-05-17)
Five bugs surfaced during the first day of operator self-use after 7e closed. All five fit the "Go compiler can't see this" genre 7f.1 (cmd/operator-lint) is being built to catch — fixing them now both restores correctness and sharpens the lint specification 7f.1 must implement.
1. The 7e validation pattern was silently broken since landing
htmx 2.0.4's default responseHandling: [{code:"[45]..", swap:false, error:true}] discards 4xx and 5xx response bodies unless evt.detail.shouldSwap = true is explicitly set in htmx:beforeSwap. The 7e validation contract (§6: handler returns 422 + form re-rendered with is-invalid/invalid-feedback) never actually rendered the inline errors — error-handler.js was firing a generic "Request failed" toast on every 4xx and never enabling the swap. Operators almost never noticed because HTML5 required caught the common case (blank fields) before the 422 path even fired.
Fix: internal/embeds/static/error-handler.js 422 branch now sets shouldSwap=true; isError=false; return so the inline validation message lands and the misleading toast doesn't fire. Other 4xx still set shouldSwap=true so server-rendered error HTML lands when present.
7f.1 lint targets raised by this:
- Any
responseHandling-sensitive JS that returns without settingshouldSwapshould be flagged (silent UI no-op). - The conventions doc §6 + §8 now both depend on this — verify the handler returns 422 with the FieldErrors-bearing partial and the JS-side opt-in is in place.
2. Same-tier IssueGrant produced orphan grants
IssueGrant created the grant row before invoking Transition. When the pool was already on the target tier, Transition's AlreadyAtTier short-circuit returned the existing provision without binding the new grant — an orphan grant landed in the DB with no pool_provisions.grant_id linkage, and the old RevokeGrantAndTransition (which looks up the provision first) couldn't reach it. The same-tier guard now runs before CreateGrant and returns 422 + FieldErrors["product_id"]="Pool is already on this tier. Use Extend to renew the existing grant."
7f.1 lint target raised: mutation handlers that create rows before invariant guards run — pattern is "side-effect → guard," correct shape is "guard → side-effect."
3. Default-reason grants showed a Revoke button that produced a no-op loop
Revoking a default-reason grant triggers Transition(End) → reapplyDefaultsForPool, which mints a fresh default grant to replace the one just revoked. Operators experienced this as "revoke doesn't work." The UI now renders System-managed instead of Revoke for grant_reason == "default"; the handler itself rejects the operation as defense-in-depth. The underlying overzealous-reapply behavior is filed in status/issues.md ("Transition.End reapplies default on every end…") for narrowing to match plan-transitions/spec.md:54.
4. Orphan grants were unreachable through any UI path
The composite Revoke flow bailed with "Provision not found" when called on a grant with no linked provision (the orphan-grant outcome of bug #2). RevokeGrantAndTransition now handles missing provisions gracefully — revokes the grant in a short tx, skips the transition, returns a success toast. Existing orphans become revocable.
5. Lifecycle vs delivery: the operator and member UIs were both lying
The operator per-org composite "Active Grants" section and the member entitlements "Sources" panel both filtered grants.status='active' and treated the result as "currently delivering entitlements." grants.status is a lifecycle field — it only changes through explicit revoke or expiry — while operational delivery lives on pool_provisions.status (one active per (pool, ladder) by GiST constraint). Result: 5 active grant rows visible in both UIs for a pool the schema guarantees has exactly one delivering provision. Fixed by adding ListGrantsWithDeliveryByOrgID (LEFT JOIN, marks each grant with delivery_state ∈ {live, superseded, inactive}) and ListDeliveringGrantsByOrgID (INNER JOIN on pool_provisions.status='active'). Operator composite section is now labeled "Grants" with a Delivery column; Member Sources panel shows only delivering rows; Member Plans page's "Current plan" badge uses the delivery query so superseded grants for a different product can't falsely badge it Current.
Convention codified in docs/operator-ux-conventions.md §9 + new §9a. Design-doc gap filed in status/issues.md ("Design gap: grants.status lifecycle vs operational delivery is undocumented") for upstream sync to membcons-db/design/entitlements/model.md.
7f.1 lint target raised: flag any for _, x := range grants { if x.Status != "active" { continue } } (or equivalent) and recommend the JOIN query. More generally: lint for "lifecycle field used as operational-state filter" — grants.status, subscriptions.status, purchases.status all have this trap shape.
7f.2 walkthrough target raised: the per-org composite walkthrough should assert exactly one row in the Grants section is Live for the test org's default pool (matches the GiST exclusion invariant); any test fixture that lets multiple rows show as Live indicates the query regressed.
7c Follow-ups Handed to 7e / 7f
Items surfaced during 7c implementation that are out of 7c scope but must not be lost. Each one has a natural home in a later phase.
Badge contrast migration — bg-info / bg-warning → text-bg-* ✅ Done in M7e Slice A
7c retired the two failing outline button variants (btn-outline-info, btn-outline-warning) but deliberately did not touch badge usage. The same color weakness existed in the Products tab's "Public" column, which rendered <span class="badge bg-info">Yes</span> — cyan background, white text, contrast ratio identical to the retired btn-outline-info.
Resolution: M7e Slice A swept all 23 bare bg-info / bg-warning badge usages across 17 operator templates and replaced them with text-bg-info / text-bg-warning (which pair the background with a contrast-checked foreground Bootstrap maintains). The convention is now codified in docs/operator-ux-conventions.md §9 anti-patterns; 7f's a11y audit can verify in passing.
Foundations Reused Downstream
The artifacts below are explicit deliverables of M7 and prerequisites for member-side UX work in M8/M10:
docs/operator-ux-research.md— methodology and findings reusable for member persona work.docs/operator-ia.md— IA principles transferable to member panel.docs/design-system.md— component primitives, tokens, accessibility baseline used everywhere.docs/operator-ux-conventions.md— form/action/confirm patterns adopted by member flows.
Links
- Authoritative milestone definition: milestones.md
- FedWiki service mapping (where M7 anchors): fedwiki-service.md
- Strategy context: strategy.md
- Active issues affecting operator UX: issues.md