Skip to content

Homeroom↔Course link — section affinity + roster cascade

A course may optionally declare which section it belongs to: SubjectGroup.homeroomId (nullable FK). The link is an affinity, not a fence — it never constrains the roster; it drives workflows. Homeroom roster events cascade symmetrically into linked courses: leaving a section removes the student from its linked courses, entering one adds them to every linked course they explicitly belong to. The decoupling's roster freedoms (mixed-track homerooms, standalone courses, manual rosters) are fully preserved.


1. Problem distillation

  • ~90% of real course rosters mirror a section (1A, 1B); ~98% of departments run sections. The decoupled model refuses to store that fact, so "the courses of 1B" is only a roster-overlap heuristic and every mid-year section change costs the admin N manual course moves. The decoupling spec's own pushback log (§8 row 3) predicted exactly this friction.
  • The 2026-07-20 from-homeroom wizard solved creation-time only. The lifecycle (join / leave / move a section) has zero course sync.
  • Fix: an explicit, optional, admin-declared link (SubjectGroup.homeroomId) plus a symmetric cascade anchored exclusively on homeroom roster events. Courses without a link (electives, in-block, IB, shared cross-section) are never touched.
  • The link is restricted to mandatory-anchor courses (optionBlockId IS NULL): "1B's math course" is a sentence, "1B's French elective" is a category error — and unlinkable in-block courses mean the cascade can never create or depend on option-block choices.

