Skip to content

Option-Block Subject merge — unify OptionBlockSubject into StudyPlanSubject

Engineering-driven iteration. This work has no ClickUp US. It originates from the audit of src/homerooms/ and src/subject-groups/ (chat reference: design conversation 2026-05-26): SubjectGroup.studyPlanSubjectId is a hard FK to StudyPlanSubject, so option-block subjects — the rows students pick during the US-32 selection window via StudentOptionBlockChoice — cannot be the target of a teaching unit. The system records option-block selection intent (US-32 v1 already landed) but cannot operationalise it: no teacher, no roster, no evaluation, no timetable anchor. This iteration closes that gap by collapsing the two subject tables into one.

1. Problem distillation

  • Today the schema cannot teach an option-block subject. SubjectGroup → StudyPlanSubject is the only FK path; OptionBlockSubject is an isolated subtree (OptionBlock → OptionBlockSubject → OptionBlockSubjectCriterion + StudentOptionBlockChoice). A student who picks "French" via the selection window has nowhere to be rostered, no teacher to attach to, no scale to be graded on.
  • StudyPlanSubject and OptionBlockSubject are structurally identical. Both carry name, level, ordinalPosition, weeklyHours, yearHours (the latter two inherited from the parent block for OB), gradingScale, criteriaGradingScale, and their own criterion child table. The only real difference is the parent FK (StudyPlan vs OptionBlock) and the implicit isMandatory (true for SPS, false for OBS).
  • Merging is cheaper than maintaining the duplication. Every downstream concept (SubjectGroup, evaluation scales cascade, future evaluation entries, future timetable cells) has to branch on "which subject table?" or replicate the relation. Merging removes that branching at the source.
  • Selection-window semantics are independent of the merge. StudentOptionBlockChoice defines a (student, optionBlock, optionBlockSubject) tuple. After the merge, the tuple becomes (student, optionBlock, studyPlanSubject) — the choice still references the same logical row, and block-level min/max validation is unchanged.
  • The Homeroom wizard's mandatory-only constraint is permanent. Per D6, homeroom-bound child SGs will never be seeded for option-block subjects. Option-block SGs are always standalone. The wizard's existing isMandatory check (homerooms.service.ts:179-212) is the load-bearing line; it stays.

Success criteria (observable behaviour that proves this works):

  • POST /subject-groups accepts a studyPlanSubjectId that refers to a row with optionBlockId IS NOT NULL and creates a standalone SG anchored on it.
  • GET /subject-groups/eligible-students?studyPlanSubjectId=<option-block-subject> returns the set of students who selected that subject via StudentOptionBlockChoice (with the existing alreadyAssigned flag).
  • GET /subject-groups/eligible-students?studyPlanSubjectId=<mandatory-subject> returns the existing grade-cohort result (no behaviour change).
  • POST /homerooms continues to reject subjectTeachers[] entries that reference an option-block subject — the existing MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE 409 with reason: NOT_MANDATORY fires unchanged.
  • PATCH /students/:id/curriculum-selection accepts the renamed choices[].studyPlanSubjectId (was optionBlockSubjectId); all five existing validation kinds (duplicate, block_not_in_plan, wrong_block, below_min, above_max) keep working.
  • prisma.studyPlanSubject is the only "subject" table; prisma.optionBlockSubject and prisma.optionBlockSubjectCriterion no longer exist as Prisma models.
  • A CHECK constraint on study_plan_subjects enforces: is_mandatory = TRUEoption_block_id IS NULL; is_mandatory = FALSEoption_block_id IS NOT NULL. Inserting an "optional non-block" subject fails.
  • EvaluationScale usage-counts and groupBy queries (src/evaluation-scales/evaluation-scales.service.ts) collapse from "count SPS + count OBS" pairs into single queries on studyPlanSubject.
  • Curriculum tree read/write (src/curriculum/curriculum.service.ts) keeps emitting OptionBlock → subjects[] nested under OptionBlock, but each leaf is now a StudyPlanSubject row.
  • Both eligible-students endpoints (GET /homerooms/eligible-students, GET /subject-groups/eligible-students) gain @RequireRoles('admin', 'hr', 'department_head') and apply the dept-head record-level filter at the query layer — closing the audit-G1 leak where any caller with composition:read could enumerate placeable students across all departments.

