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 iteration-2 design:
2026-08-12-grades-surface-iteration-2-design.md.
Implementation plan:
2026-08-12-grades-surface-iteration-2.md.
Authoring-scale, assessment-title, and fixed-type additions are governed by the
approved iteration-3 design:
2026-08-13-grades-surface-iteration-3-design.md.
Implementation plan:
2026-08-13-grades-surface-iteration-3.md.
The original criteria-based design remains useful background, but this chapter
and the iteration-2 design supersede its mutable-row/history model.
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. New application
writes do not use the legacy mutable value/context columns retained temporarily
for migration compatibility.
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 and freezes
the teaching and student context on the new date, 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 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; pre-iteration-3
revisions remain null without a fabricated backfill.
Unlike v1, 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 still 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 caller must be an
assigned teacher on assignmentDate, a tenant-wide office role, or an authorized
parameter-scoped department/curriculum role. 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 admin, platform admin, or principal 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. Legacy migration semantics¶
The migration creates revision 1 for every pre-ledger GradeEntry, preserves
all facts that exist, and advances the root head. It does not invent missing
historical department/curriculum names, dated teacher assignment, actor role,
criterion definitions, or calculation evidence. Those revisions are explicitly
LEGACY_INCOMPLETE with a reason.
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 remain nullable in Prisma during this expand/backfill
release so reset, migration, and interrupted-cutover states remain recoverable.
A deferred database trigger nevertheless rejects every committed root without a
consistent greatest-revision head. A later, separately reviewed contract
migration may apply SET NOT NULL only after the production invariant report
confirms every root is headed; this release does not pretend that deployment
sequence is atomic.
6. Read surfaces¶
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 (2026-09-03). 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. Design:
2026-09-03 spec.
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.
Preset reseeding is required after deployment because principal, department
head, and curriculum coordinator gain the write scope/action.
- admin/platform admin and principal are tenant-wide;
- department heads and curriculum coordinators are restricted to their parameterized department/curriculum branches;
- teachers are restricted to dated assigned subject groups, plus the existing tutor/student read branch where the route permits it;
- 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 pre-cutover audit
rows render intelligibly; no new route emits it.
This module provides an immutable operational ledger with tenant isolation,
reasoned corrections, withdrawal instead of ordinary deletion, and no normal
purge path. It does not claim qualified digital preservation or
conservazione a norma, and it does not itself define statutory retention,
export/signature, legal-hold, archival-vault, or institutional offboarding
policy. The institution must govern those concerns and any future preservation
integration. Grades do not emit notifications.
9. Migration and deployment order¶
The coordinated release order is:
- stop old writers and apply
20260812150000_grade_revision_ledgerthrough20260812150003_grade_criterion_definition_snapshot; - run the verifier/backfill on a controlled data copy and review every
LEGACY_INCOMPLETE/failure count; - refuse cutover if any root lacks a valid head;
- deploy the revision-native application;
- reseed preset RBAC grants.
Never modify an applied migration checksum. The policy repair and later identity and definition constraints are deliberately separate follow-up migrations for already-applied local/dev databases.