--- title: "Operator UX Research" audience: [developer] summary: "A frozen M7 (phase 7a) UX research study of the operator panel, using code-introspection methodology to inventory capabilities, tasks, pain points, and personas." --- # Operator UX Research > **Audience:** Anyone working on the operator panel IA (M7b), design system (M7c), form/action conventions (M7e), or future member-side UX (M8/M9) that wants to reuse this methodology. > > **Status:** First pass complete. Annotations are derived from code (route surface, cascade semantics, audit-log presence) and `status/issues.md`, not from operator interviews — there is no human operator pool large enough to interview, so the inventory is methodologically *code-introspection*, not survey research. Annotations are explicit estimates and should be revisited if usage telemetry is ever added. > > **Last updated:** 2026-05-08 > > **Snapshot caveat (2026-05-17):** This document is a frozen 7a deliverable. Several findings have since been resolved or refined by 7b–7e implementation work; for current state, see `status/operator-ux.md` (working journal with phase decisions and post-7e hotfixes) and the OpenSpec archive in `openspec/changes/archive/`. In particular: the **two grant paths** finding (pain point §4 below) was resolved in M7e Slice D — legacy `CreateGrant` / `RevokeGrant` retired, composite is the sole UI entry; the **stale-dropdown bug** (catalog/runtime split in pain point §3) was retired by M7d's full MPA conversion; the **per-org composite view** gap (hot path 1) is delivered at `/operator/organizations/{orgID}`. A new finding emerged post-7e that this snapshot does not contain: **grants.status is a lifecycle field, not an operational-delivery filter** — see `docs/operator-ux-conventions.md` §9a and `status/issues.md` "Design gap: grants.status lifecycle vs operational delivery is undocumented." That distinction will shape M7f.1's `cmd/operator-lint` rule set. ## Why this document exists M7 reshapes the operator panel from a six-tab SPA into a navigation-hierarchy MPA. The reshape is downstream of two questions that this document answers: 1. **What does an operator actually do here?** Not "what tabs exist," but what tasks, in what order, how often, and with what stakes. 2. **Where does the current panel fight the operator?** Pain points sourced from `status/issues.md`, lived experience, and code archaeology. The output feeds 7b (IA), 7c (design system priorities), and 7e (form/action conventions). It is also the methodology template reused for member-side UX research in M8/M9, so the structure here is intentionally portable. ## Methodology This is a small-audience, internal-tool study. Formal interview/survey methods are not appropriate; code archaeology is. The inputs: - **Route surface.** `internal/server/operator_partials.go` registers every operator route. Each route is a discrete user-visible action. This is the authoritative inventory of *what the system actually exposes* — no more, no less. - **Handler cascade semantics.** Each handler's downstream effects (HX-Trigger events emitted, audit rows written, sole-writer invariants invoked) tell us criticality. A handler that fires `planLadderMutation` and writes a `pool_provision_transitions` row is structurally more dangerous than one that mutates a single column on one row. - **Issue archaeology.** `status/issues.md` is where lived pain has been deposited over time. Resolved, open, and superseded entries all carry signal. - **Cross-tab dependency mapping.** The `HX-Trigger` event mesh (`productMutation`, `entitlementMutation`, `planLadderMutation`) reveals the *real* dependency graph between capabilities, which is hidden by the flat tab IA. ### What "frequency" means here We have no usage telemetry. Frequency is estimated from the **structural shape** of the action, not from observation: - **daily** — actions tied to per-org runtime events (lookups, billing inspection). One per ticket / per support touch. - **weekly** — actions tied to per-org administrative events (manual grants, entitlement changes for a specific customer). - **monthly** — actions tied to catalog growth (new product, new ladder tier, new entitlement set rule). - **rare** — actions tied to schema-shaping decisions (creating a new ladder, deleting a tier, backfilling an org type). Criticality is rated **1–5**: - **1** — read-only or trivially reversible. - **3** — mutation with a clear undo or narrow blast radius. - **5** — composite or cascading mutation; either irreversible, multi-row, or invariant-bearing. Pain is rated **1–5**: - **1** — fine as-is. - **3** — friction visible in the code (workarounds, multi-step flow, no search/filter, stale-state risk). - **5** — the action is a known repeat-offender source of bug reports or has no current path at all. These are estimates. If any IA decision in 7b ends up load-bearing on a specific cell, that cell should be re-examined directly. ## Capability surface Derived from the route table in `internal/server/operator_partials.go`. Each row is a capability cluster, not a single endpoint. The "Mutating routes" column lists what the operator can *change*; everything else is read-only. | Capability | Read routes | Mutating routes | |---|---|---| | Persons (browse) | `GET /partials/operator/users` | _none_ | | Organizations (browse) | `GET /partials/operator/organizations` | _none_ | | Sites (FedWiki, browse) | `GET /partials/operator/sites` | _none_ | | Org types | `GET /org-types` | `POST /org-types/{type}/default-plan`, `POST /org-types/{type}/backfill` | | Products | `GET /products`, `GET /products/{id}/edit` | `POST /products`, `PUT /products/{id}` | | Product prices | `GET /products/{id}/prices` | `POST /products/{id}/prices` | | Entitlement sets | `GET /entitlement-sets`, `GET /entitlement-sets/{id}/edit` | `POST /entitlement-sets`, `PUT /entitlement-sets/{id}` | | Entitlement set rules | `GET /entitlement-sets/{id}/rules` | `POST /entitlement-sets/{id}/rules`, `DELETE /entitlement-sets/{id}/rules/{ruleID}` | | Plan ladders | `GET /plan-ladders`, `GET /plan-ladders/{id}/edit`, `GET /plan-ladders/{id}/validation` | `POST /plan-ladders`, `PUT /plan-ladders/{id}`, `DELETE /plan-ladders/{id}` | | Plan ladder tiers | `GET /plan-ladders/{id}/tiers` | `POST /plan-ladders/{id}/tiers`, `PUT /plan-ladders/{id}/tiers/{productID}/rank`, `DELETE /plan-ladders/{id}/tiers/{productID}` | | Grants (legacy) | `GET /grants` | `POST /grants`, `POST /grants/{id}/revoke` | | Enrollment (per-org) | `GET /organizations/{orgID}/enrollment` | `POST /organizations/{orgID}/pools/{poolID}/grant`, `POST .../grant/extend`, `POST /grants/{id}/revoke-and-transition` | | Billing accounts | `GET /billing/accounts` | _none_ | | Subscriptions | `GET /billing/subscriptions` | _none_ | | Invoices | `GET /billing/invoices` | _none_ | | Payments | `GET /billing/payments` | _none_ | ### Findings from the route table - **Org types is intentionally not CRUD in the UI.** The DB *does* have a full `organization.org_types` table (`org_type` PK, `display_name`, `description`, `is_active`, `default_product_id`, `default_plan_ladder_id`), but only `'personal'` is seeded. The mutating routes are limited to setting a default plan and backfilling existing orgs. The decision not to expose create/edit in the M7 IA is deliberate — the schema supports it, but operators editing org types is risky misuse, and the only consumer today is the auto-provisioning flow for personal orgs. This will need to change when non-personal org types arrive (see issues.md "Org type CRUD when non-personal types arrive"). Until then, 7b should not allocate "create/edit org type" affordances. - **Two grant paths coexist for a real reason — not by accident.** `IssueGrant` (org-scoped, `/organizations/{orgID}/pools/{poolID}/grant`) requires the product be on a ladder and routes through `entitlements.Transition` — it is the *plan grant* path. `CreateGrant` (`/grants`) calls the legacy `CreateGrantAndMaterialize` and handles non-plan products (addons, usage, one-time). This split aligns with M6a's plan to narrow `billing.products.product_type` to `{addon, usage, one_time}` with plans carrying NULL. 7b should keep the two paths but make the distinction visible to the operator (one form for plans, one for non-plans), and consider co-locating both on the per-org page so the operator chooses by intent rather than by tab. - **Billing is entirely read-only.** No mutating operator actions. All billing state flows from Stripe webhooks. The operator's job in billing is *inspection and reconciliation*, not editing. - **Product retirement, not delete, is the right primitive.** Local products are mapped 1:1 to Stripe products via `stripe.product_mappings(product_id, stripe_product_id, sync_status)`; deleting a local product would silently break that mapping. M6a already plans a `lifecycle_status` column on `billing.products` (`draft` / `published` / `retired`) — that is the right archive primitive. 7b/7c should: surface the Stripe mapping in the product UI (so the operator can see what they're affecting), expose the lifecycle state, and provide a "retire" action rather than a delete action. Same logic applies to entitlement sets that are referenced by products. See issues.md "Product retirement and Stripe-mapping visibility in operator UI". - **Plan-ladder tier rank is the highest-criticality recurring action.** `PUT /plan-ladders/{id}/tiers/{productID}/rank` reorders a ladder while orgs are enrolled on it; sole-writer invariants in `entitlements.Transition` apply, and a `pool_provision_transitions` audit row is written per affected org. This is the screen most worth designing carefully. ## Integrations are not catalog or runtime FedWiki is the only external service the member-console integrates with today, and the operator panel reflects that as if it were a permanent state of the world: a top-level **Sites** tab sits alongside Products, Org Types, etc. That is wrong, and the M7 IA should fix it. The intended trajectory has the member-console acting as a hub for multiple external services — FedWiki, NextCloud, Discourse, and whatever else the cooperative adopts. Each of these is structurally a different concern from the operator catalog and runtime layers: they own their own resources, their own admin surfaces, their own auth and provisioning patterns. They plug *into* the entitlements/billing model rather than being part of it. For M7's IA, this means: - **No top-level "Sites" tab.** FedWiki sites do not belong at the same level as Products, Plan Ladders, Enrollment, or Billing. The route surface keeps `/partials/operator/sites` for now; the IA should relocate it. - **An "Integrations" axis.** Integration management lives in its own section — possibly a top-level `/operator/integrations/...` route family, possibly a separate operator section entirely. Each integration (FedWiki today, NextCloud / Discourse later) is one entry there. - **A standardized extension contract is out of scope for M7.** Designing the actual integration model is a separate body of work and warrants its own milestone. M7 just needs to *not entrench* the FedWiki-as-first-class assumption while that milestone is being scoped. See issues.md "Integration architecture" and the proposed integrations milestone in milestones.md. This is one of the more consequential M7 IA decisions: the panel should look like an operator console for a member coop that runs many services, not an operator console for a FedWiki host that happens to do billing. ## The hidden IA: catalog vs. runtime The cross-tab `HX-Trigger` events name the real dependency edges, and the current flat tab layout obscures them. Two layers exist in the data model: - **Catalog layer.** `org_types`, `products`, `entitlement_sets`, `plan_ladders` (and the `tiers` and `rules` under each). Configuration data — schemas, products, ladders, rules. Edited rarely; correctness is critical; mistakes cascade across all orgs. - **Runtime layer.** `enrollment` (per-org grants and transitions) and `billing` (Stripe-driven subscription state). Per-org operations. Edited often per ticket; visibility and recoverability matter most. The dependency direction is one-way, top-down: an `org_type` references a default `product`; `products` reference `entitlement_sets`; `plan_ladders` reference `products`; runtime grants and subscriptions reference `products`. Mutations in the catalog cascade into the runtime layer; runtime mutations never affect catalog state. This split is the natural seam for IA in 7b. It is also why the current cross-tab `HX-Trigger` mesh exists — the events are an ad-hoc encoding of catalog→runtime cascades that a layered IA would handle structurally. Calling out the split as a *finding* here, not a *recommendation*; 7b makes the IA call. Integrations (FedWiki today; NextCloud, Discourse, etc. later) are a **third, orthogonal axis** that does not belong inside this catalog/runtime model — see "Integrations are not catalog or runtime" below. ## Task inventory Each row is one user-visible action. The annotations are estimates derived from the methodology above. ### Browse / lookup (the entry-point layer) | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | Browse persons | daily | 1 | 3 | Likely the entry point for support tasks. No search/filter visible in route surface. | | Browse organizations | daily | 1 | 3 | Entry point to per-org enrollment + billing. Same search/filter gap. | | Inspect org enrollment state | daily | 1 | 4 | The single most important read screen. Reachable only via Organizations browse → click — not bookmarkable today (no `?orgID=` URL state on the org-enrollment partial). | ### Catalog: Org types | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | List org types | weekly | 1 | 3 | The resolved tabs issue specifically named the org-types default-plan dropdown as a stale-state offender. | | Set default plan for org type | monthly | 4 | 3 | Affects which plan auto-grants to new orgs of that type. Not retroactive. Stale-dropdown bug originated here. | | Backfill org type | rare | 5 | 4 | Mass-mutates existing orgs. The `BackfillOrgType` handler implies a one-shot operation with no clear preview/dry-run in the route surface. Highest blast-radius action in this capability. | ### Catalog: Products | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | List products | weekly | 1 | 2 | | | Create product | monthly | 4 | 3 | Multi-step (product → link entitlement sets → add price → optionally place on ladder). No single-form flow; operator threads through several screens. | | Edit product | monthly | 4 | 3 | Changing the entitlement-set linkage cascades to every org holding the product. Fires `productMutation`. | | List prices for product | rare | 1 | 2 | | | Create price | rare | 4 | 4 | Must align with Stripe-side price ID; `docs/stripe.md` is authoritative. Stripe coupling is the pain. | ### Catalog: Entitlement sets | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | List entitlement sets | weekly | 1 | 2 | | | Create entitlement set | monthly | 3 | 3 | | | Edit entitlement set | monthly | 4 | 3 | Cascades to all products that include this set, which then cascades to all orgs holding those products. Fires `entitlementMutation`. | | List rules for set | rare | 1 | 2 | | | Create entitlement set rule | monthly | 4 | 3 | Rules define what entitlements are actually granted. | | Delete entitlement set rule | rare | 4 | 4 | Only DELETE in this capability. The partial doesn't appear to gate this with `hx-confirm`-style protection in the route surface; needs verification in 7e. | ### Catalog: Plan ladders | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | List plan ladders | weekly | 1 | 2 | | | Validate ladder | weekly | 1 | 2 | Read-only diagnostic; cheap. | | Create plan ladder | rare | 4 | 3 | | | Edit plan ladder metadata | rare | 4 | 3 | | | Delete plan ladder | rare | 5 | 4 | Hard to reverse if any orgs are enrolled on it. | | Add tier (place product on ladder) | monthly | 4 | 4 | Fires `planLadderMutation`. Sole-writer invariants in `entitlements.Transition`. | | **Reorder tier rank** | rare | **5** | **4** | **Highest-criticality recurring catalog action.** Re-ranking changes upgrade/downgrade semantics for in-flight orgs; writes `pool_provision_transitions` audit rows. Worth the most-careful screen in M7. | | Delete tier | rare | 5 | 4 | Drops a product from a ladder while orgs may be enrolled on it. | ### Runtime: Enrollment (per-org grants) | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | Issue grant (org-scoped) | weekly | 4 | 3 | Manual override path. Pool-scoped → integrates cleanly with ladder model. | | Extend grant | weekly | 3 | 3 | Updates expiry on an existing grant. Lower stakes than Issue. | | Revoke grant + transition | monthly | 5 | 4 | Composite action: revokes the grant *and* transitions the pool to the next ladder rank. Effects span two domains; preview-of-effects is critical and not obviously present today. | ### Runtime: Grants (legacy path) | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | List all grants | weekly | 1 | 3 | Cross-org view. Useful for auditing recent overrides; presumably eclipsed by per-org enrollment view. | | Create grant | weekly | 4 | 3 | Older entry path. Coexists with `IssueGrant`. **Consolidation candidate** in 7b. | | Revoke grant | monthly | 4 | 3 | Older simple-revoke; does *not* run the ladder transition (`RevokeGrantAndTransition` is the newer composite). Risk of silent divergence between the two revoke flows. | ### Runtime: Billing (read-only) | Task | Freq | Crit | Pain | Notes | |---|---|---|---|---| | View billing accounts | daily | 1 | 3 | No filter/search visible; full list scan today. | | View subscriptions | daily | 1 | 3 | Probably the most-checked billing screen — Stripe state vs. local state reconciliation. | | View invoices | weekly | 1 | 2 | | | View payments | weekly | 1 | 2 | | | Reconcile billing vs. enrollment | weekly | 4 | 4 | Implicit composite task: cross-references a subscription with an org's enrollment state. No single screen; operator threads tabs. **Strong candidate for a per-org "summary" route in 7b.** | ### Integration: FedWiki sites M5 is complete (5a/5b/5c all Done as of `status/milestones.md`); only `GET /partials/operator/sites` exists today. Per the "Integrations are not catalog or runtime" finding above, FedWiki belongs in the Integrations section of the operator IA, not in the catalog/runtime tab cluster. Annotations are deferred to that section's design — frequency and pain depend on what shape the Integrations IA takes. ## Pain points (synthesis) Six themes, in rough order of how often they bite per session: ### 1. The lookup path is too long for the most common task Inspecting "what is org X currently enrolled in / what is org X being billed for" is a daily task. Today it requires: open `/operator?tab=organizations` → wait for partial → scroll/scan to find the org → click → land on the enrollment partial (which doesn't carry `orgID` in URL) → switch tabs to billing → scan the subscriptions list for the same org. There is no per-org composite view. **This is the single biggest UX leak in the panel.** ### 2. URL state is impoverished `?tab=X` is the only state preserved. Specific entities — an org, a product, a ladder, a grant — never appear in URLs. Consequences: nothing is bookmarkable, nothing is shareable in a support handoff, browser back/forward doesn't navigate within the panel, and reloading drops the operator back to tab 1. ### 3. Cross-capability cascades are invisible Editing an entitlement set affects every product that uses it, which affects every org holding any of those products. The current UI surfaces *no* preview of what an edit will cascade to. The `entitlementMutation` HX-Trigger refreshes other tabs' caches but doesn't tell the operator *what changed downstream*. Same for product edits and tier rank changes. High-criticality actions in the inventory above (Crit ≥ 4) almost all share this gap. ### 4. Two grant paths coexist `/grants` (legacy: `CreateGrant`, `RevokeGrant`) and `/organizations/{id}/pools/{poolID}/grant*` (newer, ladder-aware). The two `revoke` paths in particular are non-equivalent: `RevokeGrant` does not run the ladder transition; `RevokeGrantAndTransition` does. Operators choosing the wrong one is a silent-correctness bug waiting to happen. **7b should consolidate to the org-scoped path; 7e should pick one form pattern.** ### 5. No search/filter on browse screens Persons, organizations, sites, billing accounts, subscriptions, invoices, payments — none of the route surface includes filter or pagination parameters. Daily-use browse screens degrade linearly with growth. Free for small co-ops; expensive once tenancy reaches a few dozen orgs. ### 6. Inherited from `status/issues.md` (architectural, not yet retired) - **Tab framework fights HTMX.** The resolved "Operator panel tabs are not HTMX-idiomatic" entry patched the symptom (stale dropdowns, lost position on reload) via `HX-Trigger` events. The architectural cause (flat tab IA, DOM-cached `revealed` partials, `data-switch-tab` + `operator-tabs.js` workaround under strict CSP) is what M7 is here to retire. - **Accessibility unaudited.** Keyboard parity, focus management, contrast — none verified. WCAG 2.1 AA is the M7 floor. - **No shared form/feedback vocabulary.** Each tab grew its own confirmation, error, and success patterns. `renderOrgTypesPageWithData(... success string, errMsg string)` style is one shape; HX-Trigger flash patterns are another. 7e standardizes. ## Hot paths (frequency × criticality) Three workflows dominate operator time, and they're the ones the new IA in 7b should optimize first: 1. **"What's the state of org X?"** Browse → org → enrollment + billing summary. Bookmarkable URL: `/operator/organizations/{orgID}` (or similar). This is *the* unmet need. 2. **"Adjust org X's grants."** From the org view above, issue / extend / revoke a grant. Currently splintered across two code paths (consolidation needed) and not co-located with the read view. 3. **"Reconfigure the catalog."** Create a product, place it on a ladder, link entitlement sets, add a price. Multi-step today, no wizard or progress indicator. Lower frequency than (1) and (2) but the multi-screen ceremony is its own pain. The catalog → runtime layering in the dependency graph above is also the right axis for IA: hot paths (1)–(2) are runtime, hot path (3) is catalog, and they should not share visual weight at the top level. ## Personas ### Operator (primary) Internal staff administering the catalog and runtime layers 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. What they need from the panel: - **Speed on hot paths.** Enrollment lookups and billing inspection should be one navigation step, not three tab switches. - **Recoverability on cold paths.** Catalog edits are infrequent and high-stakes; surfaces should explain what cascades and confirm before destruction. - **Bookmark/share parity.** "Send me the link to that org's enrollment" must work. ### Read-only operator A second role worth designing for: someone who needs to inspect operator state (organizations, enrollments, billing) without being able to mutate. Useful for support handoffs and for keeping the principle of least authority intact when the team grows past one person. Concretely: every mutating route is gated, and the same screens render without action affordances when the role lacks them. ### URL structure must not bake the operator role Whatever role mechanism gets added later (read-only operator now, possibly more roles eventually), URLs should describe *what* is being looked at, not *who* is allowed to look at it. A read-only operator viewing `/operator/organizations/{orgID}` and a full operator viewing the same URL should land on the same route — what differs is which actions render. This is a hard constraint on 7b's IA: do not encode role in the URL path. ## Success criteria for M7 Derived from the analysis above; refined in 7c. - **Hot paths shorten.** Top-3 frequency-weighted tasks reach their target screen in ≤ 2 navigation steps from `/operator`. - **URL is the source of truth.** Every operator capability is bookmarkable and reload-stable. - **No SPA-tab smell.** No cross-tab `HX-Trigger` event mesh; mutations fire on the affected screen, full stop. - **Shared form/feedback vocabulary.** One way to confirm a destructive action; one way to display field errors; one way to render success. - **WCAG 2.1 AA floor.** Keyboard parity verified; color is not the sole channel. ## Open threads (handed to 7b/7e) - **Consolidate the two grant paths** (7b decision, 7e form pattern). The `RevokeGrant` vs. `RevokeGrantAndTransition` divergence is a silent-correctness risk. - **Per-org composite view route.** `/operator/organizations/{orgID}` is the natural home for hot path (1); 7b owns the URL shape. - **Cascade-preview pattern.** Catalog edits at Crit ≥ 4 (product edit, entitlement-set edit, tier reorder) all need a "what will this affect?" preview. 7e owns the pattern. - **Search/filter on browse screens.** Out of scope to design here, but 7b should reserve URL real estate for `?q=`, `?org_type=`, etc. - **Backfill dry-run.** `BackfillOrgType` is the highest-blast-radius action without a clear preview. 7e should require dry-run + confirmation for irreversible mass mutations. - **Telemetry spike.** Before 7b finalizes IA, decide whether to add minimal action-counter telemetry. Cheap to add (one middleware), un-blocks frequency cells from being permanently estimates. - **FedWiki sites.** Revisit this section after M5 lands. ## Reuse for member-side UX (M8/M9) The structure of this doc — methodology → capability surface → dependency graph → task inventory → pain → personas → success criteria — is the template. For member-side, swap "operator" for "member" and re-run the same passes against `internal/server/member_*.go` (or whatever the member surface ends up being called). The judgment-based annotations stay judgment-based unless we add telemetry first.