---
path: /operator/personas
surface: operator
domain: lilith
auth:
  signed-in (BFF additionally requires scope admin:workspace:persona for writes)
source: apps/oshun/web/src/app/operator/personas/page.tsx
status: walked
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'
---

# Operator · persona lifecycle

## 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 / 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>"`), 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 <event>" 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

- [x] **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…").
- [x] **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.
- [x] **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.
- [x] **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."
- [x] **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.
- [x] **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.
- [x] **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.
- [x] **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

- [x] **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

- [x] **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._
- [x] **<role> 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 "<role> 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._
- [x] **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._
- [x] **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:
  - [x] **Rollback triggers** (`<input>`, `aria-label="Rollback triggers"`,
        placeholder "trigger 1, trigger 2") — comma-split, trimmed, filtered →
        `rollbackPlan.triggers[]`.
  - [x] **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-manager` →
        `rollbackPlan.executorRole`.
  - [x] **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-release`
        → `rollbackPlan.revertToStatus`.
  - [x] **Rollback max seconds** (`<input type="number">`,
        `aria-label="Rollback max seconds"`) — default "1800"; `Number(...)` →
        `rollbackPlan.maxMeanTimeToRollbackSeconds`.
  - [x] **Rollback playbook summary** (`<textarea>`,
        `aria-label="Rollback playbook summary"`, placeholder "playbook
        summary") — trimmed → `rollbackPlan.playbookSummary`.
- [x] **Apply <event>** (native `<button type="button">`,
      `data-persona-transition-submit`, `onClick` → `submit()`) — 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._
- [x] **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

- [x] **Audit entry rows** (`data-testid="persona-audit-entry"`, one per
      `auditLog`, reversed) — non-interactive display: accent `event` · `from` →
      `to`. No drill-in wired.

### Footer actions

- [x] **"← Operator hub"** (`LBtn` ghost, 44 px min-height link) → `/operator`
- [x] **"Audit log"** (`LBtn` ghost, 44 px min-height link) → `/operator/audit`
- [x] **"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 `fetch` — `cache: '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`](./operator-overview.md)
- Sibling operator routes (this page's foot links):
  - [`operator-audit.md`](./operator-audit.md)
  - [`operator-sso.md`](./operator-sso.md)
- Feature spec: [`V1/features.md`](../../V1/features.md#product-surfaces) —
  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 gate** — `proxy.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 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.
- [x] **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.
