Skip to content

OneRoster provider + REST consumer — design (v1)

Status: implemented (2026-06-12) Decisions this builds on: ADR-0012 (projection, not roster core), ADR-0013 (person-rooted identity), oneroster-consumer-v1.md (boundary tables, reconcile pipeline, lifecycle mapping) Boundary contracts: @conform-ed/contracts oneroster/v1_2 (rostering service schemas, REST binding operation table, CSV binding)

  1. Provider v1 scope is CSV export AND the full REST rostering surface (Anton’s call — supersedes the earlier “REST provider as a later design doc” note). Gradebook and resource services stay out of scope.

  2. sourcedIds are core uuids everywhere. Every exported entity’s sourcedId is its core row’s uuid — one naming scheme, stable and deterministic. SIS-origin entities get OUR id, never an echo of the original SIS sourcedId. Synthesized entities derive deterministic ids from their offering: <offeringId>_course, <offeringId>_term.

  3. Enrollment collapse (the lossy direction, by design):

    core offering_membership exported enrollment
    invited active, future beginDate
    active active, dates from the effective window
    completed active, past endDate
    withdrawn / suspended / expired tobedeleted
    observer / mentor roles not exported (no wire shape)

    Role map mirrors the consumer: learner → student, teacher → teacher, assistant → proctor (the consumer maps proctor → assistant back).

  4. CSV bulk files carry the ACTIVE subset only. In full-file semantics absence is the deletion assertion, so tobedeleted rows are omitted from bulk zips; the REST provider serves them explicitly (delta consumers need the tombstone).

  5. Demographics never leave the boundary (oneroster-consumer-v1 §6): the provider serves the empty collection, and only under the demographics scope.

Projection (provider-projection-service.ts)

Section titled “Projection (provider-projection-service.ts)”

School-shaped read of the neutral core, shared by CSV export and every REST route:

OneRoster core source notes
Org organization hierarchy + kind from core (slice C): type = kind ?? school, parent/children refs from parent_organization_id, identifier = slug
academicSession learning_period kind → type reverse map; schoolYear from period_meta.schoolYear ?? ends_on year; parent ref from hierarchy
Course learning_container courseCode from meta.courseCode ?? title; synthesized per container-less offering
Class offering (status ≠ draft) classType: scheduled; terms from the junction, synthesized term from offering dates when none linked
User person (+ profile, contact points) only persons with exportable memberships; givenName/familyName split the display name on the FIRST space (self-consistent round-trip); roles built per (org, role) membership pair, first primary
Enrollment offering_membership sourcedId = membership uuid; collapse table above
  • dateLastModified = updated_at_us (µs → ISO instant).
  • Wire status: core activeactive, anything else → tobedeleted; draft offerings are not projected at all.
  • GUIDRef hrefs are absolute (contracts UrlSchema): ONEROSTER_PROVIDER_BASE_URL configures the externally reachable rostering base, with a syntactically valid localhost fallback.
  • oneroster_api_client registers external consumers: client_id unique, secret stored as SHA-256 hash only (claim-token pattern), granted scope set, active/disabled status. Admin endpoint POST /workflow/oneroster/api-clients returns the plaintext secret exactly once.
  • OAuth2 client-credentials at POST /ims/oneroster/auth/token (form body or HTTP Basic). Tokens are 1-hour HS256 JWTs signed with ONEROSTER_PROVIDER_TOKEN_SECRET — stateless, no token table. Requested scopes may only narrow the client’s granted set.
  • Scopes: roster-core.readonly and roster.readonly grant the rostering reads; roster-demographics.readonly (or roster.readonly) gates the demographics endpoints.

The full rostering operation table from the contracts binding under /ims/oneroster/rostering/v1p2: collections + single-entity routes for orgs/schools, academicSessions/terms/gradingPeriods, courses, classes, users/ students/teachers, enrollments, demographics, and the scoped convenience routes (/schools/{id}/classes, /classes/{id}/students, /terms/{id}/gradingPeriods, …) — all thin selections over the one projection.