Success criteria (observable behavior that proves this works): - POST /subject-groups with homeroomId creates a linked course; detail/list/ table DTOs carry homeroom: {id, name}; PATCH can set/change/clear the link. - Linking an in-block-anchored course fails 409 SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK; linking across curriculum/grade/AY fails 409 SUBJECT_GROUP_LINK_MISMATCH. - POST /subject-groups/from-homeroom stamps the source homeroom on created mandatory-anchor items; in-block creates stay unlinked; MERGE items never touch the target's existing link. - Moving a student 1A→1B in one command closes their memberships in 1A-linked courses they're in and opens memberships in 1B-linked courses they explicitly belong to, all at the command's single effective date; skips are reported per course; a booked SG transition the cascade collides with is superseded and reported. - Removing a student from 1A closes their 1A-linked course memberships. Adding a student to 1B (join or create-with-roster) opens the linked-course memberships. - A student in an unlinked course keeps it across any homeroom change. - A no-choice student moved into a section is never given an option-block choice by the cascade (structurally impossible: in-block courses can't be linked). - The courses-table homeroomId filter matches linked courses ∪ roster-overlap (wire-compatible). - GET /homerooms/:id lists the section's linkedCourses[].

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No M:N link (a course belongs to ≤1 section; shared cross-section courses stay unlinked and cascade-immune). - No cascade on link edits (PATCH set/clear/change is metadata-only) and no retroactive roster reconciliation when a link is added to an existing course. - No touch to curriculum selections in either direction (cascade closes never remove choices; cascade opens never add choices/tracks). - No "missing course assignments per selection" admin surface — separate upcoming spec (the safety net for everything the cascade deliberately skips). - No FE choice/prompt flow — the cascade is automatic; the response reports it. - No change to eligibility rules, pickers, boards, or combined classes.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/subject-groups/from-homeroom/from-homeroom.service.ts (batch write, createFromHomeroom :180) Skip-don't-block triage (skippedStudentIds per item), pre-tx validation + shared tx bodies; the homeroom anchor is already in closure at the createSubjectGroupInTx call site (:384) — stamping is one extra param. Its roster comes from explicit request studentIds gated by the classifier; the cascade derives its own candidate set and adds an explicit-belonging layer on top.
src/homerooms/course-blueprint.service.ts countSelected (:239–275) THE "explicitly belongs" rule (product decision 2026-07-31: bulk flows roster students who explicitly belong, never merely-eligible): already-in-course-for-subject → skip; track subject → sel.trackId === trackId; common mandatory → in roster. Private, cohort-shaped, returns a count, and its loader is StudentsPolicy-filtered. Extract the per-(student, subject) predicate into an exported pure function both consumers call (a write-path cascade must NOT be policy-filtered).
src/students/class-selection-sync.ts pruneIncompatibleClassMemberships (:539) The precedent for "a write on entity A closes memberships on entity B inside A's tx, reporting removed ids" — same close shape, same in-tx requirement, same reporting posture. Prune is selection→classes and unconditional; the cascade is homeroom→linked-courses with an explicit-belonging add side.
src/homerooms/homerooms.service.ts roster command skeleton (create :857 / add :1293 / remove :1547 / move :1647) + src/homerooms/homerooms.queries.ts interval helpers The temporal command shape: pre-tx explicit date (resolveExplicitRosterDate :208), in-tx dateless default (resolveDatelessRosterDate :234), gateSameDayClose (:299), plan/replace/supersede machinery, collectSuperseded (:268). The cascade rides these commands — it invents no new temporal machinery. affectedStudentIds probe sets and pending-plan replacement must be shown to cover cascaded SG closes/opens (§5.1 point 2 — asserted in a spec, not assumed).
src/subject-groups/subject-group-write-ops.ts (createSubjectGroupInTx :124, appendStudentsToSubjectGroupInTx :195, assertSubjectGroupBaseRoomValid :409) Append = the open half of the cascade enter path; assertSubjectGroupBaseRoomValid is the exact precedent for the link validity gate (reference must exist in tenant+AY). appendStudentsToSubjectGroupInTx runs the forward selection sync; on cascade opens it is a structural no-op by construction (mandatory anchors, matching curriculum) but that guarantee gets its own spec.
src/homerooms/homerooms.queries.ts closeSubjectGroupAssignmentsForGroups (:1275) Already-written, currently dead cross-module close helper (tx, {subjectGroupIds, studentIds, on}) — the leave-side cascade primitive. The schema docstring (schema.prisma :2648–2650) explicitly permits cross-module SG-assignment closes from homeroom/placement helpers. Handles closes only; cancel/re-anchor of booked cascaded rows and the open side are new (§5.1 point 3).

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes No new tables; homeroomId validated in-tenant at link time; cascade queries ride the existing tenant-scoped homeroom/SG queries. Column addition on an already-tenanted model.
Academic-year scope yes Link validity requires sg.academicYearId === homeroom.academicYearId (both immutable). Cascade operates within the command's AY.
RBAC entity key existing HOMEROOMS + SUBJECT_GROUPS unchanged. Shape change on existing entities, no new domain concept.
Scopes existing (composition) SUBJECT_GROUP_SCOPES.composition gains 'homeroomId' (src/common/constants/scope-fields.ts:336–344); the seed FIELD_MAPPINGS row (rbac-catalogue.ts:1023–1028) derives from that array — one edit total. Contract in the scope-fields.ts header: seed + service pick the array up automatically.
Actions none read/update implicit; cascade rides existing composition write on the homeroom roster routes. Cascade is a side-effect of an already-authorized command; no new grant surface.
Service base custom (existing HomeroomsService / SubjectGroupsService / FromHomeroomService) Cascade planner + executor in a new src/homerooms/linked-course-cascade.ts (homeroom-event-owned), calling SG-side write helpers. These modules already diverge from BaseTenantedCrudService (aggregate-response pattern).
queries.ts shape existing named fns New: findLinkedCoursesForHomerooms (link rows + anchor projection) and a cascade membership loader; reuse insertSubjectGroupAssignments (subject-groups.queries :1733), closeSubjectGroupAssignments (:1763), closeSubjectGroupAssignmentsForGroups (homerooms.queries :1275, currently dead). Named functions, no repositories; SG-assignment writes stay inside the sanctioned files per the schema docstring.
Error codes 2 new SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK (internal params {curriculumSubjectId, optionBlockId}), SUBJECT_GROUP_LINK_MISMATCH (internal params {homeroomId, field: 'curriculumId'\|'gradeId'\|'academicYearId'}). Full checklist: enum + ErrorParamsMap + ERROR_TEXT_PARAMS/ERROR_INTERNAL_PARAMS + error-messages.catalog.ts (en_US + it_IT) + examples + drift specs. HOMEROOM_NOT_FOUND reused for a dangling homeroomId. Mirrors the SUBJECT_GROUP_NAME_CONFLICT recipe (error-codes.ts :226/:763/:1206/:1429; catalog :1107).
DTO conventions scope sub-DTOs + aggregate responses homeroomId? on CreateSubjectGroupCompositionDto + UpdateSubjectGroupCompositionDto (uuid | null-to-clear); new SubjectGroupHomeroomSummaryDto {id, name} mirroring SubjectGroupOptionBlockSummaryDto (subject-group-composition.dto.ts :221–227); homeroom key added in the three mappers (toDetailDto :2842, toSubjectGroupListItem :2931 — shared with the grouped-courses leaf, toSubjectGroupTableRow :2967); HomeroomLinkedCourseDto + CourseCascadeEntryDto on the homerooms side. Follows the existing optionBlock null-carrying convention; combined-class table rows carry homeroom: null.
File-backed sub-resources n/a — no file usage touched
Custom fields yes (unchanged) customFields JSONB untouched on both entities.
Profile completeness no completion-required-fields.ts untouched. Class placement is not a person-completeness field.

