Skip to content

Per-Grade Criteria — Evaluation Criteria, Iteration 2

Partially superseded 2026-06-22: criteria are now an unordered set — the ordinalPosition field and the contiguous 1..n sequence rule (EVALUATION_CRITERIA_INVALID_ORDINAL_SEQUENCE) were removed; reads sort by name. The per-grade replace/inherit model below is unchanged. See 2026-06-22-remove-criteria-ordinal-position-design.md.

Builds on the original evaluation-criteria spec (criteria lists + subject-level criteria scale) and the curriculum grid remodel (subjects are one row spanning all grades; the per-(subject, grade) grain lives on CurriculumSubjectHours cells). This iteration adds the grade dimension to criteria so a subject's criteria can differ per grade — e.g. English grade 1 = {Listening, Speaking} vs English grade 5 = {Listening, Speaking, Writing, Grammar}.


1. Problem distillation

  • Today criteria are subject-level: CurriculumSubjectCriterion hangs off CurriculumSubject, which is one row spanning all grades. All grades of a subject share one criteria list. The grid remodel made hours and grading scale per-grade (cells), but deliberately left the criteria list grade-agnostic (schema comment "criteria stays subject-level"; buildSubjectScaleProjection "grade-agnostic, no per-grade hop").
  • Schools want a subject's criteria to be specialized per grade: the set of criteria for grade 1 may be entirely different from grade 5.
  • The criteria grading scale must likewise be specializable per grade, completing the parity the grid remodel started for the main subject scale.
  • The downstream consumer is already grade-aware (SubjectGroup carries gradeId + curriculumSubjectId), so the eventual evaluation subsystem can resolve the right per-grade criteria list without further remodel.

Success criteria (observable behavior that proves this works): - A curriculum-grid create/PATCH can set a subject's default criteria list + criteria scale (shared by all grades) AND, per grade, a replacement criteria list + criteria scale. - Reading the curriculum returns, for each (subject, grade) cell: the resolved (effective) criteria list, the resolved criteria scale, and a criteriaOverridden flag. - A grade with no override resolves to the subject default. A grade with an override resolves to only its own list (replace, not merge). - The evaluation-scales in-use guard blocks deletion of a scale referenced only by a per-grade cell criteria override (no P2003 500). - The backoffice preset editor lets an expert edit the default criteria/scales (as today) and, per offered grade, toggle inherit/customize to edit that grade's criteria list + criteria scale.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Per-criterion scale (each criterion its own scale) — still deferred to the evaluations iteration. - EvaluationEntry, vote averaging / cross-scale retrofit wiring (the pure helpers already exist, unused). - Extend/merge semantics and override-to-empty (a grade explicitly having zero criteria while the default has some) — see §9. - Per-grade criteria in the Word→JSON import format — import stays default-only (§9).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-05-18-evaluation-criteria-design.md The whole criteria substrate: SubjectCriterionInputDto/ResponseDto, syncEntities diff-by-id, the 3 criteria error codes, assertCriteriaShape (unique names + contiguous 1..n), the criteriaGradingScale ?? subject-resolved-scale fallback. Prior spec's §3 fixed the criteria scale at subject level "cheap to add per-grade later" — this iteration exercises exactly that migration path, so its subject-only conclusion is partially reversed (see §7).
docs/superpowers/specs/2026-06-03-curriculum-grid-remodel-design.md + src/curriculum/curriculum-structure-sync.ts The per-(subject, grade) cell as the home for per-grade overrides: CurriculumSubjectHours.gradingScaleId already does this for the subject scale. New criteria override rides the same cell. Grid diff-sync (syncEntitiesByKey), CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM validation, collectAllSubjectScaleIds pre-tx visibility batch. The cell holds the scale override but the criteria list lives on CurriculumSubjectCriterion rows (now keyed (subject, grade)), not on the cell. Two homes, one grade key.
src/evaluation-scales/evaluation-scales.service.ts (SCALE_FK_REFERENCES) The table-driven in-use guard counting every scale FK grain; add one row for the cell criteria-scale FK. Currently five grains; becomes six — pure addition, JSDoc count bump.
backoffice/src/components/presets/SubjectMatrix.tsx (reused by OptionBlockMatrix + TrackMatrix) The gear-expanded SubjectDetails editor for default criteria/scales; one component change covers mandatory, in-block, and track subjects. SubjectDetails currently has no grade context — must receive grades + per-cell read/write to render the per-grade panel.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Criteria are children of CurriculumSubjectCurriculum.tenantId; all writes go through the curriculum grid sync which is already tenant-guarded. No direct tenantId column. Transitive, as today.
Academic-year scope yes Via Curriculum.academicYearId; criteria/cells inherit it transitively. No direct column. Same as existing criteria.
RBAC entity key existing curriculum (no delta to entity-keys.ts). Criteria administered as part of the curriculum tree.
Scopes curriculum.configuration Reused. The new cell fields ride the existing nested grid payload under configuration. No new scope; matches original criteria spec §4.4.
Actions none write (implicit PATCH) governs the grid; no new action keys. Per RBAC conventions (read/update implicit).
Service base custom CurriculumService + curriculum-structure-sync.ts (grid sync), not BaseTenantedCrudService. The grid is a bespoke family-sync, established by the grid remodel.
queries.ts shape include/select consts curriculum.queries.ts subject-include grows the criteria where/order to carry gradeId; cell select adds criteriaGradingScaleId. Reads must surface per-grade criteria + cell criteria scale.
Error codes existing only Reuse EVALUATION_CRITERION_NOT_FOUND, EVALUATION_CRITERIA_DUPLICATE_NAME, EVALUATION_CRITERIA_INVALID_ORDINAL_SEQUENCE, and the existing criteria_scale_without_criteria 400 (SETUP_VALIDATION_FAILED). No new code: per-grade criteria/scale ride the cell, and a cell already fails CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM if its grade isn't in the curriculum's grade set — grade-offered is structural.
DTO conventions scope sub-DTOs src/curriculum/dto/scopes/ — extend the cell input/response DTOs (the hours-cell shape) + grid response projection. Established location for grid DTOs.
File-backed sub-resources n/a — criteria carry no files.
Custom fields n/a — criteria are not a custom-field carrier.
Profile completeness n/a — curriculum config is not part of person completeness.

