---
path: /operator/audit
surface: operator
domain: operator (house — audit/event log)
auth: signed-in
source: apps/oshun/web/src/app/operator/audit/page.tsx
status: walked
last_walked:
  '2026-06-25 audit ledger row-cell addendum — direct signed-in /operator/audit
  render, generatedAt/cursor anchors, exact 3-row masthead/table count, real
  /v1/audit fixture rows sorted newest-first, row metadata attributes, visible
  When/Actor/Action/Target cells, footer targets, explorer mount, and anonymous
  redirect-before-render verified in tenant-audit-log-investigation.spec.ts.
  2026-06-26 addendum — explorer Export Markdown is now browser-driven through
  the real BFF export route and the downloaded Markdown body is asserted.
  2026-06-26 meta-audit addendum — investigation create/update/share/export/
  delete writes audit.investigation.* rows and the real-HTTP spec reads the
  chain back through the explorer event API. Evidence:
  WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md §90.'
---

# Operator · audit log

## Purpose

The append-only audit ledger: the most recent rows from `GET /v1/audit`, newest
first. Every actor, action, and target laid out in a mono table for
investigation — never edited in place. The static ledger is followed by the
`OperatorAuditExplorer`, which provides search/filter, save-as-investigation,
and export/delete controls over the live `/v1/admin/audit-log/*` investigation
store.

## Entry points

- **From `/operator`** (hub) — operator-surface card; the foot row's "← Operator
  hub" returns there
- **Cross-links from sibling operator pages** — `/operator/incidents` and
  `/operator/sso` foot rows both link to the audit log; `/operator/personas`
  cross-links back here through its audit affordance
- **From `/operator/incidents`** during a triage — investigating an incident's
  actor trail leads here
