V1 Web PWA · Surface walkthrough

Operator · persona lifecycle

A per-surface walkthrough of the V1 Web PWA operator surface: layout, states, interactions, data, and cross-references.

walked
9sections11 minread1table

On this page

Context. surface operator · domain lilith · route /operator/personas · auth signed-in (BFF additionally requires scope admin:workspace:persona for writes) · source apps/oshun/web/src/app/operator/personas/page.tsx

Last walked. 2026-06-25 by Codex (mobile layout, tap targets, apply disabled state, proxy gate, and footer destinations covered), against commit HEAD' '2026-06-22 by Codex (drafted from source), against commit HEAD

Purpose#

The operator console for the persona approval lifecycle (V1-PER-004 / §3.1): it lists every registered persona with its current status, legal next-events, governance signoffs, and audit history from GET /v1/admin/personas/lifecycle, and lets an operator drive a transition through the validated POST /v1/admin/personas/:id/lifecycle. The BFF validator stays authoritative — illegal transitions, missing signoffs, missing justification, or a missing rollback plan fail honestly (the page never fakes a transition).

Entry points#

  • Sibling operator pages' foot rows — other operator surfaces cross-link to the operator hub and audit; this page is reachable from the operator hub and by direct URL. Its own foot row links out to /operator, /operator/audit, and /operator/sso.
  • Direct URL/operator/personas; session-gated only (proxy.ts redirects sessionless requests → /welcome?redirect=). No operator-role gate in the proxy.
  • Bookmark / sharemetadata.alternates.canonical = '/operator/personas'. metadata.robots = { index: false, follow: false } (kept out of SEO).

Layout regions#

page.tsx is an async server component: await bffGet<PersonaLifecycleResponse>('/v1/admin/personas/lifecycle') (falling back to { personas: [] } on null), rendered inside <LWebShell top={<LCustomerNav active="library" />}> (it reuses the customer nav with Library active, not an operator-specific bar — same pattern as operator-audit.md). Centered column maxWidth: 1080, padding 32px 56px 80px.

  • Header: LCustomerNav (Library highlighted)
  • Masthead: LMasthead — left "Operator · personas", right `${personas.length} persona${personas.length === 1 ? '' : 's'}` (e.g. "3 personas"), kicker "Approval lifecycle", title "The persona lifecycle."
  • Main: <PersonaLifecycleConsole personas={personas} /> (apps/oshun/web/src/components/lilith/PersonaLifecycleConsole.tsx) — a responsive grid (repeat(auto-fit, minmax(min(100%, 320px), 1fr))) that renders roster/detail side-by-side on wider screens and stacks them on mobile:
    • Roster: one <button> per persona (data-testid="persona-select-<personaId>"), showing displayName and status. The selected button is accent-bordered and every roster button has a 44 px minimum height plus an explicit accessible name "{displayName} {status}".
    • Detail (data-persona-detail="<personaId>"):
      • Eyebrow personaId, then displayName (serif), then status · <persona-status> (data-testid="persona-status").
      • Apply a transition form: <select> of legal events (data-testid="persona-event-select", aria-label="Lifecycle event"), and — once an event is chosen — per-role actor-id inputs, an optional Justification textarea, an optional Rollback-plan field group, and the native "Apply " submit button (data-persona-transition-submit). An alert eyebrow renders below on failure (data-testid="persona-transition-error", role="alert").
      • Lifecycle history: reversed (newest-first) auditLog, one row per entry (data-testid="persona-audit-entry") reading <event> · <from> → <to>; or the italic "No transitions recorded yet."
  • Footer action row: 44 px minimum-height LBtn ghost "← Operator hub" (/operator), LBtn ghost "Audit log" (/operator/audit), LBtn primary "SSO" (/operator/sso).

