Skip to content

OneRoster gradebook + resource services — design brief (v1)

Status: implemented (2026-06-12) — Anton settled the three gating decisions the same day (gradebook_entry + grade_category in core; resources boundary-only; implement immediately) and the proposed plan below landed as written. The gradebook write operations (PUT/POST/DELETE in the binding), originally deferred here as read-only-in-v1, subsequently landed in the deferred-items campaign (2026-06-12, slice B — provider-write-service.ts, core-backed PUT/DELETE × {categories, lineItems, results, scoreScales} under the gradebook.createput/gradebook.delete scopes; see oneroster-provider-v1.md → Deferred-items campaign). Resources stay boundary-only (snapshots; no core projection) until a real SIS resource feed exists. Builds on: oneroster-provider-v1.md (rostering provider + REST consumer), ADR-0012 (projection, not roster core), ADR-0014 (erasure) Contracts: @conform-ed/contracts oneroster/v1_2 gradebook + resource service schemas (already shipped — wire shapes are settled)

Both services ARE OneRoster 1.2 (separate service specs sharing the rostering base). They were not implemented with the rostering milestone because each requires a core-model decision the rostering work did not: the one-vocabulary rule means a neutral core home must exist before a boundary can project into it (the Learning Period precedent), and these homes don’t fully exist yet.

Wire entities: Category, ScoreScale, LineItem, Result (+ AssessmentLineItem / AssessmentResult, the class-independent variants).

What the core already has (assessment-feedback.ts):

wire core fit
LineItem.class/school/title assessment_definition (offering-scoped, title) clean
LineItem.resultValueMax assessment_definition.max_score clean
Result.score/scoreDate/student assessment_score (raw/scaled, is_final) + attempt person/finalized_at provider direction mostly clean
Result.comment feedback_entry clean enough

The four real gaps:

  1. Results are attempt-less; core scores are attempt-rooted. assessment_score hangs off assessment_attempt (NOT NULL FK, attempt_no, started/submitted timestamps). A SIS-imported grade has no attempt — there is nothing true to put in those columns. Consuming results into the core therefore needs either a new neutral entity (a gradebook entry keyed by definition + person, where assessment_score rows project INTO it as the platform-native evidence) or synthetic attempts (false data by construction — rejected on principle).
  2. LineItem REQUIRES assignDate + dueDate. assessment_definition has neither. The provider cannot emit conformant lineItems without a due-date home (deriving from offering window is semantically wrong per item). Due dates are also a real product need (teacher gradebook UI), so this is a core column decision, not a boundary hack.
  3. LineItem REQUIRES a category ref. The core has no grade-category entity (gradebook weighting groups). Synthesizing one category per class is conformant-but-hollow for the provider; the consumer would have no home for real SIS categories. Categories are also what the spreadsheet-like gradebook UI needs.
  4. ScoreScale has no core analog (letter-grade scales). Optional on the wire — v1 can keep scales boundary-only (snapshots) and emit numeric scores only.

Implied sequencing: a short core design session (gradebook entry, category, due/assign dates — decisions Anton must validate per the working rules), THEN the boundary work, which is mechanical from the rostering patterns: snapshot + mapping tables, reconcile sections, projection + routes (the query engine, auth, paging, and round-trip-gate machinery all already exist and are reused as-is).

Wire: Resource (vendorResourceId REQUIRED, title/roles/importance optional) + class/course association files — vendor content POINTERS, not content.

Core analog: external_package + assignments hold actual imported content; there is no “vendor resource pointer” entity. Options: (a) boundary-only in v1 (snapshots + mappings reserved, no core projection — the demographics treatment) — consistent with ADR-0012 and zero core cost; (b) project onto external-package assignments where vendorResourceId matches an imported package — speculative until a real SIS resource feed exists. No current product pull either way.

SIS-id echo (recorded here because it’s gradebook-coupled)

Section titled “SIS-id echo (recorded here because it’s gradebook-coupled)”

Not a OneRoster spec feature. It matters only for passback INTO a SIS that keys on its own sourcedIds (results referencing SIS lineItem/student ids). The core-uuid decision stands for everything we provide; if a passback integration requires echoing, the consumer mapping tables already hold the correlation and the provider can translate at the boundary. Decide when a concrete passback target exists — deciding now would be designing against an imagined consumer.

Proposed plan (pending the gating decisions)

Section titled “Proposed plan (pending the gating decisions)”
  1. Core: grade_category (org/offering-scoped, title, weight), gradebook_entry (definition + person, score numeric + status enum + recorded_at + meta; erasure: sever person), assessment_definition gains assign_at/due_at (nullable) + grade_category_id (nullable). assessment_score stays the attempt-rooted evidence; finalized scores project into gradebook_entry (platform-native rows carry provenance).
  2. Boundary: oneroster_line_item_snapshot/_mapping (→ assessment_definition), oneroster_category_snapshot/_mapping (→ grade_category), oneroster_result_snapshot (→ gradebook_entry via lineItem mapping + person binding), scoreScale snapshots (boundary-only).
  3. Reconcile order extends: categories → lineItems → results, same bulk/delta semantics, same held-user behavior (results for held users stay pending in snapshots).
  4. Provider: projection gains categories/lineItems/results; gradebook routes from the binding table; CSV files added to the manifest; round-trip gate extends with a graded fixture.