4. Data model plan

Schema deltas

  • SubjectGroup: add homeroomId String? @map("homeroom_id") @db.Uuid + homeroom Homeroom? @relation(fields: [homeroomId], references: [id], onDelete: SetNull) + @@index([homeroomId]).
  • Homeroom: add back-relation subjectGroups SubjectGroup[].
  • Reconcile the two model docstrings asserting full decoupling (schema.prisma :2380–2384 and :2462–2466) — the link is an affinity; rosters stay decoupled.
  • No unique on (homeroomId, curriculumSubjectId) — split groups (Math-1B taught in two groups) are legitimate; the cascade resolves ambiguity by skipping (§5.1). Deliberate contrast with the pre-decoupling subject_groups_homeroom_id_curriculum_subject_id_key.

Migration shape

  • Additive (nullable column + FK + index). Data backfill: none — existing courses stay unlinked; admins and the wizard link progressively.
  • Hazards from chapter 12 checklist: none of the destructive classes apply. Check for an uncommitted migration to fold (ch12 Rule 1) before generating. History note: 20260709105004_drop_homeroom_track_and_subject_group_homeroom dropped this column's ancestor — re-adding is deliberate; this spec is the paper trail (the old unique and the bound-SG semantics are NOT restored).
  • No new model → no rls-coverage.ts / tenanted-models.ts delta.

Indexes and uniqueness

  • New: subject_groups_homeroom_id_idx.
  • Load-bearing existing: the two subject_group_assignments gist EXCLUDEs (group- and subject-concurrency) — the cascade writes through the same helpers and inherits the TEMPORAL_INTERVAL_OVERLAP mapping via the existing exclusionMap.
  • Link-validity rot is structurally impossible: homeroom.curriculumId/gradeId and sg.curriculumSubjectId/gradeId/academicYearId are all immutable post-create, so a validated link stays valid for its lifetime.

5. API surface

No new routes. All deltas land on existing ones.