States#

  • Loading — N/A in-page: render is blocked server-side on await bffGet(...) (no client skeleton/spinner component exists). The client console's per-transition busy state is covered under Interactions ("Apply" button → "Applying…").
  • Empty — two distinct empties: (a) data.personas is [] (or bffGet returned null) → the page passes personas={[]}; the console returns the dashed card "No personas are registered in the lifecycle store." (data-persona-lifecycle-console). Masthead right reads "0 personas". (b) a selected persona with an empty auditLog → "No transitions recorded yet." under Lifecycle history.
  • Populated (short) — 1–5 personas; roster lists each, first persona auto-selected (initialPersonas[0]?.personaId); detail panel renders for the selection.
  • Populated (long) — 50+ personas; the roster is a plain CSS grid with no virtualization/pagination — verify scroll behavior; the detail column shows one selected persona at a time.
  • Error (recoverable) — a transition POST that the BFF rejects renders an inline error eyebrow (data-testid="persona-transition-error") and leaves the form intact for correction; status text is unchanged (no optimistic mutation). Messages by HTTP status: 403 → "Persona-admin access (admin:workspace:persona) is required."; 409 → "The transition was rejected — check the from-state, signoffs, justification, or rollback plan."; 400 → "The request was malformed — verify every required field."; 404 → "That persona is no longer known to the lifecycle store."; anything else → "Could not apply the transition — the service is unavailable."
  • Error (unrecoverable) — N/A: the read path fail-softs (bffGet returns null → empty state, not a 500 page); the write path surfaces errors inline rather than throwing an error boundary.
  • Offline SW policy/operator/personas is verified as network-only at the real sw.js layer: offline-shell-routes seeds matching poisoned HTML into every current oshun-* Cache Storage bucket, takes Chromium offline under the real service worker, and proves the operator document navigation fails as a document request instead of rendering the cached operator HTML or the customer shell fallback.
  • Offline write copy — the transition POST goes through the client api helper (no offline queue). Playwright drives the real form with Chromium offline (context.setOffline(true)): the network failure falls into the "service is unavailable" branch, the status stays unchanged, and the event selection remains available for retry.
  • Gated — sessionless → proxy.ts 307-redirects to /welcome?redirect=/operator/personas (never sees the page). A signed-in user without admin:workspace:persona: the page still renders (read may fail-soft to empty if the BFF 403s the GET), and a transition attempt returns 403 → the inline "Persona-admin access (admin:workspace:persona) is required." message. No in-page role gate.
  • Standalone PWA — renders inside LWebShell; robots: index:false. No PWA-specific chrome in this file — verify safe-area insets via the shell.

Interactions#

Roster#

  • Persona select button (<button>, one per persona)
    • Function: selects that persona (setSelectedId) and calls resetForm() (clears event/actorIds/justification/rollback/error); updates the detail panel and the audit history shown.
    • Keyboard: native <button> — Enter/Space activate; tab order follows DOM (roster precedes the detail form).
    • Screen reader: explicit aria-label="{displayName} {status}" (e.g. "Sufi Guide drafted").
    • Touch target: minHeight: 44 plus 10px 12px padding; measured in Playwright at 390 px.
    • Mobile (≤ 640 px): the responsive console grid stacks roster above detail; measured in Playwright at 390 px.
    • Disabled when: never.
    • Offline behavior: no-op (pure client state).
    • Telemetry: None observed.
    • Selector: data-testid="persona-select-<personaId>".

