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)withNULLS NOT DISTINCT(Postgres 17, authored raw inmigration.sql) so two default rows can't collide on name. - Criteria scale lives on the
CurriculumSubjectHourscell as a nullablecriteriaGradingScaleId, alongside the cell's existinggradingScaleIdoverride. 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):
HomeroomsPolicylives atsrc/homerooms/homerooms.policy.ts.SubjectGroupsPolicylives atsrc/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 > 1accepts several alternatives per student; the in-block picker keeps a partially-filled studentpendingfor the remaining alternatives until the block is full, and the add-gate admits each new alternative up tomaxSelections(rejecting an over-capacity assignment withOPTION_BLOCK_FULL; the single-pick case keeps theWRONG_OPTION_BLOCK_CHOICEreason). Both the gate and the picker run the SAMEclassifySubjectGroupEligibility(readingOptionBlock.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_GROUPgate rejection). The classifier supports the check viaconflictingMandatorySg; 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 underCurriculaPolicy.where(ctx)(findGroupedBoardCurricula), and — on both boards — each curriculum's covered grades from itsCurriculumGraderows. 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 whoseStudentsPolicyresolves toNEVER_MATCH_WHEREgets 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. theTRACK_MISMATCHreason); grade + curriculum are pinned by the node.targets = node.homeroomsfiltered toh.id !== sourceHomeroom.id ∧ h.trackId === sourceHomeroom.trackId(the source homeroom is known from the roster-modal context). - Courses board. The gate matches on
curriculumSubjectIdwith both SGs standalone (SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH). Each leaf carriessubject.curriculumSubjectId+ thehomeroomref:targets = node.subjectGroupsfiltered tosg.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'scurriculumSubjectId), 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 atmaxSelections(§5). After writing, it recomputes completeness and stampsconfirmedAtaccordingly: a block staysbelow_min→isComplete = false→ statusIN_PROGRESSuntil itsminSelectionsis 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'sremovedMemberships.
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 rowidround-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'sStudentOptionBlockChoice(and the subject'scarryKey) survive, and the sweep sees no break. Omitting theidkeeps 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.
captureSelectionPreImagessnapshots each confirmed selection's committed substance (chosen alternatives + applicable mandatory subjects, each with itslevelId) before the structure sync;diffCommittedSubstancecompares it after. The sweep demotesconfirmedAtwhen 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 infindSelectionInconsistenciesand 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.departmentIdHomeroom.curriculumIdHomeroom.gradeIdHomeroom.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 typedSUBJECT_GROUP_NAME_CONFLICT409 (the dispatcher tests thenametoken first, since that index now also containscurriculum_subject_id). No proactive pre-check — the typed mapping is enough.
9. Recipes¶
Add a field to either entity's composition scope¶
prisma/schema.prisma— add column to the model.prisma/migrations/— write the migration; if it preserves data, hand-write it (RENAME / DEFAULT, never auto-drop).src/common/constants/scope-fields.ts— append toHOMEROOM_SCOPES.compositionorSUBJECT_GROUP_SCOPES.composition.src/<module>/dto/scopes/<entity>-composition.dto.ts— add toCreate*CompositionDtoANDUpdate*CompositionDto.src/<module>/<entity>.service.ts— wire into create/update path.src/<module>/<entity>.queries.ts— extend include/select if it's an FK.- 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.
Add a grouped counts board (list view) on a related entity¶
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:
- Enumerate the skeleton from the catalog, not from existing rows:
findBoardDepartments(src/departments/departments.queries.ts, composed withDepartmentsPolicy.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. - 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).
- Load a zero-PII student projection (enrollment cell + selection +
whatever the counts need; see
findStudentPlacementCounts/findStudentCoverageCounts) composed withStudentsPolicy.where(ctx); onNEVER_MATCH_WHEREskip the query — zeroed counts, intact skeleton. Fold counts: dept-level over all rows, node-level only where the selection targets the node. - A
grouped-<name>.controller.tswith@Controller('<entity>')and a literal@Get('grouped-<name>')— register it BEFORE the CRUD controller in the module'scontrollersarray so its literal route wins overGET /:id(ParseUUIDPipe); Express 5 matches in registration order. Gate with@RequireScopes(<ENTITY>,'read')+ an inlinestudents.readassert (PermissionsService.checkEntityAccess, sinceScopeGuardenforces a single scope) + theadmin/department_headallowlist +@AppliesPolicyDimensions(<EntityPolicy>)+@AggregateResponse().
Add an eligibility picker (candidate list) on a related entity¶
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:
- 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. - Point the write-gate's
assertStudentsEligibleat the classifier (throw onhiddenvia the mapper) — preserving its error codes/reasons. - A permissive cohort loader in
queries.ts(load the candidate set; let the classifier bucket/hide — do NOT re-encode the rule in aWHERE). - A service method
findEligibleFor<Entity>(resolve year, composeStudentsPolicy.where(ctx), classify each row, drophidden, group bybucket) + aeligible-students.controller.ts(@Controller('students'), literal@Get('eligible-for-<entity>'),@RequireScopes(<ENTITY>,'read')+admin/department_head+@AggregateResponse()). - Route ordering (cross-module trap). The literal
/students/eligible-for-*route collides withStudentsController'sGET /students/:id(ParseUUIDPipe). Express 5 matches in registration order, which across modules is module import order inapp.module.ts— so the picker's module MUST be imported beforeStudentsModuleor the literal is captured as a malformed:idand 400s. (Homerooms + Subject Groups are deliberately ordered ahead of StudentsModule for exactly this.) - A
*-eligibility.parity.spec.tsdrift oracle (everyhiddenreason maps to a 409; everybucketis 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/:id → assignment 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/COMPLETEviaderiveSelectionStatus),curriculum {id,name},chosenTrack {id,name},confirmedAt, andselectedOptions[](the chosen option-block alternatives with block + subject names). On the single-student detail only,selectedCurriculumis the selected curriculum's grade-sliced choosable tree (tracks + mandatory subjects- option blocks with alternatives + rules) —
nullonGET /studentslist rows. Built bystudy-plan.mapper.ts, which owns the sharedflattenCurriculumForStudentgrade-slicer (extracted fromCurriculumSelectionServiceso the/curriculum-selectionendpoint and this block can't drift). The detail tree is loaded by afindOne-only enrichment pass (enrichStudyPlanStructure), so list rows pay no extra query. homeroom— the student'sHomeroomAssignment.homeroomsummary (name, department, grade, track, baseRoom, homeroomTeacher) ornull.subjectGroups— everySubjectGroupAssignment(homeroom-bound child SGs and standalone), each withhomeroomBound, subject (+ grade-resolvedweeklyHours), and orderedteachers. Built byclass-assignment.mapper.ts.
Canonical: docs/superpowers/specs/2026-06-15-student-assignment-study-plan-and-classes-design.md.
10. Where to go deeper¶
- Canonical spec —
docs/superpowers/specs/2026-05-25-us33-us33_1-design.md. - Multitenancy / record-level filtering — 02 - Multitenancy.
- RBAC scope/action conventions — 04 - RBAC §Registered entities.
- Module file shape — 05 - CRUD Patterns.
- Aggregate-response DTOs — 05 - CRUD Patterns (the pattern Homerooms + Subject Groups follow, unlike the scope-grouped students / teachers pattern).