Introduce docs/operator-ia.md and an OpenSpec change "operator-ia" with design, proposal, spec delta, and tasks; update status to mark M7b In progress
16 KiB
Operator Panel — Information Architecture
Status: Decision record for M7 phase 7b (in progress). Companion artifacts: OpenSpec change
operator-ia(proposal, design, specs delta, tasks). Builds on:docs/operator-ux-research.md— the M7a research synthesis whose findings this document commits to as a contract.
Purpose
The operator panel grew organically into a flat 12-tab SPA. M7a's research named the seam — catalog vs. runtime vs. integration — and surfaced the hot paths (per-org lookup, grant adjustments) and the silent-correctness risks (two non-equivalent grant-revoke paths). This document captures the information-architecture decisions that subsequent M7 phases (7c design system, 7d SPA→MPA conversion, 7e form & action patterns) and the member-side milestones (M8 plan catalog, M9 usage views) inherit. It is the contract those phases must honor.
The contract is documented twice:
- Here (
docs/operator-ia.md): narrative rationale, links to research, deferred items, future direction. - In the spec (
openspec/specs/operator-panel-navigation/spec.mdafter archive): testable requirements with scenarios.
When the two diverge, the spec wins. This document explains why; the spec defines what.
The three-layer model
The operator panel's top level has exactly three groups. The grouping is not aesthetic — it follows the dependency direction the data model already encodes (operator-ux-research.md, "The hidden IA").
┌─────────────────────────────────────────────────┐
│ CATALOG (rare edits, blast radius wide) │
│ org_types ─▶ products ─▶ entitlement_sets │
│ └─▶ plan_ladders │
└────────────────────────┬────────────────────────┘
│ one-way cascade
▼
┌─────────────────────────────────────────────────┐
│ RUNTIME (per-org churn, per-ticket work) │
│ organizations · grants · billing read-views │
└─────────────────────────────────────────────────┘
─ ─ ─ orthogonal axis (not part of the cascade) ─ ─ ─
INTEGRATION FedWiki sites today;
NextCloud, Discourse, others later
Catalog
Configuration entities edited rarely, with effects that cascade across all organizations. Mistakes here have wide blast radius (a product edit can change every grant referencing it).
Members: org_types, products, entitlement_sets, plan_ladders (and their nested tiers / rules).
Runtime
Per-organization operational work. The dominant workflow ("what's the state of org X?") lives here, alongside grant adjustments and read-only billing views (Stripe is the source of truth — the operator panel surfaces but does not mutate billing state directly).
Members: organizations (browse + per-org composite view), global grants (read-only browse — see §"Grant revocation" below), billing accounts, billing subscriptions, billing invoices, billing payments.
Integration
System-specific surfaces that don't fit the catalog/runtime cascade. Today this group is FedWiki-only; the axis exists so future integrations (NextCloud, Discourse, etc.) attach here rather than as new top-level groups. The pervasive FedWiki-only assumption in the codebase (open issue: "FedWiki-only integration assumption pervades UI and data patterns") is tracked separately and is not addressed by this change — M7b reserves the slot; deeper rework arrives with the second integration.
Members: FedWiki sites.
Route hierarchy
Top-level routes mirror the three-layer model. The IA contract is the route shape; the implementation rewrite that retires ?tab= and turns these into real HTTP routes lives in M7d (see §"SPA → MPA transition" below).
/operator curated landing surface
/operator/organizations runtime · browse
/operator/organizations/{orgID} runtime · per-org composite view ◀── hot path
/operator/grants runtime · global grants browse (read-only)
/operator/billing/accounts runtime · billing read-views
/operator/billing/subscriptions
/operator/billing/invoices
/operator/billing/payments
/operator/org-types catalog
/operator/products catalog
/operator/entitlement-sets catalog
/operator/plan-ladders catalog
/operator/fedwiki-sites integration
These names are the canonical route slugs. The current ?tab=<id> IDs (tab-1, users, etc.) are SPA-era artifacts and are not part of this contract; 7d's MPA conversion replaces them.
The curated landing surface
/operator renders a curated landing page, not whichever tab happens to load first. The page surfaces hot-path entry points so the dominant workflows reach their target screen in ≤ 2 navigation steps from the landing.
Required content categories:
- Organization lookup — search/jump-to-org entry for hot path #1.
- Recent runtime activity — recent grant transitions, subscription state changes.
- Recent billing activity — recent invoices, payment events.
Visual treatment and component choice are 7c's call. The IA requirement is only that these categories appear and that the landing is the default destination for /operator.
The per-organization composite view
/operator/organizations/{orgID} is the addressable home for runtime work on one organization. It co-locates:
- Enrollment state (active grants, current org-type defaults).
- Grant history (issued, transitioned, revoked).
- Billing summary (current subscription, recent invoices).
- Grant actions (issue, extend, revoke — see §"Grant revocation" below).
The route is bookmarkable and reload-stable. Sub-page splits (vertical sections, accordion panels, sub-routes) are 7d's implementation call; the IA contract is satisfied as long as the URL exists and co-locates this work.
Grant actions
Today both grant issuance and grant revocation split across global and per-org surfaces, but the splits have different root causes:
- Issuance (
status/issues.mdopen issue "Operator grant-issuance: two non-overlapping surfaces…"). Global Grants tabCreateGrantlets the operator pick any product or entitlement set; per-org EnrollmentIssueGrantrestricts the picker to plan-typed products attached to the org's ladder. Neither surface cross-links to the other, and the operator must already know the distinction to choose correctly. The two code paths exist for a real reason: permembcons-dbDoc 35, plan products are identified structurally byplan_ladder_tiersmembership, while non-plan products (addon,usage,one_time) carry a labeledproduct_type.IssueGrantis the ladder-routing path for plans;CreateGrantis the legacy path that handles the three labeled kinds. - Revocation (
status/issues.mdopen issue "Two grant-revoke paths…"). Global Grants tabRevokeGrantdoes a simple entitlement recalculation; per-org EnrollmentRevokeGrantAndTransitionis composite — the pool returns to the org-type default and a transition is recorded. Unlike issuance, the two paths are non-equivalent in effect; choosing the wrong one is silent-correctness risk. - Extension. Single path, not contested.
The UI hides both distinctions today. Operators have to internalize the difference between the surfaces every time, with no in-product affordance to guide the choice.
Decision: all grant action affordances — issue, extend, revoke — live exclusively on the per-organization composite view at /operator/organizations/{orgID}. The global Grants surface becomes read-only: it remains in the Runtime group for cross-org browse/audit/lookup, but exposes no Issue, Extend, or Revoke affordance.
The three actions consolidate differently:
-
Issuance — both code paths persist. The per-org view exposes two distinct forms, labeled by intent:
- Grant a plan — uses
IssueGrant; product picker restricted to plan-typed products on the org's ladder. - Grant a non-plan product — uses
CreateGrant; product picker restricted to products withproduct_type ∈ {addon, usage, one_time}.
Labeling the forms by what kind is being granted rather than which surface they live on lets the operator choose by intent instead of by tab. The two code paths align with the M6a product-type narrowing; consolidating them is out of scope here (see §"Related: product kind taxonomy" below).
- Grant a plan — uses
-
Revocation — consolidates to
RevokeGrantAndTransition(composite). The simpleRevokeGrantHTTP handler may stay reachable by direct URL for programmatic callers; it is no longer a UI affordance. -
Extension — co-located on the per-org view alongside issuance and revocation. Single path, no consolidation needed.
This is a contract decision. 7d implements the affordance removals from the global Grants surface and the dual-form issuance UI as part of the MPA rewrite.
Related: product kind taxonomy
The two issuance code paths and the structural-vs-labeled product kind distinction are explained in upstream membcons-db Doc 35 (Product Kind Taxonomy: Why product_type and product_kinds Coexist). The IA decision here only governs UI surface; the underlying read-path discipline (when to read billing.product_kinds view vs. products.product_type column) is tracked separately as a member-console tech-debt item in status/issues.md. M7b does not adjudicate that question — it just acknowledges that the two issuance forms reflect a real schema-level distinction, not redundancy.
Breadcrumb positional contract
Every operator page declares its position in the IA as a tuple:
(group, capability) for capability index pages
(group, capability, instance) for entity-scoped pages
group ∈ { catalog, runtime, integration }. capability is the route slug (products, organizations, etc.). instance is present when the URL is scoped to one entity (e.g. the per-org composite view).
Examples:
| Route | Position |
|---|---|
/operator/products |
(catalog, products) |
/operator/plan-ladders/{ladderID} |
(catalog, plan-ladders, {ladderID}) |
/operator/organizations |
(runtime, organizations) |
/operator/organizations/{orgID} |
(runtime, organizations, {orgID}) |
/operator/fedwiki-sites |
(integration, fedwiki-sites) |
The visual breadcrumb rendering (separator character, truncation rules, current-page styling) is 7c's call. M7b defines the positional vocabulary, not the render. Pages declare their position; the render layer consumes it.
Reserved query-parameter real estate
Browse routes reserve canonical filter parameter names so future filter UX can attach without URL-breaking changes or spec rewrites:
| Parameter | Purpose | Status |
|---|---|---|
?q=<search> |
Free-text search across the route's primary entity | Reserved; UX deferred |
?org_type=<slug> |
Filter by org type (where applicable) | Reserved; UX deferred |
?lifecycle_status=<value> |
Filter by lifecycle status (products, etc.) | Reserved; UX deferred |
Reservation rules:
- Browse routes MUST NOT use these parameter names for unrelated purposes.
- Routes MUST accept the reserved params without error even when no filter UI exists yet — unimplemented filters are silently ignored, not rejected.
- Adding visible filter controls is a future change (likely 7d or a follow-up); the URL contract is locked in now.
Search/filter UX itself is deferred — operator-ux-research.md "Open threads" handed only the URL real estate to 7b; the visual surface is for a later milestone.
SPA → MPA transition
Until 7d ships, openspec/specs/operator-panel-navigation/spec.md contains two competing contracts:
- The legacy SPA contract (tab state in
?tab=, lazy first-reveal partial loads, cross-tabHX-Triggerrefresh mesh) — unchanged by this change. - The new IA contract from this change (three-layer grouping, landing surface, per-org composite route, breadcrumb positions, reserved filter params, grant-revoke single entry point) — added by this change.
This dual-contract state is intentional and short-lived. 7d's BREAKING change removes the SPA contract; the IA contract is what survives. New code written between archive of this change and ship of 7d should target the IA contract; the SPA contract is running behavior, not target behavior.
What this change does not do
The contract is deliberately narrow. The following are explicitly out of scope for M7b:
- Component primitives, typography, spacing tokens, accessibility patterns. 7c.
- Route rewiring,
?tab=removal, partial-loader retirement, MPA conversion. 7d. - Form patterns, confirmation modals, destructive-action guards, inline validation, success/failure feedback. 7e.
- A11y audit, keyboard-nav pass, convention-drift guard. 7f.
- Action-counter telemetry, audit log surfacing, frequency validation. M11 owns this work; M7b explicitly stays on M7a's estimated frequencies. Decision: no telemetry spike in M7.
- FedWiki-sites internals or generalizing the integration model. The axis is reserved; the rework arrives with the second integration.
- Search/filter visible UX. Only URL real estate is reserved.
Deferred decisions and open threads
- Sub-page structure under
/operator/organizations/{orgID}— single page vs. vertical sections vs. sub-routes is 7d's call. The IA contract is the URL and the co-located content; the page shape is implementation. - Catalog-side landing summaries — whether
/operator/products,/operator/plan-ladders, etc. need their own most-edited / recently-changed summaries is a 7c/7d decision. Not a contract concern. - Integration-axis future shape — the second integration's arrival will force a deeper rethink. Tracked under the open issue "FedWiki-only integration assumption pervades UI and data patterns".
- Search/filter UX — beyond the URL reservation here, the visible surface is deferred.
Future direction
The IA is a decision record, not an immutable schema. If a future capability genuinely does not fit catalog, runtime, or integration, the right move is a follow-up operator-ia change adding a fourth group — not stretching one of the existing groups to absorb it. The contract is the layering principle (one-way cascade between configuration and operations, integrations orthogonal), not the specific count of groups.
Member-side UX (M8 plan catalog, M9 usage views) reuses the layering vocabulary and the design system from 7c. The catalog/runtime split applies cleanly: M8's plan catalog is a member-facing read of the catalog layer; the upgrade/downgrade flows live in the runtime layer of the member surface. This is the reuse the M7 foundation was set up to enable.