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
GradeEntrybecauseGradealready means an academic grade level. The RBAC key isgrade_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:
CREATEDcreates revision 1;CORRECTEDrecords a teacher correction;ADMIN_OVERRIDErecords an office correction;WITHDRAWNis 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:
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.