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: addhomeroomId String? @map("homeroom_id") @db.Uuid+homeroom Homeroom? @relation(fields: [homeroomId], references: [id], onDelete: SetNull)+@@index([homeroomId]).Homeroom: add back-relationsubjectGroups 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-decouplingsubject_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_homeroomdropped 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.tsdelta.
Indexes and uniqueness¶
- New:
subject_groups_homeroom_id_idx. - Load-bearing existing: the two
subject_group_assignmentsgist EXCLUDEs (group- and subject-concurrency) — the cascade writes through the same helpers and inherits theTEMPORAL_INTERVAL_OVERLAPmapping via the existingexclusionMap. - Link-validity rot is structurally impossible:
homeroom.curriculumId/gradeIdandsg.curriculumSubjectId/gradeId/academicYearIdare 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.tsandmove-students.dto.tsJSDoc currently promise "Subject Group (course) memberships are independent and unaffected" — public FE-facing copy that becomes false; rewrite to describe the cascade and thecourseCascadereport.- New error codes: catalog messages (en/it),
ERROR_EXAMPLES+ drift-spec entries, prose insubject-groups.swagger.ts. courseCascade.skipped[].reasonenum 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. linkedCourseson 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.ts—countSelectedre-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 projecthomeroom {id,name}; combined rowsnull.from-homeroom.service.spec.ts— stamping: mandatory CREATE stamped, in-block CREATE unlinked, MERGE untouched.subject-groups.queries.spec.ts— tablewhere: link ∪ overlap union; the combined-class delegation inherits it.homerooms.service.spec.ts— each roster op composes the cascade in-tx;courseCascadereport 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 newhomeroom-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 withhomeroomId→ GET carrieshomeroom→ 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).