Files
member-console/docs/operator-ux-research.md

25 KiB
Raw Blame History

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:

  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 15:

  • 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 15:

  • 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 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 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.