# Shell: Workspace pattern

Source: `apps/oshun/admin/src/lib/server-session.ts` (`getAdminServerSession`,
`fetchAdminOperatorView`), `apps/oshun/admin/src/lib/workspace-loader.ts`
(`loadWorkspaceOverview`, `loadWorkspaceDetail`),
`apps/oshun/admin/src/lib/bff-client.ts` (`AdminBffFetchResult`,
`AdminBffWorkspaceSummary`, `AdminBffWorkspaceDetailResponse`),
`apps/oshun/admin/src/components/WorkspaceEntryPoint.tsx`,
`apps/oshun/admin/src/components/WorkspaceSummaryCard.tsx`,
`apps/oshun/admin/src/components/WorkspacePage.tsx`,
`libs/oshun/navigation/src/admin-ia.ts` (`OSHUN_ADMIN_WORKSPACE_MODEL`)

The shape every admin workspace page follows. Walk this once; then per-view
files focus only on what's unique to each workspace.

## Canonical page shape

```tsx
export default async function FooPage(): Promise<JSX.Element> {
  const session = await getAdminServerSession();
  if (!session) {
    redirect('/unauthorized?reason=missing-session&returnTo=/foo');
  }
  const [detail, operatorView] = await Promise.all([
    loadWorkspaceDetail('foo', session),
    fetchAdminOperatorView(session),
  ]);
  return (
    <AdminShell session={session} currentWorkspaceId="foo" operator={operatorView.view}>
      <FooPanel detail={detail} ... />
      {/* or fallback */}
      <WorkspaceEntryPoint workspaceId="foo" accessible={detail.accessible} detail={detail.result} />
    </AdminShell>
  );
}
```

Three things happen on every page render:

1. **Server session check** — `getAdminServerSession()` returns null if no valid
   admin cookie; page redirects to `/unauthorized` with reason
   - returnTo.
2. **Parallel data fetch** — `loadWorkspaceDetail(workspaceId, session)` for
   workspace data + `fetchAdminOperatorView(session)` for the operator's display
   profile.
3. **Render** — `<AdminShell>` wraps the workspace body. The body is either a
   workspace-specific panel (when data is available and the operator has access)
   or `<WorkspaceEntryPoint>` (which itself renders an "Access not granted" or
   "Workspace data unavailable" notice).

## `getAdminServerSession()`

Returns `AdminServerSession | null`. When non-null:

- `session.session.payload.userId` — operator user id
- `session.session.payload.scopes` — array of scopes
- `session.canEnterShell` — boolean used by assistant guards

Null means no valid admin cookie. Pages handle this by redirecting, not by
rendering a denial UI inline — that's the middleware/`/unauthorized` role.

## `loadWorkspaceDetail(workspaceId, session)`

Returns an `AdminBffFetchResult<AdminBffWorkspaceDetailResponse>`:

- `{ ok: true, value: { workspace: AdminBffWorkspaceSummary, items: …, metrics: … } }`
- `{ ok: false, reason: 'workspace-unavailable' | 'forbidden' | 'network' | …, message?: string }`

Plus an `accessible` flag derived from
`canEnterAdminWorkspace(session scopes, workspaceId)`.

For pages with no BFF backing (e.g., `lilith` workspace has
`backendStatus: 'backend-pending'`), `result` may be null and the page falls
back to `<WorkspaceEntryPoint>`'s "Workspace data unavailable" notice.

### IA id vs BFF id — watch for drift

Several workspaces' `bffWorkspaceId` does not match their IA id.
`loadWorkspaceDetail(<iaId>, session)` reads `bffWorkspaceId` off the workspace
definition and hits `/v1/admin/workspaces/<bffId>`, not the IA id. When you're
tracing a log line or a network panel, the request path will reference the BFF
id; the URL and sidebar reference the IA id. Use this table to translate.

| IA id (route + sidebar) | BFF id (request path)            |
| ----------------------- | -------------------------------- |
| `dashboard`             | n/a (`composed-from-workspaces`) |
| `inbox`                 | `inbox`                          |
| `review`                | `review`                         |
| `policy`                | `policy`                         |
| **`trust-safety`**      | **`moderation`**                 |
| `lilith`                | n/a (`backend-pending`)          |
| `rights`                | `rights`                         |
| **`incidents`**         | **`incident`**                   |
| `editorial`             | `editorial`                      |
| `research-integrity`    | `research-integrity`             |
| **`personas`**          | **`persona`**                    |
| **`models`**            | **`model`**                      |
| `isis`                  | n/a (per-route loaders)          |
| `support`               | `support`                        |
| `privacy`               | `privacy`                        |
| `analytics`             | `analytics`                      |
| `admin-tools`           | n/a (`composed-from-workspaces`) |
| `messaging`             | n/a (`backend-pending`)          |
| `tenant-console`        | n/a (`backend-pending`)          |

The four bolded rows differ between IA and BFF and have caused log-tracing
confusion in the past. The drift is now codified in
`libs/oshun/navigation/src/admin-ia.ts` as `OSHUN_ADMIN_BFF_ID_DRIFT` (an
explicit list of the four mismatched pairs) plus two derivation helpers (P3
2026-05-25):

- **`resolveBffWorkspaceIdFromIaId(iaId)`** — call this whenever building a BFF
  URL or correlating a BFF call. Returns `undefined` for non-BFF-backed
  workspaces.
- **`resolveAdminWorkspaceIdFromBffId(bffId)`** — reverse direction. Use this
  when reading raw BFF telemetry / audit logs and needing to surface the
  matching IA workspace label in the cockpit.

