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)
Decisions (settled 2026-06-12)
Section titled “Decisions (settled 2026-06-12)”-
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.
-
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. -
Enrollment collapse (the lossy direction, by design):
core offering_membershipexported enrollment invitedactive, futurebeginDateactiveactive, dates from the effective windowcompletedactive, pastendDatewithdrawn/suspended/expiredtobedeletedobserver/mentorrolesnot exported (no wire shape) Role map mirrors the consumer: learner →
student, teacher →teacher, assistant →proctor(the consumer mapsproctor→ assistant back). -
CSV bulk files carry the ACTIVE subset only. In full-file semantics absence is the deletion assertion, so
tobedeletedrows are omitted from bulk zips; the REST provider serves them explicitly (delta consumers need the tombstone). -
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
active→active, anything else →tobedeleted; draft offerings are not projected at all. - GUIDRef
hrefs are absolute (contractsUrlSchema):ONEROSTER_PROVIDER_BASE_URLconfigures the externally reachable rostering base, with a syntactically valid localhost fallback.
Provider auth (provider-auth-service.ts)
Section titled “Provider auth (provider-auth-service.ts)”oneroster_api_clientregisters external consumers:client_idunique, secret stored as SHA-256 hash only (claim-token pattern), granted scope set, active/disabled status. Admin endpointPOST /workflow/oneroster/api-clientsreturns 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 withONEROSTER_PROVIDER_TOKEN_SECRET— stateless, no token table. Requested scopes may only narrow the client’s granted set. - Scopes:
roster-core.readonlyandroster.readonlygrant the rostering reads;roster-demographics.readonly(orroster.readonly) gates the demographics endpoints.
REST surface (provider-rest-routes.ts)
Section titled “REST surface (provider-rest-routes.ts)”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 oneAND/OR; dotted paths traverse refs, arrays match any element. ISO instants compare lexicographically, sodateLastModified>'…'gives REST consumers their delta query against us.sort+orderBy(asc default),limit(default 100) +offsetwithX-Total-Countand the specLinkheader (first/last always, next/prev where the window has neighbours),fieldsselection.- 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.
CSV export (csv-export-service.ts)
Section titled “CSV export (csv-export-service.ts)”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_sourcegainedrest_token_urlandrest_sync_watermark;rest_credentials_refis 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: bulkstays CSV-only).- The watermark is the max wire
dateLastModifiedseen, advanced only when the batch outcome issucceeded— 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.
Round-trip CI gate
Section titled “Round-trip CI gate”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).
withdrawnround-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 tobedeleted → retracted); 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 thegradebook.createput/gradebook.deletescopes. PUT-create accepts only uuid sourcedIds and creates the row UNDER the supplied id (sourcedIds are core uuids); deletes are soft (archived/retired/retracted —tobedeletednever 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 inoneroster_provider_assessment_*tables and GETs serve them back (homing them would forceassessment_definition.offering_idnullable — a core invariant the boundary must not bend). The four bulk POSTs allocate core ids and returnsourcedIdPairs(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):
organizationgainedkind(department/school/district/local/state/national, null = unclassified) andparent_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 andtypefrom 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 wireagentsthrough 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) withagentsrefs 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 thesourcedIdPairsallocation 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).