Query behavior (provider-query-engine.ts), applied in memory:

  • filter: field{=,!=,>,>=,<,<=,~}'value' with at most one AND/OR; dotted paths traverse refs, arrays match any element. ISO instants compare lexicographically, so dateLastModified>'…' gives REST consumers their delta query against us.
  • sort + orderBy (asc default), limit (default 100) + offset with X-Total-Count and the spec Link header (first/last always, next/prev where the window has neighbours), fields selection.
  • Errors use the imsx envelope (imsx_codeMajor: failure); unknown sourcedIds 404, missing bearer 401, missing scope 403.

In-memory evaluation over a per-request projection is a deliberate v1 simplification: rostering reads are admin-scale, not learner-scale.

GET /workflow/oneroster/export (platform admin) → { archiveBase64, counts }. Manifest marks the six rostering files bulk, everything else absent; column sets mirror the contracts CSV binding. The consumer’s own zip parser is the transport-level round-trip check (unit-tested both ways).

REST delta consumer (rest-sync-service.ts)

Section titled “REST delta consumer (rest-sync-service.ts)”

The “REST lands later” half of oneroster-consumer-v1, now landed:

  • oneroster_source gained rest_token_url and rest_sync_watermark; rest_credentials_ref is an env-key prefix (REF_CLIENT_ID / REF_CLIENT_SECRET) — never a secret.
  • POST /workflow/oneroster/sources/:sourceId/sync (platform admin): client-credentials token fetch, paged collection GETs (limit/offset, contracts Set-schema validated), filter=dateLastModified>'watermark' when a watermark exists, then the SAME reconcile pipeline in delta mode — absence never withdraws (mode: bulk stays CSV-only).
  • The watermark is the max wire dateLastModified seen, advanced only when the batch outcome is succeeded — a partial run re-fetches from the old watermark rather than dropping records.
  • Reconcile lookup maps (org/session/course/class mappings + oneroster bindings) are preloaded from persisted state per batch: an enrollment-only delta resolves its class, user, and school from earlier batches’ assertions.

tests/integration/oneroster-roundtrip.integration.test.ts (PR-gate suite): school-shaped fixtures (org, year ⊃ term, container, offering + junction, persons with contact evidence, memberships across invited/active/completed + teacher) → provider CSV export → import under a fresh consumer source → assert semantic equality through the new source’s mappings:

  • org by name; periods by title/kind/dates/hierarchy; container/offering links; the offering window re-derived from term spans; junction rows.
  • Identity is lossless: every exported user re-binds to the SAME person via unique contact evidence — no duplicate persons minted.
  • Membership states and windows reconstruct exactly through the collapse (invited/active/completed). withdrawn round-trips as absence — lossy by design and asserted elsewhere (withdrawal-by-absence test).

The same lane covers the REST provider over the real core (token endpoint + contract-validated payloads) and the REST delta consumer against a stub SIS (full fetch → enrollment-only delta → withdrawal WITHOUT absence inference).

Gradebook + resource services (implemented same day)

Section titled “Gradebook + resource services (implemented same day)”

oneroster-gradebook-resources-v1-brief.md — core: grade_category + gradebook_entry (attempt-less grade records; assessment_score stays the attempt-rooted evidence), assessment_definition gained assign_at/due_at/grade_category_id/score_scale_id. Consumer: categories → scoreScales → lineItems → results reconcile sections (results upsert one entry per definition+person; grades have NO absence inference — removal is explicit tobedeletedretracted); resources retained boundary-only; REST sync treats gradebook/resource collections as optional (404 = absent). Provider: read-only gradebook routes under the same base (gradebook.readonly scope), resource routes serve the empty boundary (resource.readonly), CSV export carries categories/lineItems/results/scoreScales/lineItemScoreScales bulk files, and the round-trip gate covers a graded fixture end to end.

Score scales are core-backed (Anton, 2026-06-12, deferred-items campaign): score_scale (org-optional like grade_category; neutral { label, value } bands; wire itemValueLHS/RHS map at the boundary). Core scales are class-independent while the wire requires a class ref, so the provider exports one wire scoreScale per (offering, scale) pair under the synthesized id <offeringId>_<scaleId>_scale — re-import creates one core scale per pair (lossy direction by design, like the enrollment collapse). Result-level scale refs stay in snapshots (entries inherit the definition’s scale).

