Skip to content

Grades — criteria-derived marks, teacher-authored, family-visible (v1)

✅ STATUS (2026-06-30): FULLY IMPLEMENTED + VERIFIED in tree. All 14 plan tasks done — GradeEntry + GradeCriterionMark models, migration 20260629151340_add_grades (applied + seeded), the src/grades/ module (record/update/remove + SG/homeroom/student/me reads + controller + app wiring), entity grade_entries (label "Student Grades") + scope/action record. Targeted suites green: e2e 12/12 (test/grade-entries.e2e-spec.ts), unit 866/866. Chapter 21 docs written. report-finals deferred. Lands in the 2026-06-30 consolidation commit.

1. Problem distillation

  • Teachers need to record marks for the students they teach, one at a time, over the year. A mark is more than a number: it has a type (written / oral / practical), an assignment date (the date it is for), a note, the recording teacher, and it lives on the subject's evaluation scale.
  • A subject mark is criteria-derived: the curriculum already defines per-(subject, grade) criteria (CurriculumSubjectCriterion) and a separate criteria grading scale. A student's subject mark for one assessment = the average of its per-criterion marks, mapped onto the subject scale — but the teacher can override that average with any value (full flexibility), and can also enter the subject mark directly (which back-fills every criterion).
  • The marks must be readable, grouped: by subject-group (the teacher's gradebook + a cards index to navigate into it), by homeroom (tutor overview), and by student. A student also gets a self-view of all their marks grouped by the subject-groups they belong to. Referents see their linked students' marks. Visibility is immediate — no publish gate.
  • Admin can see and override any mark at any time. Every value change (create / edit / delete / admin-override) is captured in the existing audit log as the value-history trail.

Success criteria (observable behavior that proves this works): - An assigned teacher of a subject-group can POST /grades a single mark for a roster student — either per-criterion, by directValue (back-fills criteria), or with an explicit finalOverride — and the response carries the computed effective mark + the criterion breakdown. - The effective subject mark equals finalOverride when present, otherwise the average of the set criterion marks mapped onto the subject scale; changing a criterion later moves a non-overridden mark, a pinned one stays. - A teacher reads GET /grades/subject-groups (cards) → clicks one → GET /grades/subject-group/:id (gradebook with every student's mark stream). A homeroom tutor additionally sees their tutees' marks in every subject. A referent sees only linked students; a student sees only their own via GET /grades/me. - A non-assigned teacher is rejected 403 on write; admin can write/override anywhere; every mutation appends an audit_log row and GET /grades/:id/history projects the timeline.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No report-card / period-final grade — the cross-assessment roll-up of a student's many marks into one subject number per term. (Derivable later from assignmentDate.) - No period/term FK on a mark, no per-type weighting, no publish/draft gate, no configurable type catalog (the three types are a fixed enum), no bulk entry.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/ + attendance spec The whole spine: teacher-authored records, soft-FK + deep snapshot columns that survive SG/curriculum reorg, hard FKs only on tenant/academicYear/student (Restrict), provenance columns (recordedBy*/lastModifiedBy*), audit-log value history, read-policy vs in-service write-authority split, and the cards → detail read shape (cohorts → grid). Grades = a custom service, like attendance (no BaseTenantedCrudService). Attendance is a daily/lesson compliance cell with a uniqueness/dedupe guarantee, a Y-set witness authority, and is immutable (no delete). Grades anchor on the SubjectGroup (a teaching unit, not a lesson), are a stream (no uniqueness), use a narrower assigned-teacher authority, and allow delete.
src/evaluation-scales/evaluation-scales.queries.ts + eval-scales / criteria / criteria it.2 The grading primitives built for exactly this consumer: proposeFinalFromCriteria(criterionVotes, criteriaScale, subjectScale), retrofitVote(value, src, tgt), valueToNumeric/numericToValue/scaleRange. Plus the live (subject, grade) cascade resolveEffectiveCellScale(cell, subject, grade, curriculum) and buildCellCriteriaProjection(...) in src/curriculum/curriculum.service.ts. Fits cleanly. (Only nuance: those helpers throw on values absent from a scale — the grades service validates inputs against the resolved scale first so it never feeds them garbage.)
src/audit-log/ + audit-log spec AuditService.record(tx, actor, params) inside the caller's transaction + historyFor(tenantId, entityType, id) projection. Grades become the second consumer after attendance — no bespoke history table. Fits cleanly. Verbs are grade-specific (grade_entry.created/.updated/.deleted/.admin_override); value payload typed for this consumer.
src/subject-groups/subject-groups.policy.ts + src/homerooms/homerooms.policy.ts (definePolicy, entity-access-policy.ts) The record-level EntityAccessPolicy shape: per-role WHERE builders, @AppliesPolicy on the controller, Policy.where(ctx) in the service, fail-closed default. The GradesPolicy WHERE is a union the existing policies don't have: teacher = (SG they teach) ∪ (student's homeroomTeacher == them), plus referent-linked and student-own branches.
src/subject-groups/subject-groups.service.ts (SubjectGroupTeacher, roster loaders) The "assigned teacher(s) of an SG" + "roster of an SG (homeroom-bound derives from the homeroom, standalone from assignments)" lookups — reused for the per-entry write authority and roster membership check. Fits cleanly.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Every GradeEntry carries tenantId; every read/write filters on it; @TenantId() from the request, never the body. Standard chapter-02 invariant.
Academic-year scope yes GradeEntry.academicYearId derived from the SubjectGroup on write (SG is AY-scoped); reads scope to the resolved active year (accept AcademicYearQueryDto override on read like other year-scoped reads). Marks are year-scoped facts; the SG already pins the year.
RBAC entity key new GRADES = 'grades' Add to src/common/constants/entity-keys.ts. New domain entity, mirrors ATTENDANCE.
Scopes new record (single, field-level) Covers type, assignmentDate, note, finalOverride, criterionMarks. Added to scope-fields.ts GRADES_SCOPES + seed. Reads use @RequireScopes(GRADES, 'read'). Same single-field-scope shape as attendance register.
Actions new record Gated on grades.record; the write routes use @RequireAction(GRADES, 'record'). read/update/delete are not actions here (delete is covered by the record action + write authority, mirroring how attendance folds edit under take). read is never an action; one verb suffices.
Service base custom GradesService NOT BaseTenantedCrudService — bespoke read shapes (cards / gradebook / student-grouped / history) and the criteria-decomposed write, exactly like AttendanceService. The scope-grouped response shape of the base service doesn't fit; attendance set this precedent.
queries.ts shape new grades.queries.ts Include/select consts for the GradeEntry + GradeCriterionMark read tree; loaders: loadAssignedTeacherIds(sgId), loadSubjectGroupRosterIds(sg), loadGradableSubjectGroupsFor(ctx) (cards), resolveScaleContext(curriculumSubjectId, gradeId) (effective subject + criteria scales + criteria list). Chapter-05 convention; no inline Prisma in the service beyond these.
Error codes new 6 (see §5) GRADE_NOT_FOUND, GRADE_NOT_AUTHORIZED, GRADE_STUDENT_NOT_IN_SUBJECT_GROUP, GRADE_CRITERION_INVALID, GRADE_VALUE_NOT_ON_SCALE, GRADE_SUBJECT_NOT_GRADABLE. Reuse NOT_FOUND, VALIDATION_FAILED. Added to error-codes.ts + examples.
DTO conventions scope sub-DTOs + bespoke read DTOs dto/scopes/grade-record.dto.ts (write fields), plus read DTOs: grade-entry-response.dto.ts, subject-group-card.dto.ts, subject-group-gradebook.dto.ts, homeroom-grades.dto.ts, student-grades.dto.ts, grade-history.dto.ts. Response fields reference real DTO classes (no unknown[]/type:'object').
File-backed sub-resources n/a — grades carry no documents.
Custom fields no Marks have no tenant-custom fields in v1.
Profile completeness no Grades don't gate a person's profile completeness.

4. Data model plan

Schema deltas

  • New enum GradeType { WRITTEN, ORAL, PRACTICAL } (@@map("grade_type")).
  • New model GradeEntry (@@map("grade_entries")) — one mark event for one student in one subject-group:
  • Identity / hard FKs (Restrict, the three a mark can never lose): tenantId, academicYearId, studentId.
  • Soft references (plain columns, no FK) — survive SG/curriculum/homeroom reorg, attendance pattern: subjectGroupId, curriculumSubjectId, homeroomId, gradeId, departmentId.
  • Snapshot columns (written once at create, never re-synced): studentName, studentIdentificationCode, subjectName, subjectCode, gradeName, homeroomName?, plus the resolved scale ids subjectScaleId / criteriaScaleId (so history reads survive a later scale swap).
  • Value: type GradeType, assignmentDate @db.Date, note? @db.VarChar, finalOverride? @db.VarChar(50) (a value string on the subject scale; null ⇒ effective = average of criteria).
  • Provenance: recordedByUserId, recordedByName, recordedAt, lastModifiedByUserId, lastModifiedByName, lastModifiedAt @updatedAt.
  • Children: criterionMarks GradeCriterionMark[].
  • New model GradeCriterionMark (@@map("grade_criterion_marks")) — one per set criterion:
  • gradeEntryId (onDelete: Cascade), criterionId (soft column, no FK — survives criterion edit/delete; mirrors the soft-FK choice on GradeEntry), snapshot criterionName, value @db.VarChar(50) (a value string on the criteria scale).
  • @@unique([gradeEntryId, criterionId]) — at most one mark per criterion per entry.

Migration shape

  • Additive. Two new tables + one new enum. No backfill (no existing rows). Hard FKs Restrict on tenant/AY/student; everything else is a plain column.
  • Hazards from chapter 12 checklist: none flagged — no destructive ops, no column type changes, no NOT NULL backfill, no partial-unique requiring hand-authored SQL (unlike attendance, grades need no dedupe index — a student legitimately has many marks per subject/type/day).

Indexes and uniqueness

  • GradeEntry: @@index([tenantId, academicYearId]), @@index([subjectGroupId]), @@index([studentId]), @@index([homeroomId]), @@index([curriculumSubjectId]). No uniqueness (stream semantics).
  • GradeCriterionMark: @@unique([gradeEntryId, criterionId]), @@index([gradeEntryId]).

5. API surface

All under @Controller('grades') + @ProtectedResource(). Reads carry @RequireScopes(GRADES, 'read') + @RequireRoles(...) and apply the async visibility builder buildGradeVisibilityWhere(...) in-service (NOT @AppliesPolicy — the soft-column anchors force a query-backed builder; see §7); writes carry @RequireScope(GRADES, 'record', 'write') + @RequireAction(GRADES, 'record') (flat-DTO singular-scope, field-filter bypassed) and run the in-service assigned-teacher authority check.

Verb Path Decorators Request DTO Response DTO
POST /grades record:write + action record RecordGradeDto GradeEntryResponseDto
PATCH /grades/:id record:write + action record UpdateGradeDto GradeEntryResponseDto
DELETE /grades/:id record:write + action record 204
GET /grades/subject-groups read + roles admin/teacher SubjectGroupCardsQueryDto (departmentId?/gradeId?/homeroomId?) SubjectGroupCardsResponseDto
GET /grades/subject-group/:id read + roles admin/teacher GradesRangeQueryDto? SubjectGroupGradebookDto
GET /grades/homeroom/:id read + roles admin/teacher GradesRangeQueryDto? HomeroomGradesDto
GET /grades/student/:id read + roles admin/teacher/referent GradesRangeQueryDto? StudentGradesDto (grouped by SG)
GET /grades/me read + roles student GradesRangeQueryDto? StudentGradesDto (own, grouped by SG)
GET /grades/:id/history read (policy-visible record) GradeHistoryDto ({ current, history: AuditLogEntryResponseDto[] })

Write contract (RecordGradeDto){ studentId, subjectGroupId, type, assignmentDate, note?, criterionMarks?: {criterionId, value}[], directValue?: string, finalOverride?: string }. Resolution: - Subject has criteria, per-criterion mode → criterionMarks[] (+ optional finalOverride to pin). Effective = finalOverride ?? avg(criterionMarks) via proposeFinalFromCriteria. - Subject has criteria, direct mode → directValue only → service expands to every criterion via retrofitVote(directValue, subjectScale, criteriaScale); finalOverride stays null → effective average == directValue. - Subject has no criteriafinalOverride (the direct subject mark); no children. - Provide-nothing (no criterionMarks, no directValue, no finalOverride) → 400 VALIDATION_FAILED. directValue + criterionMarks together → 400 (mutually exclusive).

Write authority (in-service, per request, not in the policy): 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, else 422 GRADE_STUDENT_NOT_IN_SUBJECT_GROUP. Admin edits to a mark a teacher in their position could not write → audited as grade_entry.admin_override.

Error codes (new)

Code HTTP When params
GRADE_NOT_FOUND 404 GET/PATCH/DELETE on a missing/not-policy-visible mark { gradeId }
GRADE_NOT_AUTHORIZED 403 caller is not an assigned teacher of the SG (and not admin) { subjectGroupId }
GRADE_STUDENT_NOT_IN_SUBJECT_GROUP 422 target student not on the SG roster { studentId, subjectGroupId }
GRADE_CRITERION_INVALID 422 a criterionId is not a criterion of the subject for that grade { criterionId }
GRADE_VALUE_NOT_ON_SCALE 400 a criterion value (criteria scale) or directValue/finalOverride (subject scale) is not a value on the resolved scale { value, scaleId }
GRADE_SUBJECT_NOT_GRADABLE 422 the (subject, grade) cascade resolves to no subject grading scale (nothing to grade against) { curriculumSubjectId, gradeId }

Reused: NOT_FOUND (404 — SG/homeroom/student lookups), VALIDATION_FAILED (400 — DTO-level mode violations).

Swagger considerations

  • RecordGradeDto documents the three mutually-aware fields (criterionMarks / directValue / finalOverride) with the mode rules in the JSDoc contract (FE-facing) — no backend internals.
  • GradeType surfaces via @ApiProperty({ enum }). Each response carries the computed effectiveMark + criterionMarks[] breakdown; error examples for the 6 new codes in error-examples.ts.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) add GRADES entity with scope record
