25 KiB
audience, status
| audience | status |
|---|---|
| developers, designers | 7a-complete |
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
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:
- What does an operator actually do here? Not "what tabs exist," but what tasks, in what order, how often, and with what stakes.
- 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.goregisters 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
planLadderMutationand writes apool_provision_transitionsrow is structurally more dangerous than one that mutates a single column on one row. - Issue archaeology.
status/issues.mdis where lived pain has been deposited over time. Resolved, open, and superseded entries all carry signal. - Cross-tab dependency mapping. The
HX-Triggerevent 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_typestable (org_typePK,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 throughentitlements.Transition— it is the plan grant path.CreateGrant(/grants) calls the legacyCreateGrantAndMaterializeand handles non-plan products (addons, usage, one-time). This split aligns with M6a's plan to narrowbilling.products.product_typeto{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 alifecycle_statuscolumn onbilling.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}/rankreorders a ladder while orgs are enrolled on it; sole-writer invariants inentitlements.Transitionapply, and apool_provision_transitionsaudit 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/sitesfor 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 plug-in / extension 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 / extension 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 thetiersandrulesunder 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) andbilling(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-Triggerevents. The architectural cause (flat tab IA, DOM-cachedrevealedpartials,data-switch-tab+operator-tabs.jsworkaround 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:
- "What's the state of org X?" Browse → org → enrollment + billing summary. Bookmarkable URL:
/operator/organizations/{orgID}(or similar). This is the unmet need. - "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.
- "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-Triggerevent 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
RevokeGrantvs.RevokeGrantAndTransitiondivergence 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.
BackfillOrgTypeis 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.