Skip to content

Homeroom / Course decoupling — the registration-group model

Homerooms and courses are being fully separated. A Homeroom becomes a registration / form group (a roster + pastoral attributes). A SubjectGroup ("course") becomes a teaching group with its own manually-managed roster. The two are no longer structurally linked: SubjectGroup.homeroomId is dropped, the Homeroom.trackId binding is dropped, and homeroom create/edit stops materialising courses.


1. Problem distillation

  • Today a Homeroom is a teaching cohort: creating it auto-materialises one homeroom-bound child SubjectGroup per mandatory subject (roster derived from the homeroom) and binds exactly one CurriculumTrack when the curriculum has tracks. Roster changes cascade into those bound courses.
  • Product wants a Homeroom to be a registration group instead: a roster of students (plus tutor + base room) that says nothing about what those students study. Students who chose different tracks ("addresses") must be able to share one homeroom, and a course's roster is independent and may span homerooms.
  • Courses (SubjectGroup) become uniformly standalone, created and rostered manually per course — homeroom membership has zero effect on any course.
  • The one capability worth preserving from the old homeroom-coverage machinery is gap-free days, re-anchored from the homeroom to the student (the union of each student's courses).

Success criteria (observable behavior that proves this works): - POST /homerooms accepts only header fields + studentIds; it creates zero subject groups. subjectTeachers, addToDeclaredSubjects, and trackId are gone from the homeroom DTO. - A homeroom can be created on a track-bearing curriculum without a track, and two students with different confirmed tracks can both be placed in it. - Every SubjectGroup is standalone (homeroomId column no longer exists); each course carries its own SubjectGroupAssignment rows; add/remove/move/delete work uniformly on all of them. - Attendance take and grade recording operate off a course's explicit roster; GET /grades/homeroom/:id still returns current data (homeroom sourced from the student's own HomeroomAssignment). - Timetable generation prevents any student from having a gap in their day and never double-books a student; the publish gate + diagnostics report student gaps / conflicts (no homeroom-anchored coverage remains).

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No "bulk-create / bulk-roster courses from a homeroom's roster" convenience action (deferred — §9). - No per-department "homeroom is a teaching cohort" mode; the registration-group model is global. - No data backfill / back-compat shim (greenfield; dev DB is droppable — feedback_breaking_changes_acceptable). - No change to CurriculumTrack itself or to curriculum-selection track choice; tracks remain first-class for selections and for standalone-SG track cohorting. - No change to the curriculum-match rule: a homeroom keeps its Curriculum + Grade binding and the eligibility rule that a student's selection (if any) matches.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/subject-groups/subject-groups.service.ts (standalone SG create + roster ops + eligible-for-subject-group picker) The standalone create/add/remove/move/delete paths + explicit-roster model become the only SG path once bound SGs are gone. The assertNotHomeroomBound guard family, the homeroomId: null list filter, bound-name immutability, and the bound-delete block all become unconditional / dead — deletions, not reuse.
src/attendance/attendance.service.ts + attendance.queries.ts:284-320 (student-derived homeroom snapshot) AttendanceRecord.homeroomId/homeroomName is already stamped from the student's own HomeroomAssignment, not the SG. Grades adopts the identical pattern for GradeEntry.homeroomId. Attendance's take-path roster currently reads the bound SG's homeroom roster (loadSubjectGroupRosterIds); that branch collapses to the standalone (explicit-assignment) path.
docs/superpowers/specs/2026-06-16-eligibility-classifier-pickers-design.md + src/homerooms/homeroom-eligibility.ts The single-source classifier shared by the gate + picker stays intact. The TRACK_MISMATCH branch and the now-moot ALREADY_IN_MANDATORY_SUBJECT_GROUP conflict branch (conflictingMandatorySg) are removed.
src/students/class-selection-sync.ts (forward fill + backward prune) Both helpers stay and still run inside the roster/selection $transaction. Forward: the homeroom-track fill becomes a no-op (pass null). Backward: the prune loses its appliesToTrack half (reduces to curriculum mismatch) and its bound-child-SG cascade (homeroomId: null guard + child-SG delete) — those branches disappear.
solver/app/model.py _add_homeroom_coverage (HOMEROOM_COVERAGE family) + src/timetables/generation/* The coverage/compactness machinery (day-template tiling, break-awareness, cohort id-lists feeding the constraint) is the template for the new student-anchored version. It is expressed at homeroom granularity (cohort.homeroom_id, homeroom.mandatory_group_ids). It must be reformulated over student-derived coverage cohorts — this is the one genuinely new design (see §7).

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged — tenantId on Homeroom + SubjectGroup; all queries stay tenant-scoped. No new tables; only column drops.
Academic-year scope yes Unchanged — reads/writes stay academicYearId-filtered.
RBAC entity key existing HOMEROOMS + SUBJECT_GROUPS unchanged. No new entity; this is a shape change, not a new domain concept.
Scopes existing (composition) No new scopes. SUBJECT_GROUP_SCOPES.composition drops homeroomId (scope-fields.ts:336). HOMEROOM_SCOPES.composition drops trackId. The removed fields no longer exist on the models.
Actions none read/write implicit; no new actions.
Service base custom (existing HomeroomsService / SubjectGroupsService) Both are custom services; edits are in-place deletions/simplifications. These modules already diverge from BaseTenantedCrudService (aggregate-response pattern).
queries.ts shape existing named fns Remove findBoundSubjectGroupsForRename; drop homeroomId/track from includes/selects; SG board query already unfiltered. Convention: named functions per module, no repository classes.
Error codes remove 5 + trim 1 Remove HOMEROOM_TRACK_REQUIRED, HOMEROOM_TRACK_NOT_APPLICABLE, MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE, SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE, SUBJECT_GROUP_FIELD_IMMUTABLE. Trim the TRACK_MISMATCH reason from HOMEROOM_MOVE_CURRICULUM_MISMATCH + STUDENT_NOT_IN_ELIGIBLE_POOL. Add timetable diagnostic codes if the checks are renamed (see §5). Codes have no remaining producer. HOMEROOM_MOVE_{GRADE,CURRICULUM}_MISMATCH themselves survive (grade/curriculum move guards stay).
DTO conventions scope sub-DTOs Homeroom composition DTO loses subjectTeachers/addToDeclaredSubjects/trackId (create + update); SG detail DTO loses homeroomId; student-assignment DTO loses homeroomBound + homeroom track; grouped-courses DTO loses the homeroom ref; homeroom list-item loses trackId. Contract-only edits; FE-breaking, acceptable.
File-backed sub-resources n/a — no file usage touched.
Custom fields yes (unchanged) customFields JSONB stays on both entities.
Profile completeness no completion-required-fields.ts untouched. Homeroom/course placement is not a person-completeness field.

4. Data model plan

Schema deltas

  • Homeroom: drop trackId column + track relation + @@index([trackId]). Drop the Homeroom.subjectGroups back-relation and the CurriculumTrack.homerooms back-relation.
  • SubjectGroup: drop homeroomId column + homeroom relation + @@unique([homeroomId, curriculumSubjectId]) + @@index([homeroomId]).
  • No new columns. GradeEntry.homeroomId/homeroomName stay (soft snapshot columns) — only their write source changes (from sg.homeroomId to the student's HomeroomAssignment), which is code, not schema.

Migration shape

  • Destructive (drops two FK columns + their constraints/indexes). No renames.
  • Data backfill: none. Any existing homeroom-bound SG rows either (a) survive as standalone SGs with empty rosters, or (b) are deleted in the migration. Given the droppable dev DB, we take the simplest coherent option and delete previously-bound SGs (DELETE FROM subject_groups WHERE homeroom_id IS NOT NULL) before dropping the column, so no empty-roster orphans linger. (Confirm in review — see §10.)
  • Hazards from chapter 12 checklist:
  • Dropping a column referenced by a Restrict/Cascade FK — fine (we drop the constraint in the same migration).
  • Uncommitted-migration entanglement — the tree already carries uncommitted migrations (20260708120000_combined_classes, ..._drop_day_template_break_id_default, ..._start_tick_rename) and uncommitted combined-classes code that references SubjectGroup.homeroomId. This change reworks that code. Sequencing must be resolved before we run prisma migrate dev (§10, migration-hygiene open Q).

Indexes and uniqueness

  • Removed: homerooms_track_id_idx, subject_groups_homeroom_id_idx, subject_groups_homeroom_id_curriculum_subject_id_key.
  • Unchanged and now load-bearing for the manual model: @@unique([academicYearId, gradeId, curriculumSubjectId, name]) on SubjectGroup, and @@unique([studentId, curriculumSubjectId, academicYearId]) on SubjectGroupAssignment (still the "one SG per subject per student per AY" belt).

5. API surface

No new endpoints. All deltas are shape reductions on existing routes.

Verb Path Change
POST /homerooms Request DTO drops subjectTeachers, addToDeclaredSubjects, trackId. Creates zero SGs. Response drops the subjects[] slot list.
PATCH /homerooms/:id Drops trackId from immutable set (field no longer exists); no more child-SG rename cascade.
POST /homerooms/:id/students, DELETE .../students, POST .../students/move No child-SG roster cascade. move drops the track guard (keeps grade + curriculum guards).
GET /students/eligible-for-homeroom Query drops trackId. Classifier stops hiding cross-track students + the mandatory-SG conflict.
GET /subject-groups Drops the homeroomId: null filter — now returns all courses (all are standalone).
PATCH/DELETE/roster /subject-groups/:id (+ roster sub-resources) assertNotHomeroomBound guards, bound-name immutability, and bound-delete block removed — uniform behavior.
GET /subject-groups/grouped-courses Leaf drops the homeroom ref (GroupedCourseHomeroomRefDto). Board population converges with GET /subject-groups.
GET /homerooms, /homerooms/grouped-homerooms List item drops trackId; FE move-target derivation simplifies to "any sibling homeroom in the same curriculum+grade node".
GET /students/:id (assignment scope) Drops subjectGroups[].homeroomBound and homeroom .track.
GET /grades/homeroom/:id Behavior preserved; GradeEntry.homeroomId now student-sourced. SG-cards homeroomId filter removed.
POST /timetables/generate + diagnostics The homeroom tiling family (HOMEROOM_COVERAGE/HOMEROOM_HOUR_IDENTITY/BAND_NOT_TILEABLE) is removed; HOMEROOM_GAPSTUDENT_GAP (per-student compactness), CLASS_CONFLICT folds into the existing student roster-clash, OPTION_BLOCK_SYNC re-scopes homeroom→grade. Every lesson still placed (per-SG hour budget); no double-booking stays.

Swagger considerations

  • Error examples for the 5 removed codes deleted from error-examples.ts; the error-examples.drift.spec.ts DOCUMENTED_CODES exempt list entries removed in lockstep.
  • Any renamed diagnostic codes need new ERROR_EXAMPLES + catalog + coverage entries (or, if we keep the codes and only change their semantics, none).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) SUBJECT_GROUP_SCOPES.composition drops homeroomId; HOMEROOM_SCOPES.composition drops trackId. Regenerate the seeded catalogue.
Role grants (roles.ts) none
*_SCOPES runtime constant Update src/common/constants/scope-fields.ts (SUBJECT_GROUP_SCOPES, HOMEROOM_SCOPES) to match. rbac-catalogue.drift.spec.ts + the rbac-grants.db-sync.e2e-spec.ts will need the regenerated seed to pass.

Stale prose in prisma/seed/rbac-catalogue.ts:109,158-160,266 (describing the old auto-seed cascade) is updated for accuracy but is not code-breaking.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Solver coverage is homeroom-anchored tiling (HOMEROOM_COVERAGE keys on cohort.homeroom_id + homeroom.mandatory_group_ids, requiring HOMEROOM_HOUR_IDENTITY to fill a fixed window) Dropping the tiling family entirely and replacing it with a per-student compactness (no-gap) constraint: no empty teaching slot between two of a student's lessons on a day (template breaks are not gaps). No fixed-window tiling ⇒ no hours-identity dependency. A homeroom is no longer a teaching unit, so it can't carry coverage; "fill a fixed weekly envelope" was a homeroom-cohort notion. "No gaps between the lessons a student actually has" is the guarantee product wants and is always well-defined. Simpler than what it replaces: HOMEROOM_COVERAGE / HOMEROOM_HOUR_IDENTITY / BAND_NOT_TILEABLE all delete. Compactness is essentially always satisfiable (no tiling-feasibility landmine); a residual gap surfaces as the STUDENT_GAP diagnostic. Identical-course-set cohorts survive only as an optional constraint-dedup, not a correctness unit.
A course's roster is derived (homeroom-bound) All courses carry explicit SubjectGroupAssignment rows, always. Manual course model. Coverage counts (numStudentsWithMissingAssignments) will rise until courses are created — correct, but a visible behavior change.
Grade provenance from the SG's homeroom Grade provenance from the student's homeroom (HomeroomAssignment). The SG has no homeroom anymore; the meaningful homeroom is the student's. Mirrors attendance; keeps GET /grades/homeroom/:id alive.

8. Pushback log

US says Conflicts with Proposed instead Status
"Students of different addresses can participate in the same homeroom" The homeroom-as-teaching-cohort model (bound child SGs assume one uniform roster + one track) Redefine Homeroom as a registration group; move all teaching to standalone courses with independent rosters Resolved (user-approved in chat 2026-07-08)
"Homerooms and courses kept separate at creation" The wizard cascade that materialises courses at homeroom creation Homeroom create = roster + header only; drop SubjectGroup.homeroomId entirely Resolved
Elementary reality: a class is a uniform cohort, so manual per-course rostering repeats the same 25 students N times The manual-course decision (friction for uniform classes) Accept the friction now; a "bulk-roster a homeroom into a course" helper is a deferred convenience (§9) Resolved (user accepted the tradeoff)

9. Deferrals

  • "Bulk-create / bulk-roster courses from a homeroom's roster" convenience action — a pure helper (copy a homeroom's roster into a chosen course in one call), never a stored relationship — deferred. Follow-up: revisit if the elementary friction bites. Memory: to be recorded on landing.
  • Per-department "teaching-cohort" mode — not modeled; registration-group is global. Follow-up: only if product asks.
  • Coverage-cohort scaling optimization — if distinct course-sets explode toward per-student, the constraint count grows; a smarter cohorting / lazy-constraint approach is deferred until a real timetable stresses it.
  • Class-level timetable view — whether "class 3A's timetable" survives (derived by roster-intersection, possibly ragged) or is replaced by per-student timetables is deferred to the timetable-view iteration (§10 flags the decision).

10. Open questions

All resolved in chat 2026-07-08 (user "ok" to the recommended defaults).

  • Migration hygiene / combined-classes sequencing. Resolved: commit the uncommitted combined-classes work as-is first; this change lands on top and explicitly reverts the US-69 homeroom-member branch (members become standalone-only again). The decoupling migration runs after that commit. The combined-classes commit is a discrete git action to confirm at execution time before the migration is generated.
  • Student no-gaps: how to encode (revised 2026-07-08). Resolved: drop the tiling family (HOMEROOM_COVERAGE + HOMEROOM_HOUR_IDENTITY + BAND_NOT_TILEABLE) outright and model no-gaps directly as a hard per-student compactness constraint: no empty teaching slot between two of a student's lessons on a day (template breaks don't count as gaps; per-day). Every lesson is still fully placed via the existing per-SG hour budget ("full slot coverage"); no double-booking via the existing roster-clash. No fixed-window tiling ⇒ no hours-identity dependency ⇒ no tiling-infeasibility, so no pre-check is needed — a residual gap is reported by the STUDENT_GAP diagnostic and gates publish.
  • Coverage-cohort encoding. Resolved: compactness is per-student and needs no cohort for correctness. Students sharing an identical course-membership set may be deduped into one constraint group purely as a solver-efficiency optimization (optional, not load-bearing).
  • Diagnostic codes. Resolved: rename homeroom-anchored diagnostics to student-anchored (STUDENT_GAP; fold the old CLASS_CONFLICT into the existing student roster-clash check). i18n catalog + drift specs move in lockstep.
  • Deleted-vs-orphaned bound SGs at migration. Resolved: delete previously-bound SGs in the migration (droppable dev DB) rather than leaving empty-roster standalone orphans.
  • Carried assumptions confirmed. Homeroom keeps its Curriculum + Grade binding + curriculum-match eligibility; the homeroom→selection forward sync stays (curriculum only, no track fill).

11. Verification plan

  • Unit specs:
  • homerooms.service.spec.ts — create with no subjectTeachers/trackId creates zero SGs; add/remove/move touch no SG rosters; move keeps grade+curriculum guard, has no track guard.
  • homeroom-eligibility.spec.ts + .parity.spec.ts — cross-track students admitted; TRACK_MISMATCH + conflictingMandatorySg branches gone; parity oracle still holds.
  • subject-groups.service.spec.ts — every SG deletable/renamable/rosterable; no bound-guard rejections.
  • class-assignment.mapper.spec.tshomeroomBound gone.
  • grades.service.spec.tsGradeEntry.homeroomId sourced from the student; GET /grades/homeroom/:id returns freshly-recorded grades.
  • Solver Python tests (solver/tests/) — student-anchored coverage: a uniform cohort still tiles gap-free; a mixed cohort produces no per-student gap; add a fixture proving no student is double-booked.
  • Drift specs updated in lockstep: error-examples.drift.spec.ts, error-messages.drift.spec.ts, rbac-catalogue.drift.spec.ts.
  • E2E specs:
  • homerooms.e2e-spec.ts — create on a track-bearing curriculum with no track; two different-track students in one homeroom; no SGs created.
  • subject-groups.e2e-spec.ts — create + roster a course independently; delete it.
  • test/rbac-grants.db-sync.e2e-spec.ts — passes against the regenerated seed.
  • An attendance + grades e2e proving take/record work off an explicitly-rostered course and the homeroom-grades report shows current data.
  • Manual verification (per the verify skill, once code lands): create a homeroom (roster only) → create a course → roster students → take attendance → record a grade → generate a timetable and confirm the diagnostics show no student gap and no student double-booking.

Patterns: chapter 09 (testing) + feedback_e2e_isolation_patterns.md.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-08
  • Chat reference: approved by Fabio in chat 2026-07-08 ("ok") after the four-agent blast-radius walkthrough and the domain-modeling discussion that concluded SubjectGroup.homeroomId has no remaining domain role; recommended defaults on all six §10 open questions accepted.

Status flipped to Approved in the frontmatter (this edit).