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.homeroomIdis dropped, theHomeroom.trackIdbinding 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
SubjectGroupper mandatory subject (roster derived from the homeroom) and binds exactly oneCurriculumTrackwhen 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: droptrackIdcolumn +trackrelation +@@index([trackId]). Drop theHomeroom.subjectGroupsback-relation and theCurriculumTrack.homeroomsback-relation.SubjectGroup: drophomeroomIdcolumn +homeroomrelation +@@unique([homeroomId, curriculumSubjectId])+@@index([homeroomId]).- No new columns.
GradeEntry.homeroomId/homeroomNamestay (soft snapshot columns) — only their write source changes (fromsg.homeroomIdto the student'sHomeroomAssignment), 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/CascadeFK — 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 referencesSubjectGroup.homeroomId. This change reworks that code. Sequencing must be resolved before we runprisma 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])onSubjectGroupAssignment(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_GAP → STUDENT_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; theerror-examples.drift.spec.tsDOCUMENTED_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 theSTUDENT_GAPdiagnostic 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 oldCLASS_CONFLICTinto 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 nosubjectTeachers/trackIdcreates 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+conflictingMandatorySgbranches gone; parity oracle still holds.subject-groups.service.spec.ts— every SG deletable/renamable/rosterable; no bound-guard rejections.class-assignment.mapper.spec.ts—homeroomBoundgone.grades.service.spec.ts—GradeEntry.homeroomIdsourced from the student;GET /grades/homeroom/:idreturns 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
verifyskill, 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.homeroomIdhas no remaining domain role; recommended defaults on all six §10 open questions accepted.
Status flipped to Approved in the frontmatter (this edit).