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
StudyPlanrows each owning their ownStudyPlanSubject/OptionBlockrows, with cross-grade identity existing only by name coincidence. - The mismatch is not just DTO ergonomics. The approved selection-granularity spec generates
carryKeyper 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(renamedCurriculumSubject),OptionBlock, andCurriculumTrackbecome children of Curriculum (not of a per-grade plan). Per-grade offering + hours becomeCurriculumSubjectHourscell rows (subject × grade).StudyPlanis deleted as a DB entity — "5 study plans" in the FE header is justgrades.length. - Tracks land as containment (nullable
trackIdFK: a subject/block lives in exactly one track, or at top level = common to all) — superseding the M2M +appliesToAllAddressesmembership of the addresses spec, per Fabio's call against the new UX. - The CRUD surface collapses to one structure endpoint:
PATCH /curricula/:idaccepts 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.Uuid → EvaluationScale (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): studyPlanId → curriculumId; 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: studyPlanSubjectId → curriculumSubjectId (Cascade, unchanged semantics — structural delete still wipes choices); carried Boolean @default(false) carried from granularity.
Homeroom: trackId? (Restrict) — carried from addresses spec, unchanged.
SubjectGroup: studyPlanSubjectId → curriculumSubjectId (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_modemigration in the working tree — resolve fold-or-keep before generating.
Indexes and uniqueness¶
CurriculumSubjectname 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.ts — study-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¶
UpdateCurriculumConfigDtodocs 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_LOCKEDregistered inerror-examples.tsper 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 studyPlanSubjectId → curriculumSubjectId; 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.
computeCarriedSelectiongets 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, trackcodes. 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.gradesinvariant is enforced only at selection-write and re-swept on curriculum edits; todayStudent.gradeIdis 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-runrevalidateConfirmedSelectionsForCurriculum(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 preservesid+carryKeyon 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-gradegradeScales+ per-cellgradingScaleIdround-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,blockRefrule resolution incl. orphan-ref validation error.selection-consistency.spec.ts—findSelectionInconsistencieskinds:block_not_in_track,subject_not_offered_in_grade,track_required,grade_not_in_curriculum, plus the carried-over duplicate/min/max kinds;revalidateConfirmedSelectionsForCurriculumun-confirms exactly the broken set.curriculum-selection.service.spec.ts— grade-slice flatten (cells filter), track filter, new violation kinds on write,trackIdrequired 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};moveStudentsbetween 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 —
isConfiguredtrue only when every grade has ≥1 subject with a cell in that grade; step DTO →bulkSyncstructural cast verified bytest/setup.e2e-spec.ts. - (audit 2026-06-04) Pending-selection block —
assertStudentsHaveCurriculumMatchselectsconfirmedAt; a student with an existing-but-unconfirmed selection fails with the newSELECTION_INVALIDATEDreason → callers throwCURRICULUM_SELECTION_PENDING_FOR_COHORT; a student with no selection still passes (window open). Asserted on bothhomerooms.service.spec.tsandsubject-groups.service.spec.ts. - (audit 2026-06-04) SG-per-subject backstop —
homerooms.service.spec.tsrejects 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 tomaxSelections: 1is 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 perfeedback_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:
- 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_COHORTis registered with params + an OpenAPI example but thrown nowhere, because the sharedassert-students-have-curriculum-match.helper.tsselects onlycurriculumSelection.curriculumId, neverconfirmedAt. The plan's Task 1 deletion of this "dead" code is REVERSED — instead the helper gainsconfirmedAtand aSELECTION_INVALIDATEDfailure 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 }. - DB backstop for "one SG per Subject per Student per AY" (audit F013/G6-1) — see §4
SubjectGroupAssignment: denormalizedcurriculumSubjectId+academicYearId+@@unique([studentId, curriculumSubjectId, academicYearId]), PLUS the missing cross-SG check added tohomerooms.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).