Homeroom↔course link + roster cascade — FE guide¶
BE contract for the section affinity between a class (homeroom) and its courses, and the roster cascade it drives. Consumer: sis-academic-mfe. Spec:
docs/superpowers/specs/2026-08-06-homeroom-course-link-design.md.⚠ BREAKING behavior (not shape): homeroom roster writes (add / remove / move students) now ALSO add/remove those students in the courses linked to the classes involved, reported in a new
courseCascade[]key. Screens that assumed "moving a student between classes never touches courses" must surface the cascade (render it loudly, likesuperseded[]). Additive keys everywhere else.
The mental model¶
- A course (
subject group) MAY declare which class it belongs to — "1B's math course". Stored as one optional key; most courses in a section-based school will have it. - The link never restricts the roster. Any eligible student can be in any course, linked or not. The link drives workflows only:
- class roster changes cascade into linked courses (see below);
- the courses table's
homeroomIdfilter matches linked courses; - display (course cards show their class, class detail lists its courses).
- Only courses anchored on a mandatory subject can be linked (an elective "belonging to a class" is meaningless). The class must share curriculum, grade and academic year with the course.
- Courses without the link ("shared" courses, electives, IB-style groups) behave exactly as before — no homeroom event ever touches them.
1. homeroom on every course DTO (additive)¶
Course detail (GET /subject-groups/:id, POST/PATCH responses), grouped list
leaves and table rows all carry:
Always null on COMBINED_CLASS table rows. Populate the create-form selector
from
GET /api/v1/filters/homerooms?surface=homerooms&curriculumId=<id>&gradeId=<id>.
It ships {id, name, departmentId, gradeId} and applies the linkable structural
cascade to policy-visible homeroom rows server-side.
2. Setting the link — POST / PATCH /subject-groups¶
// POST /api/v1/subject-groups
{ "composition": { "name": "Matematica 1B", "curriculumSubjectId": "cs_…",
"gradeId": "g_…", "studentIds": [],
"homeroomId": "hr_…" } } // optional
// PATCH /api/v1/subject-groups/:id
{ "composition": { "homeroomId": "hr_…" } } // set / re-link
{ "composition": { "homeroomId": null } } // clear
- PATCH-link is metadata only — changing or clearing the link never modifies the course roster (no cascade fires from the course side).
- The from-homeroom wizard stamps the link automatically on its mandatory
CREATE items — see the update in
docs/fe-guides/2026-07-20-create-courses-from-homeroom-FE-guide.md. - Deleting a homeroom orphans its courses gracefully: they survive with
homeroom: null.
New 409 codes (localized messages as usual; on either write):
| Code | Meaning | UI |
|---|---|---|
SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK |
the course's subject is an option-block alternative — not linkable | hide/disable the selector for optional courses |
SUBJECT_GROUP_LINK_MISMATCH |
the class doesn't share curriculum / grade / academic year with the course | pre-filter the selector; treat as stale-form |
HOMEROOM_NOT_FOUND (404) for a dangling id.
3. BREAKING behavior — the roster cascade + courseCascade[]¶
Homeroom roster commands now cascade through linked courses, inside the
same transaction, at the same effective date (appliedFrom):
- Student leaves a class (
DELETE /homerooms/:id/students, or the source side of a move): removed from every course linked to that class they are in. Unlinked courses keep them. - Student enters a class (
POST /homerooms/:id/students, or the target side of a move): added to the target's linked courses they explicitly belong to — same rule as the wizard'sselectedcounts (common mandatory → yes; track subject → their selection explicitly holds the track). An addition that cannot be made automatically is skipped, never an error.
The report rides the write responses — courseCascade[] on add/remove/create
responses, top-level on the move response:
"courseCascade": [
{ "studentId": "st_…",
"added": [{ "subjectGroupId": "sg_…", "name": "Matematica 1B" }],
"removed": [{ "subjectGroupId": "sg_…", "name": "Matematica 1A" }],
"skipped": [{ "subjectGroupId": "sg_…", "name": "Latino 1B",
"subjectName": "Latino", "reason": "TRACK_NOT_HELD" }] }
]
- Always present on roster-write responses;
[]when nothing is linked (aPOST /homeroomscreate response also carries[]— a brand-new class cannot have linked courses yet). - Render it loudly. The admin must see "moved to 1B: also moved in
Matematica 1B, Italiano 1B; NOT added to Latino 1B (track not held)" —
same toast contract as
superseded[]. - Skip reasons (enum, stable):
| Reason | Meaning | Suggested copy |
|---|---|---|
ALREADY_IN_COURSE_FOR_SUBJECT |
the student already holds a course for that subject this year (including the target itself — a no-op re-add) | "already in a course for this subject" |
AMBIGUOUS_LINKED_COURSES |
the class links two or more courses for one subject (split group) — no automatic pick | "choose the section manually" |
TRACK_NOT_HELD |
the course is track-scoped and the student's selection doesn't explicitly hold that track | "student is not on this track" |
NOT_ELIGIBLE |
the student doesn't qualify for the subject | "not eligible" |
- Skipped students need manual placement via the usual roster endpoints — the cascade never blocks the class operation itself.
superseded[]can now carryentity: "subject_group_membership"entries: a booked (future-dated) course transition displaced by the cascade is re-anchored onto the command's date and reported there — render alongside the existing homeroom/selection supersessions.- The same-day attendance rule applies to the whole command: if the register
is already taken today for anyone affected (class OR linked-course rows),
a dateless command lands on the next school day — one
appliedFromfor everything.
4. linkedCourses[] on class detail (additive)¶
GET /homerooms/:id (and every homeroom write response) now carries:
"linkedCourses": [
{ "id": "sg_…", "name": "Matematica 1B", "subject": { "id": "cs_…", "name": "Matematica" } }
]
Ordered by subject name, then course name; [] when nothing links. Use it for
the class detail's "courses of this class" panel and to warn before roster
operations ("this will also affect N linked courses").
5. Courses table homeroomId filter — semantics widened (no wire change)¶
GET /subject-groups/table?homeroomId=… now matches the union of the
stored link and the old derived roster overlap. A linked-but-empty course
matches; a shared unlinked course with students from two classes still matches
both. Details in the from-homeroom guide §3.
RBAC¶
No new scopes or actions. homeroomId is a field of the existing
subject_groups.composition scope (grant-holders read/write it as any other
composition field); the cascade rides the existing homeroom roster
authorization.