Transition form#

  • Lifecycle event (<select>, aria-label="Lifecycle event", data-testid="persona-event-select")
    • Function: choosing an event sets event, clears actorIds and error. The option list is selected.legalEvents (server-driven) plus the placeholder "— choose a legal event —". The chosen event looks up EVENT_REQUIREMENTS to decide which fields render (roles, justification, rollback). The map is a client-side rendering hint only; the BFF re-validates every field.
    • Keyboard: native select.
    • Screen reader: announces "Lifecycle event" + selected option.
    • Disabled when: never (but if a persona has no legalEvents, only the placeholder is selectable → no requirements → no further fields/submit).
    • Offline behavior: no-op.
    • Telemetry: None observed.
  • actor id (<input>, one per requirements.roles, aria-label="<role> actor id", data-testid="persona-signoff-<role>")
    • Function: captures the signing actor id for each required role; assembled into signoffs[] ({ role, actorId, signedAtUnixSeconds }) on submit, with signedAtUnixSeconds = floor(now/1000) - 5.
    • Keyboard: native text input.
    • Screen reader: announces " actor id" (e.g. "governance-lead actor id").
    • Disabled when: never. Blank values are sent and rejected server-side.
    • Offline behavior: no-op until submit.
    • Telemetry: None observed.
  • Justification (<textarea>, aria-label="Justification", data-testid="persona-justification") — rendered only when requirements.justification is true (e.g. return-to-draft, approve-for-release, depublish, deprecate, retire, unretire, emergency-retire).
    • Function: trimmed value set as body.justification.
    • Screen reader: announces "Justification".
    • Offline behavior: no-op until submit.
    • Telemetry: None observed.
  • Rollback plan field group (data-testid="persona-rollback-fields") — rendered only when requirements.rollback is true (e.g. approve-for-release, release-to-customers, publish). Sub-elements:
    • Rollback triggers (<input>, aria-label="Rollback triggers", placeholder "trigger 1, trigger 2") — comma-split, trimmed, filtered → rollbackPlan.triggers[].
    • Rollback executor role (<select>, aria-label="Rollback executor role") — options = ACTOR_ROLES (owner, governance-lead, safety-engineer, clinical-advisor, tradition-advisor, legal, trust-safety, release-manager); default release-managerrollbackPlan.executorRole.
    • Rollback revert-to status (<select>, aria-label="Rollback revert-to status") — options = PRE_LIVE_STATUSES (drafted, draft, in-review, approved, rehearsal, approved-for-test, approved-for-release); default approved-for-releaserollbackPlan.revertToStatus.
    • Rollback max seconds (<input type="number">, aria-label="Rollback max seconds") — default "1800"; Number(...)rollbackPlan.maxMeanTimeToRollbackSeconds.
    • Rollback playbook summary (<textarea>, aria-label="Rollback playbook summary", placeholder "playbook summary") — trimmed → rollbackPlan.playbookSummary.
  • Apply (native <button type="button">, data-persona-transition-submit, onClicksubmit()) — label is `Apply ${event}` when idle, "Applying…" while busy.
    • Function: builds { event, currentStatus, signoffs, justification?, rollbackPlan? } and POSTs to /v1/admin/personas/<personaId>/lifecycle. On success, replaces the persona in local state with the returned lifecycle and calls resetForm(). On failure, sets the inline error (status-mapped strings above).
    • Keyboard: native button, tab focusable and Enter/Space activate.
    • Disabled when: native disabled plus aria-disabled="true" while a request is pending or no valid event/requirements are selected. While busy it also exposes aria-busy="true", shows "Applying…", uses a wait cursor, and dims visually. submit() still early-returns if no selection / no event / no requirements.
    • Offline behavior: no queue — a network failure → "Could not apply the transition — the service is unavailable." error.
    • Telemetry: None observed.
  • Transition error (data-testid="persona-transition-error", role="alert", aria-atomic="true", LEyebrow in L.alert color) — non-interactive; displays the status-mapped message; cleared on persona reselect or event change.

Audit history#

  • Audit entry rows (data-testid="persona-audit-entry", one per auditLog, reversed) — non-interactive display: accent event · fromto. No drill-in wired.
  • "← Operator hub" (LBtn ghost, 44 px min-height link) → /operator
  • "Audit log" (LBtn ghost, 44 px min-height link) → /operator/audit
  • "SSO" (LBtn primary, 44 px min-height link) → /operator/sso

Data & contracts#

  • Reads: bffGet<PersonaLifecycleResponse>('/v1/admin/personas/lifecycle'){ generatedAt?: string; personas: PersonaView[] } where PersonaView = { personaId, displayName, status, legalEvents: string[], auditLog: AuditEntry[] } and AuditEntry = { event, from, to, emittedAtUnixSeconds, justification: string | null }. On null substitutes { personas: [] }. The console subsequently refreshes a single persona from the POST response, not by re-reading the list.
  • Writes: api.post('/v1/admin/personas/<personaId>/lifecycle', { body }) via @/lib/api-client — body { event, currentStatus, signoffs: [{ role, actorId, signedAtUnixSeconds }], justification?, rollbackPlan?: { triggers, executorRole, revertToStatus, maxMeanTimeToRollbackSeconds, playbookSummary } }; returns { lifecycle: PersonaView }. The BFF rejects illegal/under-signed transitions (observed contract: 409 with { error: 'lifecycle_transition_invalid', errors: string[] }).
  • Realtime: None.
  • Caching: read is RSC fetchcache: 'no-store' when an access token is forwarded (authenticated), else next.revalidate = 60; 5 s abort. The client write helper does not cache.
  • Auth/role check: proxy.ts — session-gated (sessionless → 307 /welcome?redirect=/operator/personas); no operator-role gate in the proxy and no in-page guard. The BFF enforces admin:workspace:persona for writes (and per persona-lifecycle-bff.spec.ts: GET → 401 without auth, 403 with a non-persona admin scope). bffGet forwards the oshun-access bearer + oshun-session/__session cookies.

