Skip to content

Grades — immutable operational ledger

A grade is a teacher-authored assessment mark for one student in one subject group. Grades are family-visible immediately and are persisted as an immutable, reasoned revision ledger. The current value, every correction, a withdrawal, the context in which each command was made, and the calculation evidence remain available without reconstructing facts from mutable curriculum configuration.

Canonical sources are src/grades/grades.service.ts, the revision and scale helpers beside it, and the grade models in prisma/schema.prisma.

The Prisma model is GradeEntry because Grade already means an academic grade level. The RBAC key is grade_entries; HTTP routes remain /grades.

1. Root and revision model

GradeEntry is the stable identity of one logical mark. It carries the hard tenant/academic-year/student identity, lifecycle state, and a pointer to its current revision. Its identity and head are database-guarded. Application writes put academic values in revisions. Retained root value/context columns are compatibility storage and are not read authority.

GradeEntryRevision is an immutable, full transaction-time snapshot. A command appends exactly one revision and advances the root head in the same transaction:

  • CREATED creates revision 1;
  • CORRECTED records a teacher correction;
  • ADMIN_OVERRIDE records an office correction;
  • WITHDRAWN is a full tombstone snapshot, not a deletion.

Revision numbers are positive, sequential per root, and linked through previousRevisionId. GradeCriterionMarkRevision stores the criterion values entered in that revision. The revision also stores the complete criterion definition set separately, including criteria that received no mark; this is required to explain direct overrides and perform later corrections without live curriculum reads.

Tenant, academic year, student, and root FKs are RESTRICT. The revision and criterion-revision tables use forced RLS and database triggers reject ordinary UPDATE/DELETE. The root cannot be repointed to a revision from another root, tenant, year, or student. A narrowly scoped maintenance GUC exists only for development tenant reset and controlled recovery tooling.

2. The time axes

Do not collapse these fields into one date:

Axis Field/query Meaning
Assessment valid time assignmentDate The school day the mark is for. Context snapshots and write authority are resolved on this date.
Read context time contextDate Which roster, course, homeroom, and teacher memberships the requested surface displays. Defaults to the tenant's school-today.
Transaction time recordedAt / asOf When a revision was recorded and which revision of each root was known by that instant. asOf defaults to now.

All aggregate reads also accept academicYearId; omission means the active year. from/to filter the selected revisions by assignmentDate. The service first selects the newest revision per root at asOf, then applies revision-level RBAC to that exact row. It must never select an older visible revision when the actual as-of revision is outside the caller's visibility.

Ordinary cards, gradebooks, homeroom, student, and /me views exclude a root whose selected revision is WITHDRAWN. History always retains it. A historical asOf before withdrawal therefore returns the then-active value.

3. Frozen evidence and calculation

Creation resolves the live subject scale, criteria scale, criterion definitions, dated subject-group teachers, dated student/roster context, and curriculum labels once. It freezes all of them into revision 1. Corrections reuse the prior frozen evidence; they do not consult a renamed or replaced evaluation scale. Changing assignmentDate is the exception: the service re-resolves dated course/student context and snapshots the new date, homeroom, and assigned teacher set, verifies roster membership, and rechecks that the caller is authorized in that dated subject group.

The subject-group gradebook also exposes the live effective authoring scales as compact ordered { id, name, values: { value, label }[] } objects. Those values are for creating a new grade. Every grade response separately exposes that revision's frozen subject/criteria scales; corrections must use the frozen values because later curriculum configuration cannot rewrite the revision's accepted input domain. Frozen scales are null only on honest LEGACY_INCOMPLETE rows.

Frozen scale JSON has version 1 and preserves scale id/name/type plus every ordered value's id, value, label, numeric equivalent or range. Frozen criterion definitions preserve { id, name, ordinalPosition }[] independently of marks. snapshotStatus = COMPLETE is database-checked to require the full context, actor, scale, definition, result, and calculation evidence.

The grades barrel exports freezeScale, decodeFrozenScale, FrozenScaleV1 and pickCriteriaForGrade for other frozen records. Report-card templates reference a grade scale and an optional Effort scale (set only while the template has Effort on), and their custom evaluations each reference a scale; every one of these is a real foreign key registered in SCALE_FK_REFERENCES (src/evaluation-scales/evaluation-scales.service.ts), so a scale used by a template, as either scale, or by a custom evaluation is in use exactly like a curriculum scale: it cannot be deleted and its values cannot be edited, and a tenant template or evaluation on a platform preset locks that preset too. Each report-card cycle grade freezes the template's grade scale and every custom evaluation's scale with freezeScale; the Effort scale is frozen as its id, name and ordered values and labels only, without numeric data; see report cards.

