Skip to content

14 — Homerooms & Subject Groups

Scope — US-33 (Homerooms) and US-33.1 (Subject Groups). Originally introduced together (spec docs/superpowers/specs/2026-05-25-us33-us33_1-design.md), the two entities were fully decoupled on 2026-07-08 (docs/superpowers/specs/2026-07-08-homeroom-course-decoupling-design.md): a Homeroom is a pure registration/form group and a Subject Group is a standalone teaching group with its own manual roster. On 2026-08-06 the section fact was restored as an optional link (SubjectGroup.homeroomId, 2026-08-06-homeroom-course-link-design.md): rosters stay decoupled, but a course may declare which class it belongs to, and homeroom roster events cascade into linked courses (§1.5). Unlinked courses are untouched by any homeroom event.

Read this chapter when you are about to:

  • Create or modify a Homeroom or Subject Group endpoint
  • 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 specs above are 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 = taught in that grade (weeklyHours > 0; 0/null submissions are discarded)
        ├── 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).

Decoupled rosters, optional section affinity (2026-07-08 / 2026-08-06). Creating a Homeroom seeds no Subject Groups and a Homeroom owns no courses. A Homeroom keeps its Curriculum + Grade binding purely for curriculum-match eligibility of its roster (a rostered student's selection must target the homeroom's curriculum). All Subject Groups are standalone with their own manual rosters. A course MAY declare a section affinity (SubjectGroup.homeroomId, §1.5): homeroom roster events cascade into linked courses, and nothing else reads the link — it is never a roster fence.

A Homeroom is a registration/form group bound to (Department, Curriculum, Grade, AcademicYear) — roster + optional tutor (homeroomTeacherId) + optional base room. It does not bind a CurriculumTrack (students of different tracks share one homeroom — that was the motivating case for the decoupling). Each Student belongs to at most one concurrent Homeroom per AY. Membership is a half-open [validFrom, validUntil) interval; the database exclusion constraint rejects overlapping intervals while retaining closed history.

A Subject Group is a standalone 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 has its own manual roster (SubjectGroupAssignment), created via POST /subject-groups and mutated via its own roster endpoints. There is no homeroom-bound flavour — the pre-decoupling ownership model is gone for good; every SG has its own roster, a mutable name, and is deletable. The SubjectGroup.homeroomId column returned on 2026-08-06 with different semantics than the pre-decoupling one: an optional section affinity (§1.5), never ownership and never a roster constraint.

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