Non-goals (in-scope-shaped things this iteration is explicitly not doing):

  • Homeroom wizard never seeds option-block SGs. Permanent design choice, not a deferral. Per D6 the wizard stays mandatory-only forever; admin creates standalone SGs for option-block subjects via the existing POST /subject-groups endpoint.
  • No "optional non-block" subject support. Per D1 the impossible state is made impossible by CHECK constraint. If product later asks for it, that's a separate iteration that drops the CHECK and decides new uniqueness semantics.
  • No expand/contract migration. Per D5 we ship single destructive. Greenfield (per D4); no production data preservation requirement.
  • No new RBAC scopes, no new endpoints, no new error codes. The SG service's STUDENT_NOT_ELIGIBLE_FOR_SUBJECT reason params grow one value (NOT_SELECTED_IN_OPTION_BLOCK); no new ErrorCode enum entry. The audit-G1 @RequireRoles fix on the two eligible-students endpoints is bundled into this iteration (see §5).
  • No FE/Swagger changes beyond endpoint description updates. The contract for POST /subject-groups doesn't change shape; only the set of valid studyPlanSubjectId values widens.
  • No criteria iteration coupling. Per D8 the criteria iteration (project_criteria_next_iteration.md) is the immediate next iteration. This spec lands the table merge cleanly so that follow-up operates on a single criterion table from day one. Criteria semantics (per-subject N criteria, scale cascade) are not in scope here.
  • No timetable / evaluation-entry consumer updates. Those entities don't exist yet (per chapter 14 §8 and docs/superpowers/specs/2026-05-18-evaluation-criteria-design.md §11). They land on the merged model when they're built.
  • No tenant-data preservation step. Per D4, if the migration hits friction we drop the dev DB. The migration is still written to execute correctly on populated data (preserves UUIDs, copies criteria, retargets FKs) so we don't depend on the "drop" escape hatch.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-04-15-curricula-study-plans-rbac-design.md Defines the original two-table model (StudyPlanSubject + OptionBlockSubject) and the sync-by-name contract on updateStudyPlan. Reuse the sync-by-name primitive but apply it to a single table. The two-table separation we're collapsing. The spec assumed structural divergence that never materialised.