The three authoring modes remain:

  • criterion marks: effective mark is the mean of the entered criteria projected onto the subject scale;
  • directValue: the subject-scale value is projected back onto every frozen criterion definition;
  • finalOverride: the subject mark is pinned even when criterion marks differ.

GradeType is the fixed five-value taxonomy WRITTEN | ORAL | PRACTICAL | ASSIGNMENT | PROJECT. It remains descriptive metadata only: types do not carry weights or change aggregate calculation.

title is an optional, trimmed 1–120 character assessment label, distinct from the student-facing note. It is snapshotted on every revision. PATCH omission preserves it, a string replaces it, and explicit null clears it.

effectiveMark, normalized scalar, level, and calculationVersion are stored on every complete revision. scalar is a 0–100 coordinate on that revision's frozen subject scale. level is LOW below 55, MEDIUM from 55 through 75, and HIGH above 75. This makes later reads and charts independent of live configuration.

Aggregates average stored scalars, then project the result onto a deterministic display scale selected from the newest contributing revision. They return contributingCount, excludedCount, and grades-aggregate-v1. A revision with no trustworthy scalar is excluded, never silently treated as zero.

Main-subject subject groups record one unit per mark. The main subject is the scale owner; the selected unit is snapshotted as the graded subject and the main subject is retained in parentSubject* for roll-up. Criteria come from the main subject or unit according to criteriaLevel.

4. Commands, concurrency, and lifecycle

POST /grades always targets the active academic year. The route requires the grade WRITE scope and record action. The service then requires a dated assigned teacher (including assistance), privileged/Director-function authority, or a matching department/curriculum parameter branch. The student must be on that subject group's roster on the same date.

Every correction and withdrawal requires both:

{ "expectedRevision": 3, "reason": "Corrected after assessment review" }

The service locks the root, rereads its head, and compares the token. A mismatch returns 409 GRADE_REVISION_CONFLICT; no revision is appended. This is the lost-update contract clients must use.

For an active year, the original dated assigned teacher or an authorized office role can correct or withdraw. After the year is no longer active, only privileged or Director-function authority admitted by the route's WRITE/action guards can make a reasoned correction/withdrawal. Teachers cannot retroactively change a closed year. Student, subject group, and graded unit identities cannot be changed by PATCH.

There is no DELETE /grades/:id. POST /grades/:id/withdraw appends a WITHDRAWN revision and preserves the root and all evidence. A withdrawn root cannot be corrected again.

5. Incomplete historical evidence

Historical revisions can be LEGACY_INCOMPLETE when the retained data cannot prove department/curriculum names, dated teacher assignment, actor role, criterion definitions, or calculation evidence. Preserve that label and its reason instead of inferring missing history from current rows.

Legacy-incomplete rows remain readable and appear in history. They can be withdrawn and can receive a reasoned metadata-only correction, but value or assignment-date changes are rejected with 409 GRADE_LEGACY_INCOMPLETE because the system cannot prove the inputs required to recalculate them. Their null scalars increase aggregate excludedCount.

tools/backfill-grade-revisions.ts is an idempotent recovery/verifier for roots left without heads during an interrupted coordinated cutover. It emits a deterministic JSON report and applies the same honest legacy-incomplete label. It is not a way to upgrade incomplete history to COMPLETE.

The root head columns are nullable in Prisma, but a deferred database trigger requires a consistent greatest-revision head at commit. Do not infer from schema nullability that an ordinary application command may commit a headless root.

6. Read surfaces

Gradebook student rows (including per-unit rows) and student grade summaries expose firstName and lastName alongside the compatible studentName (First Last). Clients can render surname first without parsing compound names. Gradebook ordering remains lastName, then firstName, ascending. The /me response without a student in the selected year returns empty strings for all three name fields. These directory fields do not change revision snapshots.

All aggregate surfaces accept academicYearId, contextDate, asOf, from, and to; subject-group cards add departmentId and gradeId filters.

Route Contract
GET /grades/subject-groups Dated subject-group cards and selected-revision counts.
GET /grades/subject-group/:id Every roster student on contextDate, per-student averages, ordered live authoring scales, unit streams, periodStartDate for the department TERM containing school-today (nullable outside configured terms), and summary { classAverage, histogram, unevaluated, excludedCount }.
GET /grades/homeroom/:id One row per subject group intersecting the dated homeroom members, with dated teachers, member-sliced counts/average, and periodStartDate for the homeroom department's TERM containing school-today (nullable outside configured terms). Teachers are intentionally not admitted to this route.
GET /grades/student/:id Course-grouped stream for an authorized student, including currently enrolled zero-vote courses and historical courses with enrolled: false.
GET /grades/me The same course-grouped shape for the signed-in student.
GET /grades/:id/history?asOf=... Newest-first authoritative revision history, capped at asOf when supplied, plus currentState and the selected historical revision.

