# Journey: Tenant bulk operations and exports

The platform-admin APIs ship two durable, operator-owned workflows. Bulk
operations validate, dry-run, stage, and commit four mutation kinds through real
executors; unsupported kinds fail loudly. Bulk exports enforce the
resource/format/residency matrix, create SHA-256 manifests, require a release
decision, and gate raw-file reads. `/operator/tenant` exposes the export
workflow only; there is no browser UI for drafting or committing bulk
operations.

## Personas

- **Platform operator** — holds `admin:*` or `admin:studio` and owns operation
  and export records under their authenticated user id.
- **Tenant operations specialist** — prepares user, entitlement, roster, or
  persona rows for a target tenant.
- **Compliance reviewer** — verifies an export manifest and approves or rejects
  release.
- **Workspace tenant admin** — intentionally receives `403`; the routes are not
  available to `admin:workspace:tenant-console`.

## Pre-conditions

- The BFF is running with an admin bearer. Both route families also expose
  legacy `/admin/*` aliases, while the UI uses `/v1/admin/*`.
- Admin snapshot persistence is configured if operations/archives must survive
  restart. Without the admin database, their singleton stores are process-local.
- Commit targets are available: customer auth/profile, tenant seat allocation,
  OneRoster, and active persona stores.
- Export records include their own `resourceKind`; residency validation also
  reads each record's residency field.

## Steps

### 1. Draft an operator-owned bulk operation

`POST /v1/admin/bulk-operations` accepts kind, title, rationale, target tenant,
and rows. The response starts at `drafted`; another operator's list remains
empty. Validation derives required-field and type issues, while dry-run
classifies each row as create, update, or skip and records a field-level plan.

No CSV/file upload parser feeds this request. Operators or automation must
construct the typed JSON rows directly.

### 2. Validate, dry-run, and stage

Stable transitions are `drafted → validated → dry_run_complete → staged`.
Validation errors remain inspectable at `validated` and block the dry-run
transition. Invalid or repeated transitions return `409`. Creation and every
successful transition append `admin.bulk_operation.*` audit events.

### 3. Commit through the exact executor matrix

Commit requires rationale. Non-skip rows run sequentially through these real
effects:

| Kind                  | Commit effect                                                  |
| --------------------- | -------------------------------------------------------------- |
| `users`               | Provision customer account, then allocate a target-tenant seat |
| `entitlements`        | Update an existing account plan to free/pro/premium            |
| `rosters`             | Apply one durable OneRoster commit per row                     |
| `persona_assignments` | Set the subject's active persona and surface                   |
| `content`, `taxonomy` | Fail with “no commit executor … nothing was changed”           |

A row failure stops subsequent rows and marks the operation `failed`, but
earlier successful rows remain applied and are retained in `commitOutcomes`.
This is an honest partial-commit ledger, not a transaction or automatic
rollback.

### 4. Verify mutation read-backs

The real-BFF journey reads the created user and plan through `/v1/profile`, the
roster through `/v1/admin/lms/oneroster`, and the persona selection through
`/v1/personas/active`. It also reads both universal admin search and structured
audit-log events. Unsupported content commit stays failed with null
`committedAt` and an explicit reason.

### 5. Create an integrity-manifest export

`POST /v1/admin/bulk-exports` validates the resource/format matrix, record-kind
equality, and per-record residency against the target region. A successful
archive contains version `oshun-bulk-export-v1`, file summaries and content,
file SHA-256 values, a manifest SHA-256, a pending release, and a 30-day
retention timestamp. List/create projections omit raw file content; detail read
includes it for operator inspection.

The browser panel is intentionally narrower than the API: it always creates a
two-record `content`/`json`/`us` archive from two typed titles. It does not
expose other resource kinds, formats, regions, or arbitrary records.

### 6. Verify and decide release

`POST /:opId/verify` recomputes the file/manifest integrity verdict. The API
accepts approved or rejected release decisions with rationale. The browser
renders only Approve. The archive owner is also recorded as the approver; there
is no distinct reviewer identity or four-eyes gate.

Before approval, the file endpoint returns
`409 bulk_export_release_not_approved`. After approval it returns the raw
content with file/manifest hash, residency, retention, and content-disposition
headers. The browser panel calls `getText` and renders the content inside the
page despite the button label “Download released file”; it does not create a
browser download artifact.

### 7. Inspect durability and audit evidence

