V1 Web PWA · Journey

Journey: Tenant OneRoster roster sync

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 4 specs verdict: pass· 2026-06-25
9sections6 minread

On this page
Journey at a glance
ActorsTenant LMS/SIS operatorAPI operatorMetis teacher or administratorSecurity reviewer
Automation4 E2E spec(s) · verdict: pass· 2026-06-25

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Choose the comparison boundary"] s2["2. Preview normalized JSON in the operator pa…"] s3["3. Apply a clean plan through the API"] s4["4. Understand what “sync” changes"] s5["5. Model an externally triggered pull"] s6["6. Read the committed projection"] s7["7. Follow audit evidence"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 click s1 href "#1-choose-the-comparison-boundary" click s2 href "#2-preview-normalized-json-in-the-operator-page" click s3 href "#3-apply-a-clean-plan-through-the-api" click s4 href "#4-understand-what-sync-changes" click s5 href "#5-model-an-externally-triggered-pull" click s6 href "#6-read-the-committed-projection" click s7 href "#7-follow-audit-evidence" s7 --> v(["verdict: pass"]) click v href "../results/tenant-oneroster-roster-sync.html" style v stroke:#3fb950,color:#3fb950

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#

Per-view files touched#

Cross-references#

  • tenant-onboard.md — invite acceptance and seat allocation outside OneRoster.
  • 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?