docs/superpowers/specs/2026-05-25-us33-us33_1-design.md Defines SubjectGroup.studyPlanSubjectId as the single anchor FK and the standalone-vs-bound dichotomy. Reuse: SG service shape is exactly right; we only widen what counts as a valid anchor. The current spec's mandatory-only restriction on SG anchoring (chapter 14 §8 deferral). We lift it for standalone SGs; the homeroom wizard restriction stays.
docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md Defines StudentOptionBlockChoice and the snapshot-then-mutate cascade on structural curriculum edits. Reuse: cascade primitive (tx.studentOptionBlockChoice cascade fires on parent delete) stays; we retarget the FK column. Field name optionBlockSubjectId on the choice row + write-selection.dto.ts field name. Both rename to studyPlanSubjectId.
docs/superpowers/specs/2026-05-18-evaluation-scales-design.md Defines the four-level cascade resolution (Curriculum → StudyPlan → Subject → criteria), with separate code paths for SPS-scale and OBS-scale today. Reuse: cascade walker semantics. The two-paragraph "branch on which subject table?" logic in evaluation-scales.service.ts:337-344 and :403-417. Collapses to a single studyPlanSubject query path.
src/curriculum/curriculum.service.ts (~lines 502-700) Generic criterion-list sync (syncCriteria) already abstracts over the two criterion tables with closures (listExistingIds, createOne, updateOne, deleteByIds). Reuse: the closure shape; one of the two callsites disappears, the other absorbs both contexts. The dual-callsite shape with one closure set per criterion table. Both callsites collapse to one studyPlanSubjectCriterion set after the merge.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Inherited via studyPlan.tenantId; StudyPlanSubject itself carries no tenantId column (consistent with today). OptionBlock.studyPlanId → StudyPlan.tenantId still anchors block-level tenancy. No tenancy model change — option-block subjects already inherited from the same StudyPlan parent transitively.
Academic-year scope yes Inherited via studyPlan.academicYearId. Same as today. No AY model change.
RBAC entity key existing SUBJECT_GROUPS, CURRICULA No new keys. Merge is internal to the data model; no new resource boundary.
Scopes existing SUBJECT_GROUPS.composition, CURRICULA.configuration unchanged. The merge doesn't introduce a new gateable surface.
Actions none No new action keys. read and update are implicit; create/delete on existing entities only.
Service base n/a — custom services already SubjectGroupsService, CurriculumService, CurriculumSelectionService, EvaluationScalesService all keep their existing shape; method bodies change. These services don't extend BaseTenantedCrudService; they have bespoke flows (wizard cascade, selection validation, scale resolution).
queries.ts shape extend subject-groups.queries.ts detail include grows an optionBlockId selection on studyPlanSubject so the response DTO can surface "is this an option-block anchor?". curriculum-selection.queries.ts updates the studentOptionBlockChoice select to use studyPlanSubjectId. Existing convention: named includes per response shape, no repository class.
Error codes existing — extend reason params only STUDENT_NOT_ELIGIBLE_FOR_SUBJECT reason params grow NOT_SELECTED_IN_OPTION_BLOCK. No new ErrorCode enum entry. Reason params are documented strings; growing the set is non-breaking.
DTO conventions existing scope-grouped + standalone DTOs CreateSubjectGroupCompositionDto and EligibleSubjectGroupStudentsResponseDto unchanged in shape; ChoiceDto on selection write renames optionBlockSubjectId → studyPlanSubjectId. Single-field rename on selection DTO; no shape change elsewhere.
File-backed sub-resources n/a — no files involved Schema merge doesn't touch the file model.
Custom fields n/a — StudyPlanSubject doesn't carry customFields today Out of scope; merge doesn't add a customFields surface.
Profile completeness n/a Subject merge has no completeness signal.

4. Data model plan

Schema deltas

  • StudyPlanSubject (modify):
  • Add optionBlockId String? @map("option_block_id") @db.Uuid
  • Add relation optionBlock OptionBlock? @relation(fields: [optionBlockId], references: [id], onDelete: Cascade)
  • Add selectionChoices StudentOptionBlockChoice[] (back-relation; previously on OptionBlockSubject)
  • Keep isMandatory Boolean @default(true)false is now valid (option-block alternative) but constrained by CHECK.
  • Keep @@unique([studyPlanId, name]) strict (D3). Names are unique across the entire study plan including option-block alternatives.
  • Add @@index([optionBlockId])
  • Add CHECK constraint via raw SQL in the migration: CHECK ((is_mandatory = TRUE AND option_block_id IS NULL) OR (is_mandatory = FALSE AND option_block_id IS NOT NULL)). Prisma doesn't model CHECK natively; raw SQL.

  • OptionBlock (modify):

  • Drop subjects OptionBlockSubject[] back-relation.
  • Add subjects StudyPlanSubject[] back-relation (the merged shape).
  • Hours fields (yearHours, weeklyHours) stay on OptionBlock — they're block-level group hours that all alternatives share. Per-subject hour overrides on the merged subject row remain optional; OB-side hours act as defaults for in-block subjects when the subject row's hour fields are null. Document in chapter 14 follow-up.

  • StudentOptionBlockChoice (modify):

  • Rename column option_block_subject_idstudy_plan_subject_id.
  • Rename Prisma field optionBlockSubjectIdstudyPlanSubjectId.
  • Retarget FK: studyPlanSubject StudyPlanSubject @relation(fields: [studyPlanSubjectId], references: [id], onDelete: Cascade).
  • The cascade behaviour (deleting a subject row clears matching choices) is preserved.

  • StudyPlanSubjectCriterion (modify):

  • No shape change. Receives the rows previously living in OptionBlockSubjectCriterion via migration data copy.

  • OptionBlockSubject (drop):

  • Drop model + table option_block_subjects after data migration completes within the same migration file.

  • OptionBlockSubjectCriterion (drop):

  • Drop model + table option_block_subject_criteria after data migration completes.

  • EvaluationScale relations (modify):

  • Drop blockSubjects OptionBlockSubject[] @relation("OptionBlockSubjectScale").
  • Drop asOptionBlockSubjectCriteriaScale OptionBlockSubject[] @relation("OptionBlockSubjectCriteriaScale").
  • The existing SPS-side relations (StudyPlanSubjectScale, StudyPlanSubjectCriteriaScale) absorb both kinds.

