Grades — criteria-derived marks, teacher-authored, family-visible (v1)¶
✅ STATUS (2026-06-30): FULLY IMPLEMENTED + VERIFIED in tree. All 14 plan tasks done —
GradeEntry+GradeCriterionMarkmodels, migration20260629151340_add_grades(applied + seeded), thesrc/grades/module (record/update/remove + SG/homeroom/student/me reads + controller + app wiring), entitygrade_entries(label "Student Grades") + scope/actionrecord. Targeted suites green: e2e 12/12 (test/grade-entries.e2e-spec.ts), unit 866/866. Chapter 21 docs written.report-finalsdeferred. 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 idssubjectScaleId/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 onGradeEntry), snapshotcriterionName,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
Restricton 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 NULLbackfill, 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 criteria → finalOverride (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¶
RecordGradeDtodocuments the three mutually-aware fields (criterionMarks/directValue/finalOverride) with the mode rules in the JSDoc contract (FE-facing) — no backend internals.GradeTypesurfaces via@ApiProperty({ enum }). Each response carries the computedeffectiveMark+criterionMarks[]breakdown; error examples for the 6 new codes inerror-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 fromassignmentDatewith no migration. - Period/term FK on a mark — deferred with finals;
assignmentDatelets 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/publishedAtconcept would be additive — follow-up: revisit if a school asks. - Configurable grade-type catalog — v1 is the fixed
GradeTypeenum; a managed catalog (room-types shape) is a later option — follow-up: revisit on UX feedback. - Symmetric
GET /grades/homeroomscards 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,directValueshortcut).
11. Verification plan¶
- Unit specs:
grades.compute.spec.ts— the effective-mark resolution: per-criterion average,directValueback-fill (avg == directValue, incl. cross-scale retrofit),finalOverridepins, criteria-less subject, criteria scale ≠ subject scale. (ExercisesproposeFinalFromCriteria/retrofitVotethrough 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 (mirrorssubject-groups.policy.spec.ts).- DTO spec for
RecordGradeDtomode rules (mutually-exclusivedirectValue/criterionMarks, provide-nothing → 400). - E2E specs (
test/grades.e2e-spec.ts): teacher records a per-criterion mark + reads it back with computed effective mark;directValueback-fills criteria;finalOverridepins; non-assigned teacher → 403; admin override → audit verb; referent sees only linked student; student/grades/mesees only own; cards index → gradebook navigation; delete → 204 + history shows the deleted event. Followfeedback_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.