PermissionAction (rbac-catalogue.ts) add action record on GRADES
ScopeFieldMapping (rbac-catalogue.ts) grades.record{ type, assignmentDate, note, finalOverride, criterionMarks } (the stored, readable fields). The write routes use the singular @RequireScope(GRADES, 'record', 'write') flat-DTO pattern (field-filtering bypassed, like attendance take / subject-levels), so the transient directValue shortcut and the studentId/subjectGroupId anchors pass FieldWriteGuard without being scope fields.
Role grants (roles.ts) admin: grades.read + grades.record (+ override via admin bypass). teacher: grades.read + grades.record. referent: grades.read (rows narrowed to linked students by GradesPolicy). student: grades.read (rows narrowed to self). hr/staff/principal: none in v1.
*_SCOPES runtime constant (scope-fields.ts) add GRADES_SCOPES = { record: [...] } matching the seed mapping

referent and student are profile-coupled roles whose effective roles are [activeProfile]; granting them grades.read + the policy narrowing is the same shape attendance deferred and the student-document-scope work established.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Attendance Y-set witness authority Grades use narrow assigned-SG-teacher authority (SubjectGroupTeacher ∪ admin) Grading is a teaching responsibility tied to the subject-group, not an observation any present teacher can record A substitute who is not an assigned SG teacher cannot grade — admin records it, or the teacher is added as a co-teacher
Attendance immutable compliance record (no delete, snapshot never diverges) Grades allow hard delete and an explicit finalOverride that diverges from the computed average Marks are not a legal register; teachers correct mistakes and need to pin a final that isn't the literal average (user requirement, "full flexibility") Deletes lose the row; the audit_log retains the value snapshot + a grade_entry.deleted event
Eval-scales pure live-inherit (effective always derived, never stored) The effective mark is derived unless finalOverride is set, then it is pinned The teacher must be able to overrule the criteria average A non-overridden mark moves when a criterion changes; a pinned one does not — both are intended, documented in the response (finalOverride echoed)
BaseTenantedCrudService Custom GradesService Bespoke read shapes + criteria-decomposed write don't fit the scope-grouped base response Re-implements create/update/list locally, as AttendanceService already does
definePolicy(ctx) pure record-access policy (@AppliesPolicy) GradesPolicy is an async builder buildGradeVisibilityWhere(prisma, ctx, ay) resolved in-service The soft-column subjectGroupId/homeroomId anchors can't be traversed in a where; the teacher branch needs a DB-resolved taught-SG-id set One extra query per teacher read; the pure where-core stays unit-testable; referent/student still traverse the hard student FK; v1 has no parametric branch so @AppliesPolicy adds nothing