Migration shape

  • Single destructive migration, hand-written SQL after prisma migrate dev autogenerates the model deltas.
  • Per chapter 12: any non-purely-additive migration is hand-audited. Auto-generated SQL will likely (a) drop option_block_subjects and option_block_subject_criteria outright (data loss) and (b) generate a DROP+ADD for the choice-table FK column. We replace both with safe data-preserving steps:
-- 1. Extend StudyPlanSubject with the optional optionBlockId + index.
ALTER TABLE study_plan_subjects ADD COLUMN option_block_id UUID NULL REFERENCES option_blocks(id) ON DELETE CASCADE;
CREATE INDEX idx_study_plan_subjects_option_block_id ON study_plan_subjects(option_block_id);

-- 2. Copy OBSubject rows into StudyPlanSubject preserving UUIDs. Inherit hours from the parent block where the OBS row lacks them.
INSERT INTO study_plan_subjects (
  id, study_plan_id, option_block_id, name, level, ordinal_position,
  year_hours, weekly_hours, is_mandatory,
  grading_scale_id, criteria_grading_scale_id,
  created_at, updated_at
)
SELECT
  obs.id,
  ob.study_plan_id,
  obs.option_block_id,
  obs.name,
  obs.level,
  obs.ordinal_position,
  ob.year_hours,           -- block-level hours act as the inherited default
  ob.weekly_hours,
  FALSE                    -- option-block alternatives are non-mandatory
  obs.grading_scale_id,
  obs.criteria_grading_scale_id,
  obs.created_at,
  obs.updated_at
FROM option_block_subjects obs
JOIN option_blocks ob ON ob.id = obs.option_block_id;

-- 3. Copy criterion rows preserving UUIDs and retargeting the parent FK.
INSERT INTO study_plan_subject_criteria (
  id, study_plan_subject_id, name, ordinal_position, created_at, updated_at
)
SELECT id, option_block_subject_id, name, ordinal_position, created_at, updated_at
FROM option_block_subject_criteria;

-- 4. Retarget StudentOptionBlockChoice FK in place (rename column, keep values — UUIDs were preserved in step 2).
ALTER TABLE student_option_block_choices RENAME COLUMN option_block_subject_id TO study_plan_subject_id;
ALTER TABLE student_option_block_choices DROP CONSTRAINT student_option_block_choices_option_block_subject_id_fkey;
ALTER TABLE student_option_block_choices ADD CONSTRAINT student_option_block_choices_study_plan_subject_id_fkey
  FOREIGN KEY (study_plan_subject_id) REFERENCES study_plan_subjects(id) ON DELETE CASCADE;

-- 5. Drop the now-empty source tables.
DROP TABLE option_block_subject_criteria;
DROP TABLE option_block_subjects;

-- 6. Add the impossible-state CHECK constraint.
ALTER TABLE study_plan_subjects ADD CONSTRAINT study_plan_subjects_mandatory_xor_block_chk
  CHECK ((is_mandatory = TRUE AND option_block_id IS NULL)
      OR (is_mandatory = FALSE AND option_block_id IS NOT NULL));

