Files
member-console/docs/operator-ia.md
Christian Galo fd7c61d594 Add Operator Panel IA decision record and spec delta
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
2026-05-12 15:51:42 -05:00

205 lines
16 KiB
Markdown

# Operator Panel — Information Architecture
> **Status:** Decision record for M7 phase 7b (in progress).
> **Companion artifacts:** OpenSpec change [`operator-ia`](../openspec/changes/operator-ia/) (proposal, design, specs delta, tasks).
> **Builds on:** [`docs/operator-ux-research.md`](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.md` after 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"](operator-ux-research.md#the-hidden-ia-catalog-vs-runtime)).
```
┌─────────────────────────────────────────────────┐
│ 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"](../status/issues.md)) 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"](#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"](#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.md` open issue "Operator grant-issuance: two non-overlapping surfaces…"](../status/issues.md)). Global Grants tab `CreateGrant` lets the operator pick any product or entitlement set; per-org Enrollment `IssueGrant` restricts 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: per `membcons-db` Doc 35, plan products are identified *structurally* by `plan_ladder_tiers` membership, while non-plan products (`addon`, `usage`, `one_time`) carry a labeled `product_type`. `IssueGrant` is the ladder-routing path for plans; `CreateGrant` is the legacy path that handles the three labeled kinds.
- **Revocation** ([`status/issues.md` open issue "Two grant-revoke paths…"](../status/issues.md)). Global Grants tab `RevokeGrant` does a simple entitlement recalculation; per-org Enrollment `RevokeGrantAndTransition` is 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 with `product_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"](#related-product-kind-taxonomy) below).
- **Revocation** — consolidates to `RevokeGrantAndTransition` (composite). The simple `RevokeGrant` HTTP 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`](../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:
1. Browse routes MUST NOT use these parameter names for unrelated purposes.
2. Routes MUST accept the reserved params without error even when no filter UI exists yet — unimplemented filters are silently ignored, not rejected.
3. 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"](operator-ux-research.md#open-threads-handed-to-7b7e) 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-tab `HX-Trigger` refresh 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"](../status/issues.md).
- **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.