# Journey: Tenant OneRoster roster sync

The shipped OneRoster path can preview normalized roster JSON, commit a clean
plan through admin APIs, and project committed counts into Metis. It does not
fetch from an SIS, schedule pulls, expose an operator Apply control, or remove
people and enrollments that disappear upstream. This is an import and
reconciliation seam, not a complete bidirectional roster sync.

## Personas

- **Tenant LMS/SIS operator** — normalizes institutional user and enrollment
  records and inspects a proposed diff in `/operator/lms`.
- **API operator** — calls the apply or pull endpoint after external review.
- **Metis teacher or administrator** — reads the committed roster summary in
  `/operator/metis`.
- **Security reviewer** — needs tenant-qualified authorization, durable commit
  identity, conflict evidence, and auditable changes.

## Pre-conditions

- Incoming users contain `sourcedId`, `username`, `enabledUser`, and a supported
  OneRoster role; enrollments identify an existing or incoming user and class.
- Admin routes accept `admin:*` or `admin:workspace:tenant-console`. The
  workspace scope is unqualified, and the route does not bind the requested
  tenant to a tenant claim.
- Deployable BFF mutation paths require the admin database. Startup hydrates the
  OneRoster snapshot before traffic; if the sink is unavailable, apply/pull
  return `503 oneroster_persistence_failed` instead of falling back to memory.
- An external caller, not Oshun, obtains and normalizes the SIS payload.

## Steps

### 1. Choose the comparison boundary

There are two different dry-run contracts.
`POST /v1/tenant/lms/oneroster/dry-run` compares caller-supplied `incoming*`
arrays to caller-supplied `existing*` arrays; it does not read the committed
store. `POST /v1/admin/lms/oneroster/preview` compares the incoming arrays with
the current durable tenant projection. The operator page uses the second
contract.

### 2. Preview normalized JSON in the operator page

`/operator/lms` accepts pasted normalized JSON and calls the preview endpoint.
The diff table separates user creates, user updates, enrollment creates, and
blocking conflicts. Preview is read-only: it leaves the committed roster and
commit list unchanged.

The browser surface has no file parser, provider connection, Apply button,
approval queue, or scheduling control. A clean preview therefore ends at
`canApply: true`; it does not authorize or execute the import.

### 3. Apply a clean plan through the API

`POST /v1/admin/lms/oneroster/apply` reruns the real `dryRunOneRosterImport`
engine against committed state. A conflict-free request persists the users,
enrollments, and replay-prevention commit as one ordered snapshot, publishes it
only after the save succeeds, then returns applied counts and `rosterAfter`. A
duplicate commit id is rejected, a conflicting plan mutates nothing, and a
failed save leaves both the visible roster and commit history unchanged.

Commit identity is not fully tenant-qualified in storage: the duplicate map is
keyed globally by `commitId`. Callers should not assume the same commit id can
be reused safely for another tenant.

### 4. Understand what “sync” changes

The plan creates users, updates changed users, and creates enrollments. It does
not delete or deactivate a committed user that is absent from the new payload,
and it does not remove an enrollment omitted upstream. Clean application proves
additive/update reconciliation only, not exact mirror convergence.

### 5. Model an externally triggered pull

`POST /v1/admin/lms/oneroster/pull` accepts an already normalized payload plus
pull metadata. It derives a `pull:<tenantId>:<pullId>` commit, reconciles it
against stored state, and applies a clean plan. Despite the route name, this
code performs no network request to an SIS and no periodic scheduler invokes it;
an external worker must supply the payload and timing.

### 6. Read the committed projection

`GET /v1/admin/lms/oneroster` lists commits and per-tenant roster summaries. The
Metis operator page uses that read model for aggregate users/enrollments and
tenant cards. It is a read-only projection; Metis does not initiate or repair a
roster import.

### 7. Follow audit evidence

A successful apply emits `tenant.oneroster.commit.applied`. A clean pull emits
`tenant.oneroster.pull.reconciled`, and a pull conflict emits
`tenant.oneroster.pull.conflicts_detected`. The conflict audit persists only the
count; the authorized HTTP response still returns actionable conflict text.
Apply conflicts and duplicate apply attempts return errors without audit events,
so the audit stream is not a complete attempt ledger.

## Post-conditions

- A clean API apply stores one commit plus the created/updated users and new
  enrollments and exposes updated summary counts.
- Preview has not mutated committed state.
- Conflict apply/pull and duplicate commit/pull attempts do not alter roster
  counts.