4. Data model plan

Schema deltas

  • CurriculumSubjectCriterion gains gradeId String? @db.Uuid + grade Grade? @relation(..., onDelete: Cascade). gradeId IS NULL = shared default; gradeId = G = grade-G override.
  • Grade gains the back-relation subjectCriteria CurriculumSubjectCriterion[].
  • CurriculumSubjectHours (the cell) gains criteriaGradingScaleId String? @db.Uuid + criteriaGradingScale EvaluationScale? @relation("CurriculumSubjectHoursCriteriaScale", fields: [criteriaGradingScaleId], references: [id], onDelete: Restrict).
  • EvaluationScale gains the matching back-relation asCurriculumSubjectHoursCriteriaScale CurriculumSubjectHours[] @relation("CurriculumSubjectHoursCriteriaScale") (the existing hours→scale relation already uses a named relation; this is a second distinct one).

Migration shape

  • Additive columns (both nullable) + one unique-index swap on the criterion table. No destructive drop.
  • Data backfill: none. Existing criterion rows keep gradeId = NULL, which is exactly "shared default" — semantics preserved with zero migration logic.
  • Hazards from chapter 12 checklist:
  • Unique-constraint swap: drop @@unique([curriculumSubjectId, name]) and create a raw CREATE UNIQUE INDEX curriculum_subject_criteria_subject_grade_name_key ON curriculum_subject_criteria (curriculum_subject_id, grade_id, name) NULLS NOT DISTINCT; (Postgres 17 — image: postgres:17-alpine). NULLS NOT DISTINCT is required so two default rows (both grade_id NULL) can't collide on name; a plain composite unique would treat NULLs as distinct and let duplicate defaults through. Prisma cannot express NULLS NOT DISTINCT declaratively → keep the @@unique([curriculumSubjectId, gradeId, name]) annotation for the client type but author the index by hand in migration.sql with a /// raw: NULLS NOT DISTINCT schema comment. Safe on existing data: all current rows have grade_id NULL and the old constraint already guaranteed unique names, so no collision on swap.
  • New FK with Restrict (criteriaGradingScaleId on cell): additive nullable, no existing rows reference it — no lock hazard.

