Grades Surface Iteration 2 Implementation Plan¶
APPROVED: The governing design was approved by Fabio on 2026-08-12. Implementation begins only after the mandatory spec/plan gate commit.
Goal: Replace mutable, hard-deletable grade values with an immutable transaction-time revision ledger, then ship the redesigned homeroom rollup, gradebook aggregates, course-grouped student/referent surface, frozen scalar/level values, and parametric grade writers.
Architecture: GradeEntry is the stable logical anchor and head index. GradeEntryRevision plus GradeCriterionMarkRevision are the authoritative, append-only full-state history. Every command freezes its calculation inputs and teaching context, stores the effective mark/scalar/level with a calculation version, advances the root via compare-and-swap, and emits the cross-cutting audit event in one transaction. Ordinary lists select one revision per root as of transaction instant asOf; dated rosters/teachers resolve on contextDate. Correction appends; withdrawal tombstones; the ordinary API never hard-deletes.
Spec: docs/superpowers/specs/2026-08-12-grades-surface-iteration-2-design.md (Approved; signed off 2026-08-12).
Implementation status (2026-08-12): Complete. All Tasks 1–15 below have
been implemented and reviewed. The checklist is retained as the approved
execution record; targeted grades units, registry drift tests, grades E2E,
DB-constraint E2E, disposable migration/backfill reruns, Prisma validation,
formatting, scoped lint, and a strict backend typecheck before the concurrent
schema regeneration were exercised. The repository-wide npm run build
currently reaches unrelated, concurrent
scheduled-activity/attendance schema work and is recorded in the final handoff;
neither the full unit suite nor the full E2E suite was run.
Tech stack: NestJS 11, Prisma 7, PostgreSQL with FORCE RLS, class-validator, Jest/Supertest, TypeScript strict.
Global constraints¶
- Design gate: Task 0 is blocking. No schema/code/API work before renewed approval and a committed spec.
- User-owned git and verification: Fabio owns commits and every build/test/lint/e2e/seed/migration command. Commands below are hand-off checkpoints, not commands for the implementing agent to run without explicit authorization.
- Migration safety: Before any
prisma migrate dev, read ch12, inspect the migration directory and worktree, identify any uncommitted migration, and ask whether this work must fold into it. Audit generated SQL against the hazard checklist. Never rewrite a committed migration. - Expand/backfill/cutover: Do not drop v1 grade columns or
GradeCriterionMarkin this iteration. The revision ledger becomes authoritative first; destructive contract cleanup is deferred. - Revision source of truth: No new read computes grade meaning from mutable
GradeEntryvalue columns or live evaluation scales. No new write updates/deletes legacy criterion rows. - No hard delete: Remove the controller DELETE route and service deletion path. Withdrawal is the only ordinary removal semantic.
- Two clocks:
assignmentDateis educational/event time;recordedAt/asOfis transaction time.contextDatecontrols dated structure. Never substitute one for another. - School clock: Date defaults and comparisons use the tenant school timezone; transaction instants remain UTC timestamps.
- Snapshot honesty: New roots always produce
snapshotStatus = COMPLETE. A lineage created by legacy backfill may carryLEGACY_INCOMPLETEinto later reasoned corrections/withdrawal; it is never silently promoted or fabricated, and a change needing missing inputs fails closed. - Versioned calculations: New revisions store
calculationVersion = 'grades-v1'; aggregate responses declaregrades-aggregate-v1. Future algorithm changes add a version. - Temporal composition: Compose
effectiveOn()predicates underAND; never spread twoOR-carrying fragments into one object. - Compliance copy: Describe this as an immutable operational ledger, not qualified digital preservation or
conservazione a norma. - Public API copy: Controller/DTO JSDoc and Swagger text describe client-visible behavior only.
- Reseed required: Principal/department-head/curriculum-coordinator grants require frozen-preset reconciliation in existing tenants.
- Review sequencing: Complete the final code review only after all implementation tasks and documentation are finished.
Task 0: Renewed design and compliance sign-off — complete after gate commit¶
Files:
- Modify:
docs/superpowers/specs/2026-08-12-grades-surface-iteration-2-design.md -
Modify: this plan only if the approved decisions change implementation order
-
Resolve the institutional retention classification owner/policy. No duration is hard-coded; the institution/data controller owns it.
- Confirm the product claim is “immutable operational ledger,” not
conservazione a norma, until a preservation integration is designed. - Record the decisions in spec §12, update §14, set frontmatter
status: Approved. - Commit the approved spec and plan as the mandatory design gate before Task 1; no implementation-task commits follow.
Task 1: Migration preflight and exact schema design¶
Files:
- Read:
docs/12-migrations.md - Read/modify:
prisma/schema.prisma - Read: current grade and RLS migrations
-
Modify later:
src/prisma/rls-coverage.ts,src/prisma/tenanted-models.ts,test/db-constraints.e2e-spec.ts -
Inspect
git status,prisma/migrations, and migration history. If an uncommitted migration exists, stop and ask Fabio whether to fold this schema into it. - Survey current Prisma naming/constraint precedents for immutable tables, self references, JSON checks, required tenant ids, and root head pointers.
- Finalize model names and fields from spec §4:
GradeEntryState { ACTIVE, WITHDRAWN }.GradeRevisionOperation { CREATED, CORRECTED, ADMIN_OVERRIDE, WITHDRAWN }.GradeSnapshotStatus { COMPLETE, LEGACY_INCOMPLETE }.GradeEntryRevisionwith required tenant/AY/root ids, full academic/context/value/actor snapshots, two JSON scale snapshots, calculation evidence, previous revision id, and timestamps.GradeCriterionMarkRevisionwith required tenant id, revision id, criterion snapshot, value, ordinal.- nullable expand-phase
GradeEntry.currentRevisionId/currentRevisionNumber/state, with a unique head id. - Identify legacy-only required columns and relax their NOT NULL constraints during expand. New roots must not dual-write a second mutable copy of revision value/context data.
- Design indexes for tenant+AY+student, root+revision, subject-group/date, homeroom/date, and visibility snapshot columns. Do not add speculative duplicates of existing access paths.
- Design raw SQL constraints and triggers:
- positive revision number;
- unique
(grade_entry_id, revision_number); - required reason except CREATED;
- COMPLETE snapshot shape/non-null requirements;
- scalar range and scalar/level null parity;
- immutable UPDATE/DELETE rejection on both revision tables;
- deferred root-head consistency (head belongs to root, is greatest revision, matches head number/state);
- tenant-consistency checks where Prisma relations cannot express them.
- Change
GradeEntry.tenantand.academicYeardeletion behavior from Cascade to Restrict. Keep student Restrict. - Decide the expand/backfill/cutover deployment sequence before generating SQL; document rollback behavior.
Checkpoint for Fabio: approve the exact Prisma/SQL shape before migration generation.
Task 2: Expand migration, RLS, and database constraints¶
Files:
- Modify:
prisma/schema.prisma - Create:
prisma/migrations/<timestamp>_add_grade_revision_ledger/migration.sql - Modify:
src/prisma/rls-coverage.ts - Modify:
src/prisma/tenanted-models.ts - Modify:
test/db-constraints.e2e-spec.ts -
Add/modify co-located Prisma drift specs only where the existing registries require it
-
Add enums, revision models, root head fields, relations, indexes, and Restrict FKs in Prisma.
- Generate the migration only after Fabio authorizes the migration command.
- Audit and edit SQL deliberately:
- create tables/indexes/FKs in dependency order;
- enable and FORCE RLS on both new tables;
- create tenant-isolation policies;
- add immutable functions/triggers with stable explicit names;
- replace tenant/AY grade FK actions without dropping data;
- leave root head fields nullable for backfill;
- preserve v1 columns and criterion table.
- Register every tenant-bearing table/model in the correct drift registries.
- Extend DB-constraint E2E coverage for immutability, reason, snapshot completeness, scalar range, unique revision number, RLS, and Restrict deletion.
- Add comments to schema and SQL explaining why revision immutability and legacy columns coexist.
Fabio-run checkpoint: migration applies to a disposable database and DB-constraint E2E passes. Do not proceed on a database with failed/partial migration state.
Task 3: Frozen scale snapshots and calculation versioning — pure core¶
Files:
- Create:
src/grades/grades.scale-snapshot.ts - Create:
src/grades/grades.scale-snapshot.spec.ts - Create:
src/grades/grades.aggregates.ts - Create:
src/grades/grades.aggregates.spec.ts - Modify barrel exports only where required
Core interfaces:
export interface FrozenEvaluationScaleV1 {
v: 1;
id: string;
name: string;
type: ScaleType;
values: Array<{
id: string;
value: string;
label: string;
ordinalPosition: number;
numericEq: number | null;
min: number | null;
max: number | null;
}>;
}
export const GRADE_CALCULATION_VERSION = 'grades-v1' as const;
export const GRADE_AGGREGATE_VERSION = 'grades-aggregate-v1' as const;
export type GradeLevel = 'LOW' | 'MEDIUM' | 'HIGH';
- Write specs first for stable serialization order, numeric/banded value conversion, scale deletion independence, degenerate scales, malformed JSON fail-closed behavior, scalar rounding, and 55/75 boundaries.
- Implement live-scale-to-frozen-snapshot conversion and a validated frozen-snapshot decoder. Do not cast arbitrary JSON directly to the interface.
- Adapt
valueToNumeric,scaleRange,numericToValue, and effective-mark calculation behind frozen-snapshot-compatible pure functions; do not query live scales from read mappers. - Implement
aggregateScalarsand histogram generation using stored scalars and an explicitly supplied frozen display scale. - Include
excludedCounthandling for legacy-incomplete revisions without converting them to zero.
Fabio-run checkpoint: targeted pure specs.
Task 4: Revision query layer and typed snapshots¶
Files:
- Modify:
src/grades/grades.queries.ts - Create:
src/grades/grades.revisions.ts - Create:
src/grades/interfaces/grade-revision.interface.ts -
Add co-located specs
-
Define canonical include/select constants for revision + criterion rows.
- Add
loadGradeRootForUpdateusing a transaction-scopedSELECT ... FOR UPDATEor an equivalent canonical helper. - Add named functions to:
- load current revision;
- select latest revision per root as of an instant;
- list a root's revisions newest-first with optional
asOf; - load selected revisions for student/SG/homeroom aggregate reads without N+1 queries;
- validate root/current-revision consistency.
- Build one typed
GradeRevisionSnapshotInputfrom authoring context. It must require every COMPLETE field rather than accept a broad optional object. - Add pure mappers from revision rows to internal domain state and public DTO projections.
- Ensure selected WITHDRAWN heads are excluded by ordinary read helpers and retained by history helpers.
- Keep legacy root-column readers isolated to the backfill task; no new operational code imports them.
Task 5: Idempotent legacy backfill and cutover invariant¶
Files:
- Create:
tools/backfill-grade-revisions.ts(or the repository's canonical one-shot backfill location discovered in Task 1) - Create: a focused backfill spec/test fixture
- Modify: deployment/runbook documentation named by ch12
-
Modify migration SQL only if the approved deployment shape uses SQL rather than a one-shot tool
-
Implement idempotency: a root with revision 1/head set is skipped only after its invariants are verified.
- For every v1 grade:
- copy all surviving value, criterion, context, and provenance fields;
- resolve and freeze both scales when available;
- derive historical context only from retained authoritative interval data, never today's head configuration;
- mark COMPLETE and compute/store effective mark, scalar, level, and
grades-v1only when every required snapshot fact is provable; - otherwise store the surviving truth with
LEGACY_INCOMPLETE, a deterministic reason, and null unprovable derived fields—never invent values; - set root head/state/revision number.
- Produce a deterministic report: total roots, complete revisions, legacy-incomplete revisions, skipped-idempotent roots, invariant failures, and affected ids.
- Make partial failure safe to rerun. Do not mark a root headed until its revision and criteria exist.
- Add a cutover invariant query/spec: every root has exactly one referenced head, head belongs to root, head revision number matches root, and at least one revision exists.
- Plan the later NOT NULL enforcement of root head fields. If it cannot safely ship in the same deployment, document the two-release constraint instead of pretending it is atomic.
Fabio-run checkpoint: run only on a disposable copy first; review the legacy-incomplete report before any production execution.
Task 6: Revisioned create/correct/withdraw commands¶
Files:
- Modify:
src/grades/grades.service.ts - Modify:
src/grades/grades.queries.ts - Modify/create DTOs:
src/grades/dto/update-grade.dto.tssrc/grades/dto/withdraw-grade.dto.tssrc/grades/dto/grade-entry-response.dto.ts- Modify:
src/grades/grades.controller.ts - Modify:
src/grades/grades.swagger.ts - Modify:
src/common/constants/error-codes.tsand required i18n/Swagger error maps - Modify:
src/audit-log/audit-event-labels.catalog.tsforgrade_entry.withdrawn -
Extend:
src/grades/grades.service.spec.ts -
Add typed errors:
GRADE_REVISION_CONFLICT(409; expected/actual revision params);GRADE_WITHDRAWN(409);GRADE_LEGACY_INCOMPLETE(409; requested change cannot be proven from retained inputs).- Create path:
- resolve ACTIVE year;
- validate SG teacher and student roster on
assignmentDate; - load dated context and assigned teachers;
- validate live scales/criteria for the initial authored revision;
- freeze snapshots and calculation evidence;
- insert root + revision 1 + criterion revision rows + audit event in one transaction.
- PATCH contract requires
expectedRevisionand nonblankreason. Remove these command fields before mapping authored grade values. - Correction path:
- lock root;
- verify expected revision and ACTIVE state;
- enforce ACTIVE-vs-non-ACTIVE AY authority;
- validate corrections against and carry forward the current revision's frozen scale/criterion snapshots (never adopt a later live scale implicitly);
- carry forward unchanged fields from current revision;
- preserve
LEGACY_INCOMPLETEon legacy lineages; allow withdrawal/non-dependent corrections, but reject value/date changes whose required evidence is missing; - re-resolve dated context only when assignment date changes;
- append one honest COMPLETE-or-inherited-LEGACY_INCOMPLETE revision and advance root head atomically;
- use
CORRECTEDorADMIN_OVERRIDEfrom dated teacher authority; - emit compact cross-cutting audit metadata referencing revision id/number.
- Add
POST /grades/:id/withdrawwith{ expectedRevision, reason }; append the full WITHDRAWN state and emitgrade_entry.withdrawn. - Remove
DELETE /grades/:id, the serviceremovemethod,grade_entry.deleted, and obsolete Swagger/error examples. - Do not mutate or delete legacy
GradeCriterionMarkrows on any new command. - Unit-test rollback atomicity: a failed revision insert/audit/head advance leaves no partial state.
Fabio-run checkpoint: grades service specs for commands and concurrency behavior.
Task 7: Revision-native response and family-safe history¶
Files:
- Replace/extend:
src/grades/dto/grade-entry-response.dto.ts - Replace:
src/grades/dto/grade-history.dto.ts - Modify:
src/grades/grades.service.ts - Modify:
src/grades/grades.controller.ts/grades.swagger.ts -
Extend service/controller specs
-
GradeEntryResponseDtogainsrevisionNumber,state,operation,reason,scalar,level,calculationVersion,snapshotStatus, and immutable provenance timestamps/names. - Map academic values from the selected revision only. Do not call
loadScalesByIdsin a response mapper. - Replace audit DTO history with
GradeHistoryDtoover revision DTOs: - root id/current state/head number;
- selected revision as of optional instant;
- revisions newest-first.
- Grade-id history bypasses
resolveActiveYear; authorize against tenant + grade context and frozen/datetime visibility. - Build two projections if necessary:
- family/student semantic history omits actor ids, role internals, request context, and raw JSON;
- management history may include richer actor metadata but still uses typed DTOs.
- Verify withdrawal history is readable while ordinary grade reads exclude the mark.
- Verify
currentStateandselected.statediverge correctly whenasOfpredates a later withdrawal. - Verify a deleted SG/subject/scale does not break history.
Task 8: Temporal read query contract and visibility¶
Files:
- Modify:
src/grades/dto/grades-range-query.dto.ts - Modify:
src/grades/grades.visibility.ts - Modify:
src/grades/grades.queries.ts -
Extend:
src/grades/grades.visibility.spec.ts -
Add/compose
academicYearId?,contextDate?, andasOf?validation using existing DTO/date conventions and i18n labels. - Resolve defaults once per request:
- active AY when omitted;
- school-today for active-year
contextDate; - require explicit context date for non-active aggregate/course routes;
- DB/current instant for
asOfwhen omitted. - Extend visibility builders for principal, DEPARTMENT, and CURRICULUM parameters, OR-composed with teacher/referent/student branches.
- Grade-row visibility uses frozen revision context selected as of the query, not live scale/SG joins.
- Teacher historical visibility uses frozen assigned-teacher ids and dated tutor rules; referent/student visibility stays student-anchored and tenant/AY-safe.
- Unknown roles and empty parametric sets remain fail-closed.
- Add specs separating
contextDatefromasOf, multi-hat union behavior, historical AY access, and orphaned-context reads.
Task 9: RBAC seed grants and mirrors¶
Files:
- Modify:
prisma/seed/roles.ts -
Modify:
prisma/seed/helpers/expected-preset-grants.ts -
Add
grade_entries.recordto principal, department-head, and curriculum-coordinator write-scope sets and action sets. - Mirror only the hand-written action expectations; do not duplicate derived scope expectations.
- Keep comments explicit that row narrowing remains in GradesService/visibility builders.
- Document required production reseed/frozen-preset reconciliation.
Fabio-run checkpoint: local reseed before role E2E.
Task 10: Homeroom rollup — dated and revision-native¶
Files:
- Replace:
src/grades/dto/homeroom-grades.dto.ts - Modify:
src/grades/grades.queries.ts - Modify:
src/grades/grades.service.ts - Modify: controller/swagger
-
Extend service specs
-
Load homeroom members, SG roster intersections, and SG teachers with
effectiveOn(contextDate). - Select ACTIVE revisions as of
asOf, then apply assignment-datefrom/to. - Aggregate stored scalars for only the homeroom-member slice.
- Choose the deterministic display scale from the newest contributing frozen revision; never load the live course scale for historical value display.
- Surface
excludedCountfor legacy-incomplete rows. - Return temporal metadata (
academicYearId,contextDate,asOf, aggregate version). - Preserve optional/cross-class behavior and sort deterministically.
- Controller roles: admin/principal/department-head/curriculum-coordinator; teacher remains intentionally excluded.
Task 11: Subject-group gradebook aggregates¶
Files:
- Modify:
src/grades/dto/subject-group-gradebook.dto.ts - Modify:
src/grades/grades.service.ts - Modify: controller/swagger
-
Extend service specs
-
Populate leaf and container gradebooks from selected revision rows.
- Add per-student averages, top-level container students, unit averages, class average, histogram, unevaluated, and excluded count.
- Define class average over all selected entry scalars, not average-of-student-averages.
- Histogram bins use the deterministic frozen display scale.
- Distinguish truly unevaluated students from students holding only legacy-incomplete marks.
- Admit principal/department-head/coordinator under the visibility/authority rules.
Task 12: Student/me course-grouped surface¶
Files:
- Replace:
src/grades/dto/student-grades.dto.ts - Modify:
src/grades/grades.queries.ts - Modify:
src/grades/grades.service.ts - Modify: controller/swagger
-
Extend service specs
-
Load enrollments effective on
contextDate; include zero-vote enrolled courses. - Add one
enrolled: falsecourse for each historical SG with selected ACTIVE revisions outside the dated enrollment set. - Use live dated course metadata for current enrollment headers, but every grade row uses its frozen revision context.
- Deleted/reorganized SGs use frozen revision names/codes/units and
teachers: []at the course-header level; per-grade actor/context remains intact. - Compute course/unit averages from stored scalars with the frozen display-scale rule.
- Preserve referent linked-student and student-self visibility across authorized academic years.
Task 13: E2E — migration, temporality, concurrency, and surfaces¶
Files:
- Modify:
test/grade-entries.e2e-spec.ts - Modify:
test/db-constraints.e2e-spec.ts -
Add helpers only if they are reused and match ch09 conventions
-
Create → correct → as-of-before/after → withdraw timeline; history always complete, ordinary views exclude withdrawn head.
- Delete/edit the live scale after moving curriculum references away; historic revision value/scalar/level/aggregate stays byte-for-byte stable.
- Reorganize SG/teacher/student membership;
contextDatereturns the dated cohort while vote context remains frozen. - Read a non-active AY; create is refused there; admin/principal reasoned correction succeeds under the approved rule; teacher correction does not.
- Concurrent PATCH with identical
expectedRevision: exactly one success, oneGRADE_REVISION_CONFLICT, sequential revision numbers remain gap-free. - Direct DB UPDATE/DELETE against revision tables fails for app role; tenant/AY/student deletion is restricted.
- Homeroom optional SG member slicing, gradebook histogram/unevaluated/excluded, student zero-vote/current + historical courses.
- Dept-head and coordinator in/out-of-parameter writes; principal tenant-wide; non-teacher correction records ADMIN_OVERRIDE.
- Family history contains semantic grade data/reason but not actor ids, raw roles, request context, or raw audit JSON.
- Every created row is tenant-cleaned explicitly using the suite's established disposable-fixture pattern; do not depend on grade hard delete through the API.
Fabio-run checkpoint: targeted E2E after migration, backfill fixture, and reseed.
Task 14: Documentation and authoritative FE guide¶
Files:
- Modify:
docs/21-grades.md - Modify:
docs/20-audit-log.mdonly to clarify grades history is revision-owned while audit remains cross-cutting - Modify:
docs/REFERENCE.md - Modify:
CLAUDE.mdgrades module line if its module map mirrors REFERENCE - Create:
docs/fe-guides/2026-08-12-grades-FE-guide.md -
Update migration/deployment documentation identified in Task 5
-
Document root/revision mental model, two time axes, frozen scale format, withdrawal, concurrency token, history visibility, temporal queries, and legacy-incomplete semantics.
- Correct the old ch21 claim that scale ids alone are snapshots and that scale deletion cannot break reads.
- Document the four FE-breaking changes:
- homeroom shape and teacher-role removal;
- course-grouped student/me shape;
- PATCH requires
expectedRevision+reason; - DELETE removed, withdraw endpoint added.
- FE guide includes request/response examples for current, corrected, withdrawn,
asOf, historical AY, and legacy-incomplete records. - Explain
contextDatevsasOf,scalarchart semantics, level colors/thresholds, aggregate calculation version,excludedCount, and period picker viafrom/to. - State explicitly: no grade notifications; no qualified preservation claim; retention/offboarding follow institutional governance pending the deferred integration.
- Update the documentation map after the behavioral and architectural change.
Task 15: Final review and user-run gates¶
- Review implementation against every success criterion and non-goal in the approved spec.
- Search for forbidden legacy behavior:
gradeEntry.deletein production code;- grade history calling
audit.historyForas its source; - response mappers loading
EvaluationScaleby stored grade ids; - operational writes to
GradeCriterionMark; - grade routes resolving only ACTIVE year for all reads/history;
effectiveOn(schoolToday)wherecontextDateis required.- Review migration SQL, RLS registry coverage, tenant filters, Swagger/JSDoc, i18n labels, and FE guide consistency.
- Only after the complete code review, hand the following gates to Fabio.
Verification summary — Fabio-run¶
- Targeted pure/unit suites for scale snapshots, revision selection, visibility, aggregates, and service commands.
- Apply the migration to a disposable database; run DB-constraint/RLS E2E.
- Run the idempotent backfill on a disposable data copy; review the
LEGACY_INCOMPLETEreport and rerun-idempotency result. - Reseed locally; run
npm run test:e2e -- grade-entries. - Run
npx jest src/grades, thennpm run buildandnpm run lint. - Deployment order: expand migration → backfill/invariant report → revision-native app cutover → preset reseed. Do not deploy cutover if any root lacks a valid head.
- Release note: four FE-breaking contracts; operational-ledger compliance boundary; no
conservazione a normaclaim.