- **Direct URL** — session-gated only (`proxy.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy
- **Bookmark** — `canonical = '/operator/audit'`

## Layout regions

`page.tsx` is an async server component:
`await bffGet<AuditResponse>('/v1/audit')` (falling back to `{ events: [] }` on
`null`), client-side re-sorted newest-first by `occurredAtIso`, rendered inside
`<LWebShell top={<LCustomerNav active="library" />}>` (note: it reuses the
**customer** nav with Library active, not an operator-specific bar). Centered
column `maxWidth: 1080`.

- **Header**: `LCustomerNav` (Library highlighted — verify whether an operator
  shell bar is intended instead)
- **Page anchor**: outer content column exposes `data-operator-audit-page`,
  `data-operator-audit-generated-at`, and `data-operator-audit-cursor`
- **Masthead**: `LMasthead` wrapped by `data-operator-audit-masthead` with
  `data-operator-audit-row-count`; visible copy is left "Operator · audit",
  right `${sorted.length} rows shown`, kicker "Append-only", title "The _audit_
  ledger."
- **Ledger**: a mono `<table data-operator-audit-table>` (When / Actor / Action
  / Target) with `data-operator-audit-table-count` OR the empty card
  - **`<thead>`**: `Th` cells (uppercase eyebrow style) — When, Actor, Action,
    Target, with `scope="col"` and `data-operator-audit-column`
  - **`<tbody>`**: one `<tr data-operator-audit-row>` per event; `Td` cells —
    local timestamp, actorId, accent-colored action, and `targetKind:targetId`;
    row attributes expose `data-operator-audit-id`, `data-operator-audit-actor`,
    `data-operator-audit-action`, `data-operator-audit-target-kind`,
    `data-operator-audit-target-id`, and `data-operator-audit-occurred-at`;
    visible cells expose
    `data-operator-audit-cell="when" | "actor" | "action" | "target"`
- **Explorer**: `<OperatorAuditExplorer data-audit-explorer>` — filter fields
  for text, event type prefix, actor ids, workspace ids, and time window; search
  and reset actions; save-as-investigation; saved investigation load/export
  JSON/export Markdown/delete controls; event result rows with optional deep
  links
- **Footer action row**: `LBtn` ghost "← Operator hub" (`/operator`), `LBtn`
  ghost "Incidents" (`/operator/incidents`), `LBtn` ghost "Personas"
  (`/operator/personas`), `LBtn` primary "SSO" (`/operator/sso`)

## States

- [x] **Loading** — source-verified 2026-06-25: no route-level `loading.tsx`;
      server-side `await bffGet` blocks render, so there is no skeleton state
- [x] **Anonymous user** — Playwright 2026-06-25: sessionless `/operator/audit`
      redirects to `/welcome?...` before `[data-operator-audit-page]` or
      `[data-operator-audit-table]` render
- [x] **Signed-in user** — Playwright 2026-06-25: primed signed-in session
      renders the page, generatedAt/cursor anchors, exact row counts, ledger,
      footer, and explorer. Still session-gated only; no operator-role gate in
      the proxy or in-page guard
- [x] **Empty** — source-verified 2026-06-25: `sorted.length === 0` renders
      `[data-operator-audit-empty]` with "No audit events available for the
      current cursor." Masthead right reads "0 rows shown"
- [x] **Populated (short)** — Playwright 2026-06-25: real `/v1/audit` fixture
      renders exactly three rows, newest first: `evt-3` tenant member added,
      `evt-2` SSO connection activated, and `evt-1` incident acknowledged
- [ ] **Populated (long)** — table with the full page of rows; **no pagination
      control despite `data.cursor`** — only the rows returned by one
      `/v1/audit` call are shown (verify the server-side page size and how to
      advance the cursor)
- [x] **BFF unreachable / non-2xx** — source-verified 2026-06-25: `bffGet`
      returns `null`; the page substitutes `{ cursor: null, events: [] }` →
      empty state (no fabricated rows). Dedicated outage-route E2E still pending
- [ ] **Offline** — RSC fetch (`revalidate 60`); SW behavior for `/operator/*`
      should be network-only (verify SW excludes operator routes)
- [ ] **Standalone PWA** — renders; operator surfaces are robots-noindex
      elsewhere — verify this route's robots policy (no `robots` export in this
      file)

## Interactions

### Masthead

- [x] **Masthead labels** — non-interactive; Playwright asserts "Operator ·
      audit", "Append-only", "The audit ledger.", exact "3 rows shown", and the
      matching stable row-count attribute

### Audit table

- [x] **Header row** (`<thead>`/`Th`) — non-interactive; not sortable (sort is
      fixed newest-first in code); `scope="col"` is present and Playwright
      asserts the four `columnheader` names plus stable column identifiers
- [x] **Each body row** (`<tr>`) — non-interactive display: When (localized
      `occurredAtIso`), Actor (`actorId`), Action (accent text `action`), Target
      (`targetKind:targetId`). Playwright asserts all three real fixture rows in
      newest-first order with stable id/actor/action/target-kind/target-id/
      occurred-at attributes and visible When/Actor/Action/Target cells. No row
      click / drill-in wired
  - Screen reader: announces cell contents in reading order; verify the
    `targetKind:targetId` reads clearly

### Footer actions

- [x] **"← Operator hub"** (`LBtn` ghost, link) → `/operator`
- [x] **"Incidents"** (`LBtn` ghost, link) → `/operator/incidents`
- [x] **"Personas"** (`LBtn` ghost, link) → `/operator/personas`
- [x] **"SSO"** (`LBtn` primary, link) → `/operator/sso`

### Audit explorer

- [x] **Filter fields** — text, event type prefix, actor ids, workspace ids, and
      datetime range fields render below the static ledger
- [x] **Search** — Playwright 2026-06-10/2026-06-25: needle and actor filters
      land on the real `/v1/admin/audit-log/events` request URL through the live
      BFF bridge
- [x] **Save investigation** — Playwright creates a named investigation through
      the UI and asserts it appears in the saved-investigations list
- [x] **Export JSON** — Playwright triggers a real Blob download for the saved
      investigation
- [x] **Export Markdown** — Playwright 2026-06-26: the browser clicks the
      explorer control, the live BFF bridge serves
      `GET /v1/admin/audit-log/investigations/:id/export?format=markdown`, and
      the downloaded `.md` body includes the investigation title,
      created/updated provenance line, and `## Matched events (...)`
- [x] **Delete investigation** — Playwright deletes through the UI and verifies
      both the row removal and API read-back absence

## Data & contracts

- **Reads**: `bffGet<AuditResponse>('/v1/audit')` —
  `{ generatedAt: string; cursor: string | null; events: AuditEvent[] }` where
  `AuditEvent = { id, actorId, action, targetKind, targetId, occurredAtIso }`.
  On `null` substitutes an empty list
- **Writes**: the static ledger is read-only. The explorer writes investigation
  records through `/v1/admin/audit-log/investigations`; successful
  create/update/share/export/delete also append
  `audit.investigation.created | updated | shared | exported | deleted` rows to
  the same admin audit-event store. The share event records only token
  issuance/hash presence, never the raw share token
- **Realtime**: _None observed._
- **Caching**: RSC fetch, `next.revalidate = 60`, 5 s abort; SW policy for
  operator routes TBD
- **Auth/role check**: session-gated only (`proxy.ts` redirects sessionless →
  `/welcome?redirect=`); no operator-role gate in the proxy and no in-page
  guard. `bffGet` forwards the session cookie

## Cross-references

- Parent: [`operator-overview.md`](./operator-overview.md)
- Sibling operator routes:
  - [`operator-incidents.md`](./operator-incidents.md)
  - [`operator-sso.md`](./operator-sso.md)
  - [`operator-tenants.md`](./operator-tenants.md)
  - [`operator-admin.md`](./operator-admin.md) — the inbox triage surface
- Feature spec: [`V1/features.md`](../../V1/features.md#product-surfaces)
- Architecture:
  [`V1/ARCHITECTURE.md`](../../V1/ARCHITECTURE.md#operator-surfaces)
- Journeys traversing this view:
  - [`../journeys/tenant-audit-log-investigation.md`](../journeys/tenant-audit-log-investigation.md)
  - [`../journeys/incident-triage.md`](../journeys/incident-triage.md)

## E2E coverage

- [`apps/oshun/web/e2e/tenant-audit-log-investigation.spec.ts`](../../apps/oshun/web/e2e/tenant-audit-log-investigation.spec.ts)
  — direct `/operator/audit` signed-in render, real `/v1/audit` fixture table
  rows sorted newest-first, generatedAt/cursor anchors, exact 3-row
  masthead/table count, column headers and column identifiers, stable row
  id/actor/action/target-kind/target-id/occurred-at attributes, visible
  When/Actor/Action/Target cells, footer targets (`/operator`,
  `/operator/incidents`, `/operator/personas`, `/operator/sso`),
  `/operator/incidents` "Audit log" entry-point navigation, `/operator/admin`
  audit-entry affordance, anonymous redirect-before-render, real-HTTP BFF
  audit-log access-control/CRUD/export/share/bookmark legs, and explorer UI
  filter/save/export-json/export-markdown/delete over the live BFF bridge. The
  same spec's real-HTTP leg also verifies the investigation meta-audit chain
  (`audit.investigation.created/updated/shared/exported/deleted`), confirms
  invalid create and duplicate delete emit no phantom audit row, and asserts raw
  share tokens are not present in emitted payloads.
- Remaining route-specific E2E gaps are the static ledger empty/outage branch,
  long/cursor pagination behavior, standalone/offline/SW policy, and the product
  gaps listed below.

## Open questions / known gaps

- [ ] `data.cursor` is read but never surfaced; there is no "load more" /
      next-page control — document how an investigator reaches older rows
- [ ] This operator route renders the **customer** `LCustomerNav` (Library
      active) rather than an operator bar; confirm that's intended chrome
- [ ] No `robots: { index: false }` export here unlike `/operator/admin`; verify
      operator routes are kept out of SEO at the proxy/layout level
- [ ] Confirm SW caching policy for `/operator/*` (expected network-only)
- [ ] An operator-role gate appears absent — `proxy.ts` only redirects
      sessionless users to `/welcome`; no role check in the proxy or page
      component (possible gap vs intended scope)
