---
path: /data
surface: tenant-admin
domain: governance
auth: signed-in (tenant) + scope:tenant:admin:{tenantId} (or tenant:admin:*)
source: apps/oshun/tenant-admin/src/app/data/page.tsx
status: drafted
last_walked: '—'
---

# Bulk data operations

## Purpose

Demonstrates the tenant's controlled bulk-data lifecycle — import dry-run,
staged commit, selected-set bulk action gating, and xAPI export with a hashed
manifest — each evaluated against tenant residency and audit requirements before
execution. It is the `data` workspace (`group: governance`) in
`@oshun/navigation`.

## Entry points

- Shell sidebar **Governance › Data** item (`TenantSidebar`,
  `shortLabel: 'Data'`, path `/data`).
- Direct URL `/data` (bookmarkable; scope-gated).

## Layout regions

Wrapped in `<TenantShell session currentWorkspaceId="data">`:

- **Sidebar / Header**: standard chrome (header crumb "Bulk data operations").
- **Main** (`<section data-testid="tenant-data-panel" data-tenant-id={...}>`):
  - `<h1>Bulk data operations</h1>` + subtitle on residency/audit evaluation.
  - `styles.summaryGrid` of four tiles: **Rows** (`dryRun.rowCount`),
    **Residency holds** (`dryRun.residencyViolationCount`), **Committed before
    hold** (`commit.committedRowIds.length`), **Bulk action**
    (`actionGate.verdict`).
  - `styles.table` columns **Operation / Evidence / Outcome** with three rows:
    - **Import dry-run** — `{add} add, {update} update, {conflict} conflict`;
      `ready`/`blocked` badge from `dryRun.readyToCommit`.
    - **Staged commit** —
      `resume from row {index|complete}; skipped {ids|none}`; neutral badge
      `{auditEventIds.length} audit events`.
    - **xAPI export** — truncated `manifestHashSha256` (or `failed`);
      `manifested`/`error` badge from `exportOutcome.ok`.

## States

- [ ] **Loading** — N/A; synchronous server render from `dryRunImport`,
      `planStagedCommit`, `gateBulkAction`, `executeBulkExport` over hard-coded
      `BulkRow[]` seed (no fetch).
- [ ] **Empty** — N/A; three seed rows (`u-1` existing→update, `u-2` add, `u-3`
      EU-region residency hold) always produce output.
- [ ] **Populated (short)** — always three operation rows. With the seed: `u-3`
      triggers a residency hold (`tenantResidencyRegion: 'us'`), `u-1` is a
      conflict/update (in `existingExternalIds`), `planStagedCommit` commits
      only `region === 'us'` rows.
- [ ] **Populated (long)** — N/A; fixed dataset.
- [ ] **Error (recoverable)** — N/A; no query. The xAPI export row models a
      failure path via `exportOutcome.ok === false` ("error" badge), but the
      seed produces a success.
- [ ] **Error (unrecoverable)** — no custom handler; render throw → Next
      default.
- [ ] **Offline** — N/A (server-rendered).
- [ ] **Gated** — no session →
      `redirect('/unauthorized?reason=missing-session&returnTo=/data')`;
      middleware blocks unauthenticated requests upstream.
- [ ] **Standalone PWA** — N/A; no manifest. (verify.)

## Interactions

- [ ] **Summary tiles** (`styles.summaryItem` × 4) — display-only (Rows /
      Residency holds / Committed before hold / Bulk action verdict); not
      focusable, no telemetry.
- [ ] **Operation table rows** (`<tr>` × 3: import dry-run, staged commit, xAPI
      export)
  - Function: render-only evidence + outcome badge per lifecycle stage. No "run
    import", "commit", or "download export" buttons exist in code — the page
    displays precomputed results only.
  - Keyboard: table is not interactive.
  - Screen reader: standard table semantics.
  - Touch target: N/A.
  - Mobile (≤ 640 px): `workspace.module.css` table styling.
  - Disabled when: N/A.
  - Offline behavior: N/A.
  - Telemetry: _None._

## Data & contracts

- **Reads / pure-function calls** (all from `@oshun/tenant-console`, all with
  hard-coded seed input, **no BFF call**):
  - `dryRunImport({ opId, tenantId, resourceKind, rows, validators, existingExternalIds, tenantResidencyRegion, rowResidencyRegion })`
    → counts + `readyToCommit`.
  - `planStagedCommit({ opId, rows, committable })` → `committedRowIds`,
    `skippedRowIds`, `resumeFromRowIndex`, `auditEventIds`.
  - `gateBulkAction({ actionId, tenantId, resourceKind, selection, actionVerb, rationale, previewLimit, safetyLimit })`
    → `verdict`.
  - `executeBulkExport({ opId, tenantId, resourceKind, format: 'xapi', records, residencyRegion, recordResidencyRegion, generatedAtUnixSeconds, hashSha256 })`
    → `{ ok, result.manifest.manifestHashSha256 }`. `hashSha256` is the in-file
    FNV-1a `deterministicHash`.
- **Writes**: _None_ in the page (the lifecycle functions are pure; nothing is
  persisted).
- **Realtime**: _None._
- **Caching**: SSR (dynamic; cookie read). Deterministic per tenant id.
- **Auth/role check**: `middleware.ts` + page `getTenantServerSession()` →
  `canEnterTenantShell`.

## Cross-references

- Domain logic: `libs/oshun/tenant-console/src/bulk-ops/index.ts`
  (`dryRunImport`, `planStagedCommit`, `gateBulkAction`, `executeBulkExport`,
  `BulkRow`).
- Workspace model: `libs/oshun/navigation/src/tenant-ia.ts` (`data`,
  `group: governance`).
- Sibling routes: [home.md](./home.md), [audit.md](./audit.md) (export bundles
  reference the same audit stream).

## Open questions / known gaps

- [ ] All rows/records are hard-coded seed data; no real import upload, commit
      trigger, or export download is wired. Document the eventual file-ingest +
      BFF persistence path.
- [ ] `deterministicHash` is an FNV-1a stand-in for SHA-256 manifest hashing;
      confirm whether a real digest is required for production manifests.
- [ ] Page is read-only (no action buttons); confirm the interactive workflow is
      a later phase.