A contract test (`admin-ia.test.ts`) asserts the four declared drift pairs match
the IA model and that every other BFF-backed workspace has `IA id === BFF id`
(no hidden drift). Renaming the BFF ids to match the IA ids would make all four
pairs identity-mapped but requires a coordinated rewrite of
`apps/oshun/bff/src/admin/state.ts`; until that happens, the helpers are the
canonical translation layer — do not inline the conditional in call sites.

## `loadWorkspaceOverview(session)`

Returns the full multi-workspace summary for the dashboard. Used by `/` to
render `WorkspaceSummaryCard`s for every workspace at once.

## `WorkspaceEntryPoint`

`apps/oshun/admin/src/components/WorkspaceEntryPoint.tsx` (110 lines)

The fallback / wrapper body. Renders:

- [ ] **Heading block** — h2 with `definition.label`; tagline from
      `definition.operatorPromise`; `<dl>` with `Group`, `Backend`,
      `Operator role` from `definition.backendStatus` and
      `definition.operatorRelationship`
- [ ] **`WorkspaceSummaryCard`** — summary metrics from the BFF response;
      renders queue health, item count, last-updated, etc. Falls back to "no
      summary" if `result` is null
- [ ] **`accessible === false` panel** — `role="alert"`; heading "Access not
      granted"; body explains the required scopes (`admin:*`, `admin:studio`, or
      workspace-specific) and the ADR-0029 privileged handoff path
- [ ] **`accessible === true && !detail.ok` panel** — `role="alert"`; heading
      "Workspace data unavailable"; body either
      `reason: 'workspace-unavailable'` or `detail.message`
- [ ] **Related handoffs block** — `listOshunAdminWorkspaceRelationships()`
      filtered to `from === workspaceId`; for each, shows target label, reason,
      trigger; heading "Handoffs from here"

States to walk:

- [ ] **Accessible + data ok** — full summary card renders
- [ ] **Accessible + workspace-unavailable** — "Workspace data unavailable"
      notice; rest of summary block still renders
- [ ] **Accessible + network error** — `detail.message` surfaces
- [ ] **Not accessible** — "Access not granted" alert; no summary card data
      shown
- [ ] **Empty related handoffs** — section omitted

## `WorkspaceSummaryCard`

`apps/oshun/admin/src/components/WorkspaceSummaryCard.tsx`

Renders the per-workspace summary tile. Used both inside `WorkspaceEntryPoint`
(one) and in the dashboard (many). Walk this component for its own states
(loading / empty / populated / error).

## Workspace-specific panels

Most workspaces have a dedicated panel component beyond `WorkspaceEntryPoint`.
Inventory (incomplete; per-view files fill in):

| Workspace           | Panel component                                 |
| ------------------- | ----------------------------------------------- |
| `dashboard`         | `CopilotHealthPanel`, `WorkspaceSummaryCard` ×N |
| `inbox`             | `UnifiedInboxPanel`                             |
| `review`            | (workspace panel TBD per per-view)              |
| `review/[reviewId]` | review-detail workspace component               |
| `trust-safety`      | moderation queue panel                          |
| (others)            | per-view files document the panel               |

Per-view files own the per-workspace panel walkthrough. This shell doc covers
only the `WorkspaceEntryPoint` fallback path.

## Operator copilot guard

Every workspace inherits the assistant-invocation guard from `AdminShell`. The
guard input includes:

```ts
{
  invocationPointId,
  authenticated: session.canEnterShell,
  activePath: window.location.pathname,
  viewportWidthPx: window.innerWidth,
  scopes: session.session.payload.scopes,
  entitlements: ['assistant.admin'],
  policyGrants: ['assistant:operations'],
}
```

The result becomes `assistantGuardDecision`. If denied, the assistant panel
shows the block reason. See [01-app-shell.md](./01-app-shell.md) for the full
guard composition.

## States that apply to every workspace page

- [ ] **Anonymous** — middleware redirects; never reaches the page
- [ ] **Admin session, missing workspace scope** — `accessible: false`;
      `<WorkspaceEntryPoint>` renders denial panel
- [ ] **Admin session, workspace scope, BFF ok** — workspace-specific panel
      renders with data
- [ ] **Admin session, scope ok, BFF returns `workspace-unavailable`** —
      "Workspace data unavailable" notice
- [ ] **Admin session, scope ok, BFF returns network error** — error message via
      `detail.message`
- [ ] **`backendStatus: 'backend-pending'`** workspace — no BFF call; always
      shows the entry-point fallback (Lilith is the canonical example today)

## Cross-references

- [01-app-shell.md](./01-app-shell.md) — AdminShell composition
- [02-routing-layouts.md](./02-routing-layouts.md) — middleware that enforces
  the session before the page runs
- [03-auth-session.md](./03-auth-session.md) — scopes and access matrix
- Workspace definitions: `libs/oshun/navigation/src/admin-ia.ts`
- BFF client: `apps/oshun/admin/src/lib/bff-client.ts`

## Open questions / known gaps

- [ ] Inventory every workspace's dedicated panel component (when one exists
      beyond `WorkspaceEntryPoint`) and link from each per-view file
- [ ] Document the BFF endpoint pattern (`/v1/admin/workspaces/<id>` based on
      the inbox example) and which workspaces it covers
- [ ] Confirm `forbidden-workspace` reason originates from a page-level check,
      not from middleware (middleware doesn't read scopes today)
- [ ] Document how `WorkspaceSummaryCard` behaves when summary is partial (some
      fields present, others missing)
