Skip to content

14 — Homerooms & Subject Groups

Scope — US-33 (Homerooms) and US-33.1 (Subject Groups), V1. Two entities introduced together because they share a wizard cascade and a single RBAC shape; the spec is docs/superpowers/specs/2026-05-25-us33-us33_1-design.md.

Read this chapter when you are about to: - Create or modify a Homeroom or Subject Group endpoint - Touch the wizard cascade (one transaction, three writes) - Reason about who can SEE a Homeroom / SubjectGroup (record-level visibility) - Add a field to either entity's composition scope - Pick the right shape for an "eligible candidates" picker on a similar entity

For schema reference and the field-by-field RBAC seed plan, the design spec above is canonical. This chapter explains why the shape is what it is and documents the recipes you'll re-use in adjacent work (timetable templates, teacher assignments, evaluation roll-up).


1. Mental model

Department (e.g. Elementary)
  └── Curriculum (e.g. Standard Curriculum, status READY)   ← owns the whole grid
        ├── CurriculumSubject (mandatory)        ← optionBlockId NULL; FK target for SubjectGroup
        │     └── CurriculumSubjectHours (per grade)   ← row presence = offered in that grade
        ├── OptionBlock
        │     └── CurriculumSubject (in-block)   ← optionBlockId set; FK target for standalone SG;
        │           └── CurriculumSubjectHours          cohort by StudentOptionBlockChoice
        └── CurriculumTrack (indirizzo, optional)  ← subjects/blocks scoped via nullable trackId

Both kinds of CurriculumSubject are the same table — discriminated by optionBlockId IS NULL (mandatory) vs. IS NOT NULL (in-block alternative). A raw CHECK constraint forbids the impossible state (non-mandatory without a block). Subjects/blocks/tracks are owned by the Curriculum (curriculum-level, not per-grade); whether a subject is offered in a grade is the presence of a CurriculumSubjectHours cell. There is no per-grade StudyPlan row anymore — this is the 2026-06-04 curriculum grid remodel (docs/superpowers/specs/2026-06-03-curriculum-grid-remodel-design.md, which superseded the option-block-subject-merge substrate).

Homeroom wizard is mandatory-only — permanent. The wizard cascade seeds a child SG for every mandatory subject (isMandatory = true, optionBlockId NULL) offered in the homeroom's grade (those with a cell for that grade) and applicable to the bound track — teacher assignment is optional and does not gate SG creation (a subject with no assigned teacher still gets an empty SG; see §3). Option-block alternatives are taught via standalone SGs created post-hoc by the admin (POST /subject-groups). This is the design, not a deferral — option-block subjects per-student-choice doesn't fit the cohort-roster shape of a homeroom-bound child SG.

A Homeroom is an administrative cohort bound to (Department, Curriculum, Grade, AcademicYear). When the curriculum has tracks it binds exactly one CurriculumTrack (its roster + child SGs are scoped to that track). Each Student belongs to at most one Homeroom per AY (enforced by HomeroomAssignment.studentId @unique).