For grade history, an asOf before revision 1 returns 404 GRADE_NOT_FOUND: the logical grade did not yet exist at that transaction instant, so the API does not leak its present root through an empty historical response.

Participation. Every roster the gradebook derives — the subject-group roster, the homeroom members and their course memberships, the rosterCount / totalCount / unevaluated counters — is the membership effective on contextDate ∧ status ≠ PRE_ENROLLED (participationHistoryStatusWhere, src/students/constants/participation-status.ts). A pre-enrolled student has never participated and is never a gradable row; exits are already dated by the membership close, so a student who left keeps their marks and their rows for every contextDate before the exit. Recording or re-dating a mark for a pre-enrolled student fails the roster gate (GRADE_STUDENT_NOT_IN_SUBJECT_GROUP). Per-student streams (/student/:id, /me) are by-id history and stay status-neutral.

Current enrollment headers use live metadata effective on contextDate. Individual grade rows always use their frozen revision labels and evidence. Deleted/reorganized courses therefore remain explainable; if no live header exists they appear as historical courses with frozen names and no current teachers.

The gradebook's criteria columns (root for a leaf, per unit for a container) are always the live curriculum criteria — the gradebook is the authoring surface, and new grades are always recorded against the live set. Frozen criterion evidence never surfaces on the header; it stays per grade as criterionMarks plus staleCriteria, a nullable boolean saying whether the grade's frozen criterion ids diverged from the live set (renames don't count). staleCriteria is computed on authoring surfaces (the gradebook and grade write responses) and null on read-only surfaces. A stale grade is corrected by pinning finalOverride — criterion-mark corrections keep validating against the frozen set only.

7. Visibility and RBAC

The RBAC entity is grade_entries, with scope record and action record. Route WRITE/action requirements and service record authority must both pass. Preset definitions and their reconciliation are documented in permissions.

  • admin/platform admin, Director, Communications Manager, and SEN Manager have school-wide read branches;
  • department heads and curriculum coordinators are restricted to their parameterized department/curriculum branches;
  • teacher revision visibility uses the frozen assigned-teacher evidence, plus the dated tutor/student read branch where the route permits it; current course surfaces resolve dated teaching and assistance relationships;
  • referents see linked students;
  • students see themselves.

Visibility is built from immutable revision snapshots plus the hard student relationship. Corrections that move assignmentDate must pass both the original revision authority check and the new dated assignment/roster check.

History returns semantic grade data and the reason. Public DTOs expose actor display names but never actor user ids, raw role strings, request context, or raw audit JSON.

8. Audit and governance boundary

The revision ledger is the authoritative grade history. Generic audit events remain a cross-cutting operational projection (grade_entry.created, .updated, .admin_override, .withdrawn) and commit atomically with the command, but GET /grades/:id/history never reconstructs grade state from the audit log. grade_entry.deleted remains catalogued only so historical audit rows render intelligibly; current commands do not emit it.

The ledger preserves reasoned corrections and withdrawals with tenant isolation and no ordinary purge route. Its database immutability is an application integrity contract, not an archival certification or a retention policy. Recording a grade (revision 1 only) sends one grade.recorded notification (inbox and push) to the graded student's own user when the student is ENROLLED and the user is active, unless that user is the one recording it; corrections and withdrawals send nothing, and the notification never carries the mark, title or note.

9. Authorization and source boundaries

SEN Manager reads

sen_manager reads school-wide course cards, gradebooks, student/homeroom grades and revision history with grade_entries.record:READ. Grade record/update/withdraw actions are withheld. The course-gradebook read admits SEN separately from assertCanWriteSubjectGroup; the office/write helper never treats SEN as a writer. Teacher+SEN therefore retains the Teacher assignment fence on writes. See permissions.

Assistance-derived eligible teachers

The assignment-date course context includes ordinary teachers plus effective assistants, including contributors to a combined parent. Grade creation snapshots this union in existing assignedTeacherIds/assignedTeacherNames, with ordinary subject/roster/scales and actual actor provenance. Record and assignment-date changes reload the course and revalidate teacher authority under the AY-day lock. Roster/scale/student preparation and revision-root serialization have distinct owners in GradesService; preserve their timing when changing a command. Existing revisions keep frozen correction/withdrawal authority, expectedRevision CAS, reasons and closed-year rules. Current grade lists and dated contexts include assisted courses; no grade is attached to a combined-class id.