Indexes and uniqueness

  • New: curriculum_subject_criteria (curriculum_subject_id, grade_id, name) UNIQUE NULLS NOT DISTINCT (replaces the old 2-col unique).
  • New: @@index([gradeId]) on CurriculumSubjectCriterion.
  • CurriculumSubjectHours unique stays @@unique([subjectId, gradeId]) (one cell per subject/grade) — the criteria scale is just a new nullable column on it.

5. API surface

No new routes. The new fields ride existing payloads:

Verb Path Decorators Request DTO Response DTO
POST/PATCH /curricula + /curricula/:id (grid create/family-sync) @RequireScopes('curriculum','write') (existing) grid DTO — cell input gains criteria?, criteriaGradingScaleId? grid response — cell gains criteria, effectiveCriteria, effectiveCriteriaGradingScale, criteriaOverridden
POST/PATCH setup wizard CURRICULUM step existing wizard guards same cell-field additions in src/setup/dto/steps/curriculum-step.dto.ts same
GET /curricula/:id (+ preset detail) existing per-cell criteria projection

Cell DTO additions (input): - criteria?: SubjectCriterionInputDto[] — grade override list. Omitted/empty = inherit default (replace only when non-empty; override-to-empty deferred, §9). - criteriaGradingScaleId?: string | null — grade criteria-scale override.