A Subject Group is a teaching unit anchored on one CurriculumSubject plus its own gradeId (the grade it teaches; weekly hours and eligibility resolve against that grade's CurriculumSubjectHours cell). It comes in two flavours that share a row but behave very differently:

Standalone Homeroom-bound
homeroomId NULL set
Created via POST /subject-groups Homeroom wizard cascade
Roster mutates Yes, via own roster endpoints No — derived from parent Homeroom
Deletable Yes No (SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE)
PATCH name Yes No (inherits parent Homeroom name; SUBJECT_GROUP_FIELD_IMMUTABLE)
PATCH teacherIds Yes Yes

Teachers attach via the M:N SubjectGroupTeacher junction with an explicit ordinalPosition (0 = primary). Both flavours support co-teaching; the list order is preserved on read.

The "one Subject Group per Subject per Student per AY" invariant holds across both flavours and is DB-backed: SubjectGroupAssignment denormalizes curriculumSubjectId + academicYearId (safe — the parent SG's curriculumSubjectId is immutable) so @@unique([studentId, curriculumSubjectId, academicYearId]) enforces it without a per-write race. The homeroom roster path honours the same backstop.

1.1 Per-grade criteria (iteration 2)

A subject's evaluation criteria list and its criteria grading scale can be specialized per grade — e.g. English grade 1 = {Listening, Speaking} vs English grade 5 = {Listening, Speaking, Writing, Grammar}. The original criteria spec made criteria subject-level (one list shared by all grades); the grid remodel made hours + the main grading scale per-grade (on the cell). This iteration completes the parity by adding the grade dimension to criteria too.

The model has two homes, one grade key:

  • Criteria list lives on CurriculumSubjectCriterion, now keyed (subjectId, gradeId). gradeId IS NULL = the shared default; gradeId = G = a grade-G override. The unique index is (curriculumSubjectId, grade_id, name) with NULLS NOT DISTINCT (Postgres 17, authored raw in migration.sql) so two default rows can't collide on name.
  • Criteria scale lives on the CurriculumSubjectHours cell as a nullable criteriaGradingScaleId, alongside the cell's existing gradingScaleId override. The subject row still carries the default criteria scale.

Resolution is replace, not merge. For a (subject, grade) cell: - Criteria list — a grade with an override resolves to only its own rows; a grade with no override rows resolves to the subject default (gradeId NULL). There is no per-grade "overridden" column — criteriaOverridden is derived at read time from row presence. - Criteria scale — cell.criteriaGradingScaleId ?? subject.criteriaGradingScaleId ?? resolvedSubjectScale(grade) ?? curriculum. The per-grade cell override is the new first hop on top of the prior 3-level cascade.

Per-grade criteria ride the same grid family-sync PATCH /curricula/:id (src/curriculum/curriculum-structure-sync.ts) as the rest of the grid — no new route, no new scope (still curriculum.configuration), no new error code (grade-offered is structural: a cell only exists for an offered grade, so a bad grade already fails CURRICULUM_HOURS_GRADE_NOT_IN_CURRICULUM). Criteria are an unordered set — there is no position field; reads come back sorted by name. The only criteria shape rule (unique names, EVALUATION_CRITERIA_DUPLICATE_NAME) is enforced per scope — default and each grade independently. Toggling a grade back to inherit (omit / empty criteria) deletes its override rows; override-to-empty (a grade with explicitly zero criteria while the default has some) is deferred. (Criteria carried an ordinalPosition + a contiguous 1..n rule through iteration 2; both were removed 2026-06-22 — see docs/superpowers/specs/2026-06-22-remove-criteria-ordinal-position-design.md.)

Canonical: docs/superpowers/specs/2026-06-05-evaluation-criteria-iteration-2-design.md (builds on the original 2026-05-18-evaluation-criteria-design.md).

1.2 Subject levels (IB HL/SL) and curriculum rules

A CurriculumSubject may carry a nullable levelId pointing at a SubjectLevel — a tenant catalog (own src/subject-levels/ module) that clones the EvaluationScale shape: platform-owned presets live with tenantId IS NULL (Higher Level / Standard Level seeded every pass), tenants add their own, reads see own-rows ∪ presets. It has no own RBAC entity/scope — catalog routes are gated by the singular @RequireScope(CURRICULA, 'configuration', …) sub-resource gate (the flat DTO then bypasses field-filtering); preset writes are @PlatformAdminOnly(). CurriculumService calls assertLevelsVisible on every grid write so a curriculum can only reference levels its tenant can see.

CurriculumRule carries the cross-cutting count constraints. RuleScopeType is ENTIRE_PLAN / LEVEL: a LEVEL rule pins a levelId and counts the chosen subjects at that level; ENTIRE_PLAN counts across the whole selection. Per-block cardinality ("pick N of M in this block") is not a rule — it lives on OptionBlock.minSelections/maxSelections (a BLOCK rule scope was dropped 2026-06-23 as pure redundancy). The rule shape is validated pre-tx in curriculum.validation.ts (LEVEL ⇒ levelId set; ENTIRE_PLAN ⇒ no levelId; the DB backs it with a curriculum_rules_scope_ref_chk CHECK). Enforcement lives in src/curriculum/selection-consistency.ts: findSelectionInconsistencies runs a generic rule pass (counting chosen block alternatives + applicable mandatory subjects per scope) at every selection write and in the post-edit revalidation sweep. This engine was authored with the grid remodel but lay dormant until IB levels needed it — LEVEL rules were the first consumer.

IB layout recipe. Model each IB group as a single OptionBlock with maxSelections = 1. That makes "the same subject cannot be taken at both HL and SL" structurally free (a student picks one alternative per group). The "3–4 at HL / 2–3 at SL" counts are then four LEVEL rules (AT_LEAST 3 + AT_MOST 4 on HL, AT_LEAST 2 + AT_MOST 3 on SL). No dedicated exclusivity primitive.

Canonical: docs/superpowers/specs/2026-06-08-ib-subject-levels-design.md.

1.3 SG creation is manual (+ student-keyed grid lock)

Subject Groups are created manually only. Standalone SGs are created via POST /subject-groups; homeroom-bound child SGs are created when a homeroom is created. Offering a curriculum (entering READY) has no SG side-effect — it is a pure status flip. (Auto-seeding one empty standalone SG per offered (subject, grade) cell was removed 2026-06-30; product wants SG creation fully manual.)

Classes may be created on a curriculum in ANY status — DRAFT or READY (2026-07-06). A short-lived READY gate (CLASS_REQUIRES_READY_CURRICULUM, which made POST /homerooms + POST /subject-groups reject a DRAFT anchor) shipped and was reverted the same day: product wants classes to be buildable while a curriculum is still in DRAFT. Curriculum.status no longer gates class creation at all — it only governs referent-facing selection eligibility (RUS-4) and the selection-window prerequisite. The grouped boards (grouped-homerooms / grouped-courses) therefore enumerate DRAFT and READY curricula alike in their catalog skeleton. The READY→DRAFT demote (PATCH /curricula/:id/status) is now refused only while a selection references the curriculum (CURRICULUM_IN_USE_BY_SELECTIONS); any attached homerooms/SGs carry over into DRAFT untouched (the CURRICULUM_IN_USE_BY_CLASSES code and its countClassesForCurriculum helper were removed). The original (now-reverted) gate spec: docs/superpowers/specs/2026-07-06-curriculum-ready-gate-for-classes-design.md.

The only class↔curriculum guard left is the hard-delete block. removeCurriculum still refuses to delete a curriculum whose courses have students (via guardAndClearSubjectGroupsForCurriculum), silently clearing only empty standalone courses. The subject-removal grid guard is likewise unchanged: removing a subject is blocked only by a course with students (guardAndClearStandaloneSGs); empty standalone courses are silently cleared. (The written ask "remove a subject only if no courses linked" was reconciled to "no courses with students".)

Decision B retained. The setup wizard's bulkSync still forces status = READY so finishing the curriculum step offers every curriculum (referents stay gated by the date-driven selection window; window-open still fails with SELECTION_WINDOW_PREREQ_FAILED when no READY curriculum covers an active (dept, grade)). It simply no longer creates any SGs.

The student-keyed grid lock (retained from the auto-seed era). An empty standalone SG never freezes its subject. The in-use guard guardAndClearStandaloneSGs (+ wrappers …ForSubjects / …ForGrades / …ForCurriculum in curriculum-structure-sync.ts): on a subject/grade-cell removal, if any matching SG has ≥1 student → CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS (409); otherwise the empty standalone SGs are cascade-deleted so the Restrict FK doesn't block the edit (empty homeroom-bound SGs are left; the P2003 belt covers that edge). Demote (READY→DRAFT, via PATCH /curricula/:id/status) clears empty standalone SGs and blocks on SG-with-students (and on CURRICULUM_IN_USE_BY_SELECTIONS when any selection references the curriculum). The curriculum grid stays freely editable while offered, as long as its standalone SGs are empty.

Canonical (historical, feature since removed): docs/superpowers/specs/2026-06-16-curriculum-offer-autoseed-subject-groups-design.md.


2. Why one composition scope (and not configuration)

Other admin-config entities (departments, rooms, curricula) use a single configuration scope as a catch-all because the entity is configuration. Homerooms and Subject Groups are not configuration — they hold roster + teaching assignments — so the scope is named composition instead. The word matches what the scope actually gates: the composition of the cohort.

There is exactly one scope on each entity. Custom fields ride in the same scope's JSONB customFields column, with admin-only access via the cross-cutting others scope (consistent with every other entity in the codebase).


3. The wizard cascade — one transaction, three writes

The Homeroom create endpoint runs a single prisma.$transaction:

// src/homerooms/homerooms.service.ts ─ create()
const createdId = await this.prisma.$transaction(async (tx) => {
  const homeroom = await tx.homeroom.create({
    data: {
      // … header fields
      assignments:  { create: c.studentIds.map(...) },       // ① N HomeroomAssignment
      subjectGroups:{ create: seededMandatorySubjects.map(subject => ({
        // … per-SG fields …
        teachers: { create: (teachersBySubject.get(subject.id) ?? []).map((id, idx) => ({ teacherId: id, ordinalPosition: idx })) },
      })) },                                                  // ② one child SubjectGroup per mandatory subject offered in grade+track
    },
    select: { id: true },
  });
  await applyDeclaredSubjectsSideEffect(tx, ...);            // ③ fail-soft teacher update
  return homeroom.id;
});

The SG set is seeded from the curriculum, not from the payload: one bound child SubjectGroup per mandatory subject offered in the homeroom's grade and applicable to the bound track — computed once by resolveSeededMandatorySubjects (the seed predicate mirrors the per-slot check in validateMandatorySlots). subjectTeachers only attaches teachers: an entry maps its teacherIds (index 0 = primary, preserved by ordinalPosition) onto that subject's SG, and a mandatory subject with no entry gets an SG with zero teachers (fill later via PATCH /subject-groups/:id). Each child SG is seeded with the same roster as the Homeroom. Prisma's nested writes flatten this to a few statements.

Pre-flight validations happen outside the transaction (cheaper rollback on common errors): curriculum-subject lookup, and on every subjectTeachers entry a check that the anchor is mandatory, in this curriculum, in the bound track (or common), offered in the homeroom's grade (has a cell), and not a duplicate of another entry — all funnel through MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE (with a reason); plus eligibility on every student in the initial roster. The cross-SG conflict pre-check (a student already in a standalone SG for one of the homeroom's mandatory subjects) runs over the full seeded set, not just the assigned subjects, so it stays ahead of the @@unique([studentId, curriculumSubjectId, academicYearId]) belt. Only the writes are transactional.

Add to declared subjects (fail-soft side-effect)

addToDeclaredSubjects is the "record this choice" prompt that fires when an admin picks a teacher for a subject the teacher hasn't yet listed. The helper lives at src/teachers/declared-subjects.helper.ts and is shared by both modules. Behaviour:

  • Trims + dedupes (case-insensitive) within the call AND against existing.
  • Skips whitespace-only entries.
  • Fail-soft: if the caller lacks teachers.employment:write, the side-effect is logged and skipped. The parent operation succeeds either way.

This is one of the few places we accept silent partial success — the rationale is that the SG/Homeroom create is the user-facing intent; the declared-subjects update is a convenience and should never block.


4. Record-level visibility — EntityAccessPolicy

Both modules use EntityAccessPolicy (see docs/04-rbac.md#entityaccesspolicy--single-source-of-truth and src/students/students.policy.ts as the canonical implementation):

  • HomeroomsPolicy lives at src/homerooms/homerooms.policy.ts.
  • SubjectGroupsPolicy lives at src/subject-groups/subject-groups.policy.ts.

Call HomeroomsPolicy.where(ctx) / SubjectGroupsPolicy.where(ctx) in the service to obtain the Prisma WHERE clause.

Caller role Visible Homerooms / SGs
platformAdmin All in the tenant
admin / teacher / staff All in the tenant + active AY
referent Only those whose roster contains a linked Student
student Only their own Homeroom / SGs they're rostered into
any other Defensive empty match (NEVER_MATCH_WHERE{ id: { in: [] } })

The policy returns a Prisma.<Entity>WhereInput that's AND-combined with the tenant+AY scope in the service. Write paths pass null for the context — they rely on the ScopeGuard / ActionGuard and the tenant+AY filter alone.

Reads call loadDetail(tenantId, yearId, id, ctx); non-visible records 404 silently (no leakage of existence). The same loadDetail is reused by write paths with ctx = null.


5. The roster pickers — server-bucketed, single-sourced with the gate

Restored 2026-06-16 (docs/superpowers/specs/2026-06-16-eligibility-classifier-pickers-design.md), reversing the 2026-06-15 "fold into the boards + reproduce client-side" move. The eligibility rule had ended up living in two codebases (the backend gate + the FE's board reproduction) and drifted — the multi-pick option-block fix was exactly that drift. The pickers are back as server-bucketed endpoints, and the rule now lives in ONE place the gate and the picker both call.

Two endpoints answer "who can be placed in this target?", mounted under /students (they are student-cohort queries) but authorized by the class entity's scope, not students.read (ScopeGuard reads the decorator EntityKey, not the URL — RBAC identical to the former /{homerooms,subject-groups}/eligible-students):

Verb Path Query Buckets
GET /students/eligible-for-homeroom departmentId, curriculumId, gradeId, trackId? { noSelection, selected }
GET /students/eligible-for-subject-group curriculumSubjectId, gradeId { noSelection, pending, selected }

Both are @RequireScopes(<ENTITY>, 'read') + @RequireRoles('admin','department_head') + @AggregateResponse(). No hr — placement is not an HR-ish surface.

The single source of truth. A pure classifier per entity decides each student's verdict — classifyHomeroomEligibility (src/homerooms/homeroom-eligibility.ts) and classifySubjectGroupEligibility (src/subject-groups/subject-group-eligibility.ts), each returning { kind: 'bucket', bucket } or { kind: 'hidden', reason }. Both consumers call it: the write-gate (assertStudentsEligible) turns a hidden verdict into the exact AppException it always threw (via the module's *EligibilityError mapper); the picker drops hidden rows and groups the rest by bucket. Sharing the function means the picker can never offer a student the gate would 409 — locked by *-eligibility.parity.spec.ts. The picker cohort loaders (find{Homeroom,SubjectGroup}EligibilityCohort) are deliberately permissive: they load the candidate set and let the classifier bucket/hide, rather than re-encoding the rule in a WHERE (the old WHERE-builder is exactly what drifted).

Already-assigned students are hidden (Homeroom: already in any Homeroom this AY; SG: already in an SG for this Subject this AY); moving between sibling SGs stays a separate POST /:id/students/move. Conflicting selections are hidden (different curriculum; a confirmed different track for homerooms / a strict track mismatch for mandatory SGs; or — for an in-block SG — the block is already at its maxSelections capacity holding other alternatives, which for a single-pick block reduces to "any other alternative already chosen"). The remaining students bucket as:

Homeroom → 2 buckets ({ noSelection, selected }). Option-block completeness is irrelevant to homeroom placement, so the only distinction is selection presence for this curriculum:

bucket meaning
noSelection no StudentCurriculumSelection (assignment will create one)
selected a compatible selection on this curriculum (complete or not)

Subject-group → 3 buckets ({ noSelection, pending, selected }), branching on the anchor:

bucket Mandatory SG (optionBlockId IS NULL) In-block SG
noSelection no selection no selection
pending selection on the curriculum, isComplete = false this alternative not yet chosen and the block has room (< maxSelections distinct choices)
selected isComplete = true this alternative already chosen

Multi-pick option blocks. A block with maxSelections > 1 accepts several alternatives per student; the in-block picker keeps a partially-filled student pending for the remaining alternatives until the block is full, and the add-gate admits each new alternative up to maxSelections (rejecting an over-capacity assignment with OPTION_BLOCK_FULL; the single-pick case keeps the WRONG_OPTION_BLOCK_CHOICE reason). Both the gate and the picker run the SAME classifySubjectGroupEligibility (reading OptionBlock.maxSelections), so they cannot disagree about capacity.

Completeness is the materialized isComplete flag — never confirmedAt (the referent lock). The SG anchor's grade comes from the required gradeId; the subject must be offered there. Dept_head callers are scoped: the homeroom picker returns empty for a department they don't head (via StudentsPolicy), and the SG picker 404s for a subject in a department they don't head (the anchor resolves under CurriculumSubjectsPolicy). The boards (§5.1) stay the list-view source; the create/edit modal calls the picker for its candidate list.

Known asymmetry (homeroom). The homeroom picker does not hide a student already in a standalone SG for one of the homeroom's mandatory subjects (the rare ALREADY_IN_MANDATORY_SUBJECT_GROUP gate rejection). The classifier supports the check via conflictingMandatorySg; the picker just doesn't resolve it (the gate still 409s on assignment). A future iteration can wire it.

5.1 Grouped boards (list-view companions)

Two manager-only counts endpoints back the homerooms / subject-groups list views. Iteration 2 (2026-07-02) replaced the row-driven placement boards wholesale: the tree is catalog-driven (empty nodes present) and the per-student tables are gone — each node carries aggregate counts plus the class-entity rows (roster-free). Spec: docs/superpowers/specs/2026-07-02-grouped-boards-iteration-2-design.md (supersedes the 2026-06-15 placement-boards spec). Iteration 3 (2026-07-03) added a grade level + per-subject coverage to grouped-courses — see docs/superpowers/specs/2026-07-03-grouped-courses-iteration-3-design.md.

GET /homerooms/grouped-homerooms GET /subject-groups/grouped-courses
Tree dept → curriculum → grade dept → curriculum → grade
Class leaf homerooms[] (HomeroomListItemDto, carries trackId) grade: subjectGroups[] (GroupedCourseSubjectGroupDto — standalone and homeroom-bound; the homeroom ref tells them apart, each leaf carries its own grade)
Node counts grade: numHomerooms + numStudentsWith/WithoutHomeroom; dept: the same student pair over ALL enrolled students + numStudentsWithoutCurriculum grade: numStudents, numStudentsWithMissingAssignments, numSubjectGroups, numSubjectGroupsWithoutTeacher, + subjects[] (numStudentsExpected/numStudentsPlaced per offered subject); curriculum: numSubjectGroups, numMinRequiredSubjectGroups, numStudentsWithMissingAssignments, numSubjectGroupsWithoutTeacher (Σ grades); dept: numSubjectGroups, numStudents, numStudentsWithoutCurriculum
Query filter departmentId? departmentId?

Shared mechanics:

  • Catalog-driven skeleton — departments enumerate under DepartmentsPolicy.where(ctx) (AY-scoped), READY curricula under CurriculaPolicy.where(ctx) (findGroupedBoardCurricula), and — on both boards — each curriculum's covered grades from its CurriculumGrade rows. Empty nodes ARE present: a covered grade with zero homerooms (or zero courses) renders as an actionable gap. Parametric managers get a correctly narrowed skeleton for free (both catalog policies carry DEPARTMENT/CURRICULUM branches).
  • Defensive union — a class row whose (curriculum, grade) has no skeleton node (e.g. rows left on a demoted, non-READY curriculum) creates the node from its own refs; catalog completeness never hides a row.
  • Count semantics — dept-level student counts cover ALL ENROLLED active-AY students of the dept; node-level counts cover only students whose selection targets that node. Node sums = dept totals minus numStudentsWithoutCurriculum (a no-selection student is always homeroom-less: placement forward-syncs a selection).
  • No student rows. Rosters come from the detail endpoints (GET /<entity>/:id); candidates from the §5 pickers. A caller whose StudentsPolicy resolves to NEVER_MATCH_WHERE gets zeroed counts with an intact skeleton.

RBAC — two-scope, manager-only. Gated @RequireScopes(<ENTITY>, 'read') (decorator) + an inline students.read assert in the service (the counts derive from student data; the ScopeGuard reads a single PERMISSIONS_KEY, so two entity scopes can't be ANDed by stacking decorators). Role allowlist is admin / department_head (no hr) + parametric admission via @AppliesPolicyDimensions. Narrowing reuses StudentsPolicy.where(ctx) for student counts and HomeroomsPolicy / SubjectGroupsPolicy for class rows.

SG coverage counts. numStudentsWithMissingAssignments counts students whose have < should: should/planSubjectIds from the per-student, track-filtered resolveTargetPlan (src/curriculum/resolve-target-plan.ts); have = active-AY SubjectGroupAssignments ∩ the plan subject set (stale out-of-plan rows can't inflate it). numMinRequiredSubjectGroups is the static creation floor from countMinRequiredSubjectGroups (src/curriculum/count-min-required-subject-groups.ts): Σ over covered grades of |mandatory subjects offered| + Σ over blocks offering ≥1 alternative of minSelections — deliberately NOT track-filtered (every track cohort needs its own SGs) and a floor (a subject may be split across more SGs).

Move targets are derived from the board node — no separate endpoint. A "move" (§6) re-points an already-placed student to a sibling class, and the candidate targets are already in the node's class array; FE filters them client-side rather than calling a transpose of the picker:

  • Homerooms board. The gate compares the two homerooms' trackId (HOMEROOM_MOVE_*_MISMATCH, incl. the TRACK_MISMATCH reason); grade + curriculum are pinned by the node. targets = node.homerooms filtered to h.id !== sourceHomeroom.id ∧ h.trackId === sourceHomeroom.trackId (the source homeroom is known from the roster-modal context).
  • Courses board. The gate matches on curriculumSubjectId with both SGs standalone (SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH). Each leaf carries subject.curriculumSubjectId + the homeroom ref: targets = node.subjectGroups filtered to sg.homeroom === null ∧ sg.subject.curriculumSubjectId === sourceSg.subject.curriculumSubjectId ∧ sg.id !== sourceSg.id.

6. Roster operations & atomicity

Roster mutations are sub-resources on the Homeroom (because the homeroom-bound child SGs' rosters cascade). All run inside a $transaction:

Endpoint What runs in the tx
POST /homerooms/:id/students Insert N HomeroomAssignment + insert N×M SubjectGroupAssignment (M = child SGs)
DELETE /homerooms/:id/students Delete N HA + delete N×M SGA
POST /homerooms/:id/students/move UPDATE HA homeroomId + delete source SGA + insert target SGA

The move endpoint enforces Grade match AND Curriculum match, returning HOMEROOM_MOVE_GRADE_MISMATCH / HOMEROOM_MOVE_CURRICULUM_MISMATCH 409s. The standalone-SG move (POST /subject-groups/:id/students/move) instead enforces Subject match (SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH).

6.1 Class ↔ selection bidirectional sync (2026-06-11)

The selection (StudentCurriculumSelection + StudentOptionBlockChoice) is the authoritative record of a student's study plan; class memberships are subordinate and kept consistent with it. Two helpers in src/students/class-selection-sync.ts do this, each running inside the existing roster / selection $transaction (required side-effects, not fail-soft like the declared-subjects one):

  • Forward (applyClassAssignmentToSelection) — every roster write (create wizard, POST :id/students, homeroom move-in) ensures each assigned student has a selection for the class's curriculum/track, and (for an in-block SG) the block choice. It only fills absent items — never overwrites an existing curriculum/track/choice. The block choice is appended (keyed on the alternative's curriculumSubjectId), so assigning a student to several in-block SGs of the same multi-pick block accumulates one choice per alternative — the write-gate caps this at maxSelections (§5). After writing, it recomputes completeness and stamps confirmedAt accordingly: a block stays below_minisComplete = false → status IN_PROGRESS until its minSelections is met. The homeroom/SG move endpoints skip it (source and target share curriculum+grade+track / subject, so the plan is unchanged).
  • Backward (pruneIncompatibleClassMemberships) — every selection write (PATCH /students/:id/curriculum-selection, referent or admin) removes the student's now-incompatible memberships: a Homeroom whose curriculum/track no longer matches (cascading to its child SGs), a mandatory standalone SG whose curriculum no longer matches, an in-block standalone SG whose alternative is no longer chosen. Removed ids come back in the PATCH response's removedMemberships.

isComplete = completeness; confirmedAt = referent lock (split 2026-06-12). A selection is complete iff findSelectionInconsistencies returns zero violations. That verdict is materialized on isComplete by both write paths (referent/admin write + class auto-sync) and maintained bidirectionally by the consistency sweep. confirmedAt is the referent lock: stamped when a write completes a selection, demoted (→ null) when a curriculum edit breaks a confirmed one (demote-only). Referent writes stay strict (must submit complete → auto-locks); admin paths may save partial (splitSelectionViolations rejects only hard — over-fill/structural — violations, allowing soft under-fill), leaving isComplete=false, confirmedAt=null. This is why the old CURRICULUM_SELECTION_PENDING_FOR_COHORT cohort-block is gone (via the allowPending flag on assertStudentsHaveCurriculumMatch): a partial selection is now a normal, selectable state.

All display surfaces (the per-student read status, the command-center tab + overview) derive NOT_STARTED / IN_PROGRESS / COMPLETE from isComplete via the shared deriveSelectionStatus (src/curriculum/selection-state.ts) — the old CONFIRMED/INVALIDATED labels and the partial→INVALIDATED mislabel are gone. A loosening curriculum edit can make a selection COMPLETE while leaving the referent unlocked (confirmedAt=null) — that's an intended state.

Invalidation surface — what a curriculum edit does to a confirmed selection (2026-06-16 strengthening). Two principles keep the demote precise:

  • Renames don't invalidate. Grid identity is now id-or-key (syncEntitiesByIdOrKey): a row id round-tripped from a GET is an in-place update even when the name changed, so a rename no longer delete+creates the subject — the student's StudentOptionBlockChoice (and the subject's carryKey) survive, and the sweep sees no break. Omitting the id keeps the legacy name matching for callers that don't round-trip ids (setup wizard, preset expansion, backoffice). Identity is within a container — moving a subject across containers is still delete+create (a genuine substance change → re-confirm).
  • The sweep is diff-based, not after-only. captureSelectionPreImages snapshots each confirmed selection's committed substance (chosen alternatives + applicable mandatory subjects, each with its levelId) before the structure sync; diffCommittedSubstance compares it after. The sweep demotes confirmedAt when the selection is no longer complete or a CHOSEN subject's HL↔SL level was swapped; it records notify-only descriptors (logged in v1, delivered by the deferred notifier — no demote) for new / removed / level-changed mandatory subjects, since there's nothing for the parent to re-decide. Cosmetic edits (name, scales, criteria, hours, windows, rooms, reorder, widening a block's max) never invalidate — they're not in findSelectionInconsistencies and don't change committed substance.

A grid edit has no SG side-effect — Subject Groups are created manually only (see §1.3). A removed (subject, grade) cell still clears its empty standalone SGs via guardAndClearStandaloneSGs inside syncCurriculumStructure (409 if any holds students). Track removal while a selection/homeroom references the track stays hard-blocked (CURRICULUM_TRACK_IN_USE); invalidate-and-clear is deferred. Full surface + edge cases: docs/superpowers/specs/2026-06-16-curriculum-edit-selection-invalidation-design.md.


7. Immutability rules — and what's still mutable

Header fields the admin cannot change post-creation (returns HOMEROOM_FIELD_IMMUTABLE 409 with the field key):

  • Homeroom.departmentId
  • Homeroom.curriculumId
  • Homeroom.gradeId
  • Homeroom.trackId

The reasoning: changing any of these would require re-seeding the child SG catalogue (a different curriculum / grade / track yields a different mandatory- subject set) and re-validating the entire roster against a new eligibility rule. We don't support an in-place transition; the admin duplicates the Homeroom instead.

What stays mutable: name, baseRoomId, homeroomTeacherId, customFields, and the roster sub-resource.

Renaming a Homeroom cascades to every homeroom-bound child SG name. The stored child name follows the ${subject} - ${homeroom} rule, so renaming "1A" → "1B" runs UPDATE subject_groups SET name = … for each child inside the same $transaction as the parent update. No partial state — either both the homeroom and every child SG carry the new name, or neither does.

Per-subject teacher swaps live on the child SG: PATCH /subject-groups/:id with composition.teacherIds: string[]. Set-semantics — the submitted array fully replaces the current teacher list (index 0 = primary). Omit the field for no change; pass [] to clear all teachers. Same endpoint applies to standalone SGs.

Base room (SubjectGroup.baseRoomId). An SG may carry an optional base room (composition scope, editable on both standalone and homeroom-bound SGs via the same PATCH; a uuid pins it, null clears it). When set, the timetable generator hard-forces every lesson of that SG into the room (toSolverRequest collapses the group's compatible-room domain to [baseRoomId]), and the diagnostics engine flags any lesson placed elsewhere as SUBJECT_GROUP_NOT_IN_BASE_ROOM (ERROR, blocks publish). The room is validated in-tenant/same-AY at write time; capacity + subject-room-set compatibility is enforced at generation (SUBJECT_GROUP_BASE_ROOM_INCOMPATIBLE pre-check) and diagnostics, not at write. Mirrors Homeroom.baseRoomId but is a hard force (the homeroom base room is a soft solver preference). See docs/superpowers/specs/2026-07-02-subject-group-base-room-design.md and chapter 18 §8.


8. Deferred / out of scope (V1)

Documented in spec §1 Non-goals and §9 Deferrals. Things that look like they should work but don't, yet:

  • Side effects on teacher swap: no history table, no notification, no archiving guard, no draft-timetable invalidation. The PATCH is a pure data change.
  • Pre/post-timetable Homeroom history: not modeled. The current cohort is the cohort.
  • Subject Group name uniqueness: DB enforces @@unique([academicYearId, gradeId, curriculumSubjectId, name]); the service maps the P2002 to a typed SUBJECT_GROUP_NAME_CONFLICT 409 (the dispatcher tests the name token first, since that index now also contains curriculum_subject_id). No proactive pre-check — the typed mapping is enough.

9. Recipes

Add a field to either entity's composition scope

  1. prisma/schema.prisma — add column to the model.
  2. prisma/migrations/ — write the migration; if it preserves data, hand-write it (RENAME / DEFAULT, never auto-drop).
  3. src/common/constants/scope-fields.ts — append to HOMEROOM_SCOPES.composition or SUBJECT_GROUP_SCOPES.composition.
  4. src/<module>/dto/scopes/<entity>-composition.dto.ts — add to Create*CompositionDto AND Update*CompositionDto.
  5. src/<module>/<entity>.service.ts — wire into create/update path.
  6. src/<module>/<entity>.queries.ts — extend include/select if it's an FK.
  7. E2E spec — round-trip the new field through POST/PATCH/GET.

No RBAC catalogue change is required to add a scoped field — only adding a new scope or new entity needs prisma/seed/rbac-catalogue.ts edits.

A grouped board (§5.1) is the list view — a catalog-driven tree whose nodes carry aggregate counts + the class-entity rows (no student rows). It is NOT the candidate picker (that is the separate, gate-single-sourced endpoint below). To add a board:

  1. Enumerate the skeleton from the catalog, not from existing rows: findBoardDepartments (src/departments/departments.queries.ts, composed with DepartmentsPolicy.where(ctx) ∩ active AY) + findGroupedBoardCurricula (src/curriculum/curriculum.queries.ts, CurriculaPolicy.where(ctx)status: 'READY'; carries the covered grades). Empty nodes stay in the payload.
  2. Load the class rows under the entity policy and fold them into the skeleton, creating any missing node from the row's own refs (defensive union — a row on a demoted curriculum must not vanish).
  3. Load a zero-PII student projection (enrollment cell + selection + whatever the counts need; see findStudentPlacementCounts / findStudentCoverageCounts) composed with StudentsPolicy.where(ctx); on NEVER_MATCH_WHERE skip the query — zeroed counts, intact skeleton. Fold counts: dept-level over all rows, node-level only where the selection targets the node.
  4. A grouped-<name>.controller.ts with @Controller('<entity>') and a literal @Get('grouped-<name>')register it BEFORE the CRUD controller in the module's controllers array so its literal route wins over GET /:id (ParseUUIDPipe); Express 5 matches in registration order. Gate with @RequireScopes(<ENTITY>,'read') + an inline students.read assert (PermissionsService.checkEntityAccess, since ScopeGuard enforces a single scope) + the admin/department_head allowlist + @AppliesPolicyDimensions(<EntityPolicy>) + @AggregateResponse().

The candidate list for a create/edit roster modal is a server-bucketed picker single-sourced with the write-gate (§5) — never reproduced on the FE. Pattern:

  1. A pure classify<Entity>Eligibility(row, target) → { kind:'bucket', bucket } | { kind:'hidden', reason } (src/<module>/<entity>-eligibility.ts) + <entity>EligibilityError(...) mapper. Extract it FROM the gate so the gate can consume it too.
  2. Point the write-gate's assertStudentsEligible at the classifier (throw on hidden via the mapper) — preserving its error codes/reasons.
  3. A permissive cohort loader in queries.ts (load the candidate set; let the classifier bucket/hide — do NOT re-encode the rule in a WHERE).
  4. A service method findEligibleFor<Entity> (resolve year, compose StudentsPolicy.where(ctx), classify each row, drop hidden, group by bucket) + a eligible-students.controller.ts (@Controller('students'), literal @Get('eligible-for-<entity>'), @RequireScopes(<ENTITY>,'read') + admin/department_head + @AggregateResponse()).
  5. Route ordering (cross-module trap). The literal /students/eligible-for-* route collides with StudentsController's GET /students/:id (ParseUUIDPipe). Express 5 matches in registration order, which across modules is module import order in app.module.ts — so the picker's module MUST be imported before StudentsModule or the literal is captured as a malformed :id and 400s. (Homerooms + Subject Groups are deliberately ordered ahead of StudentsModule for exactly this.)
  6. A *-eligibility.parity.spec.ts drift oracle (every hidden reason maps to a 409; every bucket is one the picker groups on) + an e2e that asserts the route resolves (a picker candidate is accepted by the create/add gate — proves routing and the gate⟺picker contract end-to-end).

Audit a Homeroom / SG operation locally

Use prisma studio plus these read queries (run with npx ts-node or in a spec):

// Who's in homeroom X plus per-child-SG?
const detail = await prisma.homeroom.findUnique({
  where: { id: X },
  include: {
    assignments: { include: { student: true } },
    subjectGroups: {
      include: {
        curriculumSubject: { select: { name: true, isMandatory: true } },
        teachers: {
          orderBy: { ordinalPosition: 'asc' },
          include: { teacher: { select: { firstName: true, lastName: true } } },
        },
        assignments: { select: { studentId: true } },
      },
    },
  },
});

Standalone vs. bound: filter subjectGroup.homeroomId null / not null. The list endpoint at GET /subject-groups returns only standalone (homeroomId IS NULL) by design.


9b. Student read surface (the assignment scope)

A student's placements also surface read-only on the student record itself. GET /students/:idassignment block (gated by students.assignment read, not by homerooms/subject_groups.composition) carries three derived keys built in src/students/:

  • studyPlan — the curriculum-selection summary (RUS-4): status (NOT_STARTED/IN_PROGRESS/COMPLETE via deriveSelectionStatus), curriculum {id,name}, chosenTrack {id,name}, confirmedAt, and selectedOptions[] (the chosen option-block alternatives with block + subject names). On the single-student detail only, selectedCurriculum is the selected curriculum's grade-sliced choosable tree (tracks + mandatory subjects
  • option blocks with alternatives + rules) — null on GET /students list rows. Built by study-plan.mapper.ts, which owns the shared flattenCurriculumForStudent grade-slicer (extracted from CurriculumSelectionService so the /curriculum-selection endpoint and this block can't drift). The detail tree is loaded by a findOne-only enrichment pass (enrichStudyPlanStructure), so list rows pay no extra query.
  • homeroom — the student's HomeroomAssignment.homeroom summary (name, department, grade, track, baseRoom, homeroomTeacher) or null.
  • subjectGroups — every SubjectGroupAssignment (homeroom-bound child SGs and standalone), each with homeroomBound, subject (+ grade-resolved weeklyHours), and ordered teachers. Built by class-assignment.mapper.ts.

Canonical: docs/superpowers/specs/2026-06-15-student-assignment-study-plan-and-classes-design.md.


10. Where to go deeper

  • Canonical specdocs/superpowers/specs/2026-05-25-us33-us33_1-design.md.
  • Multitenancy / record-level filtering02 - Multitenancy.
  • RBAC scope/action conventions04 - RBAC §Registered entities.
  • Module file shape05 - CRUD Patterns.
  • Aggregate-response DTOs05 - CRUD Patterns (the pattern Homerooms + Subject Groups follow, unlike the scope-grouped students / teachers pattern).