Cross-references#

  • Parent: operator-overview.md
  • Sibling operator routes (this page's foot links):
  • Feature spec: V1/features.md — persona approval lifecycle (V1-PER-004 / §3.1)
  • E2E:
    • apps/oshun/web/e2e/operator-personas-console.spec.ts — browser page-DOM coverage against the live BFF roster: auto-select/detail, no-scope empty fail-soft, real browser submit-for-review success with status + audit repaint, approve-for-release signoff/justification/rollback fields, 390 px mobile roster/detail stacking and 44 px core controls/footer targets, native disabled/busy submit semantics, offline transition POST service-unavailable copy with unchanged status, proxy redirect gate, and a forced 409 inline- error/no-optimistic-mutation branch.
    • apps/oshun/web/e2e/persona-lifecycle-bff.spec.ts — pure real-BFF coverage: scope gates, canonical release chain, live-exposure rollback gate, and illegal-transition 409, plus semantic envelope refusals for missing, duplicate, and future-dated signoffs and rollback MTTR above 24 hours without status/audit mutation.
  • Component sources:
    • apps/oshun/web/src/app/operator/personas/page.tsx
    • apps/oshun/web/src/components/lilith/PersonaLifecycleConsole.tsx
    • apps/oshun/web/src/lib/server/bff-fetch.ts
    • apps/oshun/web/src/lib/api-client.ts

E2E coverage#

Interaction / state Coverage
Populated roster + first-persona auto-select covered (operator-personas-console.spec.ts)
Roster click changes detail panel covered (operator-personas-console.spec.ts)
Empty state from 403/no persona-admin scope covered (operator-personas-console.spec.ts)
Real browser transition success covered: submit-for-review POST → in-review status + audit repaint + BFF read-back
Live-exposure requirement fields covered: approve-for-release reveals signoffs, justification, and rollback plan fields
Apply busy / disabled semantics covered: native disabled, aria-disabled, aria-busy, and pending label while POST is held
Offline transition POST copy covered: real browser offline submit shows service-unavailable copy, preserves status/event
Transition 409 failure mode covered: inline error, status unchanged, event selection retained, BFF state unchanged
Sessionless proxy gate covered: anonymous /operator/personas redirects with redirect= preserved
BFF scope gates covered (persona-lifecycle-bff.spec.ts)
Canonical release chain covered at BFF level (persona-lifecycle-bff.spec.ts)
Semantic lifecycle envelope refusals covered at BFF level: signoff defects and rollback MTTR leave status/audit unchanged
Mobile grid collapse / touch targets covered at 390 px: roster/detail stack, form controls, submit button, and footer links
Network-offline operator SW policy covered (offline-shell-routes.spec.ts real sw.js + poisoned operator document cache)

Open questions / known gaps#

  • No operator-role gateproxy.ts only redirects sessionless users; any signed-in user can load the page (writes still 403 at the BFF without admin:workspace:persona). Confirm whether a proxy/page operator gate is intended (same gap noted in operator-audit.md).
  • Customer nav on an operator routeLCustomerNav active="library" is reused; confirm an operator-specific bar isn't intended.
  • EVENT_REQUIREMENTS drift risk — the client field-rendering map duplicates the BFF's per-event requirements (roles/justification/rollback). It is an honest hint (server re-validates), but a drift would hide a field; confirm there's a contract test keeping them in sync.
  • No telemetry — no analytics events are fired on select/submit/error; confirm whether operator actions should be instrumented.
  • SW policy for /operator/personas — verified network-only (no cached operator page replay containing sensitive lifecycle data). Broader /operator/* routes should keep this same policy.
  • No client-side list refresh — after a transition only the mutated persona is updated locally; generatedAt from the list read is never surfaced. Confirm staleness handling for concurrent operators.