When configured, operation states and full export archives—including file
content—write through to durable admin snapshots. Structured audit events cover
operation lifecycle/failure and export execute/release/download. Retention is
metadata only: the store does not purge expired archives or block a read after
`retentionExpiresAtUnixSeconds`.

## Post-conditions

- Supported mutation rows have observable downstream effects and per-row
  outcomes; unsupported kinds cannot masquerade as committed.
- Operation and export lists remain isolated by operator user id.
- An approved export has verifiable file/manifest hashes and release/audit
  metadata.
- The UI can create, inspect, verify, approve, and display one fixed-shaped
  content export.
- Bulk mutation UI, upload parsing, atomic rollback, reviewer separation,
  retention enforcement, and a real browser file download are absent.

## Failure modes

- **Workspace scope** — tenant-console workspace admins receive `403` even for
  their own tenant.
- **Validation block** — malformed user rows reach `validated` with issues but
  cannot enter dry-run complete.
- **Partial commit** — earlier executor effects survive when a later row fails;
  retry/compensation must inspect `commitOutcomes`.
- **Unsupported kind** — content/taxonomy operations end `failed` with no false
  commit timestamp.
- **Duplicate transition/op id** — invalid state changes and duplicate export
  ids are rejected per operator.
- **Residency/kind mismatch** — schema-valid export requests fail `422` before
  an archive is created.
- **Same-actor approval** — the archive owner can approve their own release;
  compliance separation is descriptive, not enforced.
- **Retention metadata only** — an expired timestamp does not delete or deny an
  archive/file.
- **UI format narrowing** — operators cannot use the page to export rights,
  users, OneRoster, CSV, or a non-US region.
- **Misnamed download** — the web control displays response text in the panel
  rather than handing a saved file to the browser.

## E2E coverage

- [`apps/oshun/web/e2e/tenant-bulk-operations-export-bff.spec.ts`](../../apps/oshun/web/e2e/tenant-bulk-operations-export-bff.spec.ts)
  covers auth/isolation, users/entitlements/rosters/persona executors,
  validation, unsupported failure, downstream read-backs, export matrix,
  integrity, release gate, response headers, and audit/search evidence over the
  real BFF.
- [`apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts`](../../apps/oshun/web/e2e/tenant-bulk-operations-export-ui.spec.ts)
  drives the fixed content export panel through create, inspect, verify,
  approve, get-text “download,” and audit read-back.
- **Coverage depth: deep for the implemented APIs and export UI, partial for the
  named operator workflow.** Mutation draft/diff/commit has no browser path;
  upload parsing, compensation, dual control, expiry enforcement, and varied
  export UI are unimplemented.

## Per-view files touched

- [`operator/operator-tenant.md`](../operator/operator-tenant.md) — fixed-shape
  export release panel; no bulk-operation panel.
- [`operator/operator-audit.md`](../operator/operator-audit.md) — structured
  lifecycle evidence.
- [`operator/operator-metis.md`](../operator/operator-metis.md) — OneRoster
  executor read-back.
- [`customer/09-account/profile.md`](../customer/09-account/profile.md) —
  account/plan effect read-back at the API boundary.

## Cross-references

- [`tenant-bulk-member-invite-scim.md`](./tenant-bulk-member-invite-scim.md) —
  separate CSV/SCIM/member seams.
- [`tenant-oneroster-roster-sync.md`](./tenant-oneroster-roster-sync.md) —
  durable roster executor target.
- [`persona-voice-avatar-approval-workflow.md`](./persona-voice-avatar-approval-workflow.md)
  — adjacent persona governance, not invoked by assignment commit.
- Sources: the two `admin-bulk-*.ts` route/store pairs,
  `apps/oshun/bff/src/admin/bulk-commit-executors.ts`,
  `apps/oshun/web/src/components/lilith/TenantBulkExportPanel.tsx`, and the
  admin bulk contracts under `libs/contracts/src/common/`.

## Open questions

- Which operator UI owns JSON/CSV upload, row diff review, commit rationale,
  partial-outcome inspection, and compensation?
- Should row effects be transactional per operation, or what resumable
  idempotency/rollback contract makes partial commits safe?
- How will two-person release approval ensure the requester cannot approve their
  own export?
- What job enforces retention expiry and records purge evidence?
- Should the browser create a real downloaded file and verify response hashes
  client-side instead of only rendering response text?
