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
compositionscope - 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 offorwardHomeroom), 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.moveStudentsremains 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)withNULLS NOT DISTINCT(Postgres 17, authored raw inmigration.sql) so two default rows can't collide on name. - Criteria scale lives on the
CurriculumSubjectHourscell as a nullablecriteriaGradingScaleId, alongside the cell's existinggradingScaleIdoverride. The subject row still carries the default criteria scale.
Resolution is replace, not merge. For a (subject, grade) cell:
- Criteria list — a grade with an override resolves to only its own rows; a
grade with no override rows resolves to the subject default (
gradeId NULL). There is no per-grade "overridden" column —criteriaOverriddenis 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. Mathematics →
Algebra, Geometry). It owns its own cells exactly like a leaf — hours,
grading scale, level, rooms, period duration.
parentSubjectId = null, ≥1 child. It also carriescriteriaLevel(SUBJECTdefault, orUNIT). - unit — a child of a main subject (
parentSubjectIdset). It carries only criteria (+ a per-unit criteria scale inUNITmode) — no hours, subject-mark scale, level, rooms, or period duration (submitting any →CURRICULUM_UNIT_HAS_CELLS). Its criteria can vary per grade:criteriais the default list;criteriaByGradeadds 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-cellcriteriaequivalent). The criteria scale is not per-grade — it stays the unit-levelcriteriaGradingScaleId. It is never choosable (aCHECKforbids 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.tscreate) — rejects an anchor whoseparentSubjectId != null(SUBJECT_GROUP_ANCHOR_IS_UNIT); a leaf or main subject is allowed. The offered-in-grade gate uses the sharedanchorOffersGrade(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'sofferedCells/offeredGradeIdsare 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.
1.5 The homeroom link + roster cascade (2026-08-06)¶
~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,SetNullon homeroom delete. Set viaPOST /subject-groups(optionalcomposition.homeroomId), changed or cleared via PATCH (nullclears; 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) carryhomeroom {id,name} | null; the homeroom detail carries the mirrorlinkedCourses[].- 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.tsplanner, executed byHomeroomsServiceinside each roster command's transaction at the command's oneappliedFrom): 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'sselectedrule, extracted toclassifyExplicitBelonging(src/homerooms/explicit-belonging.ts) and shared withcountSelected— 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 incourseCascade[](per student:added/removed/skipped) — render loudly, likesuperseded[]. - 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_membershipentries; 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):
HomeroomsReadPolicylives atsrc/homerooms/homerooms.policy.ts.SubjectGroupsReadPolicylives atsrc/subject-groups/subject-groups.policy.ts.HomeroomsWritePolicy/SubjectGroupsWritePolicyare 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
referentandstudentroles hold nohomerooms.composition/subject_groups.compositiongrant, 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 throughstudents.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 sharedSTUDY_PLAN_SELECTION_SELECTprojection) so the shape can't drift. -
Second-pass, GET-only. Built by
HomeroomsService.enrichRosterStudyPlansaftertoDetailDto, viafindRosterStudyPlanRows— kept offhomeroomDetailIncludeso 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 againststudentsscopes — so the block would be a cross-entity leak.enrichRosterStudyPlansruns only whencanReadStudentAssignment(ctx, permissions)holds:students.assignmentread (PermissionsService.checkScope), platform admins always. OtherwisestudyPlanis omitted. Same soft-gate shape asgetGroupedHomerooms'students.readassert, but omit-not-403. - The heavy
selectedCurriculumtree load is deduped bycurriculumId(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(). Nohr— 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 > 1accepts several alternatives per student; the in-block picker keeps a partially-filled studentpendingfor the remaining alternatives until the block is full, and the add-gate admits each new alternative up tomaxSelections(rejecting an over-capacity assignment withOPTION_BLOCK_FULL; the single-pick case keeps theWRONG_OPTION_BLOCK_CHOICEreason). Both the gate and the picker run the SAMEclassifySubjectGroupEligibility(readingOptionBlock.maxSelections), so they cannot disagree about capacity.
Completeness is the materialized isComplete flag — never confirmedAt (the
referent lock). The SG anchor's grade comes from the required gradeId; the
subject must be offered there. Dept_head callers are scoped: the homeroom picker
returns empty for a department they don't head (via 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) underCurriculaPolicy.where(ctx)(findGroupedBoardCurricula), and — on both boards — each curriculum's covered grades from itsCurriculumGraderows (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 whoseStudentsReadPolicyresolves toNEVER_MATCH_WHEREgets 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 holdmandatorySubjects[]+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 defensiveweeklyHours: nullrow so the course stays visible. The SG leaf'ssubjectsummary carriesoptionBlock {id,name}(also onGET /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_MISMATCHare the belt; there is no track dimension).targets = node.homeroomsfiltered toh.id !== sourceHomeroom.id. - Courses board. The gate matches on
curriculumSubjectId(SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH). Each leaf carriessubject.curriculumSubjectId:targets = node.subjectGroupsfiltered tosg.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 toGET /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 orderdepartment → 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 asctiebreak, so pages never shuffle between requests. - Effective track =
subject.track ?? optionBlock.track(same rule as the board's node placement). ThetrackIdfilter is strict: it excludes common (track: null) courses — omit the filter to mean "all tracks", or passincludeCommonTrack=trueto admit common courses (combine withtrackIdfor "these tracks OR Common"). optionBlockIdfilters to courses whose anchorcurriculumSubjectsits in one of the given option blocks (curriculumSubject.optionBlockId IN …); mandatory (no-block) courses never match. UnliketrackIdthere is no "include mandatory" companion toggle — omit the filter to include them. The option block is already on every row assubject.optionBlock {id,name}(null = mandatory), so this filter needs no payload change; dropdown values come fromGET /filters/option-blocks?surface=subject-groupsprojected from the visible course rows (or, within a chosen curriculum,GET /filters/subjects?surface=subject-groups'optionBlockrefs).- Unknown filter ids yield an empty page, never 404 (dropdowns and tables refresh independently; a stale id must not error the table).
searchis 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 intobuildHomeroomsTableWhere; course search lives inbuildSubjectGroupSearchWhere/buildCombinedClassSearchWhere, appended per side so the id-filter builders stay pure.- No roster. Rows are class headers +
numStudents; rosters stay on the:iddetail. - 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
SubjectGroupis 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 noSTUDENTSread. - Mandatory + optional both listed. Each leaf is
{ curriculumSubject {id,name}, isOptional, optionBlock {id,name}|null }; option-block alternatives getisOptional: true+ the block ref. Every level carriesnumMissingMandatory+numMissingOptionalfor badges. Fully-covered branches are pruned (no badge ⟹ nothing to create). - Same filter object as
/subject-groups/table(reusesSubjectGroupsTableQueryDtoverbatim so the FE passes its filter state unchanged). HonoursdepartmentId/curriculumId/gradeId/trackId(+includeCommonTrack, matched on the effective track via the puresubjectMatchesTrackFilter, mirroringbuildEffectiveTrackWhere) /curriculumSubjectId/optionBlockId(in-block subjects only — a catalog dimension liketrackId, applied as an in-memory guard)/academicYearId; ignoresteacherId,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_headonly, noteacher. A teacher'sSubjectGroupsReadPolicyself-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 onGroupedCoursesController(registers beforeGET /:id). Reuses the board query blocks (findBoardDepartments/findGroupedBoardCurricula/findBoardSubjectsForCurricula) + one newfindCoveredSubjectGradePairs(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-homeroomsreturns{ noSelection, selected }, with ordinaryHomeroomListItemDtorows sorted by curriculum name, homeroom name, then id. Already-forward-assigned and non-ENROLLED visible students return empty buckets.GET /students/{studentId}/assignable-subject-groupsreturns a pruneddepartment -> grade -> curriculum -> tracks[]tree. Every track node hasmandatorySubjects[]andoptionBlocks[]; blocks containsubjects[], and each subject collapses its non-emptysubjectGroups[]section list. A common node usestrack: null. Each subject carries the classifier bucketnoSelection,pending, orselected.
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 passestrackId: 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'scurriculumSubjectId), so assigning a student to several in-block SGs of the same multi-pick block accumulates one choice per alternative — the write-gate caps this atmaxSelections(§5). After writing, it recomputes completeness and stampsconfirmedAtaccordingly: a block staysbelow_min→isComplete = false→ statusIN_PROGRESSuntil itsminSelectionsis met. The homeroom/SG move endpoints skip it (source and target share curriculum+grade 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'sremovedMemberships. 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 rowidround-tripped from a GET is an in-place update even when the name changed, so a rename no longer delete+creates the subject — the student'sStudentOptionBlockChoice(and the subject'scarryKey) survive, and the sweep sees no break. Omitting theidkeeps the legacy name matching for callers that don't round-trip ids (setup wizard, preset expansion, backoffice). Identity is within a container — moving a subject across containers is still delete+create (a genuine substance change → re-confirm). - The sweep is diff-based, not after-only.
captureSelectionPreImagessnapshots each confirmed selection's committed substance (chosen alternatives + applicable mandatory subjects, each with itslevelId) before the structure sync;diffCommittedSubstancecompares it after. The sweep demotesconfirmedAtwhen the selection is no longer complete or a CHOSEN subject's HL↔SL level was swapped; it records notify-only descriptors (logged in v1, delivered by the deferred notifier — no demote) for new / removed / level-changed mandatory subjects, since there's nothing for the parent to re-decide. Cosmetic edits (name, scales, criteria, hours, windows, rooms, reorder, widening a block's max) never invalidate — they're not infindSelectionInconsistenciesand don't change committed substance.
A grid edit has no SG side-effect — Subject Groups are created manually only
(see §1.3). A removed (subject, grade) cell still clears its empty 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:
validFromomitted, affected register clean → today — 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)validFromomitted, 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 (else422, payload naming the earliest permitted date)validFrom= future → scheduled; current lists are unchanged and the change appears in the response's pending blockvalidFrom= past →422 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
SubjectGroupTeacherepisode 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 sourceREMOVEDplus one destinationADDEDevent; - 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.departmentIdHomeroom.curriculumIdHomeroom.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.
7b. Teacher–department link invariant (2026-08-13)¶
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).
- Materialization —
syncSubjectGroupTeachersInTx(the one body all three SGT-writing surfaces compose) and the two homeroom tutor write sites callensureTeacherDepartmentLinks(src/teachers/teacher-department-links.helper.ts) in the same transaction, for the full desired set (createMany({ skipDuplicates: true })— idempotent, self-healing). - Removal guard —
PATCH /teachers/:idrefuses to drop a required department with 409TEACHER_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).
- Backfill —
20260813160000_backfill_teacher_department_linksinserted 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 typedSUBJECT_GROUP_NAME_CONFLICT409 (the dispatcher tests thenametoken first, since that index now also containscurriculum_subject_id). No proactive pre-check — the typed mapping is enough.
9. Recipes¶
Add a field to either entity's composition scope¶
prisma/schema.prisma— add column to the model.prisma/migrations/— write the migration; if it preserves data, hand-write it (RENAME / DEFAULT, never auto-drop).src/common/constants/scope-fields.ts— append toHOMEROOM_SCOPES.compositionorSUBJECT_GROUP_SCOPES.composition.src/<module>/dto/scopes/<entity>-composition.dto.ts— add toCreate*CompositionDtoANDUpdate*CompositionDto.src/<module>/<entity>.service.ts— wire into create/update path.src/<module>/<entity>.queries.ts— extend include/select if it's an FK.- E2E spec — round-trip the new field through POST/PATCH/GET.
No RBAC catalogue change is required to add a scoped field — only adding a new
scope or new entity needs prisma/seed/rbac-catalogue.ts edits.
Add a grouped counts board (list view) on a related entity¶
A grouped board (§5.1) is the list view — a catalog-driven tree whose nodes carry aggregate counts + the class-entity rows (no student rows). It is NOT the candidate picker (that is the separate, gate-single-sourced endpoint below). To add a board:
- Enumerate the skeleton from the catalog, not from existing rows:
findBoardDepartments(src/departments/departments.queries.ts, composed withDepartmentsPolicy.where(ctx)∩ active AY) +findGroupedBoardCurricula(src/curriculum/curriculum.queries.ts,CurriculaPolicy.where(ctx)∩status: 'READY'; carries the covered grades). Empty nodes stay in the payload. - Load the class rows under the entity policy and fold them into the skeleton, creating any missing node from the row's own refs (defensive union — a row on a demoted curriculum must not vanish).
- Load a zero-PII student projection (enrollment cell + selection +
whatever the counts need; see
findStudentPlacementCounts/findStudentCoverageCounts) composed withStudentsReadPolicy.where(ctx); onNEVER_MATCH_WHEREskip the query — zeroed counts, intact skeleton. Fold counts: dept-level over all rows, node-level only where the selection targets the node. - A
grouped-<name>.controller.tswith@Controller('<entity>')and a literal@Get('grouped-<name>')— register it BEFORE the CRUD controller in the module'scontrollersarray so its literal route wins overGET /:id(ParseUUIDPipe); Express 5 matches in registration order. Gate with@RequireScopes(<ENTITY>,'read')+ an inlinestudents.readassert (PermissionsService.checkEntityAccess, sinceScopeGuardenforces a single scope) + theadmin/department_headallowlist +@AppliesPolicyDimensions(<EntityPolicy>)+@AggregateResponse().
Add an eligibility picker (candidate list) on a related entity¶
The candidate list for a create/edit roster modal is a server-bucketed picker single-sourced with the write-gate (§5) — never reproduced on the FE. Pattern:
- A pure
classify<Entity>Eligibility(row, target) → { kind:'bucket', bucket } | { kind:'hidden', reason }(src/<module>/<entity>-eligibility.ts) +<entity>EligibilityError(...)mapper. Extract it FROM the gate so the gate can consume it too. - Point the write-gate's
assertStudentsEligibleat the classifier (throw onhiddenvia the mapper) — preserving its error codes/reasons. - A permissive cohort loader in
queries.ts(load the candidate set; let the classifier bucket/hide — do NOT re-encode the rule in aWHERE). - A service method
findEligibleFor<Entity>(resolve year, composeStudentsReadPolicy.where(ctx), classify each row, drophidden, group bybucket) + aeligible-students.controller.ts(@Controller('students'), literal@Get('eligible-for-<entity>'),@RequireScopes(<ENTITY>,'read')+admin/department_head+@AggregateResponse()). - Route ordering (cross-module trap). The literal
/students/eligible-for-*route collides withStudentsController'sGET /students/:id(ParseUUIDPipe). Express 5 matches in registration order, which across modules is module import order inapp.module.ts— so the picker's module MUST be imported beforeStudentsModuleor the literal is captured as a malformed:idand 400s. (Homerooms + Subject Groups are deliberately ordered ahead of StudentsModule for exactly this.) - A
*-eligibility.parity.spec.tsdrift oracle (everyhiddenreason maps to a 409; everybucketis one the picker groups on) + an e2e that asserts the route resolves (a picker candidate is accepted by the create/add gate — proves routing and the gate⟺picker contract end-to-end).
Audit a Homeroom / SG operation locally¶
Use prisma studio plus these read queries (run with npx ts-node or in a
spec):
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/:id → assignment block (gated by students.assignment read,
not by homerooms/subject_groups.composition) carries three derived keys
built in src/students/:
studyPlan— the curriculum-selection summary (RUS-4):status(NOT_STARTED/IN_PROGRESS/COMPLETEviaderiveSelectionStatus),curriculum {id,name},chosenTrack {id,name},confirmedAt, andselectedOptions[](the chosen option-block alternatives with block + subject names). On the single-student detail only,selectedCurriculumis the selected curriculum's grade-sliced choosable tree (tracks + mandatory subjects- option blocks with alternatives + rules) —
nullonGET /studentslist rows. Built bystudy-plan.mapper.ts, which owns the sharedflattenCurriculumForStudentgrade-slicer (extracted fromCurriculumSelectionServiceso the/curriculum-selectionendpoint and this block can't drift). The detail tree is loaded by afindOne-only enrichment pass (enrichStudyPlanStructure), so list rows pay no extra query. homeroom— the student'sHomeroomAssignment.homeroomsummary (name, department, grade, baseRoom, homeroomTeacher) ornull.subjectGroups— everySubjectGroupAssignment(all standalone), each with subject (+ grade-resolvedweeklyHours) and orderedteachers. Built byclass-assignment.mapper.ts.
Canonical: docs/superpowers/specs/2026-06-15-student-assignment-study-plan-and-classes-design.md.
10. Where to go deeper¶
- Canonical spec —
docs/superpowers/specs/2026-05-25-us33-us33_1-design.md. - Multitenancy / record-level filtering — 02 - Multitenancy.
- RBAC scope/action conventions — 04 - RBAC §Registered entities.
- Module file shape — 05 - CRUD Patterns.
- Aggregate-response DTOs — 05 - CRUD Patterns (the pattern Homerooms + Subject Groups follow, unlike the scope-grouped students / teachers pattern).
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).
- Model —
CombinedClass { name, sharedWeeklyHours, academicYearId }+ nullableSubjectGroup.combinedClassId(onDelete: SetNull). NogradeId(members may span grades — cross-grade combination, same department) and nodepartmentId(derived from members). The nullable FK is the forward head: it guarantees ≤1 combination per section, while the dated membership lives incombined_class_memberships(see §6.2). Membership carries no attributes beyond its interval.ScheduledLessondual-anchors onsubjectGroupIdXORcombinedClassId(DB CHECKscheduled_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 assoloWeeklyHours). 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 ownSubjectGroupAssignmentroster), 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_DEGENERATEWARNING, never auto-deleted; cleanup is manual. A degenerate combination may still own lessons — they stay placed; the WARNING flags the cleanup. - Lists everywhere —
GET /subject-groups/tablemixesCOMBINED_CLASSrows into the courses table (kinddiscriminator; name, derived department, deduped union teachers, distinct unionnumStudents,sharedWeeklyHours;curriculum/grade/subject/tracknull; filters match via any member). Contributor rows keepcombinedClassIdas 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 X — PATCH /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.