-- 7. Tighten name uniqueness — already @@unique([studyPlanId, name]); no change needed.
  • Data backfill is the migration itself (steps 2-3). UUID preservation means StudentOptionBlockChoice rows don't need a separate UPDATE.
  • Hazards from chapter 12 checklist:
  • Data loss on DROP TABLE: addressed — INSERT runs before DROP within the same migration.
  • Column rename (RENAME, not auto-drop): addressed — explicit ALTER ... RENAME COLUMN for student_option_block_choices.
  • FK constraint replacement: addressed — explicit DROP + ADD constraint.
  • CHECK constraint on existing data: addressed — the INSERT step writes is_mandatory = FALSE, option_block_id IS NOT NULL, satisfying the constraint. Existing mandatory rows have is_mandatory = TRUE, option_block_id IS NULL by default.
  • Cascade direction: option_block_id ON DELETE CASCADE — deleting an OptionBlock wipes its alternative subjects (and via the existing StudentOptionBlockChoice cascade, all matching choice rows). Matches the existing behaviour where DELETE OptionBlockSubject cascaded to choices.

Indexes and uniqueness

  • @@unique([studyPlanId, name]) on StudyPlanSubject stays strict (D3). One "Math" per plan total — no shadowing between mandatory and in-block.
  • @@index([optionBlockId]) added.
  • @@index([studyPlanId]) retained.
  • study_plan_subjects_mandatory_xor_block_chk CHECK constraint added via raw SQL.
  • OptionBlock @@unique([studyPlanId, name]) unchanged.
  • StudyPlanSubjectCriterion @@unique([studyPlanSubjectId, name]) unchanged (now absorbs OB criterion uniqueness).

5. API surface

No new endpoints. Three existing endpoints change behaviour or DTO shape; one endpoint stays unchanged in shape but widens its accepted input.