Verb Path Change
POST /subject-groups composition.homeroomId? — validated: homeroom exists in (tenant, AY) else HOMEROOM_NOT_FOUND; anchor non-block else SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK; (curriculumId, gradeId, academicYearId) triple-match else SUBJECT_GROUP_LINK_MISMATCH.
PATCH /subject-groups/:id composition.homeroomId? (uuid sets/changes, null clears; same validation). Metadata-only — never cascades rosters.
POST /subject-groups/from-homeroom CREATE items with a mandatory anchor are stamped homeroomId = dto.homeroomId; in-block CREATE items stay unlinked; MERGE items never touch the target's link.
GET /subject-groups/:id, /subject-groups, /subject-groups/table (+ grouped leaf via the shared list-item mapper) DTOs gain homeroom: {id, name} \| null (combined-class rows: null).
GET /subject-groups/table homeroomId filter widens to link ∪ roster-overlap: {homeroomId: {in}} OR the existing dated-overlap leg (queries :357–381). Wire-compatible; missing-courses continues to accept-and-ignore it.
GET /homerooms/:id New linkedCourses[]: HomeroomLinkedCourseDto {id, name, subject {id, name}} (also on write-path reloads — cheap header-level include).
POST/DELETE /homerooms/:id/students, POST /homerooms (roster), POST /:id/students/move Cascade per §5.1. Responses gain courseCascade: CourseCascadeEntryDto[]{studentId, added: [{subjectGroupId, name}], removed: [{subjectGroupId, name}], skipped: [{subjectGroupId, name, subjectName, reason}]} (move: top-level, beside appliedFrom/superseded). superseded[] gains entity: 'subject_group_membership' entries (the kind already exists on SupersededChangeDto).
DELETE /homerooms/:id Behavior unchanged; FK SetNull unlinks surviving courses. Linked courses do NOT block deletion (temporal delete guard untouched).

5.1 Cascade semantics (core design)

Anchor rule: cascades ride homeroom roster events, exclusively. Link edits, SG-side roster ops, curriculum edits, selection writes: none of them trigger it.

Leave (roster DELETE / move-out): close the student's current memberships in every course linked to that homeroom, at the command's date X (cancel to [X, X) for never-governed rows — standard shapes, via the close helpers).

Enter (roster POST / create-with-roster / move-in): for each course linked to the entered homeroom, open a membership at X iff the student explicitly belongs (the predicate extracted from countSelected): common mandatory → yes; track-scoped mandatory anchor → only if selection.trackId explicitly equals the track. Otherwise skip — per course, reported, never blocking the homeroom command:

skipped[].reason When
ALREADY_IN_COURSE_FOR_SUBJECT Any current course on the same subject this AY (incl. already in the target — reported for uniformity, effectively a no-op)
AMBIGUOUS_LINKED_COURSES ≥2 linked courses on the same subject (split groups) — the admin decides manually
TRACK_NOT_HELD Track-scoped anchor and the selection doesn't explicitly hold the track
NOT_ELIGIBLE Belt: any classifySubjectGroupEligibility hidden verdict — the cascade never adds someone the gate would 409

Move = leave + enter in one command, one date, one transaction.

Temporal composition (no new machinery; three integration points): 1. All cascaded closes/opens use the command's single appliedFrom. 2. The dateless-default probe and gateSameDayClose affected-sets already cover cascade closes: a cascaded close only happens for a student whose homeroom row is closing, and the probe is per-student-any-cell (hasAttendanceCellsOnForStudents). Cascade opens never shorten an interval → no gate. This equivalence is asserted in a unit spec, not assumed. 3. Cascaded rows are derived state, recomputed from the homeroom plan on every command — never provenance-stamped. When a command supersedes/cancels/ re-anchors a booked homeroom transition (§1.0 replace semantics), the cascade locates the student's pending SG transitions on courses linked to the affected homeroom at the superseded boundary and cancels/re-anchors them the same way, reporting them as superseded[] entity: 'subject_group_membership' entries. A collision with an independently-booked admin SG plan follows newest-wins like every other replace.

Forward selection sync: cascade opens are mandatory-anchor-only and enter requires curriculum match (homeroom eligibility), so syncRosterSelectionsInTx is a structural no-op on cascade opens — it never creates a choice, and track is only ever present when already explicitly held, so there is nothing to fill. It still runs (completeness recompute / invariant maintenance), and the no-choice guarantee is locked by a unit spec.

