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.tsredirects sessionless requests →/welcome?redirect=). No operator-role gate in the proxy. - Bookmark / share —
metadata.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>"), showingdisplayNameandstatus. 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, thendisplayName(serif), thenstatus · <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."
- Eyebrow
- Roster: one
- Footer action row: 44 px minimum-height
LBtnghost "← Operator hub" (/operator),LBtnghost "Audit log" (/operator/audit),LBtnprimary "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.personasis[](orbffGetreturnednull) → the page passespersonas={[]}; 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 emptyauditLog→ "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 (
bffGetreturnsnull→ empty state, not a 500 page); the write path surfaces errors inline rather than throwing an error boundary. - Offline SW policy —
/operator/personasis verified as network-only at the realsw.jslayer:offline-shell-routesseeds matching poisoned HTML into every currentoshun-*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
apihelper (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.ts307-redirects to/welcome?redirect=/operator/personas(never sees the page). A signed-in user withoutadmin: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 callsresetForm()(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: 44plus10px 12pxpadding; 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>".
- Function: selects that persona (
Transition form#
- Lifecycle event (
<select>,aria-label="Lifecycle event",data-testid="persona-event-select")- Function: choosing an event sets
event, clearsactorIdsanderror. The option list isselected.legalEvents(server-driven) plus the placeholder "— choose a legal event —". The chosen event looks upEVENT_REQUIREMENTSto 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.
- Function: choosing an event sets
-
actor id (<input>, one perrequirements.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, withsignedAtUnixSeconds = 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.
- Function: captures the signing actor id for each required role; assembled
into
- Justification (
<textarea>,aria-label="Justification",data-testid="persona-justification") — rendered only whenrequirements.justificationis 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.
- Function: trimmed value set as
- Rollback plan field group (
data-testid="persona-rollback-fields") — rendered only whenrequirements.rollbackis 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); defaultrelease-manager→rollbackPlan.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); defaultapproved-for-release→rollbackPlan.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.
- Rollback triggers (
- Apply
(native <button type="button">,data-persona-transition-submit,onClick→submit()) — label is`Apply ${event}`when idle, "Applying…" whilebusy.- 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 returnedlifecycleand callsresetForm(). On failure, sets the inlineerror(status-mapped strings above). - Keyboard: native button, tab focusable and Enter/Space activate.
- Disabled when: native
disabledplusaria-disabled="true"while a request is pending or no valid event/requirements are selected. While busy it also exposesaria-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.
- Function: builds
- Transition error (
data-testid="persona-transition-error",role="alert",aria-atomic="true",LEyebrowinL.alertcolor) — 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 perauditLog, reversed) — non-interactive display: accentevent·from→to. No drill-in wired.
Footer actions#
- "← Operator hub" (
LBtnghost, 44 px min-height link) →/operator - "Audit log" (
LBtnghost, 44 px min-height link) →/operator/audit - "SSO" (
LBtnprimary, 44 px min-height link) →/operator/sso
Data & contracts#
- Reads:
bffGet<PersonaLifecycleResponse>('/v1/admin/personas/lifecycle')—{ generatedAt?: string; personas: PersonaView[] }wherePersonaView = { personaId, displayName, status, legalEvents: string[], auditLog: AuditEntry[] }andAuditEntry = { event, from, to, emittedAtUnixSeconds, justification: string | null }. Onnullsubstitutes{ 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
fetch—cache: 'no-store'when an access token is forwarded (authenticated), elsenext.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 enforcesadmin:workspace:personafor writes (and perpersona-lifecycle-bff.spec.ts: GET → 401 without auth, 403 with a non-persona admin scope).bffGetforwards theoshun-accessbearer +oshun-session/__sessioncookies.
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 browsersubmit-for-reviewsuccess 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.tsxapps/oshun/web/src/components/lilith/PersonaLifecycleConsole.tsxapps/oshun/web/src/lib/server/bff-fetch.tsapps/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 gate —
proxy.tsonly redirects sessionless users; any signed-in user can load the page (writes still 403 at the BFF withoutadmin:workspace:persona). Confirm whether a proxy/page operator gate is intended (same gap noted inoperator-audit.md). - Customer nav on an operator route —
LCustomerNav 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;
generatedAtfrom the list read is never surfaced. Confirm staleness handling for concurrent operators.