- Successful apply, successful pull, and pull conflict have the documented audit
  evidence.
- A successful apply/pull acknowledgement means roster state and its commit id
  are recoverable after restart; a failed save emits no success audit.
- No claim is established for SIS connectivity, periodic execution, deletion/
  deactivation, operator approval, or browser-side commit.

## Failure modes

- **Cross-tenant workspace authority** — an unqualified tenant-console scope can
  operate on a request-selected tenant; tenant membership is not enforced.
- **False source of truth** — the tenant dry-run endpoint can compare two
  caller-provided snapshots and therefore cannot prove parity with committed
  state.
- **Preview mistaken for commit** — the operator UI can show `canApply: true`
  but has no Apply control or mutation receipt.
- **No actual pull** — the pull endpoint receives data; it neither connects to
  an SIS nor schedules itself.
- **Stale records remain** — missing users and enrollments are not disabled or
  removed, so upstream deletions do not converge.
- **Global commit-id collision** — the same commit id can conflict across
  tenants because duplicate identity is not tenant-keyed.
- **Username or reference conflict** — collisions and enrollments for unknown
  users return a blocked plan; apply returns `422 has_conflicts`.
- **Incomplete attempt audit** — duplicate and conflicting applies leave no
  audit event, while pull conflicts do.
- **Persistence unavailable** — deployable mutations fail closed with `503`;
  they do not publish a process-local roster or success audit.
- **Replica race** — mutation ordering is per BFF process over one whole-store
  snapshot; there is no distributed compare-and-swap across replicas.
- **No account-subject link** — roster `sourcedId`/`username` values are
  institution identities, not an exact customer-auth ownership key. Account
  DSAR/erasure must not guess from usernames; tenant offboarding is separate.

## E2E coverage

- [`apps/oshun/web/e2e/tenant-oneroster-provisioning-bff.spec.ts`](../../apps/oshun/web/e2e/tenant-oneroster-provisioning-bff.spec.ts)
  covers authorization, validation, clean/conflict dry-runs, apply/list,
  duplicates, pull reconciliation/conflicts, mutation safety, and audit
  read-back against the real BFF.
- [`apps/oshun/web/e2e/tenant-bulk-operations-export-bff.spec.ts`](../../apps/oshun/web/e2e/tenant-bulk-operations-export-bff.spec.ts)
  proves a roster bulk-operation row awaits the same durable authority and is
  visible through the OneRoster projection read-back.
- [`apps/oshun/web/e2e/operator-lms.spec.ts`](../../apps/oshun/web/e2e/operator-lms.spec.ts)
  drives the browser preview form and proves clean/conflict previews do not
  mutate the committed roster.
- [`apps/oshun/web/e2e/tenant-oneroster-metis-card.spec.ts`](../../apps/oshun/web/e2e/tenant-oneroster-metis-card.spec.ts)
  applies through the API and verifies the committed summary in
  `/operator/metis`.
- **Coverage depth: deep for the local preview/apply/read spine; absent for SIS
  network ingestion, scheduling, browser commit, and removal convergence.**

## Per-view files touched

- [`operator/operator-lms.md`](../operator/operator-lms.md) — paste-and-preview
  diff table, without Apply.
- [`operator/operator-metis.md`](../operator/operator-metis.md) — read-only
  OneRoster summary card.
- [`operator/operator-tenants.md`](../operator/operator-tenants.md) — tenant
  context adjacent to the roster projection.
- [`tenant-bulk-member-invite-scim.md`](./tenant-bulk-member-invite-scim.md) —
  separate identity and membership projections.

## Cross-references

- [`tenant-onboard.md`](./tenant-onboard.md) — invite acceptance and seat
  allocation outside OneRoster.
- [`tenant-audit-log-investigation.md`](./tenant-audit-log-investigation.md) —
  audit-store investigation boundaries.
- Sources: `oneroster-route.ts`, `admin-oneroster-apply.ts`,
  `oneroster-apply-store.ts`, `OneRosterDiffPreviewForm.tsx`, and the
  `@metis/integrations` `dryRunOneRosterImport` engine.

## Open questions

- How should every route bind tenant id to a tenant-qualified admin claim?
- Which production worker owns SIS credentials, fetching, cadence, backoff,
  replay, and source receipts before calling the pull endpoint?
- Should commit identity be `(tenantId, commitId)` rather than globally keyed?
- What policy deactivates missing users and removes stale enrollments without
  erasing intentional local exceptions?
- Where should review and Apply live, and what audit event should capture every
  rejected, conflicting, or duplicate attempt?