The "one concurrent Subject Group per Subject per Student per AY" invariant is DB-backed: SubjectGroupAssignment denormalizes curriculumSubjectId + academicYearId (safe — the parent SG's curriculumSubjectId is immutable). A Postgres EXCLUDE USING gist constraint rejects overlapping [validFrom, validUntil) intervals without preventing a student from leaving and later rejoining.

1.0 Scheduled changes replace by default (2026-07-30, contract §20)

Every membership identity (homeroom per student; (student, subject) for SGs) carries at most one outstanding scheduled transition (contract §2b #24). That invariant is enforced by the writers, not by refusals: an admin roster command that collides with a booked plan replaces it, newest wins, inside its own transaction — there is no flag and no TEMPORAL_PENDING_CHANGE_EXISTS on any admin route (the code survives only as the normalize corruption invariants):

  • remove dated after a booked join → the join is cancelled whole to [d, d) (a booked move's source reopens) — never a join-then-leave mini-episode;
  • move dated after a booked join → the join is cancelled and the target opens at the new date (re-date + re-target in one command);
  • add/create over a booked leave from another group → the leave is re-anchored onto the command's boundary, composing one move-shaped transition. Uniform on both axes since the 2026-08-06 forward-eligibility spec: homerooms always worked this way; the SG one-concurrent gate now reads the forward membership too (forwardSubjectGroup, the twin of forwardHomeroom), so a student whose removal is only booked is offered by the picker and accepted by the gate the same day — the QA "remove here, can't assign there" dead-end is gone. moveStudents remains the atomic cross-group composition, no longer the only one;
  • a dateless batch over rival booked dates → every plan is re-dated onto the ONE fresh default (today on a clean register, §6.0) and reported.

Write responses carry superseded[] ({studentId, entity, was: {on, incomingGroupId, outgoingGroupId}}) reporting every replaced plan — render it loudly (a toast naming what was replaced is the FE contract). A re-anchor pulled onto today takes the same-day close gate like any close. The forward selection sync tolerates a compatible pending curriculum-selection at another date (it writes nothing — contract §18), and is otherwise admin-wins: an incompatible booked selection is cancelled whole, the class-implied fill lands at the command's own date, and the response's superseded[] gains an {entity: 'curriculum_selection', was: {on}} entry (contract §20). The from-homeroom batch reports the same way; its skip now reads the same forward rule (a current member or booked join skips; a booked leave is placed and superseded), and the course-blueprint selected counts collapse to the same forward set — the wizard, the picker, and the gates all answer "already spoken for?" identically.

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 via POST /subject-groups; creating a Homeroom seeds none (homeroom/course decoupling). 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 courses. The subject-removal grid guard is likewise unchanged: removing a subject is blocked only by a course with students (guardAndClearEmptySubjectGroups); empty 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 SG never freezes its subject. The in-use guard guardAndClearEmptySubjectGroups (+ 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 SGs are deleted so the Restrict FK doesn't block the edit. Demote (READY→DRAFT, via PATCH /curricula/:id/status) clears empty 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 SGs are empty.

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

1.4 Subject units (main subject decomposed into graded units)

A CurriculumSubject has one of three shapes, read off two columns (parentSubjectId, whether it has children):

  • leaf — the subject as always: its own per-grade cells (CurriculumSubjectHours), scale, criteria. parentSubjectId = null, no children.
  • main subject (container) — a subject taught in units (e.g. MathematicsAlgebra, Geometry). It owns its own cells exactly like a leaf — hours, grading scale, level, rooms, period duration. parentSubjectId = null, ≥1 child. It also carries criteriaLevel (SUBJECT default, or UNIT).
  • unit — a child of a main subject (parentSubjectId set). It carries only criteria (+ a per-unit criteria scale in UNIT mode) — no hours, subject-mark scale, level, rooms, or period duration (submitting any → CURRICULUM_UNIT_HAS_CELLS). Its criteria can vary per grade: criteria is the default list; criteriaByGrade adds per-grade overrides (replace semantics), limited to the main subject's offered grades (a unit has no cell, so this is its per-grade anchor — the leaf's per-cell criteria equivalent). The criteria scale is not per-grade — it stays the unit-level criteriaGradingScaleId. It is never choosable (a CHECK forbids a unit from being in an option block or carrying a track) and never gets its own Subject Group.

Hours live on the main subject (inverted 2026-07-20 — see the iteration spec). A main subject is an ordinary leaf-with-cells; a unit is a thin criteria-only child. The main subject is the unit of everything structural: it is scheduled as one SG worth its own cell hours, coverage counts it once, the selection sees it as one choosable subject, the timetable places one slot, and the student's course list shows it once (with its own weekly hours). There is no per-unit scheduling and no granularity flag.

Where the shape matters (each reads the two columns, no flag):

  • SG create (subject-groups.service.ts create) — rejects an anchor whose parentSubjectId != null (SUBJECT_GROUP_ANCHOR_IS_UNIT); a leaf or main subject is allowed. The offered-in-grade gate uses the shared anchorOffersGrade (subject-groups.queries.ts): a leaf and a main subject both read their own cells (only taught cells are stored, so cell presence ⟺ offered).
  • Coverage boards / selection — units are filtered out at the source query (findBoardSubjectsForCurricula, loadConsistencyStructure: parentSubjectId: null); a main subject's offeredCells/offeredGradeIds are its own cells. So a unit is never a board row nor a choosable/coverage subject; the main subject counts as one expected course.
  • Grades — a mark on a main-subject SG names a unit; the subject-mark scale resolves from the main subject (see ch21).
  • Attendance — a take on a main-subject SG optionally tags the units covered (see ch19).

The grid authoring DTO nests units[] under a subject (CurriculumUnitInputDto, no further nesting; a unit carries only criteria + optional per-grade criteriaByGrade) and the read response carries isContainer, criteriaLevel, and nested units[] (a unit projects hours: [], its default criteria, and its criteriaByGrade overrides; criteriaByGrade is [] on a leaf/main subject). There is no effectiveHours — a main subject's hours are its own cells. Family-sync treats units as followers of their main subject (parent-scoped identity; unit rows recurse one level, never deeper). Spec/plan: 2026-07-14-subject-units-design.md (baseline) + 2026-07-20-subject-units-iteration-1-design.md (hours inversion) + plan.

~90% of courses in a section-based school ARE "1B's math course"; the 2026-07-08 decoupling erased that fact from the model (its own pushback log predicted the friction). The link restores it without reopening ownership:

  • SubjectGroup.homeroomId — nullable FK, SetNull on homeroom delete. Set via POST /subject-groups (optional composition.homeroomId), changed or cleared via PATCH (null clears; a link change performs ZERO roster writes), auto-stamped by the from-homeroom wizard on its mandatory CREATE items (§5.4). Course DTOs (detail/list/table) carry homeroom {id,name} | null; the homeroom detail carries the mirror linkedCourses[].
  • Mandatory-anchor-only. assertHomeroomLinkValid (subject-group-write-ops.ts) refuses an in-block anchor (SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK) and a homeroom that doesn't share curriculum/grade/AY (SUBJECT_GROUP_LINK_MISMATCH). A section-owned elective is nonsense, and the restriction makes cascade-created option-block choices structurally impossible. There is deliberately no (homeroomId, subject) unique — split sections are legit; the cascade skips on ambiguity instead.
  • Affinity, not a fence. No roster validation reads the link — eligibility is exactly the classifier's rules, and a linked course can roster any eligible student. The ONLY consumer is the cascade below (plus the table filter union, §5.2/§5.4, and display surfaces).
  • The cascade (src/homerooms/linked-course-cascade.ts planner, executed by HomeroomsService inside each roster command's transaction at the command's one appliedFrom): a student leaving a homeroom (remove or move-out) is removed from the courses linked to it their forward membership holds; a student entering one (add / move-in) is added to the target's linked courses they explicitly belong to — the blueprint's selected rule, extracted to classifyExplicitBelonging (src/homerooms/explicit-belonging.ts) and shared with countSelected — with the eligibility classifier as a belt. Additions that cannot be made automatically are skipped, never blocked, with a per-course reason (ALREADY_IN_COURSE_FOR_SUBJECT | AMBIGUOUS_LINKED_COURSES | TRACK_NOT_HELD | NOT_ELIGIBLE). Roster-write responses report the whole thing in courseCascade[] (per student: added/removed/skipped) — render loudly, like superseded[].
  • Temporal composition. Cascade boundaries share the command's date; displaced booked SG transitions are re-anchored via the forward-eligibility primitives and reported as superseded[] subject_group_membership entries; the same-day close gate and the dateless walk probe the union of homeroom-affected and cascade-affected students, so an attendance cell on a linked course slides the whole command together. Cascaded rows are derived state — never provenance-stamped; re-running commands recomputes them from the homeroom plan.

Canonical: docs/superpowers/specs/2026-08-06-homeroom-course-link-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).

Custom-field surfacing + validation (2026-07-15). These two services are bespoke (not BaseTenantedCrudService) and return an aggregate/flat detail DTO, so they don't get the base read funnel. They now wire into the custom-fields subsystem explicitly: the detail response (GET /:id, create, patch, move source/target) carries a top-level customFields bag built by the shared buildCustomFieldValues(raw, defs) helper — every defined field present, unset → value: null — surfaced entity-wide (union of composition + others defs, since these entities have a single custom-field surface). Writes route through CustomFieldsService.validateCustomFieldsFlat(tenantId, entityKey, customFields, mode) (entity-wide variant of validateCustomFields): unknown key / wrong type → 400 VALIDATION_FAILED, required-on-create enforced; PATCH merges onto the existing JSONB. There is deliberately no flat→scope-grouped reshape (it would fight the roster-bearing @AggregateResponse() design). Spec: docs/superpowers/specs/2026-07-15-custom-fields-always-surface-design.md; FE contract: docs/fe-guides/2026-07-15-custom-fields-always-surface-BREAKING.md.


3. Homeroom create — header + roster only

The Homeroom create endpoint runs a single prisma.$transaction that writes the header and the roster — and nothing else. It seeds no Subject Groups, assigns no subject-teachers, and binds no track:

// src/homerooms/homerooms.service.ts ─ create()
const createdId = await this.prisma.$transaction(async (tx) => {
  const homeroom = await tx.homeroom.create({
    data: {
      // … header fields (department, curriculum, grade, tutor, base room)
      assignments: { create: c.studentIds.map(...) },   // N HomeroomAssignment
    },
    select: { id: true },
  });
  await applyClassAssignmentToSelection(tx, ...);        // forward class→selection sync (§6.1)
  return homeroom.id;
});

Pre-flight validations happen outside the transaction (cheaper rollback on common errors): each rostered student is run through classifyHomeroomEligibility (§5) — ENROLLED, in the target department + grade, with a matching or absent curriculum selection, and not already in another homeroom. There is no subject-teacher seeding, no per-slot mandatory-subject validation, and no cross-SG conflict pre-check — a homeroom no longer touches any course.

(The pre-decoupling create ran a three-write wizard cascade that seeded one homeroom-bound child SubjectGroup per mandatory subject offered in the grade+track, attached subjectTeachers, and fired a fail-soft addToDeclaredSubjects side-effect. All of that was removed on 2026-07-08 — reversing the 2026-07-01 "seed all mandatory SGs" design.)


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):

  • HomeroomsReadPolicy lives at src/homerooms/homerooms.policy.ts.
  • SubjectGroupsReadPolicy lives at src/subject-groups/subject-groups.policy.ts.
  • HomeroomsWritePolicy / SubjectGroupsWritePolicy are the mutation fences.

Call HomeroomsReadPolicy.where(ctx) / SubjectGroupsReadPolicy.where(ctx) in the service to obtain the Prisma WHERE clause.

Caller role Visible Homerooms / SGs
platformAdmin All in the tenant
admin / hr / secretary / principal All in the tenant + active AY
teacher Effective SGs they teach; homerooms they tutor (empty included) OR containing ≥1 effective assignment of a studentRelatedToTeacherOn student (direct/combined course, tutor, supervised activity, or break/lunch duty)
department_head / curriculum_coordinator (parametric) Personal teacher reach ∪ their managed departments/curricula; identical in combined and active management-role views
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: [] } })