The subject-level criteria and criteriaGradingScaleId stay = the default. SubjectCriterionInputDto/ResponseDto are unchanged (no gradeId field — the grade is implied by which cell carries the array on input, and the criterion row's gradeId is set by the sync).

Swagger considerations

  • Add criteriaOverridden: boolean to the cell response with a JSDoc one-liner: "true when this grade replaces the subject default criteria list."
  • No new ErrorCode → no new error-example entry. The EVALUATION_SCALE_IN_USE example's references param shape gains one optional count key (additive).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none — new cell fields ride the existing curriculum.configuration nested grid payload (the grid is one scope blob, not field-mapped sub-keys)
Role grants (roles.ts) none
*_SCOPES runtime constant none

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Declarative @@unique Hand-authored CREATE UNIQUE INDEX ... NULLS NOT DISTINCT (Prisma can't express it) Default rows (grade_id NULL) must still be unique-by-name; Postgres default treats NULLs as distinct Index lives in raw migration.sql; schema carries an annotation comment. Standard for this repo's CHECK/partial-index cases.
Original criteria spec §3 ("criteria scale is per-subject, not per-grade; cheap to add per-grade later") Add a per-grade criteria-scale override on the cell We are now exercising that explicitly-flagged migration path Criteria-scale resolution grows one level (cell → subject → resolved-subject-scale-at-grade → curriculum). The subject-level default remains the primary affordance.
Criteria list as a flat per-subject collection Criteria list is now keyed (subject, grade) with NULL = default; resolution is replace computed at read time (no stored "overridden" column — derived from row presence) Matches the cell model's "row presence = offered" idiom; avoids a denormalized flag that can drift Read projection must group criterion rows by gradeId and apply the replace rule per cell.

8. Pushback log

US says Conflicts with Proposed instead Status
(User) "criteria for English grade 1 may be different from grade 5" — implies possibly fully independent per-grade sets Original criteria spec's subject-level-only model Shared default + per-grade replace override (confirmed in brainstorming) Resolved
(User, brainstorming) full per-grade parity incl. scale YAGNI lean toward lists-only Add per-grade criteria scale on the cell now (confirmed) Resolved

9. Deferrals

  • Override-to-empty — a grade explicitly having zero criteria while the default has some — not expressible (empty/omitted cell criteria = inherit). Deferred; add an explicit criteriaOverride: 'inherit'|'replace' discriminator or a sentinel flag if it ever comes up — follow-up: revisit at next iteration.
  • Per-criterion scale (each criterion its own scale) — still future, tracked in [[project-criteria-next-iteration]] — follow-up: the evaluations iteration.
  • Evaluation entries / vote averaging / cross-scale retrofit wiring — pure helpers exist (proposeFinalFromCriteria et al.), unused — follow-up: the evaluations iteration.
  • Per-grade criteria in the Word→JSON import (backoffice/src/curriculum-import.ts) — import stays default-only (criteria: string[] → subject default; gradeId NULL). "Quadro orario" docs specify hours per grade, not criteria per grade — follow-up: extend the import schema only if experts ask.

10. Open questions

  • None — all resolved in brainstorming (grade grain = shared default + override; semantics = replace; scope = lists + per-grade scale; backoffice = full per-grade panel; override-to-empty deferred).

11. Verification plan

  • Unit specs:
  • src/curriculum/curriculum-structure-sync.spec.ts — per-grade criteria round-trip (default + grade override on create); PATCH adds/edits a grade override; replace resolution (grade with override → only its rows; grade without → default); duplicate default name rejected (NULLS NOT DISTINCT); criteria-shape (unique + contiguous 1..n) enforced per scope (default and each grade independently); cell criteriaGradingScaleId set with no resolved criteria for that grade (no cell override AND no default) → existing "criteria scale without criteria" 400; override cleanup — a cell toggled back to inherit (criteria array omitted/empty) deletes that grade's override rows; a grade removed from the curriculum set drops its override rows.
  • src/curriculum/curriculum.service.spec.ts (or the structure-sync projection spec) — per-(subject, grade) scale cascade: cell.criteriaGradingScaleId ?? subject.criteriaGradingScaleId ?? resolvedSubjectScale(grade) ?? curriculum; criteriaOverridden flag true only when a grade override exists.
  • src/evaluation-scales/evaluation-scales.service.spec.ts — in-use counting covers the sixth FK grain (CurriculumSubjectHours.criteriaGradingScaleId); a scale referenced only by a cell criteria override is blocked with EVALUATION_SCALE_IN_USE, not P2003.
  • DTO spec for the cell input — criteria array shape (cap, dup name, bad ordinal) + criteriaGradingScaleId shape.
  • E2E specs: test/subject-groups.e2e-spec.ts / curriculum e2e — create a curriculum with a subject whose grade-1 and grade-5 criteria differ; GET asserts each cell resolves its own list/scale. Follow feedback_e2e_isolation_patterns.md (unique markers, deleteMany cleanup).
  • Manual verification: backoffice presets tab — gear-expand a subject, customize grade 1's criteria, confirm grade 5 still inherits; save → reload → round-trips.

12. Backoffice plan (in-scope this iteration)

The /backoffice preset editor edits CurriculumPreset.definition (the grid). SubjectMatrix.tsx is the single criteria editor, reused by OptionBlockMatrix and TrackMatrix, so one component change covers all subject flavours.

  • backoffice/src/types.tsHoursCellInput gains criteria?: CriterionInput[] and criteriaGradingScaleId?: string | null. SubjectInput.criteria/criteriaGradingScaleId stay = the default. (CriterionInput is reused unchanged.)
  • SubjectMatrix.tsx / SubjectDetails — pass grades into SubjectDetails. Keep the existing default criteria/scale editors. Add a "Per-grade overrides" section below them: one entry per offered grade (a grade where the subject has a cell), each with an inherit / customize toggle. Toggling to customize seeds that grade's cell criteria from the default (so experts edit a starting point, not a blank) and reveals: the grade's criteria list editor (add/rename/reorder/remove, contiguous ordinalPosition) + a criteria-scale <select> (— inherit —/scales). Writes find-or-update the matching HoursCellInput by gradeId. Toggling back to inherit clears the cell's criteria/criteriaGradingScaleId.
  • Non-offered grades are not listed (no cell → nowhere to hang the override; the cell is what carries per-grade criteria, and a cell only exists for offered grades). The panel stays consistent with "row presence = offered."
  • curriculum-import.ts — unchanged; per-grade criteria are editor-only (§9).
  • The pre-existing per-cell subject-scale override (HoursCellInput.gradingScaleId) stays on the JSON tab as today — out of scope for this UI pass.

13. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-05
  • Chat reference: approved in chat 2026-06-05 — Fabio: "Implement this spec with a workflow" after the brainstorm (grade grain → replace semantics → per-grade scale → full backoffice panel). Pre-commit refinement: dropped the redundant CURRICULUM_CRITERIA_GRADE_NOT_OFFERED (grade-offered is structural since criteria ride cells).

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