Per-Grade Criteria — Evaluation Criteria, Iteration 2¶
Partially superseded 2026-06-22: criteria are now an unordered set — the
ordinalPositionfield and the contiguous1..nsequence rule (EVALUATION_CRITERIA_INVALID_ORDINAL_SEQUENCE) were removed; reads sort by name. The per-grade replace/inherit model below is unchanged. See2026-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 onCurriculumSubjectHourscells). 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:
CurriculumSubjectCriterionhangs offCurriculumSubject, 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 (
SubjectGroupcarriesgradeId+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 CurriculumSubject → Curriculum.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¶
CurriculumSubjectCriteriongainsgradeId String? @db.Uuid+grade Grade? @relation(..., onDelete: Cascade).gradeId IS NULL= shared default;gradeId = G= grade-G override.Gradegains the back-relationsubjectCriteria CurriculumSubjectCriterion[].CurriculumSubjectHours(the cell) gainscriteriaGradingScaleId String? @db.Uuid+criteriaGradingScale EvaluationScale? @relation("CurriculumSubjectHoursCriteriaScale", fields: [criteriaGradingScaleId], references: [id], onDelete: Restrict).EvaluationScalegains the matching back-relationasCurriculumSubjectHoursCriteriaScale 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 rawCREATE 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 DISTINCTis required so two default rows (bothgrade_id NULL) can't collide on name; a plain composite unique would treat NULLs as distinct and let duplicate defaults through. Prisma cannot expressNULLS NOT DISTINCTdeclaratively → keep the@@unique([curriculumSubjectId, gradeId, name])annotation for the client type but author the index by hand inmigration.sqlwith a/// raw: NULLS NOT DISTINCTschema comment. Safe on existing data: all current rows havegrade_id NULLand the old constraint already guaranteed unique names, so no collision on swap. - New FK with Restrict (
criteriaGradingScaleIdon cell): additive nullable, no existing rows reference it — no lock hazard.
Indexes and uniqueness¶
- New:
curriculum_subject_criteria (curriculum_subject_id, grade_id, name)UNIQUENULLS NOT DISTINCT(replaces the old 2-col unique). - New:
@@index([gradeId])onCurriculumSubjectCriterion. CurriculumSubjectHoursunique 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: booleanto 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. TheEVALUATION_SCALE_IN_USEexample'sreferencesparam 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 explicitcriteriaOverride: '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 (
proposeFinalFromCriteriaet 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 + contiguous1..n) enforced per scope (default and each grade independently); cellcriteriaGradingScaleIdset 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;criteriaOverriddenflag 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 withEVALUATION_SCALE_IN_USE, not P2003.- DTO spec for the cell input —
criteriaarray shape (cap, dup name, bad ordinal) +criteriaGradingScaleIdshape. - 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. Followfeedback_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.ts—HoursCellInputgainscriteria?: CriterionInput[]andcriteriaGradingScaleId?: string | null.SubjectInput.criteria/criteriaGradingScaleIdstay = the default. (CriterionInputis reused unchanged.)SubjectMatrix.tsx/SubjectDetails— passgradesintoSubjectDetails. 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 cellcriteriafrom the default (so experts edit a starting point, not a blank) and reveals: the grade's criteria list editor (add/rename/reorder/remove, contiguousordinalPosition) + a criteria-scale<select>(— inherit —/scales). Writes find-or-update the matchingHoursCellInputbygradeId. Toggling back to inherit clears the cell'scriteria/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.