8. Pushback log

US says Conflicts with Proposed instead Status
(User description) "proposed subject final, computed not stored" Implied a single per-subject final / term roll-up Per-assessment subject mark computed from that entry's criteria; the cross-assessment term roll-up is deferred (report-card iteration) Resolved (chat 2026-06-26)
(User description) "a grade is computed BY the criteria (avg)" A pure-derived value can't express a teacher overruling the average Store an optional finalOverride; effective = override ?? avg Resolved (chat 2026-06-26)
(User first framing) attendance-style bulk entry Teachers "never bulk insert grades — always one at a time" Single-record POST /grades; no entries[] Resolved (chat 2026-06-26)
No formal ClickUp US exists The Pushback-vs-US loop assumes a US n/a — feature is user-described; decisions captured in §1 + this log Resolved

9. Deferrals

  • Report-card / period-final grade (cross-assessment subject roll-up per term) — needs an aggregation policy (weighting, which marks count) the user hasn't specified — follow-up: future spec grades-period-finals, derivable from assignmentDate with no migration.
  • Period/term FK on a mark — deferred with finals; assignmentDate lets a period be derived later — follow-up: same.
  • Per-type weighting (written vs oral count differently) — lands with finals — follow-up: same.
  • Publish/draft gate — v1 is immediate-visibility; a visibleToFamily/publishedAt concept would be additive — follow-up: revisit if a school asks.
  • Configurable grade-type catalog — v1 is the fixed GradeType enum; a managed catalog (room-types shape) is a later option — follow-up: revisit on UX feedback.
  • Symmetric GET /grades/homerooms cards index — only the SG cards index is in v1; the homeroom card→detail flow can reuse the existing homerooms list or add this trivially — follow-up: add on FE request.
  • Compact "one number per subject" homeroom/gradebook summary — needs the deferred roll-up; v1 returns mark streams the FE pivots — follow-up: with finals.

