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 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 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. 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:

  • 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 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:

{ "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 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:

  1. stop old writers and apply 20260812150000_grade_revision_ledger through 20260812150003_grade_criterion_definition_snapshot;
  2. run the verifier/backfill on a controlled data copy and review every LEGACY_INCOMPLETE/failure count;
  3. refuse cutover if any root lacks a valid head;
  4. deploy the revision-native application;
  5. 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.