Verb Path Decorators Request DTO Response DTO
POST /subject-groups @RequireAction(SUBJECT_GROUPS, 'create'), @RequireRoles('admin','hr','department_head') CreateSubjectGroupDto (unchanged shape; studyPlanSubjectId now accepts option-block-anchored rows) SubjectGroupDetailDto (unchanged)
GET /subject-groups/eligible-students @RequireScopes(SUBJECT_GROUPS, 'read') + new @RequireRoles('admin','hr','department_head') (audit-G1 fix, bundled) ListEligibleSubjectGroupStudentsQueryDto (unchanged) EligibleSubjectGroupStudentsResponseDto (unchanged shape; behaviour branches on the target subject's anchor)
GET /homerooms/eligible-students @RequireScopes(HOMEROOMS, 'read') + new @RequireRoles('admin','hr','department_head') (audit-G1 fix, bundled) ListEligibleHomeroomStudentsQueryDto (unchanged) EligibleHomeroomStudentsResponseDto (unchanged)
PATCH /students/:id/curriculum-selection existing WriteSelectionDtofield rename: choices[].optionBlockSubjectId → choices[].studyPlanSubjectId SelectionReadResponseDto — same rename on the read shape
GET /curricula/:id, /study-plans/:id, etc. existing unchanged Response includes OptionBlock.subjects[] rows that are now StudyPlanSubject shape — adds optionBlockId, gains isMandatory: false field (already present in DTO but always false in this context)

Swagger considerations

  • Update subject-groups.swagger.ts:ApiCreateSubjectGroup description: remove "V1 limitation: only mandatory subjects" line; add "Accepts both mandatory and option-block subjects."
  • Update subject-groups.swagger.ts:ApiGetEligibleSubjectGroupStudents description: document the branch (mandatory subjects → grade cohort; option-block subjects → students who selected via StudentOptionBlockChoice) and the tightened role gate.
  • Update homerooms.swagger.ts:ApiGetEligibleHomeroomStudents description: document the tightened role gate.
  • Update write-selection.dto.ts ChoiceDto field description: replace OptionBlockSubject references with StudyPlanSubject (in-block alternative).
  • Update subject-groups.service.ts file-level docstring (the "V1 limitation" comment at lines 37-43): replace with a note about the dual-anchor model.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none — no new scopes
PermissionAction (rbac-catalogue.ts) none — no new actions
ScopeFieldMapping (rbac-catalogue.ts) none — no new fields appear in any scope group
Role grants (roles.ts) none — surface is unchanged
*_SCOPES runtime constant none

The merge is purely structural and does not introduce a new gateable surface. The drift check (rbac-catalogue.drift.spec.ts) should continue to pass without seed updates.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Two-table subject model from 2026-04-15-curricula-study-plans-rbac-design.md Collapsing into one StudyPlanSubject table with nullable optionBlockId The two tables are structurally identical; the split forced consumer code (SG, evaluation scales, future timetable/evaluation-entry) to branch unnecessarily. The split was speculative; in practice the rows behave the same way. Consumers that want "option-block alternatives only" use WHERE optionBlockId IS NOT NULL. Consumers that want "mandatory only" use WHERE isMandatory = TRUE (equivalent to optionBlockId IS NULL under the CHECK). Slight loss of "the schema enforces the distinction"; gained: every downstream consumer drops a branch.
Two-table criterion model Collapsing OptionBlockSubjectCriterion into StudyPlanSubjectCriterion Same reasoning as the subject merge. The criterion shape is identical; the parent FK was the only divergence. Same as above.
"All in-block alternatives share block-level hours" (current OB design) The merged row has its own yearHours/weeklyHours columns that override the block's hours when set The columns already exist on OptionBlockSubject as nullable; we surface them as nullable on the merged row. Default behaviour (when null) is "inherit from OptionBlock". Resolution responsibility for "what are this subject's hours?" moves from the OB-only lookup to a tiny coalesce step. Document in chapter 14 follow-up.
Existing API field name optionBlockSubjectId on WriteSelectionDto.ChoiceDto Renamed to studyPlanSubjectId The FK target table changes; the field name should match. Greenfield (per D4); breaking the request shape is acceptable. FE code referencing optionBlockSubjectId on the selection PATCH payload breaks until updated. Acceptable per D4 + D6.

8. Pushback log

US says Conflicts with Proposed instead Status
(No US — engineering-driven; nothing to push back on at the US level.) n/a n/a n/a
2026-04-15-curricula-study-plans-rbac-design.md implicitly assumed two tables persist as distinct shapes downstream The downstream branching cost (SG, evaluation scales, future timetable) outweighs the conceptual separation Single table with optionBlockId discriminator + CHECK constraint enforcing the legal states Resolved — this spec replaces the assumption
2026-05-20-rus4-curriculum-selection-design.md writes optionBlockSubjectId into StudentOptionBlockChoice and into the request DTO The column and DTO field both rename to studyPlanSubjectId Rename both. Validation kinds (duplicate, block_not_in_plan, wrong_block, etc.) keep their semantics unchanged. Resolved — straightforward rename

9. Deferrals

  • Criteria iteration (per D8) — the next iteration after this one, per user direction. Lands on the merged criterion table from day one. Tracked in project_criteria_next_iteration.md. Follow-up: spec to be drafted immediately after this lands.
  • Timetable consumers — no entity exists yet. When timetable cells start referencing SubjectGroup, they automatically pick up the unified shape. No follow-up needed pre-emptively.
  • Evaluation entriesdocs/superpowers/specs/2026-05-18-evaluation-criteria-design.md §11 deferred the entry table; when it lands it references SubjectGroup (which is now anchorable on either mandatory or option-block subjects, transparently).
  • Optional non-block subjects — explicitly forbidden by CHECK (per D1). If product later requires "an optional subject not in any block" (e.g. extracurricular), this is a new iteration that drops the CHECK and decides new uniqueness + selection-window semantics.
  • Chapter 14 update — replace §1 mental-model diagram (OptionBlock → OptionBlockSubject becomes OptionBlock → StudyPlanSubject) and §8 deferral note (remove the "Option-block subjects → SubjectGroup" line). Folded into the implementation plan, not a separate iteration.
  • Backend-data preservation tooling — per D4 we accept "drop the dev DB if the migration goes sideways". Production-grade rollback steps are not in scope; if the migration becomes a candidate for tenant deployment later, an expand/contract variant gets specified at that point.

10. Open questions

  • None. D1–D8 resolved in chat 2026-05-26.

11. Verification plan

  • Unit specs:
  • src/curriculum/curriculum.service.spec.ts — exercise: structural curriculum PATCH adds/removes/updates in-block subjects via the single studyPlanSubject table; criterion sync uses the single closure set; cascade snapshot returns removedStudyPlanSubjectIds (renamed from removedOptionBlockSubjectIds).
  • src/subject-groups/subject-groups.service.spec.ts — exercise: assertStudentsEligible branches correctly for option-block-anchored subjects (cohort from StudentOptionBlockChoice); findEligibleStudents branches on the same predicate; rejecting mandatory-cohort eligibility check for an option-block subject returns the new NOT_SELECTED_IN_OPTION_BLOCK reason.
  • src/students/curriculum-selection.service.spec.ts — exercise: validation kinds still fire with the renamed field; wrong_block still detects subject-id-not-in-block; cascade still wipes choice rows when the parent subject is removed.
  • src/evaluation-scales/evaluation-scales.service.spec.ts — exercise: scale-usage counts collapse from count(SPS) + count(OBS) pairs into single count(SPS) calls; groupBy consolidates identically.
  • src/homerooms/homerooms.service.spec.ts — exercise: the wizard still rejects subjectTeachers[] entries whose studyPlanSubjectId references an option-block subject row (now via isMandatory = false, equivalently optionBlockId IS NOT NULL).
  • src/homerooms/homerooms.controller.spec.ts (or controller-level e2e — whichever is conventional in this module) — exercise: the audit-G1 role gate. referent/student/teacher calling /homerooms/eligible-students get 403; dept_head with no department gets the never-matches sentinel; dept_head with a department gets only that department's pool.

  • E2E specs:

  • test/subject-groups.e2e-spec.ts — add scenarios: (a) POST /subject-groups with an option-block-anchored studyPlanSubjectId succeeds and returns the standalone SG; (b) GET /subject-groups/eligible-students?studyPlanSubjectId=<option-block> returns only students who selected that subject; (c) GET /subject-groups/eligible-students?studyPlanSubjectId=<mandatory> matches existing grade-cohort behaviour (regression).
  • test/curriculum-selection.e2e-spec.ts — update to the renamed DTO field; assert all five validation kinds keep semantics; assert that removing an in-block subject via curriculum PATCH wipes matching StudentOptionBlockChoice rows.
  • test/curriculum.e2e-spec.ts (if it exists) — assert that GET curriculum tree returns OptionBlock.subjects[] rows shaped as StudyPlanSubject.
  • test/command-center.e2e-spec.ts — re-run; verify no behavioural change in dashboard rollup (only field renames flow through).
  • test/homerooms.e2e-spec.ts — regression scenario: wizard rejects option-block subject in subjectTeachers with MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE + reason: NOT_MANDATORY. Add audit-G1 scenarios: non-admin role calling /homerooms/eligible-students returns 403; dept_head sees only their own department's pool.
  • test/subject-groups.e2e-spec.ts — add audit-G1 scenarios mirroring the Homeroom side: non-admin role calling /subject-groups/eligible-students returns 403; dept_head with a subject in a non-headed department gets the never-matches sentinel.

  • Manual verification (per chapter 11 "manual verification" recipe):

  • Reseed dev DB.
  • Create a curriculum with one option block containing two alternatives ("French", "German").
  • As a student, complete the selection window selecting "French".
  • As admin, POST /subject-groups with studyPlanSubjectId = <French row id> — expect success.
  • Call GET /subject-groups/eligible-students?studyPlanSubjectId=<French row id> — expect the student appears with alreadyAssigned: true.
  • Verify in Prisma Studio: the student_option_block_choices row's study_plan_subject_id matches the French row's id.
  • Delete the French subject via curriculum PATCH — expect the choice row cascades away; confirmedAt reset on the parent selection.

Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline (stamp uniqueness-bound fields with Date.now(), use prisma.deleteMany for singletons lacking DELETE endpoints).


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-26
  • Chat reference: approved by Fabio in chat 2026-05-26 after design walkthrough. D1–D8 resolved in-thread; post-draft flags 1–5 resolved in follow-up turn (hours inheritance kept inheritable; CHECK constraint kept strict; audit-G1 fix bundled into scope; greenfield breaking-change policy confirmed and saved to memory; chapter 14 update folded into implementation plan).

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