Referent/student branches vs role grants. The seeded referent and student roles hold no homerooms.composition / subject_groups.composition grant, so the module routes above 403 at the ScopeGuard. Their policy branches remain useful to cross-module projections, including /filters/* surfaces. A referent sees the child's class/course summaries through students.assignment.

The policy returns a Prisma.<Entity>WhereInput that's AND-combined with the tenant+AY scope in the service. Mutations resolve a scope/action-qualified role slice and apply the matching write policy to every target/anchor. A management WRITE grant therefore cannot borrow a personally taught row outside its values.

Reads call loadDetail(tenantId, yearId, id, ctx); non-visible records 404 silently. Trusted post-authorization reloads use ctx = null, but target resolution itself is always write-policy-qualified.

Every access-context detail response intersects roster and pending-student rows with StudentsReadPolicy. Homeroom linkedCourses additionally intersects SubjectGroupsReadPolicy. Broad roles still receive complete sets because their nested policy resolves to the tenant base; ctx = null is reserved for trusted internal reloads.

4.1 Roster study-plan block (detail GET)

GET /homerooms/:id embeds a studyPlan block on each roster student — the identical curriculum-selection object GET /students/:id returns in its assignment scope (StudentStudyPlanSummaryDto: status / curriculum / chosenTrack / confirmedAt / selectedOptions[] + the grade-sliced selectedCurriculum tree). It reuses the students module's buildStudyPlanSummary

  • flattenCurriculumForStudent (and the shared STUDY_PLAN_SELECTION_SELECT projection) so the shape can't drift.

  • Second-pass, GET-only. Built by HomeroomsService.enrichRosterStudyPlans after toDetailDto, via findRosterStudyPlanRows — kept off homeroomDetailInclude so write-path reloads (POST/PATCH/roster ops) don't pay for it and never carry the block.

  • Secondary scope gate. The homeroom route only requires homerooms.read, and the aggregate response is not field-filtered against students scopes — so the block would be a cross-entity leak. enrichRosterStudyPlans runs only when canReadStudentAssignment(ctx, permissions) holds: students.assignment read (PermissionsService.checkScope), platform admins always. Otherwise studyPlan is omitted. Same soft-gate shape as getGroupedHomerooms' students.read assert, but omit-not-403.
  • The heavy selectedCurriculum tree load is deduped by curriculumId (a homeroom's roster shares one grade and, via the forward sync, usually one curriculum).

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 { 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).

Cohort is a dated question, on both sides. departmentId/gradeId on the student record are the planned terminal placement (ch01 / docs/fe-guides/2026-07-27-student-placement-temporal-BREAKING.md); who is in a cohort today is the placement interval governing today. All four loaders — the two picker cohorts and the two gate loads — therefore run their rows through resolveDisplayCohorts before classifying, and the picker's WHERE uses studentPlacementDisplayWhere. Display tier, so a student no interval covers (pre-year, before the calendar starts) keeps the pair on the record and stays composable. Sharing the classifier is only half of parity: fed different pairs, one shared rule still produces two answers — a student moving next Monday was briefly admitted by the picker's WHERE and then hidden as WRONG_GRADE against the record. The loader half is locked in homerooms.queries.spec.ts and subject-groups.queries.spec.ts.

Already-spoken-for students are hidden, read on the forward membership (Homeroom: forwardHomeroom; SG: forwardSubjectGroup, uniform since the 2026-08-06 forward-eligibility spec) — a current member or a booked join hides, a booked leave does not: a student removed today (effective tomorrow) is offerable in another group the same day, and the write supersedes the leave (§1.0). Moving between sibling SGs can also ride the atomic POST /:id/students/move. Conflicting selections are hidden — and only EXPLICIT conflicts count (admin-driven placement, 2026-07-31): a different curriculum; a track-scoped anchor (mandatory subject or option block under a track) when the selection explicitly holds another track (WRONG_TRACK — a null trackId, track not chosen yet, stays placeable and the forward sync fills the track); or — for an in-block SG — the block already at its maxSelections capacity holding other alternatives, which for a single-pick block reduces to "any other alternative already chosen". Homeroom placement does not consult track at all. 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 StudentsReadPolicy), 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.

5.1 Grouped boards (list-view companions)

Two counts endpoints back the homerooms / subject-groups list views (managers see their slice; plain teachers are admitted read-only since 2026-07-10 and self-narrow to their own assignments). 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. Iteration 4 (2026-07-10) put the CV-defined structure on the coverage rows so defined-but-empty groups render — see docs/superpowers/specs/2026-07-10-grouped-courses-iteration-4-design.md. Iteration 5 (2026-07-10, same day) turned that structure into the tree itself: grouped-courses is now dept → grade → curriculum → track with SG leaves nested under their subject's coverage row — see docs/superpowers/specs/2026-07-10-grouped-courses-iteration-5-design.md.

GET /homerooms/grouped-homerooms GET /subject-groups/grouped-courses
Tree dept → curriculum → grade dept → grade → curriculum → track (common track: null node first, then cv tracks — all catalog-emitted)
Class leaf homerooms[] (HomeroomListItemDto) nested under the subject's coverage row: tracks[].mandatorySubjects[].subjectGroups[] / tracks[].optionBlocks[].subjects[].subjectGroups[] (GroupedCourseSubjectGroupDto — all standalone; [] = defined-but-empty group)
Node counts grade: numHomerooms + numStudentsWith/WithoutHomeroom; dept: the same student pair over ALL enrolled students + numStudentsWithoutCurriculum per-grade curriculum node: numStudents, numStudentsWithMissingAssignments, numSubjectGroups, numSubjectGroupsWithoutTeacher, numOfferedSubjectGroups (mandatory offered in the grade + Σ block minSelections); coverage rows: numStudentsExpected/numStudentsPlaced + weeklyHours + ordinalPosition; dept: numSubjectGroups, numStudents, numStudentsWithoutCurriculum
Query filter departmentId? departmentId?

Shared mechanics:

  • Catalog-driven skeleton — departments enumerate under DepartmentsPolicy.where(ctx) (AY-scoped), curricula (any status) under CurriculaPolicy.where(ctx) (findGroupedBoardCurricula), and — on both boards — each curriculum's covered grades from its CurriculumGrade rows (the courses board pivots them above the curriculum). 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 policy-hidden curriculum or an uncovered grade) 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 StudentsReadPolicy resolves to NEVER_MATCH_WHERE gets zeroed counts with an intact skeleton.
  • CV-defined tree (courses board, iterations 4-5). The board IS the curriculum structure: each per-grade cv node emits its common (track: null) node first, then every cv track by ordinal (empty nodes included); track nodes hold mandatorySubjects[] + optionBlocks[] (block ref w/ min/max) whose coverage rows nest their SG leaves — the FE renders every curriculum-defined section (including empty ones) from the board alone, no grid fetch, no client-side joins. A subject lands in the node of its effective track (subject.track ?? block.track ?? null). Row/block presence stays "cell presence ⟺ taught"; a leaf whose subject lost its hours cell keeps a defensive weeklyHours: null row so the course stays visible. The SG leaf's subject summary carries optionBlock {id,name} (also on GET /subject-groups + detail — shared DTO).

RBAC — two-scope, role-narrowed. 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 / teacher (no hr) + parametric admission via @AppliesPolicyDimensions. Narrowing reuses StudentsReadPolicy.where(ctx) for student counts and HomeroomsReadPolicy / SubjectGroupsReadPolicy for class rows. A plain teacher (2026-07-10) gets a read-only board: the skeleton stays department-wide (DepartmentsPolicy / CurriculaPolicy narrow by employment department), the class leaves follow the own-assignments policy branch (§4), and — since 2026-08-02 — the student counts narrow to the related set (StudentsReadPolicy's teacher branch is studentRelatedToTeacherOn, no longer dept-wide; spec 2026-08-02-teacher-people-visibility-narrowing-design.md). The BE does NOT prune skeleton nodes with zero visible leaves; the teacher view trims client-side.

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). numOfferedSubjectGroups (renamed from numMinRequiredSubjectGroups in iteration 5 — "required" overstated it) is per grade, computed from the board's own catalog rows: |mandatory subjects taught in the grade| + Σ minSelections over blocks offering ≥1 alternative there — deliberately NOT track-filtered (every track cohort needs its own SGs) and still a floor (a subject may be split across more SGs). The whole-cv countMinRequiredSubjectGroups util was deleted with the rename (the board was its only consumer).

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. Grade + curriculum are pinned by the node, so any sibling homeroom in the node is a valid target (HOMEROOM_MOVE_GRADE_MISMATCH / HOMEROOM_MOVE_CURRICULUM_MISMATCH are the belt; there is no track dimension). targets = node.homerooms filtered to h.id !== sourceHomeroom.id.
  • Courses board. The gate matches on curriculumSubjectId (SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH). Each leaf carries subject.curriculumSubjectId: targets = node.subjectGroups filtered to sg.subject.curriculumSubjectId === sourceSg.subject.curriculumSubjectId ∧ sg.id !== sourceSg.id.

5.2 Table list surfaces (v1) + the /filters/* companion

Product moved the classes/courses list pages from trees to filterable, paginated tables (spec docs/superpowers/specs/2026-07-12-table-lists-and-filters-design.md). Two flat table GETs shipped alongside the boards; the grouped trees above (§5.1) are swagger-deprecated but still live — when the FE finishes migrating, the trees get deleted and the /table routes rename onto the bare paths (GET /homerooms, GET /subject-groups) in a cleanup iteration. Don't build anything new on the tree shapes.

Iteration 2 (multi-value filters, 2026-07-13) — spec .../2026-07-13-table-lists-and-filters-iteration-2-design.md. Every id filter on both tables and every optional narrowing param on /filters/* accepts multiple values (repeat the param or comma-separate): within a field the values OR (IN), different fields still AND. Reuses @TransformToArray() + @IsUUID('4',{each:true}), so a lone value stays wire-compatible (additive — no FE migration for single-value callers). The required curriculumId anchor on /filters/subjects stays single (on /filters/tracks it was later loosened to an optional multi-value narrow — see the tracks note below). "Common (no track)" is selectable via an includeCommonTrack boolean (SG table + subjects filter), backed by the shared buildEffectiveTrackWhere(trackIds, includeCommon) in subject-groups.queries.ts.

GET /homerooms/table GET /subject-groups/table
Row HomeroomTableRowDto = HomeroomListItemDto + createdAt/updatedAt SubjectGroupTableRowDto = SubjectGroupListItemDto + track {id,name}\|null + createdAt/updatedAt; subject summary carries code
Filters (multi-value: within-field OR, cross-field AND — 2026-08-24) departmentId, curriculumId, gradeId, homeroomId, homeroomTeacherId, studentId (roster membership); search (class name) departmentId, curriculumId, gradeId, trackId (effective track, strict — see below) + includeCommonTrack bool, teacherId (any position), curriculumSubjectId, optionBlockId (in-block courses only — mandatory never match), homeroomId (stored link ∪ derived roster overlap — see §5.4); search (course name OR subject name)
sortBy whitelist name, department, grade, homeroomTeacher, numStudents, createdAt name, department, grade, subject, numStudents, createdAt (track not sortable — computed across two relations)
Envelope {data, meta}meta is the standard PaginationMetaDto same

Shared semantics:

  • Filter algebra (2026-08-24 product update). Values inside one dimension OR (IN); different dimensions AND. A disjoint department + homeroom now yields an empty page. The same algebra applies to GET /students, setup rollover, the placement-readiness companions, and the communications recipient picker. Policy and AY predicates remain additional AND fences. On combined-class rows, each selected course dimension must be satisfied by a current member; values within that dimension still union.
  • Cascading option lists. /filters/* follows the UI order department → grade → curriculum → homeroom: grades accept selected departments; curricula accept departments AND grades; homerooms accept departments AND grades AND curricula. When the user changes an earlier dimension, the FE clears all selected values to its right before requesting the next options. The backend is stateless and cannot clear client state.
  • Deterministic pagination. orderBy = chosen column (if any) → the default composite (dept name → grade ordinal → [subject name →] name) → final id asc tiebreak, so pages never shuffle between requests.
  • Effective track = subject.track ?? optionBlock.track (same rule as the board's node placement). The trackId filter is strict: it excludes common (track: null) courses — omit the filter to mean "all tracks", or pass includeCommonTrack=true to admit common courses (combine with trackId for "these tracks OR Common").
  • optionBlockId filters to courses whose anchor curriculumSubject sits in one of the given option blocks (curriculumSubject.optionBlockId IN …); mandatory (no-block) courses never match. Unlike trackId there is no "include mandatory" companion toggle — omit the filter to include them. The option block is already on every row as subject.optionBlock {id,name} (null = mandatory), so this filter needs no payload change; dropdown values come from GET /filters/option-blocks?surface=subject-groups projected from the visible course rows (or, within a chosen curriculum, GET /filters/subjects?surface=subject-groups' optionBlock refs).
  • Unknown filter ids yield an empty page, never 404 (dropdowns and tables refresh independently; a stale id must not error the table).
  • search is a case-insensitive substring (contains), ANDed with every selected filter dimension. Classes match the homeroom name; courses match the course's own name OR its subject name, and a combined-class row matches its own name OR any member course's subject name (a combined class has no single subject). Homeroom search folds into buildHomeroomsTableWhere; course search lives in buildSubjectGroupSearchWhere / buildCombinedClassSearchWhere, appended per side so the id-filter builders stay pure.
  • No roster. Rows are class headers + numStudents; rosters stay on the :id detail.
  • Same RBAC as the trees: @RequireScopes(<ENTITY>,'read') + @AppliesPolicy(<Entity>Policy) — teachers self-narrow to own assignments, parametric managers to their slice.

Subject drill-down — GET /subject-groups/by-subject/:curriculumSubjectId (2026-08-14). The drill-down from a table row to "all courses of this subject" remains its own endpoint because it returns full detail rows (including rosters), not table headers. The anchor pins subject → curriculum → department; the only query param is the academicYearId override. It returns { subjectGroups, combinedClasses }: every course anchored on the subject across all its grades (ordered grade → name: Arte 1A, 1B, 2A…), each in the full SubjectGroupDetailDto shape (roster + pending changes included — deliberately richer than the table's header rows), plus the combined classes ≥1 of them contributes to today in the GET /combined-classes/:id shape (match via each course's combinedClassId, which the detail DTO carries since this iteration — resolved from the membership episode effective today, never the forward head). Detail-route semantics: unknown/foreign anchor or another year's curriculum → 404 (SUBJECT_GROUP_REQUIRES_CURRICULUM_SUBJECT), unlike the table's empty page. The existence probe is tenant-scoped, not CurriculumSubjectsPolicy-scoped — that policy fails closed for teacher/family sessions, which do reach this page; row visibility stays on SubjectGroupsReadPolicy (same gate as the table), so a teacher sees only their own sections and the combined side keeps the table's two-leg any-member rule (CombinedClassesService.listAnchoredOnSubject). Unpaginated: the set is bounded by sections × grades.

The /filters/* module (src/filters/) populates table dropdowns. Every request has a required surface=students|homerooms|subject-groups|teachers|timetables (restricted per route below). The service starts from that surface's dated read policy, applies the endpoint's upstream cascade with the same table-WHERE builder, projects distinct option ids, and loads the existing response DTO. Missing, invalid, or endpoint-unsupported surfaces return 400; there is no tenant-catalog fallback.

Route Supported surfaces Existing params (besides required surface) Projection
/filters/departments students, homerooms, subject-groups, teachers, timetables academicYearId?; timetable also requires timetableId departments represented by surviving rows/cells
/filters/grades same five academicYearId?, departmentId?; timetable also requires timetableId grades represented after department cascade
/filters/curricula same five academicYearId?, departmentId?, gradeId?; timetable also requires timetableId curricula represented after cascade
/filters/tracks students, subject-groups academicYearId?, curriculumId? effective selected/taught tracks
/filters/option-blocks subject-groups academicYearId?, curriculumId? blocks represented by visible course rows
/filters/subjects subject-groups required single curriculumId, gradeId?, trackId?, includeCommonTrack? curriculum-subject ids represented by plain or combined course presentations
/filters/homerooms same five academicYearId?, departmentId?, curriculumId?, gradeId?; timetable also requires timetableId effective roster/leadership homerooms or visible homeroom rows
/filters/rooms homerooms, subject-groups, timetables academicYearId?; timetable also requires timetableId represented-department/shared rooms on table surfaces; exact scheduled rooms on timetable
/filters/teachers homerooms, subject-groups, timetables search?, page?, limit?; timetable also requires timetableId effective tutors/course teachers/supervisors/duties, additionally intersected with TeachersPolicy
/filters/students students, homerooms, timetables search?, departmentId?, gradeId?, page?, limit?; timetable also requires timetableId policy-visible source students/roster rows; timetable returns ENROLLED authoring candidates in the anchored AY using display-cohort department/grade

Structural routes keep bare {data} and remain authenticated-only; people routes keep paginated {data,meta} and their existing scope gates. The same WHERE is used for people data and count. Student-surface structural facts come from effective/display placement and effective selections/assignments, never raw forward-head cohort columns. Homeroom student options intersect the roster with StudentsReadPolicy; subject-group projection includes both plain groups and visible combined presentations. OR-within one dimension and AND-across dimensions are unchanged.

surface=teachers is the Teachers table's own four-dimension companion, not a course proxy. GET /teachers and its option projection share teacherCohortDisplayWhere: employment departments; effective taught or led grades/curricula; and led, stored-affinity, or effective-roster-overlap homerooms. Values within a dimension union and selected dimensions intersect. A simple teacher's identity directory remains tenant-wide; Department Principal narrowing still comes from TeachersPolicy before projection.

surface=timetables is anchored by a required timetableId and is supported on the seven grid filters in the table. The timetable's owning year is authoritative; a supplied mismatching academicYearId is 404. Its projector is owned by TimetablesModule, reuses the grid selection engine, and contributes lesson, activity, and break cohorts plus exact scheduled rooms and assigned teachers. The student endpoint is intentionally candidate-shaped rather than a surviving-cell projection: it applies StudentsReadPolicy, timetable AY, status = ENROLLED, search, and display-cohort department/grade, and uses the same WHERE for page/count. See chapter 18 §6. Staff and mutation forms have no substitute surface: Staff has no structural relation, while employment and role forms use their write-side catalogues.

5.3 Missing-courses coverage widget

GET /subject-groups/missing-courses is the courses-page coverage-gap widget (spec docs/superpowers/specs/2026-07-13-subject-groups-missing-courses-design.md): a pruned department → curriculum → grade → missingSubjects[] tree of every subject the curriculum offers in a grade (a positive-hours grid cell) that has zero courses yet.

  • "Missing" is catalog-driven, per-slot. A subject is missing iff it is offered in the grade AND no SubjectGroup is anchored on (curriculumSubject, grade, AY). Creating one course clears it — extra sections never change the count (the "at least one" rule). Student selections are irrelevant, so the widget works before the selection window opens and the endpoint needs no STUDENTS read.
  • Mandatory + optional both listed. Each leaf is { curriculumSubject {id,name}, isOptional, optionBlock {id,name}|null }; option-block alternatives get isOptional: true + the block ref. Every level carries numMissingMandatory + numMissingOptional for badges. Fully-covered branches are pruned (no badge ⟹ nothing to create).
  • Same filter object as /subject-groups/table (reuses SubjectGroupsTableQueryDto verbatim so the FE passes its filter state unchanged). Honours departmentId/curriculumId/gradeId/trackId (+includeCommonTrack, matched on the effective track via the pure subjectMatchesTrackFilter, mirroring buildEffectiveTrackWhere) /curriculumSubjectId/optionBlockId (in-block subjects only — a catalog dimension like trackId, applied as an in-memory guard)/academicYearId; ignores teacherId, page, limit, sortBy, sortDir (a not-yet-created course has no teacher, and the tree is neither paginated nor sorted).
  • Gate diverges from the boards: admin + department_head only, no teacher. A teacher's SubjectGroupsReadPolicy self-narrows to their own assigned courses, which would hide other teachers' courses and surface false gaps; managers see all courses in scope, so coverage is correct. Route lives on GroupedCoursesController (registers before GET /:id). Reuses the board query blocks (findBoardDepartments/findGroupedBoardCurricula/ findBoardSubjectsForCurricula) + one new findCoveredSubjectGradePairs (subjectGroup.groupBy).

5.4 Create courses from a homeroom (wizard) + the homeroomId filter

The "create courses for a class" wizard (spec docs/superpowers/specs/2026-07-20-create-courses-from-homeroom-design.md) adds a read + a write. (Authored pre-link with "zero schema changes"; since 2026-08-06 the batch stamps the section affinity — see below — while the roster decoupling is otherwise preserved.)

GET /homerooms/:id/course-blueprint — one-call wizard read (CourseBlueprintService, src/homerooms/course-blueprint.*): the homeroom + full roster, the common (track-less) mandatory subjects and option blocks the curriculum offers in the homeroom's grade, and each track's subjects/blocks (effective-track placement, board conventions; units never appear). Every subject leaf carries existingSubjectGroups[] (array — every section on (subject, grade, AY); non-empty ⟺ "already exists", entries are the batch's merge targets) and selected: number — the roster a course created for the subject would receive, computed SELECTION-based over one grade-wide cohort load (deliberately NOT the picker's eligibility classifier — product decision 2026-07-31: the bulk flow rosters courses with the students that explicitly belong, never merely-eligible ones): option-block subject → explicit choice on this alternative; track subject → selection explicitly on the track; common mandatory → the homeroom's roster; minus students already in a course for the subject this AY (the batch would skip them). 0 is valid — the batch still creates the shell. Gate mirrors the pickers: @RequireScopes(SUBJECT_GROUPS,'read') + admin/department_head + @AppliesPolicyDimensions(SubjectGroupsReadPolicy) — the dimensions metadata must match the scope entity (boot-time assertPolicyScopeAlignment); same dims as HomeroomsReadPolicy, so parametric admission is identical. The homeroom still resolves under HomeroomsReadPolicy.where(ctx) in the service (non-visible → 404). Candidate rows still come from the §5 picker — whose rows now carry homeroomId next to homeroomName for client-side class filtering of the grade-wide pool.

POST /subject-groups/from-homeroom — atomic batch write (src/subject-groups/from-homeroom/): grade + curriculum derive from homeroomId, ACTIVE year only. Item without subjectGroupId = CREATE (required name; optional teacherIds/baseRoomId/customFields); with it = MERGE (append-only roster — never touches an existing course's name, teachers or room; teacherIds/baseRoomId/customFields are rejected on merge items, a name is accepted but ignored since the FE sends it uniformly). name is required on a create item precisely so bulk-creating the same subject for several homerooms in a grade names each section distinctly, rather than defaulting to the subject name and tripping the (academicYearId, gradeId, curriculumSubjectId, name) uniqueness (SUBJECT_GROUP_NAME_CONFLICT); a missing name on a non-empty create item is VALIDATION_FAILED. All validation is pre-tx and reuses the single-endpoint gates (anchor checks, the shared classifier, teacher/room/custom-field validation); the writes then compose the SAME tx bodies as POST /subject-groups / POST /:id/students — extracted to subject-group-write-ops.ts (createSubjectGroupInTx / appendStudentsToSubjectGroupInTx + the shared P2002 mapper and base-room assert) — inside ONE $transaction. All-or-nothing: any hard violation 409s and writes nothing. One softening (pinned by the parity spec's BATCH_REASON_TRIAGE): a student whose forward membership already holds an SG for the subject this AY (current member, or booked join) becomes a per-student skip reported in skippedStudentIds — whole-class mandatory rosters survive pre-placed students, and blueprint-driven re-runs are idempotent. A booked leave does not skip (2026-08-06): the batch places the student, re-anchors the leave onto its one boundary, and reports it in superseded[]. A CREATE item with a submitted-empty roster creates the course as an empty shell (no EMPTY_ROSTER skip since 2026-07-31 — the admin picked the course; name is therefore required on every create item). Response {created[], updated[]} (the skipped[] section is gone with the skip). RBAC is two stacked decorators — @RequireAction(SUBJECT_GROUPS,'create') + @RequireScope(SUBJECT_GROUPS,'composition','write') (the singular scope also tells FieldWriteGuard the flat body is domain-shaped — bulk-assign precedent). New error code: SUBJECT_GROUP_BATCH_TARGET_MISMATCH (stale merge target).

Section stamping (2026-08-06). A CREATE item whose anchor is mandatory comes back linked to the wizard's homeroom (homeroom {id,name} on the created course) — true by construction, so the batch runs no extra link validation. In-block CREATE items stay unlinked (mandatory-anchor-only rule, §1.5) and MERGE items never touch the target course's own link.

homeroomId on GET /subject-groups/table — the "courses of class 2A" filter is a union since 2026-08-06: the stored section link OR the derived roster overlap (≥1 rostered student currently in the homeroom). The overlap leg stays so a shared-unlinked course serving two classes matches both; the link leg matches a linked course even with an empty roster. Combined-class rows match via any member (the leg lives in buildSubjectGroupsTableWhere, so the combined mapping inherits the union). missing-courses accepts-and-ignores it (a not-yet-created course has no roster and no link).


5.5 Placement-readiness companions and student-first action pickers

The homerooms and courses tables each have a student companion. Both reuse the table query DTO and return the standard paginated { data, meta } envelope. Both start from { id, firstName, lastName, department, grade, curriculum, track }; the course companion also returns selectionStatus, hasMissingMandatoryCourseAssignments, and hasMissingOptionalCourseAssignments. department and grade are required { id, name } references resolved from the student's display cohort. curriculum and track are always present and nullable. Results are fixed-order (lastName, firstName, id); sortBy and sortDir remain accepted only so the FE can reuse the table querystring.

Surface Meaning Student-row filter dimensions
GET /homerooms/table/unassigned-students Active or requested-AY ENROLLED students with no forward homeroom membership. A booked join hides the student; a booked leave with no replacement exposes them. Curriculum context comes from the genuine forward selection head. departmentId, gradeId, curriculumId, studentId; homeroomId and homeroomTeacherId contribute no rows.
GET /subject-groups/table/students-with-placement-gaps Active or requested-AY ENROLLED students whose settled selection is incomplete or whose forward course assignments miss mandatory/optional curriculum coverage. Curriculum/track show the forward planned target. departmentId, gradeId, planned curriculumId, planned trackId, includeCommonTrack, effective-today homeroomId; teacherId, curriculumSubjectId, and optionBlockId contribute no rows.

Across each companion, values within a dimension union and different dimensions intersect; an active non-student dimension listed as contributing no rows makes the result empty. Case-insensitive student-name search is another intersection. Both reads require the table entity's read permission plus students.identity:read and students.assignment:read, and StudentsReadPolicy narrows the cohort. A caller with no visible students receives an empty page.

The course companion is a union worklist. A row appears when the settled selection lifecycle is NOT_STARTED/IN_PROGRESS, when mandatory course coverage is missing, or when optional course coverage is missing. A COMPLETE selection with complete coverage is hidden. Reasons overlap without duplicating the student: lifecycle is exposed by selectionStatus, while the two booleans mirror the dashboard categories. Optional coverage follows curriculum rules over actual forward assignments, not persisted optional choices. With no planned curriculum, both assignment flags are false rather than guessed.

Lifecycle and placement deliberately use different temporal views. Complete- wins pickSettledSelection supplies selectionStatus; the genuine forward selection head supplies the displayed curriculum/track and coverage target. A current COMPLETE version plus a scheduled partial edit can therefore report selectionStatus: COMPLETE while explaining gaps against the scheduled target. Booked joins count, booked leaves expose gaps, and membership in a real SubjectGroup still counts when that course is a CombinedClass member.

After selecting a student, admin and department-head clients use the active-year-only inverse pickers:

  • GET /students/{studentId}/assignable-homerooms returns { noSelection, selected }, with ordinary HomeroomListItemDto rows sorted by curriculum name, homeroom name, then id. Already-forward-assigned and non-ENROLLED visible students return empty buckets.
  • GET /students/{studentId}/assignable-subject-groups returns a pruned department -> grade -> curriculum -> tracks[] tree. Every track node has mandatorySubjects[] and optionBlocks[]; blocks contain subjects[], and each subject collapses its non-empty subjectGroups[] section list. A common node uses track: null. Each subject carries the classifier bucket noSelection, pending, or selected.

Both pickers anchor the student in the active AY under StudentsReadPolicy, filter targets through the corresponding entity policy, and apply the exact eligibility classifier used by the roster-add command. Missing, historical, cross-tenant, and policy-hidden student ids share the same generic 404. The read is advisory: POST /homerooms/{id}/students or POST /subject-groups/{id}/students revalidates all state in its transaction.

A CombinedClass is deliberately absent from the subject-group picker because it is not a roster write target. Its real member SubjectGroups may appear, and membership in such a SubjectGroup still satisfies curriculum coverage.

The command-center overview uses the same planned-state vocabulary. Its breaking selectionProcess shape is now:

{
  "byDepartment": [
    {
      "departmentId": "uuid",
      "departmentName": "Elementary",
      "numStudentsWithoutCurriculum": 0,
      "numStudentsWithoutHomeroom": 0,
      "numStudentsWithMissingMandatoryCourseAssignments": 0,
      "numStudentsWithMissingOptionalCourseAssignments": 0
    }
  ]
}

It folds the active-AY ENROLLED cohort by display department. Each counter is a distinct-student predicate and the four predicates overlap, so there is no total and clients must not sum them. Curriculum and membership evidence use the forward planned state. Mandatory gaps are missing applicable common/selected-track subjects; optional gaps are under-filled applicable blocks or lower-bound curriculum rules, derived from actual forward course assignments even when no option choice was saved. With an unresolved track, only common obligations are assessed. Assignments to ordinary SubjectGroups count even when those groups participate in a CombinedClass.


6. Roster operations & atomicity

A Homeroom's roster is its HomeroomAssignment intervals. Since 2026-08-06 a roster command also cascades into the courses linked to the homeroom(s) it touches (§1.5) — unlinked courses keep fully independent rosters. Reads and counts resolve the interval effective on the request's captured school date. All roster mutations run inside a $transaction:

Endpoint What runs in the tx
POST /homerooms/:id/students Open/amend N membership intervals (+ forward selection sync, §6.1) + enter-side cascade into the class's linked courses
DELETE /homerooms/:id/students Close or cancel N membership intervals + leave-side cascade out of the class's linked courses
POST /homerooms/:id/students/move Close the source and open/amend the target at one shared boundary + symmetric cascade (out of source-linked, into target-linked)

Subject Group rosters mutate independently via POST / DELETE /subject-groups/:id/students and POST /subject-groups/:id/students/move — those endpoints never consult the link (PATCH-link is metadata-only).

6.0 Current state, scheduled state, and history

Every command resolves one effective date X; all rows, selection-sync effects, and structural audit events produced by that command use that same date. An omitted date uses the data-sensitive default (contract §7, 2026-07-30): the earliest date an explicit request would be accepted — today while no attendance cell exists for anyone the command affects (always today for pure additions), the next school day once one does. The close/shrink half resolves inside the command transaction, under the exclusive AY-day lock, with the same probe the explicit-today gate uses. An explicit today is allowed only when no attendance cell exists for a student whose current interval will be shortened. Past dates are rejected.

A student may have one pending transition per membership family — kept so by replacement, not refusal: reissuing a command re-dates, retargets, cancels, or replaces that transition (reported in superseded[], §1.0); nothing can stack a second future transition. A scheduled removal is a current row with a future validUntil, so pending detection must inspect both boundaries rather than only rows with validUntil = null. Cancelled additions remain as empty [X, X) evidence and appear in neither current nor pending reads.

Group detail and roster-write responses expose pendingChanges[]; current members remain in students[] until their boundary. Each entry carries the person's firstName/lastName beside the id (same for the SG pendingTeacherChanges[] block), so FE renders a scheduled arrival or departure without a per-person lookup. Each entry also carries counterpart: { id, name } | null (2026-08-06) — the other leg of the same booked move: on a REMOVAL the group the student joins that same day, on an ADDITION the group they leave, null for a plain exit or fresh arrival. The pairing is derived at read time from adjacent legs — same student, same AY, exact boundary-date equality, a different group (and, for SGs, the same curriculumSubjectId: a same-day leave and join across subjects are two independent changes) — so it is write-path-agnostic: legs produced by any command, including cascades, pair the same way, and a cancelled [X, X) destination pairs with nothing. Pending payloads are filtered through the same student-visibility policy as the roster, so a restricted caller cannot infer another student's scheduled change (counterpart enrichment runs after that narrowing). Effective history is immutable; only a transition that has never governed a day is amendable. Every open, close, move, and amendment records a structural audit event anchored on the student.

The homeroom move endpoint enforces Grade match AND Curriculum match, returning HOMEROOM_MOVE_GRADE_MISMATCH / HOMEROOM_MOVE_CURRICULUM_MISMATCH 409s (no track dimension). The 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 (homeroom/SG create, POST :id/students, homeroom move-in) ensures each assigned student has a selection for the class's curriculum (+ track when the caller supplies one — a homeroom passes trackId: null), and (for an in-block SG) the block choice. Curriculum, track, carried flags, and choices form one effective-dated aggregate. 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 for a homeroom / subject for an SG, so the plan is unchanged).
  • Backward (pruneIncompatibleClassMemberships) — every selection write (PATCH /students/:id/curriculum-selection, referent or admin) closes the student's now-incompatible memberships: a Homeroom whose curriculum no longer matches (a homeroom is a registration group — no track, no owned courses, so it prunes on curriculum alone), a mandatory SG whose curriculum no longer matches, an in-block SG whose alternative is no longer chosen. Removed ids come back in the PATCH response's removedMemberships. The membership close and replacement selection share the aggregate's effective date.

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 SGs via guardAndClearEmptySubjectGroups inside syncCurriculumStructure (409 if any holds students). Track removal while a selection 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.

6.2 Teacher assignments and combined-class memberships are episodes

Two more families joined §6.0's valid-time model: who teaches a course (SubjectGroupTeacher) and which combination a course belongs to (CombinedClassMembership). Both carry validFrom / validUntil as half-open [validFrom, validUntil) date intervals; validUntil = NULL means open-ended forward, which is not the same as "current". Overlap is refused by the database, not by write-path discipline — one EXCLUDE USING gist constraint per family (subject_group_teachers_concurrent_excl, combined_class_memberships_concurrent_excl), non-deferrable, surfacing as 409 TEMPORAL_INTERVAL_OVERLAP. The old @@unique([subjectGroupId, teacherId]) is gone: it made re-hiring a teacher onto a course they had left impossible, because the closed episode still occupied the pair.

Why teacher assignment is load-bearing rather than display data: it is a write-authorization input. "Was this teacher teaching this student on D" decides whether they may edit day D's attendance, so answering it from today's rows lets a teacher reassigned in March lose (or gain) authority over February. loadSubjectGroupTeachersOn(db, ids, D) (ordered ids plus live display names) and loadTeachesStudentOnDateMap(db, timetableId, weekday, D, studentIds) are the dated answers; attendance wires them in (19 - Attendance).

The wire shapes did not change. teacherIds and subjectGroupIds are still set-semantics — the client PATCHes the list it wants, not a diff. The service computes the diff against the open episodes and turns it into closes and opens on one effective date per command:

  • validFrom omitted, affected register cleantoday — the change is current in the same read, nothing pending (contract §7, 2026-07-30; pure additions are always this row — they affect no register)
  • validFrom omitted, a cell already anchors the affected unit today → the next school day — the change books instead of refusing (resolved in-tx under the AY-day lock; per distinct grade with chronological MAX for combined-class member changes)
  • validFrom = today → whole-day change, allowed only while no affected attendance cell exists on today (else 422, payload naming the earliest permitted date)
  • validFrom = future → scheduled; current lists are unchanged and the change appears in the response's pending block
  • validFrom = past422 TEMPORAL_BACKDATE_FORBIDDEN

Every response reports the applied date as appliedFrom, and combined-class responses carry pendingChanges[] ({ subjectGroupId, kind: ADDITION | REMOVAL, on }). A boundary-free edit moves no interval — a pure teacher reorder, a name/hours-only combination edit, a no-op member set — so a validFrom supplied on one of those is rejected 400 VALIDATION_FAILED on the validFrom field rather than silently ignored. Reordering teachers therefore stays immediate: ordinalPosition is presentation order, not a dated fact.

A dateless change is therefore usually current in the same read — the "does not show up in today's lists" case is the dirty-day exception: once today's register has been taken for the affected unit, the dateless change books onto the next school day, shows up as pending, and materializes on its date. Pre-year is the degenerate clean case — no register can exist before the calendar starts, so dateless is always today.

SubjectGroup.combinedClassId survives as the forward head — the terminal planned state, kept in the same transaction as the episode rows and nowhere else. It answers "is this section spoken for" (the ≤1 combination guarantee, and the COMBINED_CLASS_MEMBER_ALREADY_COMBINED check), while the episode table answers "which combination on date D". The two differ in exactly one window: a scheduled leave releases the head immediately, which is what makes a mid-year move expressible as two commands — leave CC-1 effective X, then join CC-2 effective the same X. Until X, current reads still answer CC-1 and both combinations report the move in pendingChanges.

Current reads are as-of-today, never "the open row": every include filters on effectiveOn(schoolToday). This applies to the teacher lists on SG detail / list / table responses, the combination's members, the courses table's combined-side filters, and the teacher-visibility policies (a teacher whose assignment starts next week does not yet see the course).

Deletes are guarded (spec E, amended 2026-07-30): DELETE /subject-groups/:id and DELETE /combined-classes/:id refuse with 409 TEMPORAL_ROW_IMMUTABLE once the structure has governed a lived school day — a child episode or a timetable reference overlapping [the year's earliest calendar start, today]. Before the year's calendar starts nothing is lived, so pre-year configuration (episodes effective today included) still hard-deletes and cascades freely.

6.3 Teacher assignment and roster notifications (2026-08-25)

The Product-approved notification consumers are derived from successful episode deltas, never from request intent:

  • opening a new SubjectGroupTeacher episode notifies that teacher's active linked user account; a stayer, pure reorder, close/removal, no-op, or rejected command does not;
  • direct course creation, teacher-list PATCH, and create-from-homeroom course creation all feed the same joiner builder. Several courses assigned to one teacher by the same command/date become one counted notification; a singular assignment also carries the course/subject/grade/department IDs and names;
  • actual add/remove/move deltas notify the live homeroom tutor or the course teachers effective on the movement's appliedFrom. A move is one source REMOVED plus one destination ADDED event;
  • the homeroom-to-linked-course cascade contributes its actual course opens and closes, so each affected course audience is notified once per direction;
  • initial roster formation (including the create-from-homeroom wizard), import, rollover, unrelated reconciliation, class/course deletion, and cancelled boundary changes are excluded.

Each recipient/cohort/direction/date payload is destination-neutral and names the stable academic year, department, grade, and class/course aggregate. A single-student change also carries the student's ID and display name; a bulk change carries the count and reloadable cohort ID rather than an unbounded list of up to 500 student IDs.

Audience and payload context are resolved inside the owning transaction. The immutable intents are registered with PostCommitCoordinator, so rollback drops them and NOTIFICATION_PORT is called only after the outer request transaction commits. Delivery remains fail-soft and adds inbox + push only; there is no new email spec.


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

The reasoning: curriculumId + gradeId define the curriculum-match eligibility rule the roster was validated against, and departmentId pins the cohort's scope; changing any of them would silently invalidate the roster. We don't support an in-place transition; the admin duplicates the Homeroom instead. (There is no child-SG catalogue to re-seed — homerooms own no courses.)

What stays mutable: name, baseRoomId, homeroomTeacherId, customFields, and the roster sub-resource. Renaming a Homeroom is a pure header update — it has no course-name cascade (a homeroom owns no SubjectGroups).

Per-subject teacher swaps live on the 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.

Base room (SubjectGroup.baseRoomId). An SG may carry an optional timetable default room (composition scope; a uuid sets it, null clears it). The placement plan exposes that room so the FE can preselect it for a new lesson, but the lesson's own roomId is authoritative: users may assign another in-tenant room without a diagnostic or publish failure. Generation receives the full capacity + subject-room-set compatible room domain and does not hard-force the default. The default room itself is validated in-tenant/same-AY at SG write time; capacity and subject-room-set rules apply to each lesson's selected room. Combined-class members may have different defaults; a combined placement-plan row exposes one only when the non-null member defaults agree. This supersedes the hard-force behavior in docs/superpowers/specs/2026-07-02-subject-group-base-room-design.md; see chapter 18 §1/§8.


A teacher with an active-or-future teaching tie in department D always holds a TeacherDepartment(teacher, D) row. Ties: SubjectGroupTeacher episodes (department = the SG's grade's department, one immutable hop) and current homeroom tutorship (Homeroom.departmentId, non-temporal).

  • MaterializationsyncSubjectGroupTeachersInTx (the one body all three SGT-writing surfaces compose) and the two homeroom tutor write sites call ensureTeacherDepartmentLinks (src/teachers/teacher-department-links.helper.ts) in the same transaction, for the full desired set (createMany({ skipDuplicates: true }) — idempotent, self-healing).
  • Removal guardPATCH /teachers/:id refuses to drop a required department with 409 TEACHER_DEPARTMENT_REQUIRED (findRequiredTeacherDepartmentIds: open episodes, future-closed episodes, tutorships; empty [X, X) rows and fully-past episodes never constrain).
  • Import — the setup-only teacher import silently keeps required departments the file omits (auto-union; the file loses).
  • Never deleted — ending a tie leaves the link; removal is always an explicit employment edit. Accepted race: guard vs concurrent assignment is same-tx read-then-write, not serialized (spec divergence ledger).
  • Backfill20260813160000_backfill_teacher_department_links inserted the missing links for pre-existing ties (idempotent, data-only).

Spec: docs/superpowers/specs/2026-08-13-teacher-department-invariant-design.md.


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.
  • Header history: homeroom and subject-group header changes are not temporalized. Membership history is modeled by the roster intervals above.
  • 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 StudentsReadPolicy.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 StudentsReadPolicy.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):

const today = await schoolTodayFor(prisma, tenantId);

// Who's in homeroom X? (roster only — a homeroom owns no courses)
const detail = await prisma.homeroom.findUnique({
  where: { id: X },
  include: {
    assignments: {
      where: effectiveOn(today),
      include: { student: true },
    },
  },
});

// A course's roster + teachers (independent of any homeroom):
const course = await prisma.subjectGroup.findUnique({
  where: { id: Y },
  include: {
    curriculumSubject: { select: { name: true } },
    teachers: {
      orderBy: { ordinalPosition: 'asc' },
      include: { teacher: { select: { firstName: true, lastName: true } } },
    },
    assignments: {
      where: effectiveOn(today),
      select: { studentId: true },
    },
  },
});

GET /subject-groups returns every SG (all are standalone). An unlinked homeroom and course share students only incidentally, via the students' own selections. A linked course (homeroomId set, §1.5) additionally follows the section through the roster cascade — but the link is workflow affinity, never a constraint on either roster.


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, baseRoom, homeroomTeacher) or null.
  • subjectGroups — every SubjectGroupAssignment (all standalone), each with 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).

Combined classes (co-taught sections)

A combined class links 2+ subject groups as taught together for part of the week and is a first-class teaching unit (iteration 3, 2026-07-12): the shared meeting is ONE ScheduledLesson row anchored on the combination itself, placed / listed / viewed / attended exactly like a course. Every member keeps its own roster, grading, and per-subject reporting — the combination owns only the shared meetings. Module: src/subject-groups/combined-classes/ (controller registered in SubjectGroupsModule; RBAC reuses the SUBJECT_GROUPS entity — admin / department_head only).

  • ModelCombinedClass { name, sharedWeeklyHours, academicYearId } + nullable SubjectGroup.combinedClassId (onDelete: SetNull). No gradeId (members may span grades — cross-grade combination, same department) and no departmentId (derived from members). The nullable FK is the forward head: it guarantees ≤1 combination per section, while the dated membership lives in combined_class_memberships (see §6.2). Membership carries no attributes beyond its interval. ScheduledLesson dual-anchors on subjectGroupId XOR combinedClassId (DB CHECK scheduled_lessons_anchor_xor); deleting a combination cascades its own lessons and unlinks the members (their solo lessons survive).
  • Hours are curriculum-anchored — the admin declares one number (sharedWeeklyHours); each member's solo remainder is derived (cellMinutes − sharedMinutes, exposed as soloWeeklyHours). No deviation is expressible: if a subject really needs different hours, edit the curriculum.
  • Admissible members = any subject group, including option-block children — the v1 fence (COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD, error code deleted) is lifted: the solver pair-tiles a paired block child with its combination inside the block band (see 18 - Timetables).
  • Validation envelope (ordered checks) lives in combined-classes.service.ts::validateMembers: ≥2 members, exists+AY, shared teaching window (COMBINED_CLASS_MEMBER_WINDOW_MISMATCH — so the shared lesson exists in every segment a member is taught), not already combined, one department, disjoint rosters (over each member's own SubjectGroupAssignment roster), base-room compatible, cell exists, shared ≤ min(cell), and the combine-time layer of the duration gate (equal durations + divisibility, when curriculum-resolved).
  • Degenerate membership — a member SG deleted by the curriculum sweep or manually leaves the combination with < 2 members. It goes inert + COMBINED_CLASS_DEGENERATE WARNING, never auto-deleted; cleanup is manual. A degenerate combination may still own lessons — they stay placed; the WARNING flags the cleanup.
  • Lists everywhereGET /subject-groups/table mixes COMBINED_CLASS rows into the courses table (kind discriminator; name, derived department, deduped union teachers, distinct union numStudents, sharedWeeklyHours; curriculum/grade/subject/track null; filters match via any member). Contributor rows keep combinedClassId as a badge. The grouped-courses tree gains no combined node (a combination has no curriculum position) — leaves keep the badge only.
  • Grades stay per-member — marks anchor on the member SG; the combination never appears in the grades surface.
  • Attendance — the register is the union roster with write-time contributor attribution; see 19 - Attendance.

Recipe — link two sections: POST /combined-classes { name, sharedWeeklyHours, subjectGroupIds: [a, b] } (add validFrom to pick the effective date — omitted means today while the affected registers are clean, else the next school day, §6.2). Update replaces the membership set, closing and opening episodes on one date; delete unlinks the sections (SetNull) and cascades the combination's own lessons, leaving the sections (and their solo lessons) alive.

Recipe — move a section between combinations mid-year: two commands sharing one date XPATCH /combined-classes/CC-1 { validFrom: X, subjectGroupIds: […without the mover] }, then PATCH /combined-classes/CC-2 { validFrom: X, subjectGroupIds: […with the mover] }. The first releases the forward head so the second is admissible. Until X, current reads answer CC-1 and both responses report the move in pendingChanges.

Canonical spec: docs/superpowers/specs/2026-07-12-combined-classes-iteration-3-design.md.