10. Open questions

  • None — all design decisions resolved during the 2026-06-26 brainstorm (scope ladder, criteria-derived + overridable mechanic, two dates, fixed type enum, immediate visibility, teacher read = SGs ∪ homeroom tutees, single-record insert, cards→detail reads, model name GradeEntry, delete allowed, directValue shortcut).

11. Verification plan

  • Unit specs:
  • grades.compute.spec.ts — the effective-mark resolution: per-criterion average, directValue back-fill (avg == directValue, incl. cross-scale retrofit), finalOverride pins, criteria-less subject, criteria scale ≠ subject scale. (Exercises proposeFinalFromCriteria/retrofitVote through the service contract.)
  • grades.service.spec.ts — write authority (assigned teacher ✓ / non-assigned ✗ / admin ✓), roster membership gate, criterion-belongs-to-subject gate, value-on-scale gate, GRADE_SUBJECT_NOT_GRADABLE, audit verb selection (created/updated/deleted/admin_override).
  • grades.policy.spec.ts — per-branch coverage: admin pass-through, teacher SG-taught ∪ homeroom-tutor union, referent linked-only, student own-only, fail-closed default + platform-admin sentinel (mirrors subject-groups.policy.spec.ts).
  • DTO spec for RecordGradeDto mode rules (mutually-exclusive directValue/criterionMarks, provide-nothing → 400).
  • E2E specs (test/grades.e2e-spec.ts): teacher records a per-criterion mark + reads it back with computed effective mark; directValue back-fills criteria; finalOverride pins; non-assigned teacher → 403; admin override → audit verb; referent sees only linked student; student /grades/me sees only own; cards index → gradebook navigation; delete → 204 + history shows the deleted event. Follow feedback_e2e_isolation_patterns.md.
  • Manual verification: none required beyond the above; the compute + policy logic is fully unit/e2e covered.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-26
  • Chat reference: brainstormed with Fabio in chat 2026-06-26 (scope ladder → criteria-derived overridable mechanic → policy/authority → reads); approved in chat ("go on") after spec review.

Until this section is filled, no implementation code is written.