Audit: every cascaded open/close records the existing SG-membership structural events, anchored on the student, same actor, inside the command tx.

Swagger considerations

  • remove-students.dto.ts and move-students.dto.ts JSDoc currently promise "Subject Group (course) memberships are independent and unaffected" — public FE-facing copy that becomes false; rewrite to describe the cascade and the courseCascade report.
  • New error codes: catalog messages (en/it), ERROR_EXAMPLES + drift-spec entries, prose in subject-groups.swagger.ts.
  • courseCascade.skipped[].reason enum documented on the DTO (ALREADY_IN_COURSE_FOR_SUBJECT | AMBIGUOUS_LINKED_COURSES | TRACK_NOT_HELD | NOT_ELIGIBLE). Payload vocabulary the FE labels — not error codes, no i18n catalog entries.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) derived — FIELD_MAPPINGS reads SUBJECT_GROUP_SCOPES.composition, which gains 'homeroomId'; no second edit.
Role grants (roles.ts) none — rides existing subject_groups.composition / homerooms.composition grants.
*_SCOPES runtime constant src/common/constants/scope-fields.ts:336 — add 'homeroomId' to SUBJECT_GROUP_SCOPES.composition. rbac-catalogue.drift.spec.ts + rbac-grants.db-sync.e2e-spec.ts pass against the regenerated seed. NEEDS PROD RESEED on release.

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
SG rosters are written only by SG-module commands (post-decoupling) Homeroom commands write SG assignments cross-module (through the sanctioned query helpers; the schema docstring :2648–2650 already carves out this exception for closes — we extend it to opens) The cascade must be atomic with the homeroom command Homeroom roster tx grows (N linked-course writes); bounded by the grade's mandatory-subject count
Picker/gate roster admission = classifier buckets (merely-eligible is placeable) The cascade adds only explicitly-belonging students (blueprint selected rule); the classifier is demoted to a belt Product decision 2026-07-31: automatic flows must never make choices for a student — an automatic cascade is the extreme case Cascade under-places (e.g. track-not-yet-chosen students are skipped); the missing-assignments surface catches the tail
Decoupling: "homeroom membership has no effect on any course" (spec 2026-07-08, ch14 §1) Partially revised: membership events affect linked courses Product: the 90% section world pays N manual moves per student move Decoupling docstrings / ch14 / FE guides need reconciliation; unlinked courses keep the old guarantee verbatim
Table homeroomId filter = derived roster-overlap only Union: stored link ∪ overlap "Courses of 1B" should surface linked-but-not-yet-rostered courses without dropping the shared-unlinked-course match The filter is no longer a pure roster fact; documented

8. Pushback log

US says (chat) Conflicts with Proposed instead Status
"student is ADDED to all courses that have the link with the homeroom" A literal reading would append option-block choices / tracks via the forward sync (automatic choice-making) Explicitly-belongs rule + skip triage; then hardened further: link restricted to mandatory anchors, making choice interaction structurally impossible Resolved in chat 2026-08-06 (Fabio proposed the mandatory-only restriction himself)
"removed from ALL THE COURSES (mandatory or not)" Moot under mandatory-only linkability: removal = all linked courses, which are all mandatory-anchored Resolved (same message)
Earlier in the same chat: link-agnostic "remove from all mandatory courses on reassignment" as v1 Would strip shared cross-section mandatory courses no admin ever marked as section-bound Link-driven cascade (this spec) — explicit declaration replaces the heuristic Resolved — superseded by Fabio's later message
"Moving a student … should at least trigger a choice from the user" (opening message) The automatic-cascade decision later in chat Automatic + loud courseCascade/superseded[] reporting; FE renders the outcome, no confirmation round-trip Resolved — final semantics are unconditional

