Grades — Criteria-Derived Marks, Teacher-Authored, Family-Visible¶
A grade is a teacher-recorded mark for one student in one subject group. The subject-level mark is criteria-derived: it is the average of the per-criterion marks, mapped onto the subject's grading scale — and the teacher can override it with any value. The value lives entirely on the evaluation-scales subsystem; grades are the consumer the criteria primitives (proposeFinalFromCriteria, retrofitVote) were built for. Like attendance, grades are teacher-authored records with admin override, an audit-log value-history trail, and a read-policy-vs-write-authority split — but they anchor on a SubjectGroup (a teaching unit), not a daily/lesson cell.
Spec: 2026-06-26-grades-criteria-based-v1-design.md. Plan: 2026-06-26-grades-criteria-based-v1.md.
Naming. The Prisma model is
GradeEntry(the academic grade-level model is alreadyGrade). The RBAC entity key isgrade_entries(label Student Grades) —gradesis taken by the grade-level entity. The user-facing routes are/grades. Don't conflate the two.
1. Mental model — the effective mark¶
For a subject with defined criteria (e.g. Math → Knowledge, Skills), the student's mark for one assessment is the average of that assessment's criterion marks, mapped to the subject scale. The teacher records it three ways:
- Per-criterion — a mark per (or only some) criterion. Effective = average of the set criteria.
- Direct (
directValue) — one subject-scale value; the service back-fills every criterion with the retrofit of that value, so the average equals it. - Override (
finalOverride) — pins the subject mark to any value, diverging from the average.
Effective mark =
finalOverride ?? average(criterion marks). Computed at read, never stored — except the override, which is the one pinned value. A subject with no criteria is the degenerate case: the entered value is the mark (stored asfinalOverride, no children).
Pure core: grades.compute.ts — computeEffectiveMark, expandDirectValue, and resolveWriteMarks (the mode resolver). All unit-tested in grades.compute.spec.ts.
2. Data model — GradeEntry + GradeCriterionMark¶
GradeEntry is a stream record (no uniqueness — a student has many marks per subject/type/day). Like AttendanceRecord it carries a deep snapshot + soft FKs: only tenant/academicYear/student are hard FKs (student Restrict); subjectGroupId/curriculumSubjectId/homeroomId/gradeId/departmentId are plain columns (no FK), so a mark survives SG/curriculum/homeroom hard-delete or reorg, still naming the context by its *Name/*Code snapshot columns.
Two snapshot scale ids — subjectScaleId / criteriaScaleId — are written at record time. Reads recompute the effective mark from these stored ids (loadScalesByIds, null-tolerant), not the live cascade, so a later subject/scale deletion can't break a grade view. Writes use the live cascade (they need the current criteria list to validate).
GradeCriterionMark (child, Cascade) is one row per set criterion: criterionId (soft, no FK — survives criterion edits), snapshot criterionName, and value (on the criteria scale). @@unique([gradeEntryId, criterionId]).
Value fields: type (GradeType {WRITTEN, ORAL, PRACTICAL}), assignmentDate (teacher-set, the date the grade is for), note?, finalOverride?. Provenance: recordedBy*/recordedAt (set once) + lastModifiedBy*/lastModifiedAt (@updatedAt). Migration 20260629151340_add_grades is purely additive (no raw-SQL constraints).
3. Scale resolution & validation¶
On write, loadScaleContext resolves, for the SG's (curriculumSubject, grade):
- the effective subject scale via the live cascade
resolveEffectiveCellScale(cell → subject → CurriculumGrade → Curriculum)— null →422 GRADE_SUBJECT_NOT_GRADABLE; - the effective criteria scale (cell → subject criteria scale, else falls back to the subject scale);
- the criteria list (replace semantics: grade-specific rows win, else the
gradeId IS NULLdefaults —pickCriteriaForGrade).
Each criterion value is checked ∈ criteria-scale values (assertValueOnScale → 400 GRADE_VALUE_NOT_ON_SCALE), each criterionId ∈ the subject's criteria (assertCriterionInSubject → 422 GRADE_CRITERION_INVALID), and finalOverride ∈ subject-scale values.
4. Write authority (in-service, narrower than reads)¶
GradesService.assertCanWrite — the caller must be an assigned teacher of the SG (SubjectGroupTeacher, any ordinal incl. co-teachers) or admin, else 403 GRADE_NOT_AUTHORIZED. The targeted student must be on the SG roster (homeroom roster if homeroom-bound, else the SG's assignments), else 422 GRADE_STUDENT_NOT_IN_SUBJECT_GROUP. This is narrower than attendance's Y-set — grading is a teaching responsibility, not a witnessed observation. Referent/student can't even reach the write routes (they lack the grade_entries.record action — ScopeGuard/ActionGuard 403).
edit/delete re-run the same authority against the entry's (live) SG; if the SG was deleted, only admin can edit/delete. Audit verb is grade_entry.admin_override when an admin edits a mark they are not an assigned teacher of, else grade_entry.updated; create/delete are .created/.deleted.
5. Read visibility — the async builder¶
Because subjectGroupId/homeroomId are soft columns (not traversable relations), the read where is built in-service by buildGradeVisibilityWhere (NOT definePolicy/@AppliesPolicy):
- admin / platform admin → all tenant records;
- teacher →
subjectGroupId IN (their taught SGs)∪ students in a homeroom they tutor (student.homeroomAssignment.homeroom.homeroomTeacherId— via the hardstudentFK). Resolved by loading the taught-SG-id set (one query); the puregradeVisibilityWhereshapes the rest (unit-tested); - referent → linked students (
student.referents.some(...)); student → own (student.userId); - anything else → match-nothing (fail-closed).
Routes role-gate with @RequireRoles(...); visibility is immediate (no publish gate).
6. Routes, DTOs & error-code map¶
All under @Controller('grades') + @ProtectedResource(). Writes: @RequireScope(GRADE_ENTRIES, 'record', 'write') + @RequireAction(GRADE_ENTRIES, 'record'). Reads: @RequireScopes(GRADE_ENTRIES, 'read') + @RequireRoles(...). All use @AggregateResponse() (the bespoke shapes + the computed effectiveMark bypass FieldFilterInterceptor).
| Route | Who | DTO in → out |
|---|---|---|
POST /grades |
admin + assigned SG teacher | RecordGradeDto → GradeEntryResponseDto |
PATCH /grades/:id |
admin + assigned SG teacher | UpdateGradeDto → GradeEntryResponseDto |
DELETE /grades/:id |
admin + assigned SG teacher | — → 204 |
GET /grades/subject-groups |
admin + teacher | SubjectGroupCardsQueryDto → SubjectGroupCardsResponseDto |
GET /grades/subject-group/:id |
admin + assigned teacher | GradesRangeQueryDto → SubjectGroupGradebookDto |
GET /grades/homeroom/:id |
admin + teacher | GradesRangeQueryDto → HomeroomGradesDto |
GET /grades/student/:id |
admin + teacher + referent | GradesRangeQueryDto → StudentGradesDto |
GET /grades/me |
student | GradesRangeQueryDto → StudentGradesDto |
GET /grades/:id/history |
per visibility | — → GradeHistoryDto |
RecordGradeDto = { studentId, subjectGroupId, type, assignmentDate, note?, criterionMarks?[], directValue?, finalOverride? } — provide one write mode (see §1; directValue + criterionMarks together → 400 VALIDATION_FAILED). UpdateGradeDto = the same minus studentId/subjectGroupId, all optional; any value-mode field fully re-resolves the mark composition, otherwise only type/assignmentDate/note change.
Error codes (all new): GRADE_NOT_FOUND (404), GRADE_NOT_AUTHORIZED (403), GRADE_STUDENT_NOT_IN_SUBJECT_GROUP (422), GRADE_CRITERION_INVALID (422), GRADE_VALUE_NOT_ON_SCALE (400), GRADE_SUBJECT_NOT_GRADABLE (422). Reused: NOT_FOUND, VALIDATION_FAILED.
Read shapes. The gradebook (a teacher's entry surface) includes every roster student (empty stream allowed) + the criteria. Homeroom/student/me views are derived from the visible grade rows (a student/subject with no visible grades doesn't appear; the full roster lives on the homerooms surface). Scale loads are memoized per (subjectScaleId, criteriaScaleId) (loadScalesForRows).
7. Audit & history¶
Grades are the 2nd audit-log consumer (after attendance). Verbs grade_entry.created / .updated / .deleted / .admin_override, value payload { type, assignmentDate, finalOverride, effectiveMark, criterionMarks }, recorded inside the mutation's $transaction via AuditService.record. GET /grades/:id/history = { current, history } via AuditService.historyFor('grade_entry', id) (newest-first). A deleted mark's .deleted event survives in audit_logs, but the history endpoint 404s once the entry is gone (it loads the live entry first).
8. RBAC wiring (adding an entity touches more than the obvious files)¶
Entity grade_entries, single field-level scope record, action record. Grants: admin (all, via ALL_WRITE/ALL), teacher (record write + action), referent/student (read — the visibility builder narrows rows); principal/hr/dept_head inherit a harmless read scope but can't reach the role-gated routes. Beyond entity-keys.ts / scope-fields.ts / rbac-catalogue.ts / roles.ts, a new entity also trips three drift guards — wire all of them or unit suites fail:
src/common/constants/entity-groups.ts— add the key to a group (entity-groups.spec.ts).src/permissions/rbac-catalogue.drift.spec.ts— its ownRUNTIME_SCOPE_FIELDS+knownRuntimeArraysmirrors.src/permissions/interfaces/decorators.interfaces.ts— add a new action verb toACTION_NAMES(here'record'), or@RequireActionwon't type-check.docs/REFERENCE.md§4 — a| `grades/` | … |row (docs-module-map.drift.spec.ts).
9. Recipes & gotchas¶
Add a GradeType — extend the enum (schema + additive migration). No FE enum is rendered server-side; it surfaces via the DTOs' @ApiProperty({ enum }).
directValue vs finalOverride — directValue fills the criteria (average follows, recomputes if a criterion later changes); finalOverride pins the mark (stays put). On a subject with no criteria they're equivalent (both stored as the override).
Reads survive deletion, writes don't — a read recomputes from the stored scale-id snapshots, so a deleted subject/scale degrades gracefully (effective = finalOverride ?? null); editing the value of a mark whose subject was deleted is blocked (GRADE_SUBJECT_NOT_GRADABLE) because the live criteria can't be validated.
Deferred (v1): report-card/period finals (the cross-assessment roll-up — derivable from assignmentDate), per-type weighting, publish/draft gate, a configurable type catalog, and a symmetric GET /grades/homerooms cards index. See the spec §9.