Skip to content

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 already Grade). The RBAC entity key is grade_entries (label Student Grades) — grades is 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 as finalOverride, no children).

Pure core: grades.compute.tscomputeEffectiveMark, 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 NULL defaults — pickCriteriaForGrade).

Each criterion value is checked ∈ criteria-scale values (assertValueOnScale400 GRADE_VALUE_NOT_ON_SCALE), each criterionId ∈ the subject's criteria (assertCriterionInSubject422 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;
  • teachersubjectGroupId IN (their taught SGs) ∪ students in a homeroom they tutor (student.homeroomAssignment.homeroom.homeroomTeacherId — via the hard student FK). Resolved by loading the taught-SG-id set (one query); the pure gradeVisibilityWhere shapes 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 RecordGradeDtoGradeEntryResponseDto
PATCH /grades/:id admin + assigned SG teacher UpdateGradeDtoGradeEntryResponseDto
DELETE /grades/:id admin + assigned SG teacher — → 204
GET /grades/subject-groups admin + teacher SubjectGroupCardsQueryDtoSubjectGroupCardsResponseDto
GET /grades/subject-group/:id admin + assigned teacher GradesRangeQueryDtoSubjectGroupGradebookDto
GET /grades/homeroom/:id admin + teacher GradesRangeQueryDtoHomeroomGradesDto
GET /grades/student/:id admin + teacher + referent GradesRangeQueryDtoStudentGradesDto
GET /grades/me student GradesRangeQueryDtoStudentGradesDto
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 own RUNTIME_SCOPE_FIELDS + knownRuntimeArrays mirrors.
  • src/permissions/interfaces/decorators.interfaces.ts — add a new action verb to ACTION_NAMES (here 'record'), or @RequireAction won'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 finalOverridedirectValue 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.