Deferred-items campaign (settled 2026-06-12 — ✅ ALL slices complete)

Section titled “Deferred-items campaign (settled 2026-06-12 — ✅ ALL slices complete)”

Anton’s directive: every deferrable item is a slice until ALL are finished. Final status of the former “Deferred” list (slices A–E below; the two platform items also landed: F — learning_period + offering_learning_period joined the learner sync registry (period shape unfiltered reference data, junction scoped to the learner’s offerings) with period badges on learner-web offering cards; G — the admin identity-review panel (IdentityReviewPanel, /workflow/identity-review/* routes, review-service.ts): correlation holds resolve by binding a candidate (creates the identity binding, settles sibling candidates — the next sync releases the held user) or dismissing; merge reviews move the forced-mint husk’s identity surfaces (bindings, deduped contact evidence) onto the candidate and archive the husk):

  • Gradebook WRITE operations — ✅ implemented (slice B, 2026-06-12, provider-write-service.ts): PUT/DELETE for categories/lineItems/results/scoreScales write the core homes under the gradebook.createput / gradebook.delete scopes. PUT-create accepts only uuid sourcedIds and creates the row UNDER the supplied id (sourcedIds are core uuids); deletes are soft (archived/retired/retracted — tobedeleted never enters the core); a result PUT whose (lineItem, student) pair already has an entry updates that entry (one current grade per definition+person). The class-independent assessmentLineItems/assessmentResults persist boundary-side in oneroster_provider_assessment_* tables and GETs serve them back (homing them would force assessment_definition.offering_id nullable — a core invariant the boundary must not bend). The four bulk POSTs allocate core ids and return sourcedIdPairs (GuidPairSet — the POST response shape, not a separate feature).
  • “Vendor metadata endpoints” — struck. Audited the full contracts binding against the implemented routes: no such operations exist; the only unimplemented binding operations were the gradebook writes.
  • Org hierarchy — ✅ implemented (slice C, 2026-06-12, Anton: parent FK + kind on core organization): organization gained kind (department/school/district/local/state/national, null = unclassified) and parent_organization_id (set-null). Consumer maps wire org types onto kinds and links parents in a second pass (an org asserted without a parent clears its link — delta semantics); the provider exports real parent/children refs and type from kind. Round-trip gate covers a district ⊃ school fixture.
  • Guardian links — ✅ implemented (slice D, 2026-06-12, Anton: relationship entity now, consent chain stays in its own lane): new core person_guardian (ward + guardian person FKs, kind parent/legal_guardian/relative/other, effective window; erasure: cascade on either side). Consumer maps wire agents through identity bindings (kind inferred from the agent user’s role — REST roles or the optional roles.csv), and CLOSES links a present user stops asserting (effective_until — never deletes). Provider exports guardians of exported wards as users (relationship role at the ward’s primary org, enabledUser: false) with agents refs on the ward; CSV carries agentSourcedIds plus a roles.csv bulk file. Round-trip gate proves the agents chain end-to-end (a dropped link would be closed by the absence pass).
  • SIS-id echo / passback — ✅ implemented as the outbound gradebook push client (slice E, 2026-06-12, Anton-approved reframing; gradebook-push-service.ts, POST /workflow/oneroster/sources/:sourceId/push-grades): platform-authored grades in a source’s mapped classes push back to that source’s gradebook endpoints, every reference translated to THEIR sourcedIds through the consumer’s mapping tables and identity bindings. Unmapped categories PUT under our uuid; unmapped line items POST to their class and the sourcedIdPairs allocation persists as a lineItem mapping — the passback correlation, making re-pushes idempotent. CI round-trips the loop against our own provider over real HTTP (sync from ourselves → author a grade on the synced offering → push → the grade lands on the original offering for the same person). The literal “echo foreign ids from our provider” remains correctly blocked on a concrete target. Hardening found en route: optional services tolerate 403 as absent (not just 404), and same-day membership collapse (date-only beginDate) upserts instead of violating uniqueness.
  • Demographics projection — NOT deferred: decided (oneroster-consumer-v1 §6, data minimization). Demographics never leave the boundary; the endpoints are conformant (empty collection, 404 single).