Skip to content

Curriculum grid remodel — curriculum-level subjects/blocks with per-grade hour cells


1. Problem distillation

  • The FE (setup wizard and curricula page — same component) renders a curriculum as one grid: rows = subjects / option blocks / tracks (indirizzi), columns = grades, cells = per-grade weekly hours ("—" = not offered that grade). The BE stores the transpose: per-grade StudyPlan rows each owning their own StudyPlanSubject/OptionBlock rows, with cross-grade identity existing only by name coincidence.
  • The mismatch is not just DTO ergonomics. The approved selection-granularity spec generates carryKey per OptionBlock row = per (study plan, block). A student advancing grade N → N+1 carries a prior choice keyed to the grade-N block, but the target structure is the grade-N+1 plan — the keys never match, so a ONCE block would never actually carry. "Choice once per curriculum" semantically requires the block to be one entity across grades.
  • Remodel: Subject (renamed CurriculumSubject), OptionBlock, and CurriculumTrack become children of Curriculum (not of a per-grade plan). Per-grade offering + hours become CurriculumSubjectHours cell rows (subject × grade). StudyPlan is deleted as a DB entity — "5 study plans" in the FE header is just grades.length.
  • Tracks land as containment (nullable trackId FK: a subject/block lives in exactly one track, or at top level = common to all) — superseding the M2M + appliesToAllAddresses membership of the addresses spec, per Fabio's call against the new UX.
  • The CRUD surface collapses to one structure endpoint: PATCH /curricula/:id accepts the whole grid (full-structure diff-sync); the /curricula/:id/study-plans/* sub-resource routes are deleted.
  • Carried forward unchanged from the two superseded-substrate specs: the post-mutate selection-invalidation sweep, track-on-selection/homeroom, SelectionMode + carryKeys + carried-locks, deferred carry executor.

Success criteria (observable behavior that proves this works): - GET /curricula/:id returns the grid in one call: common subjects, common blocks, and tracks (each with nested subjects/blocks), every subject carrying its per-grade hour cells — no FE-side pivoting. - PATCH /curricula/:id with a full grid payload diff-syncs the structure: renaming "English" is one operation; changing Year-3 hours for one subject is one cell in one payload; unchanged rows keep their ids and carryKeys. - A ONCE option block has one carryKey valid across all grades of the curriculum — the cross-grade carry-match flaw is structurally impossible. - Selection validation rejects an alternative not offered in the student's grade and a block/subject outside the student's track; the invalidation sweep un-confirms selections broken by any structural edit. - All /curricula/:id/study-plans* routes are gone; setup-wizard bulkSync and preset expansion speak the grid shape.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - The carry executor — still deferred to the year-end-rollover iteration (granularity spec §9 stands; computeCarriedSelection simplifies, see §9 here). - Curriculum duplicate-into-target-AY (US-29 remainder) — unbuilt; the duplication-preserves-keys hard requirement carries forward. - Fine-grained per-row endpoints (autosave-per-edit) — Fabio chose full-structure sync; revisit only if FE asks. - Some-but-not-all track sharing (M2M membership) — dropped with containment; a shared subject is duplicated per track. Revisit only if product re-asks. - Evaluation entries / grade books — criteria remain identity+order rows.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/curriculum/study-plan-sync.ts syncEntitiesByKey name-keyed diff-sync (in-place update preserves id/carryKey; rename = delete+create), wipe-then-insert syncSubjectRooms, pre-tx assertRoomsInScope/collectSubjectRoomIds, TxClient threading Per-plan iteration disappears — sync runs once per curriculum over per-container collections (top-level / per-track / per-block); a new syncHoursCells child-sync per subject is needed
docs/superpowers/specs/2026-06-03-curriculum-addresses-design.md CurriculumTrack entity (name/code/ordinal), trackId on StudentCurriculumSelection + Homeroom (Restrict, required-at-service-layer when tracks exist), post-mutate revalidateConfirmedSelectionsForCurriculum sweep, track-deletion hard-block when in use, the ADDRESS_ error-code family (renamed TRACK_ here) M2M membership (StudyPlanSubjectAddress/OptionBlockAddress) + appliesToAllAddresses + by-code references threaded through per-plan sync — all replaced by one nullable containment FK on curriculum-level entities
docs/superpowers/specs/2026-06-03-selection-granularity-design.md SelectionMode {PER_GRADE, ONCE}, OptionBlock.selectionMode, Curriculum.trackSelectionMode, autogenerated immutable carryKeys, carried/trackCarried provenance + SELECTION_CHOICE_LOCKED, carried-aware wipe-replace, deferred executor carryKey grain was per (plan, block) — broken across the grade hop (§1). Re-grounded per curriculum-level block/subject: one key spans grades
docs/superpowers/specs/2026-05-26-option-block-subject-merge-design.md Single-row two-flavour subject (isMandatory XOR optionBlockId) enforced by raw-SQL CHECK constraint; precedent for raw constraints Prisma can't model Fits cleanly — the CHECK migrates verbatim onto curriculum_subjects, plus one new CHECK for containment (in-block subject has no own trackId)
src/students/curriculum-selection.service.ts + curriculum-selection.queries.ts validateChoices violation-list shape, window gates, wipe-replace in $transaction, flattenCurriculumForStudent, APPLICABLE_CURRICULUM_TREE_SELECT Tree select re-shapes (no StudyPlan hop); flatten becomes a grade-column slice of the grid + track filter; two new violation kinds (offered-in-grade)
src/departments/departments.service.ts (grades nested sub-resource) Curriculum keeps owning child sync inside one $transaction, P2002 → CONFLICT mapping Fits cleanly

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Curriculum.tenantId unchanged; all new children reach tenant through the curriculum FK chain (as StudyPlan children do today); CurriculumSubjectRoom room-scope check stays service-layer Same posture as today
Academic-year scope yes Curriculum.academicYearId unchanged; children inherit through the chain; SubjectGroup keeps its own academicYearId Same
RBAC entity key existing CURRICULA No delta to entity-keys.ts; the deleted study-plans controller used the same key Surface shrinks, grants unchanged
Scopes existing configuration Grid structure lives inside the existing configuration scope payload Zero seed delta
Actions none read/update implicit per convention
Service base custom (as today) CurriculumService keeps its bespoke shape; study-plan-sync.ts is rewritten as curriculum-structure-sync.ts Aggregate sync never fit BaseTenantedCrudService
queries.ts shape rewrite curriculum.queries.ts: gridInclude (subjects+cells / blocks / tracks / rules), CurriculumWithGrid payload type; curriculum-selection.queries.ts: tree select drops the StudyPlan hop, gains cells + containment FKs
Error codes mostly carried + 2 new TRACK_ family (renamed from the addresses spec's ADDRESS_; membership-shape codes dropped); SELECTION_CHOICE_LOCKED carried; new CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM (cell references a grade outside the curriculum's grade set); new CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS (409 — grid PATCH deleting/renaming a subject still referenced by SubjectGroups; types what is an UNCAUGHT P2003 500 today; Fabio 2026-06-03: block, no auto re-pointing)
DTO conventions scope sub-DTOs, rewritten dto/scopes/curriculum-configuration.dto.ts absorbs the grid; study-plan-configuration.dto.ts deleted
File-backed sub-resources n/a No files on curriculum
Custom fields no Unchanged — curriculum has none
Profile completeness no Selection completeness still derives from confirmedAt

4. Data model plan

Schema deltas

Deleted models: StudyPlan, StudyPlanSubject, StudyPlanSubjectRoom, StudyPlanSubjectCriterion, StudyPlanRule, OptionBlock (old shape). Greenfield destructive drop — no rename gymnastics.

Curriculum (modified): - subjectLabel String @default("Subject") — moved up from StudyPlan (one label per curriculum). - trackSelectionMode SelectionMode @default(PER_GRADE) — carried from granularity spec. - relations: subjects CurriculumSubject[], optionBlocks OptionBlock[], tracks CurriculumTrack[], rules CurriculumRule[]; studyPlans removed.

CurriculumGrade (modified): - gradingScaleId String? @db.UuidEvaluationScale (Restrict) — the per-grade scale override, preserving StudyPlan's cascade position: Curriculum → CurriculumGrade → Subject → criteria (4 levels, same resolution order as today).

CurriculumTrack (new — as in addresses spec): - id, curriculumId (Cascade), name VarChar(150), code VarChar(30), ordinalPosition - @@unique([curriculumId, name]), @@unique([curriculumId, code]), @@index([curriculumId])

CurriculumSubject (new — ex-StudyPlanSubject, re-parented): - id, curriculumId (Cascade), optionBlockId? (Cascade), trackId? (Cascade → CurriculumTrack), name VarChar(150), isMandatory Boolean @default(true), level VarChar(20)?, ordinalPosition Int - gradingScaleId?, criteriaGradingScaleId? (Restrict, as today) - carryKey String @default(dbgenerated("gen_random_uuid()")) @db.Uuid — one key spanning all grades - relations: hours CurriculumSubjectHours[], criteria CurriculumSubjectCriterion[], rooms CurriculumSubjectRoom[], subjectGroups SubjectGroup[], selectionChoices StudentOptionBlockChoice[] - per-subject yearHours/weeklyHours columns removed — hours live only in cells - CHECK 1 (carried verbatim): is_mandatory XOR (option_block_id IS NOT NULL) — mandatory ⟺ not in a block - CHECK 2 (new): option_block_id IS NULL OR track_id IS NULL — an in-block subject inherits its track from the block; it never carries its own trackId

CurriculumSubjectHours (new — the grid cell): - id, subjectId (Cascade → CurriculumSubject), gradeId (Cascade → Grade), weeklyHours Float?, yearHours Float?, gradingScaleId? (Restrict → EvaluationScale) - Row presence = "offered in that grade." A cell may exist with both hours null/0 (screenshot: Mandarin Year 1 = 0). Service-layer invariant: gradeId must be in the curriculum's CurriculumGrade set (CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM); removing a grade from the curriculum cascade-deletes its cells (explicit deleteMany in sync, since CurriculumGrade↔cell has no direct FK). - gradingScaleId is the per-(subject, grade) scale override — full parity with the old model, where per-grade subject rows made every subject-level scale implicitly grade-specific. Resolution for a (subject, grade) pair: cell → subject → grade (CurriculumGrade.gradingScaleId) → curriculum. Criteria scale stays subject-level (criteria lists are per-subject, not per-cell): criteriaGradingScaleId ?? the subject's resolved scale. - @@unique([subjectId, gradeId]), @@index([gradeId])

OptionBlock (new shape — re-parented): - id, curriculumId (Cascade), trackId? (Cascade), name VarChar(150), minSelections Int @default(0), maxSelections Int @default(1), ordinalPosition Int - selectionMode SelectionMode @default(PER_GRADE) (granularity) - carryKey String @default(dbgenerated("gen_random_uuid()")) @db.Uuid - block-level yearHours/weeklyHours dropped — the grid shows hours per subject cell only; the block-default-with-subject-override inheritance is deleted (divergence §7) - block grade-applicability is derived: applicable in grade G ⟺ ≥1 member subject has a cell for G

CurriculumSubjectCriterion, CurriculumSubjectRoom — straight renames of the StudyPlanSubject* models, FK re-pointed to CurriculumSubject. Semantics (criteria identity+order; room preference set, Cascade rationale) unchanged.

CurriculumRule (ex-StudyPlanRule): studyPlanIdcurriculumId; everything else verbatim. A rule now constrains the whole curriculum; at selection time it evaluates against the student's grade slice.

StudentCurriculumSelection: trackId? (Restrict → CurriculumTrack) + trackCarried Boolean @default(false) — carried from the two specs, unchanged.

StudentOptionBlockChoice: studyPlanSubjectIdcurriculumSubjectId (Cascade, unchanged semantics — structural delete still wipes choices); carried Boolean @default(false) carried from granularity.

Homeroom: trackId? (Restrict) — carried from addresses spec, unchanged.

SubjectGroup: studyPlanSubjectIdcurriculumSubjectId (Restrict, as today); + gradeId (Restrict → Grade, required) — the grade was previously implicit via the per-grade plan; without it the teacher hour-budget (teachers.queries.ts sums subjectGroup.studyPlanSubject.weeklyHours) cannot resolve which hours cell applies, and eligibility/name-uniqueness lose their grade grain. Homeroom-bound SGs inherit homeroom.gradeId; standalone SGs take it from the create DTO, validated as a grade the subject is offered in. Uniques re-pointed and keep the grade grain: @@unique([academicYearId, gradeId, curriculumSubjectId, name]), @@unique([homeroomId, curriculumSubjectId]). (Amendment during plan-writing 2026-06-03 — hour-budget fallout discovered in teacherHourBudgetSelect.)

SubjectGroupAssignment (audit amendment 2026-06-04 — F013/G6-1): + denormalized curriculumSubjectId + academicYearId (copied from the parent SG at assignment-create; safe because SubjectGroup.curriculumSubjectId is immutable — SUBJECT_GROUP_FIELD_IMMUTABLE), + @@unique([studentId, curriculumSubjectId, academicYearId]) — the DB backstop for the "one SubjectGroup per Subject per Student per AY" invariant that today is enforced only by a pre-transaction read in subjectGroups.assertStudentsEligible (no constraint behind it → two concurrent addStudents race past it, and homerooms.assertStudentsEligible omits the check entirely, so a standalone "Mathematics" SG + a homeroom whose bound plan includes mandatory Mathematics double-rosters the student and double-counts the teacher hour-budget). Mirrors how HomeroomAssignment.studentId @unique closes the equivalent homeroom race. The existing @@unique([studentId, subjectGroupId]) (per-SG dedupe) stays. Option-block alternatives are distinct curriculumSubjects so picking different alternatives stays legal.

Enum: SelectionMode { PER_GRADE, ONCE } (granularity, unchanged).

Migration shape

  • Destructive restructure — drop the six study-plan-substrate tables and recreate the curriculum-level set; selections/choices wiped with them. Sanctioned by feedback_breaking_changes_acceptable (greenfield, droppable dev DB). No expand/contract, no backfill.
  • Raw SQL in the migration: the two CHECK constraints on curriculum_subjects + the per-container name-uniqueness index (below).
  • Hazards (chapter 12 audit at migrate-time): table drops with inbound FKs (SubjectGroup/choices) must be ordered; uncommitted 20260603113920_department_attendance_mode migration in the working tree — resolve fold-or-keep before generating.

Indexes and uniqueness

  • CurriculumSubject name uniqueness is per container (top-level / per-track / per-block) — containment duplication requires the same name to coexist in two tracks. Raw SQL: CREATE UNIQUE INDEX ... ON curriculum_subjects (curriculum_id, name, COALESCE(track_id, '00000000-0000-0000-0000-000000000000'), COALESCE(option_block_id, '00000000-0000-0000-0000-000000000000')) (COALESCE form — portable below PG 15, equivalent to NULLS NOT DISTINCT).
  • OptionBlock: same per-container rule → raw unique index on (curriculum_id, name, COALESCE(track_id, zero-uuid)).
  • CurriculumSubjectHours @@unique([subjectId, gradeId]); @@index([curriculumId]) on subjects/blocks/tracks/rules; @@index([optionBlockId]), @@index([trackId]) on subjects; carryKeys stay unindexed (granularity spec rationale holds).

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /curricula existing (CURRICULA read via scope) existing list query CurriculumConfigResponseDto[] (header + grades + counts; no full grid)
GET /curricula/:id existing CurriculumConfigResponseDto (full grid)
POST /curricula existing @RequireScopes('write') CreateCurriculumDto (wraps CreateCurriculumConfigDto) full grid
PATCH /curricula/:id existing UpdateCurriculumDto (wraps UpdateCurriculumConfigDto) full grid
DELETE /curricula/:id existing
GET /curricula/presets existing CurriculumPresetSummaryDto[] (unchanged)
POST /curricula/expand-preset existing ExpandPresetDto (unchanged) grid-shaped form data (subjects[]/optionBlocks[], no studyPlans)
/curricula/:id/study-plans* deleted
GET/PATCH /students/:id/curriculum-selection existing WriteSelectionDto (+trackId, unchanged choice pairs → curriculumSubjectId) tree = grade-column slice of the grid + granularity annotations

DTO shapes (dto/scopes/curriculum-configuration.dto.tsstudy-plan-configuration.dto.ts deleted):

HoursCellDto            { gradeId: uuid; weeklyHours?: number|null; yearHours?: number|null;
                          gradingScaleId?: uuid|null }   // per-(subject, grade) scale override
SubjectInputDto         { name; level?; hours: HoursCellDto[];          // [] = offered nowhere (valid while drafting)
                          gradingScaleId?; criteriaGradingScaleId?;
                          criteria?: CriterionInputDto[]; roomIds?: uuid[] }
OptionBlockInputDto     { name; minSelections; maxSelections; selectionMode?; subjects: SubjectInputDto[] }
TrackInputDto           { name; code; subjects?: SubjectInputDto[]; optionBlocks?: OptionBlockInputDto[] }
RuleInputDto            { constraint; quantity; scopeType; blockRef?; targetType; targetLevel? }  // blockRef = name of a block (any container), replacing blockIndex
CreateCurriculumConfigDto {
  name; departmentId; grades: uuid[]; status?;
  gradingScaleId?; gradeScales?: { gradeId: uuid; gradingScaleId: uuid }[];   // per-grade override (ex StudyPlan.gradingScaleId)
  subjectLabel?; trackSelectionMode?;
  subjects?: SubjectInputDto[];          // common mandatory rows
  optionBlocks?: OptionBlockInputDto[];  // common blocks
  tracks?: TrackInputDto[];           // tracks (indirizzi)
  rules?: RuleInputDto[];
}
UpdateCurriculumConfigDto = PartialType(CreateCurriculumConfigDto)   // structure arrays now INCLUDED: present = full-sync that container family, absent = untouched

Ordering: ordinalPosition is server-assigned from array order within each container (subjects, blocks, tracks are separately ordered; FE renders common subjects → common blocks → tracks). Diff-sync identity: name-keyed per container via syncEntitiesByKey — in-place edits preserve id/carryKey; renames are delete+create. Rename/delete blast radius: inbound choice FKs cascade-wipe (today's semantics); inbound SubjectGroup FKs are Restrict — a rename/delete of a subject any SG references is blocked with CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS (409); today the identical situation is an uncaught P2003 500, so this is a strict improvement, but it means the "rename in one operation" success criterion holds only for subjects without live SGs (admin deletes/re-anchors SGs first). carryKey never appears in any DTO.

Response mirrors the input shape + ids, carried-style system fields where relevant, and a gradeIds-ordered cell list per subject; each response cell carries gradingScale + effectiveGradingScale (resolved cell → subject → grade → curriculum) so the FE never re-implements the cascade.

Swagger considerations

  • UpdateCurriculumConfigDto docs must state the container-array sync semantics (omitted array = untouched; present array = authoritative full state for that family) — FE-facing contract copy.
  • Error examples: CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM, TRACK_* family, SELECTION_CHOICE_LOCKED registered in error-examples.ts per chapter 06.
  • Selection read tree documents the grade-slice semantics (only cells/blocks applicable to the student's grade and track appear).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) one row DELETED — the second curricula.configuration mapping (tableName: 'study_plans', rbac-catalogue.ts:214) dies with the table (corrected 2026-06-03 review — originally claimed "none"; the drift guard cannot catch this because it doesn't validate tableName against the DB)
Role grants (roles.ts) none — deleted study-plans routes shared the CURRICULA key
*_SCOPES runtime constant STUDY_PLAN_SCOPES deleted (scope-fields.ts:255-270), surviving columns folded into CURRICULUM_SCOPES.configuration (subjectLabel, + trackSelectionMode); SUBJECT_GROUP_SCOPES.composition renames studyPlanSubjectIdcurriculumSubjectId; rbac-catalogue.drift.spec.ts updated (import + two-mappings invariant) (same correction)

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Approved addresses spec (M2M membership + appliesToAllAddresses) Containment: nullable trackId FK, exactly-one-track-or-common New grid UX is pure containment; Fabio chose it explicitly Some-but-not-all sharing dropped; a shared subject is duplicated per track (and per-container name uniqueness exists precisely to allow that)
Addresses-spec M2M also allowed per-ALTERNATIVE membership (a common block containing track-scoped alternatives, e.g. common "Language" block with "Russian" only for INF) In-block subjects inherit the block's track wholesale (curriculum_subjects_block_track_chk) The grid UX has no per-alternative track affordance; choices validate against the block's track A block whose alternatives differ per track must be duplicated per track
Approved granularity spec (carryKey per StudyPlan-owned row) carryKey per curriculum-level block/subject Per-plan keys can never match across the grade hop — ONCE carry was structurally broken None — strictly more correct
Old 4-level cascade (curriculum → StudyPlan → subject → criteria) Both per-grade grains kept: CurriculumGrade.gradingScaleId (per-grade default, StudyPlan's old slot) AND CurriculumSubjectHours.gradingScaleId (per-subject-per-grade, the old model's implicit grain via per-grade subject rows). Resolution: cell → subject → grade → curriculum Fabio (2026-06-03 follow-up): "we need to keep the per-grade specialization of evaluation scales" — full parity chosen over grade-default-only One extra cascade level; the per-cell override has no dedicated FE affordance yet (rides the cell editor)
OptionBlock block-level hours with per-subject override (option-block-subject-merge spec) Block hours deleted; hours exist only on subject×grade cells The grid renders hours per subject row only; block-default inheritance has no UI and complicates the cell model FE/admin sets each alternative's cells explicitly
@@unique([studyPlanId, name]) subject uniqueness Per-container raw COALESCE unique index Containment duplication requires same-name subjects in different tracks Raw SQL index (Prisma can't model it); P2002 mapping keyed by index name
One structure endpoint replaces header + per-plan sub-resource CRUD PATCH /curricula/:id syncs the whole grid Fabio chose full-structure sync; single funnel → invalidation sweep wired exactly once Larger payloads; concurrent edits last-write-wins (same as today's per-plan PATCH)
StudyPlanRule.blockIndex reference RuleInputDto.blockRef by block name No per-plan index space anymore; names are the sync identity already Renaming a block orphans rules referencing it in the same payload — validation error, admin fixes both

8. Pushback log

US says Conflicts with Proposed instead Status
(Prototype) grid implies blocks/tracks span grades as single entities Current per-grade StudyPlan substrate AND both approved 2026-06-03 specs' structural grounding Full remodel; both specs' semantics re-grounded here; their plans superseded by one consolidated plan Resolved (Fabio: full remodel)
(Prototype) tracks shown with "choice once" label per track Granularity spec has ONE trackSelectionMode per curriculum (not per track) Keep per-curriculum mode; FE renders the same label on every track row Resolved — no model change
(Prototype) shows only containment Fabio's earlier explicit some-but-not-all requirement ("electronics and informatics may share automatic") Containment wins per Fabio's review of the new UX Resolved

9. Deferrals

  • Carry executor — still ships with the year-end-rollover iteration. computeCarriedSelection gets simpler: block/alternative matching by curriculum-level carryKeys, no grade-hop identity problem; new rule — a carried alternative must have a cell for the student's new grade, else that block's carry is skipped (referent picks fresh). Follow-up: project_selection_granularity_design.
  • Duplication-preserves-keys — when US-29 duplicate-into-AY is built it must copy carryKeys (both), selectionMode, trackSelectionMode, track codes. Carried forward verbatim.
  • Per-row write endpoints — only if FE moves to autosave-per-edit.
  • Some-but-not-all track membership — only if product re-asks; would re-introduce M2M alongside the FK.
  • Distinct "partially carried" selection status — carried forward from granularity spec.
  • Re-validate/clear selection on student grade/department change — the student.gradeId ∈ curriculum.grades invariant is enforced only at selection-write and re-swept on curriculum edits; today Student.gradeId is PATCH-immutable (re-enrollment is unbuilt) so nothing can desync it. When year-rollover / re-enrollment (US-29) lands, moving a student's grade/department must re-run revalidateConfirmedSelectionsForCurriculum (or clear the selection) from the student side. Ships with the rollover iteration.
  • Carried forward from the addresses spec ledger (still deferred, unchanged): track-scoped CurriculumRules; per-track grading-scale override; biennio/triennio modelling stays a tenant choice (common subjects = biennio); import-pipeline support for tracks.

10. Open questions

None — all resolved in chat 2026-06-03 (remodel direction, containment, full-structure sync, per-grade scale override kept; judgment calls flagged in §7 for sign-off review).


11. Verification plan

  • Unit specs:
  • curriculum.service.spec.ts (rewrite) — grid create round-trip (common/track/block containers, cells); diff-sync preserves id+carryKey on in-place edit, regenerates on rename; cell referencing a non-curriculum grade → CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM; per-container same-name subjects in two tracks succeed, in one container → CONFLICT; grade removal drops its cells; track delete blocked when referenced by selection/homeroom; per-grade gradeScales + per-cell gradingScaleId round-trip + cascade resolution order (cell → subject → grade → curriculum; criteria scale = criteria pick ?? subject's resolved scale).
  • curriculum-structure-sync.spec.ts (ex study-plan-sync tests) — container sync, rooms wipe-then-insert, blockRef rule resolution incl. orphan-ref validation error.
  • selection-consistency.spec.tsfindSelectionInconsistencies kinds: block_not_in_track, subject_not_offered_in_grade, track_required, grade_not_in_curriculum, plus the carried-over duplicate/min/max kinds; revalidateConfirmedSelectionsForCurriculum un-confirms exactly the broken set.
  • curriculum-selection.service.spec.ts — grade-slice flatten (cells filter), track filter, new violation kinds on write, trackId required when tracks exist, carried-locks (SELECTION_CHOICE_LOCKED) unchanged from granularity spec.
  • homerooms.service.spec.ts — slot subject must be mandatory + offered in the homeroom's grade + in {common, homeroom.trackId}; moveStudents between same-curriculum/grade homerooms on different tracks → 409 (reason: 'TRACK_MISMATCH').
  • SG-holdings guard — grid PATCH renaming/deleting a subject (or a block whose alternative) an SG references → CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS; after SG deletion the same PATCH succeeds with a fresh row id/carryKey.
  • evaluation-scales.service.spec.ts — in-use counting covers all FIVE scale FK columns (curriculum / grade / subject / criteria / hours cell); a scale referenced only by a cell is blocked from deletion with the typed error, not P2003.
  • Wizard contract — isConfigured true only when every grade has ≥1 subject with a cell in that grade; step DTO → bulkSync structural cast verified by test/setup.e2e-spec.ts.
  • (audit 2026-06-04) Pending-selection block — assertStudentsHaveCurriculumMatch selects confirmedAt; a student with an existing-but-unconfirmed selection fails with the new SELECTION_INVALIDATED reason → callers throw CURRICULUM_SELECTION_PENDING_FOR_COHORT; a student with no selection still passes (window open). Asserted on both homerooms.service.spec.ts and subject-groups.service.spec.ts.
  • (audit 2026-06-04) SG-per-subject backstop — homerooms.service.spec.ts rejects adding a student already in a standalone SG for one of the homeroom's mandatory subjects; the new @@unique([studentId, curriculumSubjectId, academicYearId]) P2002 maps to the typed conflict.
  • (audit 2026-06-04) Sweep min/max tightening — selection-consistency.spec.ts / sweep: a confirmed 2-pick selection on a block PATCHed to maxSelections: 1 is un-confirmed (above_max) — the surviving-block bound-change case, not just removed structure.
  • E2E specs: curriculum e2e rewritten to the grid shape (create → GET grid → PATCH one cell → PATCH rename track → sweep effect on a confirmed selection); selection e2e re-pointed (curriculumSubjectId); study-plans e2e deleted. Date.now() stamping per feedback_e2e_isolation_patterns. (audit 2026-06-04) + a sweep-on-bound-tightening e2e, a pending-selection-block e2e (confirm a student INVALIDATED via structural edit, then attempt to roster → CURRICULUM_SELECTION_PENDING_FOR_COHORT), and an SG-per-subject double-roster e2e.
  • Manual verification: build the screenshot's curriculum (3 common subjects, 1 common block, 2 tracks — one containing a nested block) through the wizard; confirm one GET renders it; flip a cell and a track membership; verify a confirmed selection invalidates.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-03
  • Chat reference: approved in chat 2026-06-03 ("k go on") after grid-screenshot review; four gating decisions answered via structured questions (full remodel / containment / full-structure sync / keep per-grade scale override) + §7 judgment calls reviewed in the same walkthrough

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


13. Audit amendments (2026-06-04)

A full-surface multi-agent audit (20 finders × 4 lenses × 5 clusters → dedup → 3-voter adversarial verify → completeness critic → remodel-classification; report at docs/superpowers/reviews/2026-06-04-curriculum-surface-audit.md) ran against the current curriculum surface before executing this remodel. 131 confirmed findings; no blockers. Most (DIES/FIXED) need no action. The deltas that change THIS spec/plan, decided by Fabio 2026-06-04:

  1. Implement the spec-required pending-selection block (audit G1-1/G1-2; US-33 §1/§5/§8). The block — "V1 blocks assignment if any cohort student has a pending curriculum selection" — was never wired: CURRICULUM_SELECTION_PENDING_FOR_COHORT is registered with params + an OpenAPI example but thrown nowhere, because the shared assert-students-have-curriculum-match.helper.ts selects only curriculumSelection.curriculumId, never confirmedAt. The plan's Task 1 deletion of this "dead" code is REVERSED — instead the helper gains confirmedAt and a SELECTION_INVALIDATED failure reason, and both roster-add consumers (homerooms + subject-groups mandatory-anchor) throw the cohort code. An existing-but-unconfirmed selection blocks; no selection still passes (window open). Params generalized to { pendingStudentIds: string[]; curriculumSubjectId?: string }.
  2. DB backstop for "one SG per Subject per Student per AY" (audit F013/G6-1) — see §4 SubjectGroupAssignment: denormalized curriculumSubjectId + academicYearId + @@unique([studentId, curriculumSubjectId, academicYearId]), PLUS the missing cross-SG check added to homerooms.assertStudentsEligible (symmetry with the SG side). Closes both the cross-module asymmetry and the concurrent-write race.

SHAPE plan-amendments folded into the plan tasks (rewrite would otherwise carry the gap forward): F022 sweep covers surviving-block min/max tightening (test bar added); F008 typed P2003/P2002 belt on the selection write() tx; F036 single-findFirst isConfigured; F004 route the grid-PATCH P2002/P2003 catches through the shared mapP2002; G6-3/G6-4 extract assertCurriculumNotInUseLocked(tx, tenantId, id) (lock-then-count) shared by demote + remove + bulkSync, with the constraint-vs-lock-vs-serializable decision matrix recorded in chapter 16; F101 add tenant_id to the demote FOR UPDATE raw SQL.

Independent (SURVIVES) backlog — tracked in the audit report, NOT in this remodel: F014 (homerooms/SG queries-convention extraction), F024 (tracked 51 KB test/*.log), thin-controller PrismaService injections (F025/F107/F034/F123), F074 (Swagger US-37 leak), F009 (ACTIVE_STUDENT_STATUSES triplication), evaluation-scales TOCTOU + test gaps (F081/F020/F021/F087/F088/F089).