Files
cgalo5758 929c176ce1 Unify application shell across both surfaces
Extract the top bar and account menu into shell_topbar.html and the
member rail into shell_rail_member.html, backed by a single
server.Shell value. Move session controls into the account menu, add
the mirrored Operator panel/Member dashboard surface switch, and turn
the rail into an offcanvas drawer below lg with shell.js closing it on
navigation. Update docs, specs, and tests.
Unify application shell across both surfaces

Extract the top bar and member rail into shared partials and introduce
server.Shell as the single data value for page chrome. Move session
controls into an account menu, make the rail an offcanvas drawer below
lg, and add the mirrored surface switch.
2026-08-30 01:07:48 -05:00

504 lines
45 KiB
Markdown

# operator-panel-navigation Specification
## Purpose
TBD - created by archiving change fix-operator-panel-tabs. Update Purpose after archive.
## Requirements
### Requirement: Operator panel groups capabilities under a three-layer information architecture
The operator panel SHALL organize all capabilities under exactly three conceptual groups: **Catalog**, **Runtime**, and **Integration**. Catalog contains configuration entities edited rarely with wide blast radius (`org_types`, `products`, `entitlement_sets`, `plan_ladders`). Runtime contains per-organization operational surfaces (organizations, persons, grants, billing read-views). Integration contains system-specific surfaces (FedWiki sites today; other integrations later).
The groups govern URL grouping, conceptual categorization, breadcrumb derivation, and the one-way `catalog → runtime → integration` dependency direction. Capabilities SHALL NOT appear at the operator panel's top level outside one of these three groups; the conceptual grouping is carried by each page's machine-readable IA-position declaration.
Sidebar **presentation** is a flat list of exactly eight section entries — no children, no indentation, no headed groups (maintainer decisions 2026-08-23: the sidebar names the sections; the sections navigate themselves; People joined as the eighth entry with the directory) — followed by exactly one non-section entry: the surface switch "Member dashboard", separated from the sections by the rail's border-top idiom, linking to `/`, never marked active (unify-shell; the only separated entry the rail may carry). The render order SHALL be: Overview, People, Organizations, Grants, Billing, Products, Domains, Integrations, then the surface switch. Second-level surfaces SHALL be reached in-page, not from the sidebar: person detail from the People directory's rows, `org_types` from the Organizations page's header affordance, the billing read-views from the Billing wrapper's in-page pill row, `entitlement_sets` and `plan_ladders` from the Products page's header affordances, and provider surfaces from the Integrations home's provider rows. The conceptual layer lives in the IA declarations; a second-level page SHALL mark its section's sidebar entry active. Below the `lg` breakpoint the same sidebar renders as a drawer opened from the shell's top bar (chrome-conventions, "One shell for both surfaces"); no other navigation to the sections exists.
#### Scenario: Catalog capabilities are reachable from within their task surfaces
- **WHEN** an operator views the Organizations page or the Products page
- **THEN** the Organizations page carries an affordance to `org_types`, and the Products page carries affordances to `entitlement_sets` and `plan_ladders`
- **AND** none of them appear in the sidebar
- **AND** their IA-position declarations still carry the Catalog conceptual group
#### Scenario: The sidebar is a flat list of section entries
- **WHEN** an operator views the operator panel sidebar
- **THEN** it renders exactly the eight entries Overview, People, Organizations, Grants, Billing, Products, Domains, Integrations in that order
- **AND** no child entries, indentation, or headed group blocks are rendered
- **AND** the only entry after the eight is the separated "Member dashboard" surface switch
#### Scenario: Sections navigate themselves
- **WHEN** an operator views an operator section with sub-surfaces
- **THEN** the sub-surfaces are reachable from within the section (the Billing wrapper's pill row, header affordances on the section's page, or the section's own rows)
- **AND** not from sidebar children
#### Scenario: Second-level pages mark their section active
- **WHEN** an operator is on a second-level page (for example person detail, org types, a billing view, or entitlement sets)
- **THEN** the sidebar marks that section's single entry (People, Organizations, Billing, or Products) as active, and no other
#### Scenario: The sidebar is reachable on phones
- **WHEN** an operator views any operator page below the `lg` breakpoint
- **THEN** the top bar's toggler opens the same sidebar as a drawer, sections first and the surface switch last
### Requirement: Operator panel exposes a curated landing surface at /operator
The operator panel SHALL render a curated landing surface at `/operator` that answers "what is this deployment doing right now?" rather than defaulting to the first capability tab. The landing surface SHALL be the default destination when an operator navigates to `/operator` without further path or query parameters. The surface SHALL present, coarsest-first: a lookup affordance, an at-a-glance overview region, a recent-activity timeline, and a system-status region.
The landing-surface lookup affordance SHALL be a text input that accepts a person name (substring), an email address (exact), or an organization name (case-insensitive substring). Submitting the input SHALL resolve the entry to one of: `/operator/persons/{personID}` (when a person match is unique), `/operator/organizations/{orgID}` (when no person matches and exactly one organization name matches), or an inline disambiguation listing (when multiple persons match a name substring, or when no person matches and several organizations do; organization candidates render name, type, and owner, each linking to its composite). Persons take precedence because every personal organization's name contains its owner's display name, so a name that finds a person must land on the person. Resolution SHALL be performed server-side; no client-side autocomplete is required.
The at-a-glance region SHALL present headline metrics spanning the deployment's membership, organizations, catalog, billing, and entitlement-delivery state, each sourced from its owning module's query layer. The organizations headline SHALL count deliberately created organizations: active organizations whose type is neither the auto-created `personal` type nor a reserved type. Personal orgs exist one-per-member by structural convention (an organizations count that includes them tracks the people count and reads as an error), and the negative-space predicate means `team`, `enterprise`, and deployment-defined types all count without code changes while the reserved system org never does. Billing coverage SHALL include both commitment and receivables. Commitment SHALL render as monthly-normalized recurring money across active subscriptions: monthly prices at face value, yearly prices at one-twelfth, each times item quantity; trialing subscriptions SHALL be excluded from the money and stated in the caption. Receivables SHALL render as a count of invoices in `open` status (issued and awaiting payment) with the outstanding balance in the caption. Money values SHALL be summed per currency with the headline showing the largest currency bucket; when other currency buckets exist the caption SHALL acknowledge them, and cross-currency conversion is explicitly out of scope for this surface. The headline row is reserved for core capability state: per-feature counts (such as domain claims) SHALL NOT render as headline tiles — feature moderation surfaces are reached from the sidebar, not the landing row. Where a count's owning capability has a browse surface, its tile SHALL link there. Count sources SHALL degrade independently: a failed count query SHALL be logged and rendered as an explicit unavailable marker (an em dash) — NEVER as a zero, since a zero read off a broken query invites exactly the wrong operational conclusion — and SHALL NOT fail the page. Any count presented as "currently delivering" SHALL derive from `pool_provisions.status` (delivery state); it SHALL NOT be computed from `grants.status` alone, which is an issuance ledger, not current state.
The recent-activity section SHALL be **a single unified timeline** that interleaves grant events, transition events, invoice events, and payment events from across the operator panel, ordered by event timestamp (newest first). Each rendered event row SHALL identify the organization (and person, where applicable) and link to the originating organization composite, the originating person detail, or the relevant billing page based on the event type. The section is one timeline because an operator's mental model of "what's been happening" is temporal, not domain-partitioned — an invoice payment that lands near the grant transition that triggered it is more useful adjacent than separated. Money amounts in event rows SHALL render as formatted currency (symbol/code and decimal placement per the console's standard money formatting), never as raw integer minor units.
The system-status region SHALL list every registered integration provider (registry-driven, not a hardcoded subset). For each provider it SHALL show two honestly-labeled facts: the **registry status** (the operator-managed lifecycle value, labeled so it cannot read as a health-check result) and a **configuration-readiness signal** derived from the same required-key resolution the settings pages perform (a provider with any required key unresolved is visibly not configured). The region's copy SHALL NOT describe this state as "connected" or as being "read live" — the console performs no connectivity checks and the status is a stored value. The region SHALL report integration outbox depth split into pending, retrying, and dead-letter buckets, and its caption SHALL name which integrations' queued work the outbox actually covers, so an all-quiet outbox cannot imply health for integrations that do not enqueue through it. Only the dead-letter bucket — work that has exhausted retries — SHALL receive alarm styling; pending and retrying are normal draining states.
The landing surface's heading hierarchy SHALL descend without skipping levels (H1, then H2, …). The operator shell (page title, navbar brand) SHALL render the configured deployment name (`deployment-name`, defaulting to the generic product name), identically to the member surface, so the application presents one identity across surfaces; with the default value the shell remains deployment-neutral for screenshots and first impressions. The landing surface SHALL use the console's standard bordered-card presentation; it SHALL NOT introduce bespoke elevation or hover-motion card treatments absent from the rest of the console.
#### Scenario: Navigating to /operator shows the landing surface
- **WHEN** an authenticated operator navigates to `/operator`
- **THEN** the panel renders the curated landing surface
- **THEN** the landing surface includes an organization lookup entry point
- **THEN** the landing surface includes an at-a-glance region of headline metrics
- **THEN** the landing surface surfaces a single recent-activity section interleaving grants, transitions, invoices, and payments
- **THEN** the landing surface includes a system-status region
#### Scenario: Landing surface is the documented default
- **WHEN** an operator follows an internal link to `/operator` from anywhere in the panel
- **THEN** the landing surface renders instead of an arbitrary capability tab
#### Scenario: Lookup input resolves a unique person match
- **WHEN** an operator submits the lookup input with a value that matches exactly one person (by email or name substring)
- **THEN** the panel issues a 302 redirect to `/operator/persons/{personID}` for that match
#### Scenario: Lookup input resolves an organization by name
- **WHEN** an operator submits the lookup input with a value that matches no person and exactly one organization's name (case-insensitive substring)
- **THEN** the panel issues a 302 redirect to `/operator/organizations/{orgID}` for that organization
#### Scenario: Ambiguous organization names disambiguate
- **WHEN** an operator submits the lookup input with a value that matches no person and several organizations' names
- **THEN** the panel renders a disambiguation listing of those organizations, each linking to its composite
#### Scenario: Lookup input renders a disambiguation list on ambiguous match
- **WHEN** an operator submits the lookup input with a value that matches more than one person by name substring
- **THEN** the panel renders a disambiguation listing showing each candidate person
- **THEN** each candidate row links to `/operator/persons/{personID}` for that person
#### Scenario: Lookup input reports no match
- **WHEN** an operator submits the lookup input with a value that matches no person or organization
- **THEN** the panel renders the landing surface with a visible no-match message
- **THEN** no redirect occurs
#### Scenario: Recent activity timeline interleaves event types
- **WHEN** an operator views `/operator` and any recent grant, transition, invoice, or payment events exist
- **THEN** the "Recent activity" section renders one row per event ordered by event timestamp (newest first), interleaving event types
- **THEN** each row identifies the organization (and person, where applicable) and its event type
- **THEN** each row links to the originating organization composite, person detail, or relevant billing page based on event type
#### Scenario: Activity money renders as formatted currency
- **WHEN** the recent-activity timeline renders an invoice or payment event carrying a money amount
- **THEN** the amount renders as formatted currency with its currency named
- **THEN** no row renders a raw integer of minor units (e.g. "3600 usd")
#### Scenario: Organizations headline excludes auto-created personal orgs
- **WHEN** an operator views `/operator` on a deployment holding personal orgs, team orgs, and the reserved system org
- **THEN** the organizations tile counts only active organizations that are neither `personal`-typed nor reserved-typed
- **THEN** its caption states the exclusion so the number is self-interpreting
#### Scenario: Billing commitment renders as monthly-normalized recurring money
- **WHEN** an operator views `/operator` with active subscriptions on monthly and yearly prices
- **THEN** the recurring-money headline sums monthly prices at face value and yearly prices at one-twelfth, times item quantity, over active subscriptions only
- **THEN** trialing subscriptions appear in the caption count but contribute nothing to the money
- **WHEN** the active subscriptions bill in more than one currency
- **THEN** the headline shows the largest currency bucket and the caption acknowledges the other buckets
#### Scenario: Billing coverage includes a receivables headline
- **WHEN** an operator views `/operator`
- **THEN** the at-a-glance region includes a tile counting invoices in `open` status
- **THEN** the tile links to `/operator/billing/invoices`
- **THEN** its caption states the outstanding balance and what was counted (issued and awaiting payment)
#### Scenario: People caption carries the 30-day trend
- **WHEN** an operator views `/operator`
- **THEN** the People tile's caption states how many persons joined in the last 30 days
#### Scenario: Delivering-grants headline counts deliberate grants
- **WHEN** an operator views `/operator`
- **THEN** the delivering-grants headline counts deliberately issued (non-default) delivering grants — the set an operator acts on
- **THEN** its caption names how many delivering signup defaults were excluded, so the full delivering picture stays one glance away
#### Scenario: Headline row carries no per-feature counts
- **WHEN** an operator views `/operator`
- **THEN** the at-a-glance region renders no domain-claims tile or other per-feature count
- **THEN** domain moderation remains reachable from the sidebar's Domains entry
#### Scenario: Landing cards use the console's standard idiom
- **WHEN** the landing surface renders
- **THEN** its cards render with the console's standard bordered-card treatment
- **THEN** no card carries a bespoke elevation (shadow) or hover-motion treatment
#### Scenario: A failed count degrades to an unavailable marker, not a zero
- **WHEN** an operator views `/operator` and one metric's backing query fails
- **THEN** that tile renders an em dash with an unavailable caption instead of a value
- **THEN** every other tile renders its real value
- **THEN** the page returns 200 and the failure is logged
#### Scenario: Delivering count reflects provisions, not the grant ledger
- **WHEN** a grant row is `active` in `grants.status` but has no active row in `pool_provisions`
- **THEN** the "delivering" headline count does not include it
#### Scenario: System region lists all registered providers with honest state
- **WHEN** an operator views `/operator` with multiple integration providers registered
- **THEN** the system-status region lists every registered provider with its registry status under a label that cannot read as a health check
- **THEN** a provider with an unresolved required key shows a visible not-configured signal
- **THEN** the region's copy makes no "connected" or "read live" claim
#### Scenario: Outbox caption names its coverage
- **WHEN** an operator views the system-status region's outbox report
- **THEN** the caption names which integrations' queued work the outbox covers
- **THEN** an empty outbox does not present itself as evidence that uncovered integrations are healthy
#### Scenario: Only dead-letter outbox work raises alarm styling
- **WHEN** the integration outbox contains pending and retrying entries but no dead-letter entries
- **THEN** the outbox report renders without alarm styling
- **WHEN** the integration outbox contains at least one dead-letter entry
- **THEN** the dead-letter bucket renders with alarm styling
#### Scenario: Shell renders the configured deployment name
- **WHEN** any operator page renders
- **THEN** the page title and navbar brand render the configured deployment name, matching the member surface
- **THEN** with no name configured, the shell renders the generic product name and stays deployment-neutral
#### Scenario: Landing heading hierarchy has no skips
- **WHEN** an operator views `/operator`
- **THEN** the document outline descends H1 → H2 (→ H3) with no skipped levels
### Requirement: Per-organization composite view is addressable at /operator/organizations/{orgID}
The operator panel SHALL provide a per-organization composite view at the route `/operator/organizations/{orgID}` that co-locates enrollment state, grant history, billing summary, member roster, and grant actions for one organization. The route SHALL be bookmarkable and reload-stable. The composite view SHALL be reachable from the Runtime group's organization browse surface.
The billing-summary section SHALL show: the current subscription status and plan (if any), the next renewal date (if any), the most recent invoice (date, amount, paid status), and the outstanding balance across unpaid invoices. The section SHALL link out to `/operator/billing/accounts` for the full billing view.
The members section SHALL list all persons enrolled at the organization, sorted alphabetically by display name. Each row SHALL link to the person's resolved-detail view at `/operator/persons/{personID}`.
#### Scenario: Per-org composite view is bookmarkable
- **WHEN** an operator loads `/operator/organizations/{orgID}` directly via URL
- **THEN** the page renders the composite view for that organization without requiring prior navigation through the panel
#### Scenario: Composite view co-locates runtime work
- **WHEN** an operator views `/operator/organizations/{orgID}`
- **THEN** the page surfaces the organization's enrollment state, grant history, billing summary, and member roster on the same page
- **THEN** grant actions (issue, extend, revoke) are reachable from the same page without navigating to a different top-level capability
#### Scenario: Composite view exposes a billing summary
- **WHEN** an operator views `/operator/organizations/{orgID}` for an organization with billing activity
- **THEN** the page renders a billing-summary section showing current subscription status, next renewal date, most recent invoice (date, amount, paid status), and outstanding balance
- **THEN** the section links to the full billing view at `/operator/billing/accounts`
#### Scenario: Composite view exposes a member roster
- **WHEN** an operator views `/operator/organizations/{orgID}` for an organization with enrolled members
- **THEN** the page renders a members section listing each enrolled person sorted alphabetically by display name
- **THEN** each member row links to `/operator/persons/{personID}` for that person
### Requirement: Per-organization composite view is the sole UI entry point for grant actions
The operator panel SHALL expose grant action affordances (issue, extend, revoke) only from the per-organization composite view. The global Grants surface in the Runtime group SHALL be read-only — it presents grants for browse/audit/lookup but exposes no action affordances. Underlying HTTP handlers (e.g. the simple `RevokeGrant`, the legacy `CreateGrant` issuance path) MAY remain reachable by direct URL for programmatic callers but SHALL NOT be surfaced as operator UI affordances.
Grant issuance SHALL be exposed as a single product-based form on the per-organization composite view. The form's product picker SHALL list published products available to the organization, including internal wrap products, which SHALL be visually marked as internal in the picker; the picker SHALL NOT be partitioned into intent-labeled variants by catalog shape (no separate "plan" picker and "non-plan" picker). Whether the resulting conferral is position-bearing (occupies a ladder rung) SHALL be derived by the `entitlement-conferral` capability's `confer` primitive from the product's catalog shape (`billing.product_conferral_shapes`) — the operator SHALL NOT select or declare this behavior on the form. The form SHALL carry uniform bounds — an optional `quantity` and an optional `valid_until` — available for any product regardless of catalog shape, and a `grant_reason` select restricted to the fixed domain excluding `'default'`. The full semantics of grant issuance (conferral derivation, the uniform-bounds contract, the `grant_reason` domain, and expiration scheduling) are specified by the `plan-enrollment-administration` capability's "Operator grant issuance" requirement; this requirement governs only the composite view's role as the sole UI entry point and the single-form shape of that entry point, not conferral behavior itself.
Grant revocation SHALL be enacted by the decree-first, source-resolved revoke behavior owned by the `plan-enrollment-administration` capability's "Operator grant revocation" requirement: the entry point sets the grant's `revoked_*` ledger fields and then calls `entitlements.end_conferral` resolved by the grant's own source, ending only the provision that grant itself delivered. Ending a provision SHALL NOT re-mint the org-type default as a side effect — re-application of the org default, when desired, is a separate `ReapplyDefaultsForPool` call (owned by `auto-provisioning`, per `plan-transitions`). This requirement governs only the composite view's role as the sole UI entry point for revocation, not the revoke semantics themselves; no Revoke affordance SHALL be surfaced outside the per-organization composite view.
#### Scenario: Per-org view exposes revoke using the source-resolved behavior
- **WHEN** an operator views `/operator/organizations/{orgID}`
- **THEN** each active grant for the organization has a Revoke affordance
- **THEN** invoking Revoke sets the grant's `revoked_*` fields and calls `end_conferral` resolved by the grant's source, ending only that grant's own provision (`end_conferral` itself never re-applies the org default; the entry point then performs the guarded baseline restoration owned by `plan-enrollment-administration`, which runs only when the default ladder is left vacant)
#### Scenario: Per-org view exposes a single issuance form for any published product
- **WHEN** an operator views `/operator/organizations/{orgID}`
- **THEN** the page exposes exactly one grant issuance form
- **THEN** the form's product picker lists published products available to the organization, with internal wrap products marked as internal
- **THEN** no separate plan-typed and non-plan-typed issuance forms exist
- **THEN** the form exposes uniform bounds (`quantity`, `valid_until`) and a `grant_reason` select over the fixed domain excluding `'default'`, regardless of which product is picked
#### Scenario: Per-org view exposes extension
- **WHEN** an operator views `/operator/organizations/{orgID}`
- **THEN** each active grant has an Extend affordance that issues an extend-as-replace with new bounds (a new lineaged grant consumed by `confer`, per the `plan-enrollment-administration` capability), never mutating the incumbent grant in place
#### Scenario: Global Grants surface exposes no action affordances
- **WHEN** an operator views the global Grants surface in the Runtime group
- **THEN** the grant rows render in a read-only listing
- **THEN** no Issue, Extend, or Revoke affordance appears on the surface
### Requirement: Every operator page declares its IA position for breadcrumb derivation
Every operator panel page SHALL declare its position in the IA as a positional tuple of the form `(group, capability)` or `(group, capability, instance)`, where `group` is one of `catalog`, `runtime`, or `integration`, `capability` is the capability slug (e.g. `products`, `organizations`), and `instance` is present when the page is scoped to one entity (e.g. an organization's composite view). The visual rendering of breadcrumbs is out of scope for this specification.
#### Scenario: Capability index page declares its IA position
- **WHEN** an operator loads `/operator/products`
- **THEN** the page declares its IA position as `(catalog, products)`
#### Scenario: Instance page declares its IA position
- **WHEN** an operator loads `/operator/organizations/{orgID}`
- **THEN** the page declares its IA position as `(runtime, organizations, {orgID})`
### Requirement: Browse routes reserve query-parameter real estate for filters
Browse routes under the operator panel (e.g. `/operator/organizations`, `/operator/products`, `/operator/plan-ladders`) SHALL reserve the query parameters `?q=<search>` and `?<facet>=<value>` (where `<facet>` is a capability-appropriate field such as `org_type`, `lifecycle_status`) as the canonical shape for future filter additions. Browse routes SHALL NOT use these parameter names for unrelated purposes. Implementing visible filter controls is out of scope for this specification; the reservation establishes URL contract only.
#### Scenario: Reserved parameter names are not reused
- **WHEN** a new feature is added to a browse route
- **THEN** the feature SHALL NOT use `q`, `org_type`, `lifecycle_status`, or other established facet parameter names for any purpose other than search and filtering
#### Scenario: Unknown filter params do not error
- **WHEN** an operator loads a browse route with a reserved-but-unimplemented filter param (e.g. `/operator/organizations?q=acme`)
- **THEN** the route renders without error
- **THEN** the unimplemented filter is ignored rather than rejected
### Requirement: Operator panel uses MPA routing with hx-boost for in-panel navigation
The operator panel SHALL serve each top-level capability at its own route under `/operator/<capability>` and return full HTML responses (not HTMX fragments) from those routes. In-panel navigation SHALL use `hx-boost` applied at the layout shell so navigations produce real browser history entries and bookmarkable URLs while avoiding full-document reparse.
The set of top-level capability routes SHALL include:
- `/operator` (curated landing surface — see the landing-surface requirement)
- `/operator/persons` (runtime browse directory — see `operator-people-directory`) and `/operator/persons/{personID}` (per-person detail — see the person-detail requirement)
- `/operator/organizations` (runtime browse) and `/operator/organizations/{orgID}` (per-org composite — see the composite-view requirement)
- `/operator/grants` (runtime browse, read-only — see the grant-action-consolidation requirement)
- `/operator/billing/{accounts,subscriptions,invoices,payments}` (runtime read-views) and `/operator/billing/invoices/{invoiceID}` (per-invoice detail — see `operator-billing-views`)
- `/operator/org-types`, `/operator/products`, `/operator/entitlement-sets`, `/operator/plan-ladders` (catalog)
- `/operator/products/{productID}` (catalog, per-product composite — see the addressable-composite requirement)
- `/operator/plan-ladders/{ladderID}` (catalog, per-ladder composite — see the addressable-composite requirement)
- `/operator/entitlement-sets/{setID}` (catalog, per-entitlement-set composite — see the addressable-composite requirement)
- `/operator/integrations/fedwiki` (integration; the legacy `/operator/fedwiki-sites` path 301-redirects here)
#### Scenario: Top-level capability route is bookmarkable
- **WHEN** an operator loads any top-level capability route (e.g. `/operator/products`) directly via URL
- **THEN** the page renders the full capability surface
- **THEN** no prior navigation through `/operator` is required
#### Scenario: Unknown operator paths stay in the operator shell
- **WHEN** an operator loads a `/operator/*` path no page claims (e.g. a truncated or mistyped URL)
- **THEN** the response SHALL be HTTP 404 rendered inside the operator shell with a link back to the operator overview
- **AND** it SHALL NOT fall through to the member-facing dashboard
#### Scenario: In-panel navigation uses hx-boost
- **WHEN** an operator clicks any in-panel navigation link
- **THEN** the navigation is intercepted by `hx-boost` and the response replaces the layout's main content region
- **THEN** the browser's URL bar and history reflect the destination route
- **THEN** a manual page reload at that URL renders the same page
#### Scenario: Toast container and modal survive boosted navigation
- **WHEN** an operator triggers a `HX-Trigger: showSuccessToast` response on a POST that redirects to a different capability
- **THEN** the success toast renders after the boosted navigation completes
- **THEN** the toast container and the confirm-action modal are outside the boost swap target so they are not destroyed by the swap
### Requirement: Legacy ?tab= URLs redirect for one release
The operator panel SHALL handle legacy `/operator?tab=<tab-id>` URLs by issuing a 301 redirect to the equivalent top-level capability route for the duration of one release. After that release the shim SHALL be removed and `/operator?tab=<anything>` SHALL render the landing surface (the unknown filter param is ignored per the reserved-filter-params requirement).
#### Scenario: Known ?tab= redirects to capability route
- **WHEN** an operator loads `/operator?tab=organizations`
- **THEN** the panel issues a 301 redirect to `/operator/organizations`
#### Scenario: ?tab=people redirects to the landing surface
- **WHEN** an operator loads `/operator?tab=people`
- **THEN** the panel issues a 301 redirect to `/operator` (since persons is lookup-only — no top-level browse exists)
#### Scenario: Unknown ?tab= renders landing
- **WHEN** an operator loads `/operator?tab=<unknown-id>` after the shim is removed
- **THEN** the landing surface renders without error
- **THEN** the `tab` parameter is ignored per the reserved-filter-params requirement
### Requirement: Operator panel exposes a plan-topology overview under the Catalog group
The plan-topology overview is no longer a separate destination: the Plan Ladders surface at `/operator/plan-ladders` SHALL open with the topology overview (the cross-ladder grid and structural-health strip, per the `operator-topology-overview` capability, whose requirements hold unchanged at this home) as its map-first view, with ladder management beneath it. The overview's IA position is the Catalog conceptual group via the Plan Ladders page's declaration. `/operator/plan-topology` SHALL NOT be a route: with no production deployments there is no old URL to stay compatible with, so no redirect exists and the path receives the operator panel's ordinary 404 handling.
#### Scenario: Ladders surface opens map-first
- **WHEN** an authenticated operator navigates to `/operator/plan-ladders`
- **THEN** the topology grid and health strip render as the surface's opening view
- **AND** ladder management (list, create) renders on the same surface beneath or behind the map
#### Scenario: The old topology URL is gone, not redirected
- **WHEN** an operator requests `/operator/plan-topology`
- **THEN** the panel answers with its ordinary 404 handling, not a redirect
### Requirement: Provider admin surfaces resolve from the registry, not a hardcoded map
The operator panel SHALL resolve a provider resource's admin surface from the provider registry rather than from a hardcoded resource-key→surface map. Adding or removing a provider SHALL NOT require editing a navigation map in code. Provider surfaces are presented within the dedicated Integrations section (see "Operator panel exposes a dedicated Integrations section"); this requirement governs only how each surface link is *resolved* (from the registry), not where the section lives.
#### Scenario: Provider surface is registry-resolved
- **WHEN** the operator panel renders the admin-surface link for a provisioning provider
- **THEN** the link URL SHALL be obtained from the provider registry
- **AND** SHALL NOT be obtained from a hardcoded resource-key→surface map
#### Scenario: Adding a provider requires no navigation-map edit
- **WHEN** a new provisioning provider is registered
- **THEN** its admin surface SHALL become reachable without editing any hardcoded resource-key→surface map in code
#### Scenario: FedWiki surface resolves under its provider identity
- **WHEN** an operator opens FedWiki's admin surface
- **THEN** it SHALL be resolved via the registry for provider `fedwiki` (resource `fedwiki_sites`)
- **AND** SHALL NOT be resolved via a literal `"sites"` map entry
### Requirement: Operator panel exposes a dedicated Integrations section
The operator panel SHALL present integrations as a dedicated, registry-driven section, distinct from the catalog and runtime groups. The sidebar SHALL render a single "Integrations" entry that links to a section landing at `/operator/integrations`; provider surfaces are reached from that landing's rows, not from sidebar children (maintainer decision 2026-08-23), and integration admin pages and settings pages SHALL mark the "Integrations" entry active. The landing SHALL be registry-driven and SHALL list every registered provider of every kind in one table — a payments provider appears alongside provisioning ones, never as a half-present entry — showing each provider's display name, status, kind, declared lifecycle operations, owned resource keys, a Manage link to its admin surface (when the provider declares one), and a Settings link to `/operator/integrations/{integrationKey}/settings` (when the integration declares configuration — sourced from the adapter registry). Integration admin pages SHALL live under the `/operator/integrations/` URL namespace, SHALL open with a heading identifying the integration, and SHALL cross-link to their settings page; settings pages SHALL cross-link back to the admin page. Adding or removing a provider, or an adapter with declared configuration, SHALL change the landing without any template or handler edit. Per-key configuration writes live on the linked settings pages, not on the landing; the landing's only write affordance is the unrecognized-override cleanup (see `integration-settings`), which SHALL be gated behind the shared confirm-action modal.
#### Scenario: Sidebar shows the Integrations section home
- **WHEN** an operator views any operator page
- **THEN** the sidebar SHALL include a single "Integrations" entry linking to `/operator/integrations`
- **AND** no per-provider sidebar entries render
- **AND** an integration admin page or settings page marks the "Integrations" entry active
#### Scenario: Integrations landing lists registered providers from the registry
- **WHEN** an operator opens `/operator/integrations`
- **THEN** the page SHALL list each registered provider of every kind with its display name, status, kind, declared lifecycle operations, and owned resource keys
- **AND** for FedWiki the row SHALL link to `/operator/integrations/fedwiki`
- **AND** a payments provider (e.g. Stripe) SHALL have a row even though it declares no admin surface
- **AND** the listing SHALL be derived from the provider registry, not a hardcoded list
#### Scenario: Adding a provider populates the section without code edits
- **WHEN** a second conforming provisioning provider is registered
- **THEN** it SHALL appear on the `/operator/integrations` landing with its Manage or Settings link
- **AND** no operator template or page handler SHALL require editing for it to appear
#### Scenario: Empty state when no provisioning providers are registered
- **WHEN** `/operator/integrations` is opened and no provisioning provider is registered
- **THEN** the page SHALL render an empty state rather than an error
#### Scenario: Settings links cover every configuring adapter
- **WHEN** an operator opens `/operator/integrations`
- **THEN** every registered integration adapter that declares configuration SHALL have a Settings link to `/operator/integrations/{integrationKey}/settings` on its row
- **AND** an adapter that is not a provisioning provider (e.g. a payments integration) SHALL still get its link
- **AND** the links SHALL be derived from the adapter registry, not a hardcoded list
### Requirement: Per-product, per-plan-ladder, and per-entitlement-set composite views are addressable
The operator panel SHALL provide a per-product composite view at `/operator/products/{productID}`, a per-plan-ladder composite view at `/operator/plan-ladders/{ladderID}`, and a per-entitlement-set composite view at `/operator/entitlement-sets/{setID}`. Each route SHALL render a full operator page (the operator shell), SHALL be bookmarkable and reload-stable, and SHALL be reachable from its capability's browse list via boosted in-panel navigation (a real `<a href>` under the shell's `hx-boost`), not via a fragment swap that leaves the URL unchanged. Each instance page SHALL declare its IA position as the `(group, capability, instance)` tuple — `(catalog, products, {productID})`, `(catalog, plan-ladders, {ladderID})`, and `(catalog, entitlement-sets, {setID})`.
The product composite SHALL co-locate, on the one URL, the product edit form, the product's prices view, and the purchasability/readiness panel including the Sync-to-Stripe action. The plan-ladder composite SHALL co-locate, on the one URL, the ladder edit form, the ladder's tier/rank manager, and the structural-invariant validation surface for that ladder. The entitlement-set composite SHALL co-locate, on the one URL, the entitlement-set edit form and the rules manager (rule list, add-rule form, and per-rule delete).
#### Scenario: Product composite is bookmarkable
- **WHEN** an operator loads `/operator/products/{productID}` directly via URL
- **THEN** the page renders the composite view for that product without requiring prior navigation through the panel
#### Scenario: Product composite is reachable from the browse list via boosted navigation
- **WHEN** an operator activates a product's link on the `/operator/products` browse list
- **THEN** the navigation targets `/operator/products/{productID}` as a real route under `hx-boost`
- **THEN** the browser URL and history reflect `/operator/products/{productID}`
- **THEN** a manual reload at that URL renders the same composite page
#### Scenario: Product composite co-locates editing, prices, and readiness
- **WHEN** an operator views `/operator/products/{productID}`
- **THEN** the page surfaces the product edit form, the product's prices view, and the purchasability/readiness panel (including the Sync-to-Stripe action when applicable) on the same page
#### Scenario: Plan-ladder composite is bookmarkable
- **WHEN** an operator loads `/operator/plan-ladders/{ladderID}` directly via URL
- **THEN** the page renders the composite view for that ladder without requiring prior navigation through the panel
#### Scenario: Plan-ladder composite is reachable from the browse list via boosted navigation
- **WHEN** an operator activates a ladder's link on the `/operator/plan-ladders` browse list
- **THEN** the navigation targets `/operator/plan-ladders/{ladderID}` as a real route under `hx-boost`
- **THEN** the browser URL and history reflect `/operator/plan-ladders/{ladderID}`
#### Scenario: Plan-ladder composite co-locates editing, tiers, and validation
- **WHEN** an operator views `/operator/plan-ladders/{ladderID}`
- **THEN** the page surfaces the ladder edit form, the tier/rank manager, and the structural-invariant validation surface for that ladder on the same page
#### Scenario: Entitlement-set composite is bookmarkable and co-locates editing and rules
- **WHEN** an operator loads `/operator/entitlement-sets/{setID}` directly via URL
- **THEN** the page renders the composite view for that entitlement set without requiring prior navigation through the panel
- **THEN** the page surfaces the entitlement-set edit form and the rules manager (rule list, add-rule form, and per-rule delete) on the same page
#### Scenario: Entitlement-set composite is reachable from the browse list via boosted navigation
- **WHEN** an operator activates an entitlement set's link on the `/operator/entitlement-sets` browse list
- **THEN** the navigation targets `/operator/entitlement-sets/{setID}` as a real route under `hx-boost`
- **THEN** the browser URL and history reflect `/operator/entitlement-sets/{setID}`
#### Scenario: Instance pages declare their IA position
- **WHEN** an operator loads `/operator/products/{productID}`
- **THEN** the page declares its IA position as `(catalog, products, {productID})`
- **WHEN** an operator loads `/operator/plan-ladders/{ladderID}`
- **THEN** the page declares its IA position as `(catalog, plan-ladders, {ladderID})`
- **WHEN** an operator loads `/operator/entitlement-sets/{setID}`
- **THEN** the page declares its IA position as `(catalog, entitlement-sets, {setID})`
### Requirement: Landing surface foregrounds setup progress while setup is incomplete
While any **required** step of the operator setup checklist (see `operator-setup-checklist`) derives incomplete, the curated landing surface at `/operator` SHALL render a setup-progress region ahead of the metric tiles, summarizing which steps are complete and linking each incomplete step to the surface that completes it (or linking the checklist surface as a whole). Once every required step derives complete, the region SHALL NOT render, even when conditional steps remain. The region derives from the same predicates as the checklist, so the landing surface and the checklist never disagree.
#### Scenario: Fresh instance leads with setup, not zeros
- **WHEN** an operator visits `/operator` on a deployment where no checklist
step is complete
- **THEN** the setup-progress region renders ahead of the metric tiles
- **AND** it links the first incomplete steps to their completing surfaces
#### Scenario: Configured instance shows no setup region
- **WHEN** an operator visits `/operator` on a deployment where every
required checklist step derives complete
- **THEN** the landing surface renders without the setup-progress region
### Requirement: Landing surface orders attention before totals and links its content
The curated landing surface SHALL order its regions with the operator's daily reads first: the setup-progress region (while required setup is incomplete, per `operator-setup-checklist`), then the at-a-glance metric tiles, then the system-status region (delivery queue and integration state), then recent activity — the counts an operator checks every day outrank the system cards (maintainer decision 2026-08-23), and attention-shaped content is still never the page's last region. Activity-feed entries SHALL link to the subject's surface wherever an addressable one exists (the organization, invoice, or grant the entry describes), so no feed entry is a dead end when a destination exists.
#### Scenario: Metric tiles render ahead of the system cards
- **WHEN** an operator views `/operator`
- **THEN** the at-a-glance tiles render before the system-status region
- **AND** the system-status region renders before recent activity, so attention-shaped content (queue state, unconfigured integrations) is not the last region on the page
#### Scenario: Feed entries link to their subjects
- **WHEN** the recent-activity feed shows an entry about an entity with an addressable operator surface
- **THEN** the entry links to that surface
### Requirement: Person detail is addressable and cross-linked
The person-detail view at `/operator/persons/{personID}` SHALL be bookmarkable (no prior navigation required) and reachable from the People directory's rows, the landing-surface lookup affordance, and organization member tables. It SHALL include an organization-memberships section listing each organization the person belongs to, each row linking to that organization's composite view at `/operator/organizations/{orgID}`.
#### Scenario: Person detail is bookmarkable
- **WHEN** an operator loads `/operator/persons/{personID}` directly via URL
- **THEN** the page renders the resolved-detail view for that person
#### Scenario: Person detail is reachable from organization context
- **WHEN** an operator views an organization's member listing on `/operator/organizations/{orgID}`
- **THEN** each member row links to `/operator/persons/{personID}` for that member
#### Scenario: Person detail exposes organization memberships
- **WHEN** an operator views `/operator/persons/{personID}` for a person who belongs to one or more organizations
- **THEN** the page renders an organization-memberships section listing each organization the person belongs to
- **THEN** each row links to `/operator/organizations/{orgID}` for that organization