Skip to content

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, like superseded[]). 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 homeroomId filter 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:

"homeroom": { "id": "hr_…", "name": "1B" }   // or null = not section-affiliated

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's selected counts (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 (a POST /homerooms create 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 carry entity: "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 appliedFrom for 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.