9. Deferrals

  • M:N link (a course serving 1A+1B as a declared fact) — the single FK covers the 90% case; shared courses stay unlinked and cascade-immune, which is behaviorally correct — follow-up: revisit only if "shared course shows as unaffiliated" bites; FK→junction is an additive migration.
  • Cascade/reconciliation on link edits (PATCH-linking a course that already has rosters) — metadata-only now — follow-up: next iteration if admins ask for "re-sync roster from section".
  • Per-section option-block groups (French-1A / French-1B as block alternatives) — unlinkable by design; placement stays manual — follow-up: the missing-assignments surface (separate spec) is the net.
  • Missing-assignments admin surface ("who misses which course per their selection") — announced by Fabio as its own upcoming spec; builds on resolveTargetPlan / numStudentsWithMissingAssignments — follow-up: own spec.
  • linkedCourses on homeroom list/table rows — detail-only for now — follow-up: FE request.

10. Open questions

All resolved in chat 2026-08-06:

  • Link vs no link → stored nullable FK ("spec the addition of a nullable link").
  • Affinity vs fence → affinity; the roster is never constrained ("i dont want the roster to be strict").
  • Cardinality → single FK; M:N deferred (§9).
  • Linkable anchors → mandatory-only ("scope the link to mandatory courses… elective is inherently nonsense").
  • Cascade symmetry → symmetric on enter/leave ("symmetric is fine").
  • Trigger set → any exit (remove or move-out) + any enter (add, create-roster, move-in); link edits never.
  • Add-side admission → explicitly-belongs + skip triage (recommended in chat, unobjected; §8 row 1).
  • Automatic vs prompt → automatic with loud reporting.

11. Verification plan

  • Unit specs:
  • linked-course-cascade.spec.ts (new) — pure planner: leave closes only linked+current; enter opens only explicitly-belonging; all four skip reasons; ambiguity (2 linked courses, same subject); booked-transition supersede/cancel/re-anchor shapes; idempotence (already in the target course).
  • course-blueprint.service.spec.tscountSelected re-expressed over the extracted predicate; blueprint counts unchanged (regression lock on the extraction).
  • subject-groups.service.spec.ts — link validation triple (in-block anchor / mismatch fields / dangling homeroom); PATCH set/clear is metadata-only (no roster writes); mappers project homeroom {id,name}; combined rows null.
  • from-homeroom.service.spec.ts — stamping: mandatory CREATE stamped, in-block CREATE unlinked, MERGE untouched.
  • subject-groups.queries.spec.ts — table where: link ∪ overlap union; the combined-class delegation inherits it.
  • homerooms.service.spec.ts — each roster op composes the cascade in-tx; courseCascade report shape; probe-set equivalence assertion (§5.1 point 2); forward-sync no-choice guarantee on cascade opens.
  • Drift specs in lockstep: error-examples.drift.spec.ts, error-messages.drift.spec.ts, rbac-catalogue.drift.spec.ts.
  • E2E specs:
  • homerooms.e2e-spec.ts (or a new homeroom-course-cascade.e2e-spec.ts) — full move 1A→1B: linked closes + opens at one date, courseCascade + superseded[] payloads; join/create-roster enter-cascade; remove leave-cascade; an unlinked course untouched across a move; a dated (future) move books cascaded transitions and a subsequent command re-anchors them; same-day gate parity with an attendance cell present.
  • subject-groups.e2e-spec.ts — link round-trip (POST with homeroomId → GET carries homeroom → PATCH null clears); 409s for both new codes; homeroom DELETE → link nulls, course survives.
  • Manual verification: create section 1A + wizard-create its courses (stamped) → move a student to 1B → confirm the FE toast renders courseCascade, rosters flipped on one date, and an unlinked elective is retained.

Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md (per-worker DB clones), plus the temporal fixture discipline memory (dated fixtures).


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-08-06
  • Chat reference: approved by Fabio in chat 2026-08-06 ("approved, move on with the plan") after the brainstorm that settled affinity-not-fence, mandatory-anchor-only linkability, and the symmetric explicitly-belongs cascade.

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