Skip to content

US-33 + US-33.1 — Homerooms & Subject Groups (V1)

Iteration note (2026-05-25) — Per-subject teacher assignment shifted from single FK to M:N via a new SubjectGroupTeacher junction with explicit ordinalPosition (0 = primary). Motivation: co-teaching is common in Italian high schools (lead + support; split-class). DTO contract is now teacherIds: string[] on the wizard's subjectTeachers[] entries and on standalone-SG create / PATCH. PATCH uses set-semantics — array fully replaces; absent = no change; [] = clear all teachers. The Homeroom-level homeroomTeacherId (tutor) stays singular — different concept.

1. Problem distillation

  • Schools need a way to declare administrative student groupings (Homerooms — classi) where a fixed set of students stays together for mandatory subjects, and a way to declare curriculum-driven teaching units (Subject Groups — gruppi materia) where students partition into teachable cohorts per Subject. Both must precede the Timetable Engine (US-35 onwards).
  • A Homeroom is a roster + structural metadata that, when created, synchronously materialises one child Subject Group per mandatory Subject in its bound Curriculum, each inheriting the Homeroom's roster. Optional-block Subject Groups and Subject Groups for Departments that don't use Homerooms are independent (no homeroomId) and built from study-plan selections.
  • Admin needs a teacher-selector UX during creation that surfaces budget signals (departments, assignment count, working days, contract hours allocated vs. total, historically-taught subjects) — all advisory; never blocking. The selector is shared between the Homeroom wizard's "teacher per mandatory subject" step and the Subject Group creation flow.
  • Teachers and Staff need read access to all Homerooms and Subject Groups (V1 scope is open across the AY); Students and Parents see only their own/child's records. Creation is Admin-only.
  • Two upstream gaps from US-4 must be filled in this iteration to unblock the warnings the UX requires: Department.maxStudentsPerClass does not exist on the schema and is referenced by US-33 S1 + US-33.1 S6. The HomeroomRole Department enum (NONE | ADMINISTRATIVE | SCHEDULING) is orphaned post-US-33-rewrite and must be retired in the same migration.

Success criteria (observable behavior that proves this works): - Admin can POST /homerooms with a single atomic payload (header + roster + per-mandatory-subject teacher assignment) and observe one Homeroom row + N HomeroomAssignment rows + N SubjectGroup rows (one per mandatory Subject) created in one transaction. - Admin can POST /subject-groups for a standalone Subject Group (no homeroomId) with a roster drawn from study-plan-selection state; assignment respects "exactly one Subject Group per Subject per Student per AY". - GET /homerooms returns the tree department → curriculum → grade → homerooms[] with numStudents on each leaf; filters department, curriculum, grade narrow the tree. - GET /homerooms/:id returns header + roster + per-mandatory-subject teacher slots (each slot showing the assigned Teacher or null). - GET /subject-groups returns the same tree shape; GET /subject-groups/:id returns the roster with each Student's homeroom name (when any). - From the Homeroom detail page, Admin can: change the homeroom teacher (PATCH /homerooms/:id), change the teaching teacher of a mandatory-subject slot (PATCH /subject-groups/:id on the homeroom-bound child SG), add students to the roster, remove students from the roster, and move a set of students to another Homeroom of the same (Curriculum, Grade) — atomic per operation. - From the Subject Group detail page (standalone), Admin can: change the teaching teacher, add/remove students, and move a set of students to another Subject Group bound to the same studyPlanSubjectId. - The Homeroom-wizard "select students" step (and the add-students-to-existing-Homeroom picker) is powered by GET /homerooms/eligible-students — returns students enrolled in the target (department, curriculum, grade, currentAY) with status = ENROLLED who are not currently assigned to any Homeroom. The picker shows only the eligible pool (no "already-assigned" entries leak through). - The Subject Group-creation "select students" step (and the add-students-to-existing-standalone-SG picker) is powered by GET /subject-groups/eligible-students?studyPlanSubjectId=… — returns all students for whom this Subject is in their study plan (mandatory subject of the student's bound study plan, or selected via Option Block), each carrying an alreadyAssigned: boolean flag (true if currently in any Subject Group for the same studyPlanSubjectId). Sorted by alreadyAssigned ascending (not-yet-assigned first), then lastName/firstName. The FE picker renders already-assigned rows as visible-but-disabled. - Adding/removing students from a Homeroom cascades to all homeroom-bound child Subject Groups in the same transaction (roster of homeroom-bound SGs derives from the Homeroom). - Teacher record's declaredSubjects (optional list) is updated inline when the creation/edit payload requests it, gated by teachers.employment:write; the create operation succeeds regardless of whether the declared-subjects side-effect is permitted. - Department CRUD + setup wizard accept maxStudentsPerClass; Department.homeroomRole column and HomeroomRole enum are removed. - StudyPlanSubject.weeklyLessons / yearLessons are renamed to weeklyHours / yearHours end-to-end (schema + DTO + tests + import code).

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Pre/post timetable-generation history & confirmation flows (US-33 S7-S8, US-33.1 S11-S12). V1 stubs hasPublishedTimetable(academicYearId) as false; the "replace, no history" branch is the only one wired. - Auto-removal of Homeroom assignment on Student status change to non-Enrolled (US-33 S9). Deferred; admin removes manually. - Subject cancellation cascade (US-33.1 S8) — depends on a US-32 hook we haven't built; deferred. - "Manually resolved" flag on Subject Group assignments for students with overdue curriculum selection (US-33.1 S4 second half). V1 blocks the assignment if any student in the cohort has pending curriculum selection. - Subject Group split UX (US-33.1 S6). The Admin creates as many Subject Groups as needed via POST /subject-groups; we don't ship a server-side "split" operation in V1. - Subject removal lock post-timetable (US-33.1 S14). The Curriculum module already guards Subject removal against StudyPlanSubject usage; the timetable-aware lock is deferred until US-38 lands. - Teacher tutor-only assignment (US-37 split). homeroomTeacherId on Homeroom is optional at creation; it absorbs what US-37 calls "Tutor Teacher". Full US-37 (subject-teacher assignment + supervision) is its own iteration. - Side effects of teacher-assignment changes. Changing Homeroom.homeroomTeacherId or any SubjectGroup.teacherId is a flat column write in V1 — no history-row insert, no hour-budget re-validation, no overlap/conflict check against other assignments, no notification fan-out, no scheduled-timetable reconciliation, no archiving-blocking guards (US-37 S6-S8). The wizard's teacher-budget signals are advisory at creation time only; subsequent teacher swaps don't re-run those checks. All such side-effects are explicitly deferred to future iterations (most belong with US-37).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/curriculum/ Multi-table tenant+AY-scoped entity with nested sub-resources (Curriculum + StudyPlan + StudyPlanSubject); single configuration scope; aggregate detail endpoint shape; queries.ts with include/select consts + named functions; tight FK cascade between parent and dependents Homeroom has roster (M2M HomeroomAssignment table) which Curriculum doesn't; mandatory-subject auto-materialisation is novel
src/academic-years/ @AggregateResponse() precedent — AggregateResponseDto-based DTOs bypass FieldFilterInterceptor so derived fields (numStudents) ride free; nested grouped responses (periods within year) AY is a singleton-ish admin object; Homeroom is a leaf in a 4-level grouped tree
docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md "Dual-surface scope" precedent (a scope gates both field-level access and a sub-resource); single-atomic-payload pattern with no intermediate state; deferral discipline around "next iteration" follow-ups RUS-4 is a single-student sub-resource; this iteration creates a new top-level entity family
src/departments/ Single configuration scope with create/delete actions + standard CRUD layout; nested sub-resource (Grade) under Department; customFields JSONB pattern; ordinalPosition-based sort defaults Department doesn't have a wizard-style atomic creation with cascade-materialised dependents
src/rooms/ Tenant+AY-scoped configuration entity with M2M sub-resource (CanteenLunchShift); FK-with-deny-delete pattern (onDelete: Restrict) that we'll mirror on Homeroom→BaseRoom and SubjectGroup→Teacher Rooms don't carry a roster of person-entities

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes tenantId filter on every query; cascade from AcademicYear All new tables follow the canonical multi-tenant pattern (prisma/schema.prisma tenant convention)
Academic-year scope yes academicYearId FK on Homeroom, SubjectGroup; all assignments inherit AY via parent Both entities are per-AY snapshots, matching Student/Teacher snapshot convention
RBAC entity key new: homerooms, subject_groups Append to src/common/constants/entity-keys.ts (HOMEROOMS = 'homerooms', SUBJECT_GROUPS = 'subject_groups') Two distinct entities — separate role-grant matrix rows + separate seed entries
Scopes new: homerooms.composition, subject_groups.composition Single-scope per entity in rbac-catalogue.ts SCOPES; not configuration — the entity isn't admin-only config, it's a composition of students × teacher × room × subject-teachers Splitting into multiple scopes (e.g. identity + roster) buys nothing in V1 since Teachers + Staff see the whole aggregate; the naming composition is more honest than configuration
Actions new: homerooms.create, homerooms.delete, subject_groups.create, subject_groups.delete Append to rbac-catalogue.ts ACTIONS map; each action's scopeKeys references the corresponding *.composition Standard 2-action shape; read/update are implicit (per feedback_rbac_actions_convention)
Service base custom Both services extend a bespoke service class — not BaseTenantedCrudService — because the wizard POST is a multi-table atomic operation that doesn't map to the generic CRUD shape; will use PrismaService.$transaction directly Cascade-materialise pattern + roster join-table writes require explicit transaction control
queries.ts shape include/select consts + named functions One queries.ts per module (src/homerooms/queries.ts, src/subject-groups/queries.ts) with LIST_INCLUDE, DETAIL_INCLUDE, and named functions (findHomeroomsGrouped, findHomeroomDetail, etc.) Matches the convention codified in docs/05-crud-patterns.md and memory Queries convention
Error codes new Creation: HOMEROOM_NAME_CONFLICT, STUDENT_ALREADY_IN_HOMEROOM, STUDENT_NOT_IN_ELIGIBLE_POOL, CURRICULUM_HAS_NO_STUDY_PLAN, MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE, SUBJECT_GROUP_NAME_CONFLICT, STUDENT_ALREADY_IN_SUBJECT_GROUP, SUBJECT_GROUP_REQUIRES_STUDY_PLAN_SUBJECT, BASE_ROOM_CAPACITY_EXCEEDED (warning, returned as non-blocking advisory), CURRICULUM_SELECTION_PENDING_FOR_COHORT. Roster ops: SUBJECT_GROUP_ROSTER_DERIVED (operating on a homeroom-bound SG's roster directly), SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE (deleting a homeroom-bound SG independently of its Homeroom). Move ops: HOMEROOM_MOVE_GRADE_MISMATCH, HOMEROOM_MOVE_CURRICULUM_MISMATCH, SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH. Immutability: HOMEROOM_FIELD_IMMUTABLE (PATCH attempt on departmentId/curriculumId/gradeId), SUBJECT_GROUP_FIELD_IMMUTABLE (PATCH attempt on studyPlanSubjectId/homeroomId). Domain-coded; surfaced via existing global filter (chapter 06)
DTO conventions scope sub-DTOs for writes; aggregate DTOs for reads dto/scopes/homeroom-composition.dto.ts + dto/scopes/subject-group-composition.dto.ts for create/update; dto/homeroom-list-response.dto.ts + dto/homeroom-detail-response.dto.ts (both extending AggregateResponseDto) for reads Matches src/departments/dto/scopes/department-configuration.dto.ts pattern for writes + src/academic-years/dto/academic-year-detail.dto.ts pattern for reads
File-backed sub-resources n/a — Homerooms / SubjectGroups carry no documents No file uploads in this surface
Custom fields yes customFields: Json? on both Homeroom and SubjectGroup; new pickCustomFields plug-in entries in src/common/constants/entity-keys.ts CUSTOM_FIELD_ENTITY_KEYS (extend) and seed others scope Aligns with existing pattern on Department, Grade, Room, Curriculum
Profile completeness no n/a — Homerooms/SubjectGroups don't surface in person-record completeness checks The COMPLETION_REQUIRED_REGISTRY is for person entities; Homeroom is an admin-configured grouping, not a person record

4. Data model plan

Schema deltas

New tables:

  • homerooms
  • id UUID PK, tenantId UUID FK→Tenant, academicYearId UUID FK→AcademicYear
  • name VarChar(150)
  • departmentId UUID FK→Department (onDelete: Restrict)
  • curriculumId UUID FK→Curriculum (onDelete: Restrict) — immutable post-creation
  • gradeId UUID FK→Grade (onDelete: Restrict)
  • baseRoomId UUID? FK→Room (onDelete: SetNull) — optional
  • homeroomTeacherId UUID? FK→Teacher (onDelete: SetNull) — optional at creation
  • customFields Json? @default("{}")
  • createdAt, updatedAt timestamps
  • Indexes: (tenantId), (tenantId, academicYearId), (departmentId), (gradeId), (curriculumId), (homeroomTeacherId)
  • Unique: (academicYearId, gradeId, name) — homeroom name unique per Grade per AY

  • homeroom_assignments

  • id UUID PK, homeroomId UUID FK→Homeroom (onDelete: Cascade), studentId UUID FK→Student (onDelete: Cascade), academicYearId UUID FK→AcademicYear
  • createdAt, updatedAt
  • Indexes: (homeroomId), (studentId), (academicYearId)
  • Unique: (academicYearId, studentId) — a Student belongs to at most one Homeroom per AY

  • subject_groups

  • id UUID PK, tenantId UUID FK→Tenant, academicYearId UUID FK→AcademicYear
  • name VarChar(150)
  • studyPlanSubjectId UUID FK→StudyPlanSubject (onDelete: Restrict) — points at the (Subject × Curriculum × Grade × AY) row that defines what is being taught
  • teacherId UUID? FK→Teacher (onDelete: SetNull) — optional at creation
  • homeroomId UUID? FK→Homeroom (onDelete: Cascade) — when set, marks this SG as a homeroom-bound mandatory-subject group; roster is derived from Homeroom
  • customFields Json? @default("{}")
  • createdAt, updatedAt
  • Indexes: (tenantId), (tenantId, academicYearId), (studyPlanSubjectId), (teacherId), (homeroomId)
  • Unique: (academicYearId, studyPlanSubjectId, name) — name unique per Subject per AY; (homeroomId, studyPlanSubjectId) — at most one homeroom-bound SG per (Homeroom, Subject)

  • subject_group_assignments

  • id UUID PK, subjectGroupId UUID FK→SubjectGroup (onDelete: Cascade), studentId UUID FK→Student (onDelete: Cascade), academicYearId UUID FK→AcademicYear
  • createdAt, updatedAt
  • Indexes: (subjectGroupId), (studentId), (academicYearId)
  • Unique: (academicYearId, studentId, subjectGroupId) — idempotency; the "one Subject Group per Subject per Student per AY" invariant is enforced at service level by joining with StudyPlanSubject.studyPlanSubjectId (Postgres can't express it cleanly without a redundant subjectId column — see Divergence Ledger)

Mutations to existing tables:

  • Department
  • Add: maxStudentsPerClass Int? @map("max_students_per_class") — optional, drives non-blocking sizing warnings
  • Drop: homeroomRole column + HomeroomRole enum entirely

  • StudyPlanSubject and OptionBlock (both carry the same shared-hours fields)

  • Rename: weeklyLessonsweeklyHours (Float?), yearLessonsyearHours (Float?) on both tables. Column rename migration + all DTO/test/import refactors. Confirmed during planning — keeping naming consistent across the StudyPlan + OptionBlock pair

  • Teacher

  • Add: declaredSubjects String[] @default([]) @map("declared_subjects") — Postgres text array; per-AY snapshot field on the Employment scope

Migration shape

  • Type: mixed — additive (new tables, new fields) + destructive (drop homeroomRole column + HomeroomRole enum) + renaming (weeklyLessons/yearLessonsweeklyHours/yearHours).
  • Data backfill:
  • Department.homeroomRole: no data to preserve — the column is informational only (no behavioural consequence today, per the gap audit in docs/coverage/per-us/US33-define-homerooms-and-assign-students.md preamble). Drop directly.
  • StudyPlanSubject.weeklyLessons / yearLessons: rename in-place via ALTER COLUMN ... RENAME TO ... — preserves data, zero-downtime.
  • Teacher.declaredSubjects: default empty array; no backfill.
  • Hazards from chapter 12 checklist:
  • Hazard: dropping homeroomRole enum. Postgres enums can't be dropped while a column references them. Migration order: (1) ALTER TABLE departments DROP COLUMN homeroom_role; (2) DROP TYPE "HomeroomRole";. Verify no remaining references in setup-wizard state (audit src/setup/).
  • Hazard: column rename on StudyPlanSubject. The setup wizard, Curriculum CRUD DTOs, import pipeline, and any e2e tests reference the old names. Refactor in a single PR so the migration is atomic with the code change.
  • Hazard: 4 new tables with FKs into existing Student / Teacher / Department / Grade / Curriculum / Room / StudyPlanSubject. Verify each onDelete rule before merge:
    • Homeroom.departmentId/curriculumId/gradeId → Restrict (deleting a Dept/Curriculum/Grade with active Homerooms must fail loudly)
    • Homeroom.baseRoomId → SetNull (Room deletion downgrades the base assignment, doesn't break the Homeroom)
    • Homeroom.homeroomTeacherId → SetNull (Teacher archival downgrades; doesn't orphan)
    • HomeroomAssignment.homeroomId/studentId → Cascade (deletes ride with parent)
    • SubjectGroup.studyPlanSubjectId → Restrict
    • SubjectGroup.teacherId → SetNull
    • SubjectGroup.homeroomId → Cascade (homeroom-bound SGs are owned by their Homeroom)
    • SubjectGroupAssignment.subjectGroupId/studentId → Cascade

Indexes and uniqueness

Already enumerated above per table. Highlights: - homerooms (academicYearId, gradeId, name) UNIQUE — name uniqueness scope = Grade × AY (per US-33 S3) - homeroom_assignments.studentId @unique — Student in at most one Homeroom per AY (per US-33 S1). The shipped schema uses a single-column unique on studentId rather than a composite (academicYearId, studentId); this is functionally equivalent because Student.id is AY-scoped at the row level (a Student row exists per AY). Composite was the original draft; single-column is the canonical truth. - subject_groups (academicYearId, studyPlanSubjectId, name) UNIQUE — name uniqueness scope = Subject × AY (per US-33.1 S6 Notes) - subject_groups (homeroomId, studyPlanSubjectId) UNIQUE — at most one homeroom-bound SG per (Homeroom, Subject) - subject_group_assignments (academicYearId, studentId, subjectGroupId) UNIQUE — assignment idempotency; the "one SG per Subject per Student" invariant lives in service-layer validation (see Divergences)


5. API surface

Verb Path Decorators Request DTO Response DTO
POST /homerooms @RequireAction(HOMEROOMS, 'create') CreateHomeroomDto (scope-grouped under composition) HomeroomDetailDto (AggregateResponseDto)
GET /homerooms/eligible-students @RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() ListEligibleHomeroomStudentsQueryDto (required: departmentId, curriculumId, gradeId) EligibleHomeroomStudentsResponseDto{ students: [{ id, firstName, lastName, identificationCode? }] }. Filters to Student.status = ENROLLED AND Student.academicYearId = currentAY AND NOT EXISTS HomeroomAssignment(studentId). Sorted by lastName, firstName.
GET /homerooms @RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() ListHomeroomsQueryDto (filters: departmentId?, curriculumId?, gradeId?) HomeroomListResponseDto — tree [ { department, curricula: [ { curriculum, grades: [ { grade, homerooms: [...] } ] } ] } ]
GET /homerooms/:id @RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() HomeroomDetailDto{ id, name, department, curriculum, grade, baseRoom?, homeroomTeacher?, students: [{ id, firstName, lastName }], subjects: [{ studyPlanSubjectId, subjectGroupId, name, weeklyHours, teacher? }] }
PATCH /homerooms/:id @RequireScopes(HOMEROOMS, 'write') UpdateHomeroomDto (scope-grouped under composition; mutable: name, baseRoomId, homeroomTeacherId, customFields. Immutable: departmentId, curriculumId, gradeId. Roster is NOT mutated through this endpoint — use the /students sub-resource) HomeroomDetailDto
DELETE /homerooms/:id @RequireAction(HOMEROOMS, 'delete') 204 No Content
POST /homerooms/:id/students @RequireScopes(HOMEROOMS, 'write') AddStudentsToHomeroomDto{ studentIds: string[] } HomeroomDetailDto
DELETE /homerooms/:id/students @RequireScopes(HOMEROOMS, 'write') RemoveStudentsFromHomeroomDto{ studentIds: string[] } (body on DELETE; documented in Swagger) HomeroomDetailDto
POST /homerooms/:id/students/move @RequireScopes(HOMEROOMS, 'write') MoveStudentsBetweenHomeroomsDto{ studentIds: string[], targetHomeroomId: string } { source: HomeroomDetailDto, target: HomeroomDetailDto }
POST /subject-groups @RequireAction(SUBJECT_GROUPS, 'create') CreateSubjectGroupDto (scope-grouped under composition) SubjectGroupDetailDto (AggregateResponseDto)
GET /subject-groups/eligible-students @RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() ListEligibleSubjectGroupStudentsQueryDto (required: studyPlanSubjectId) EligibleSubjectGroupStudentsResponseDto{ students: [{ id, firstName, lastName, identificationCode?, alreadyAssigned: boolean, assignedSubjectGroupId?: string, homeroomName?: string }] }. Returns the full eligible cohort (any student whose bound study plan contains this Subject as mandatory OR whose Option Block selection includes it). Sorted by alreadyAssigned asc, then lastName, firstName.
GET /subject-groups @RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() ListSubjectGroupsQueryDto (filters: departmentId?, curriculumId?, gradeId?) SubjectGroupListResponseDto — tree as Homerooms (only standalone homeroomId IS NULL)
GET /subject-groups/:id @RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() SubjectGroupDetailDto — works for both standalone AND homeroom-bound child SGs; response: { id, name, ..., homeroomId?, students: [{ id, firstName, lastName, homeroomName? }] }
PATCH /subject-groups/:id @RequireScopes(SUBJECT_GROUPS, 'write') UpdateSubjectGroupDto (scope-grouped under composition). For standalone (homeroomId IS NULL): mutable name, teacherId, customFields; studyPlanSubjectId immutable. For homeroom-bound (homeroomId IS NOT NULL): only teacherId + customFields mutable (rest of fields are owned by the parent Homeroom). Body includes optional addToDeclaredSubjects flag for the teacher-prompt persistence SubjectGroupDetailDto
DELETE /subject-groups/:id @RequireAction(SUBJECT_GROUPS, 'delete') 204 No Content. Rejected with SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE (409) when homeroomId IS NOT NULL — homeroom-bound SGs die with their parent Homeroom
POST /subject-groups/:id/students @RequireScopes(SUBJECT_GROUPS, 'write') AddStudentsToSubjectGroupDto{ studentIds: string[] }. Rejected when homeroomId IS NOT NULL — roster derives from Homeroom; use POST /homerooms/:hrId/students instead SubjectGroupDetailDto
DELETE /subject-groups/:id/students @RequireScopes(SUBJECT_GROUPS, 'write') RemoveStudentsFromSubjectGroupDto{ studentIds: string[] }. Rejected when homeroomId IS NOT NULL SubjectGroupDetailDto
POST /subject-groups/:id/students/move @RequireScopes(SUBJECT_GROUPS, 'write') MoveStudentsBetweenSubjectGroupsDto{ studentIds: string[], targetSubjectGroupId: string }. Target MUST reference the same studyPlanSubjectId; both source and target must be standalone (homeroomId IS NULL) { source: SubjectGroupDetailDto, target: SubjectGroupDetailDto }

Wizard request body shape (CreateHomeroomDto):

{
  composition: {
    name: string,
    departmentId: string,
    curriculumId: string,
    gradeId: string,
    baseRoomId?: string | null,
    homeroomTeacherId?: string | null,
    studentIds: string[],                    // initial roster
    subjectTeachers: {                       // teacher per mandatory subject in the bound study plan
      studyPlanSubjectId: string,
      teacherId: string | null,
    }[],
    addToDeclaredSubjects?: {                // opt-in inline persistence of "record this choice" prompt
      teacherId: string,
      subject: string,                       // free-text subject name (case-insensitive trim+dedupe at write)
    }[],
    customFields?: Record<string, unknown>,
  }
}

Service materialises: 1 Homeroom row + N HomeroomAssignment rows + N SubjectGroup rows (one per subjectTeachers entry whose study-plan-subject isMandatory = true; service validates this) + optional Teacher.declaredSubjects mutation (guarded — see §6).

Standalone request body (CreateSubjectGroupDto):

{
  composition: {
    name: string,
    studyPlanSubjectId: string,              // any Subject (mandatory or optional); homeroomId stays null
    teacherId?: string | null,
    studentIds: string[],
    addToDeclaredSubjects?: { teacherId, subject }[],
    customFields?: Record<string, unknown>,
  }
}

Eligible-students picker semantics

The Homeroom wizard step 2 and the Subject Group creation flow both need a server-driven list of "students this picker can offer". The two queries differ semantically:

GET /homerooms/eligible-students — filtered list (no flags): - Eligibility predicate: Student.tenantId = :tenantId AND Student.academicYearId = :currentAY AND Student.departmentId = :departmentId AND Student.gradeId = :gradeId AND Student.status = ENROLLED AND NOT EXISTS (SELECT 1 FROM homeroom_assignments WHERE studentId = student.id). Curriculum match runs through the student's StudentCurriculumSelection.curriculumId = :curriculumId (or the student's bound study plan when selection is not yet recorded — service joins through StudyPlan). - "Already-assigned" students are excluded entirely. The picker shows only the placeable pool. - Used by both the create-Homeroom wizard step 2 AND the POST /homerooms/:id/students add-students-picker (the current Homeroom's own roster is shown separately on the detail page, so it doesn't need to appear in this picker — admins are adding students not in any Homeroom).

GET /subject-groups/eligible-students — full cohort with alreadyAssigned flag: - Eligibility predicate: students whose study plan contains the target studyPlanSubjectId as mandatory, OR whose StudentOptionBlockChoice resolves to a Subject equivalent to the target studyPlanSubjectId (via the option block subject mapping). Service joins through StudyPlan + StudentCurriculumSelection. - For each eligible student, compute alreadyAssigned by checking if a SubjectGroupAssignment exists for any Subject Group with this studyPlanSubjectId. If true, also return assignedSubjectGroupId for FE display. - Optional homeroomName per student (joined through HomeroomAssignment) — helps the admin recognise who's where. - Sorted by alreadyAssigned ascending (unassigned first), then lastName, firstName. FE renders already-assigned rows visible-but-disabled. - Used by both the create-standalone-SG wizard AND the POST /subject-groups/:id/students add-students-picker.

Both endpoints are tenant + AY scoped automatically. Neither paginates in V1.

Roster-operation semantics

POST /homerooms/:id/students (atomic): 1. Validate every studentId is in the source eligibility pool: Student.departmentId = Homeroom.departmentId AND Student.gradeId = Homeroom.gradeId AND Student.curriculumId = Homeroom.curriculumId AND Student.status = ENROLLED AND Student.academicYearId = Homeroom.academicYearId. 2. Reject with STUDENT_ALREADY_IN_HOMEROOM (409) if any studentId already has a HomeroomAssignment for this AY (regardless of which Homeroom). 3. Insert one HomeroomAssignment per student. 4. Cascade: for every child homeroom-bound SubjectGroup of this Homeroom, insert one SubjectGroupAssignment per newly-added student. Same transaction.

DELETE /homerooms/:id/students (atomic): 1. Reject with 404 if any studentId is not currently assigned to this Homeroom. 2. Delete the HomeroomAssignment rows for the listed students. 3. Cascade: delete the matching SubjectGroupAssignment rows in every homeroom-bound child SG. Same transaction. 4. The student's standalone Subject Group memberships (for optional subjects, or for departments without Homerooms) are preserved.

POST /homerooms/:id/students/move (atomic): 1. Validate source ≠ target. 2. Validate target.gradeId = source.gradeId AND target.curriculumId = source.curriculumId AND target.academicYearId = source.academicYearId — moves across Grade/Curriculum are NOT allowed (that's enrollment change, not homeroom move). 3. Reject with 404 if any studentId is not currently in the source Homeroom. 4. Delete the source-Homeroom assignments + source-child-SG assignments for the listed students. 5. Insert the target-Homeroom assignments + target-child-SG assignments. Same transaction. 6. Returns both detail DTOs so the FE can refresh both panes.

POST /subject-groups/:id/students (atomic, standalone only): 1. Reject with SUBJECT_GROUP_ROSTER_DERIVED (409) if homeroomId IS NOT NULL. 2. Validate every studentId follows a study plan that includes the SG's studyPlanSubjectId (join through StudentCurriculumSelection for option-block subjects + the mandatory-subject set of the student's study plan for mandatory ones). 3. Reject with STUDENT_ALREADY_IN_SUBJECT_GROUP (409) if any studentId is already assigned to another SG with the same studyPlanSubjectId (the "one SG per Subject per Student per AY" invariant). 4. Reject with CURRICULUM_SELECTION_PENDING_FOR_COHORT (409) if any studentId has a pending curriculum selection for the relevant option block. 5. Insert SubjectGroupAssignment rows.

DELETE /subject-groups/:id/students (standalone only): symmetric to Homeroom remove; same SUBJECT_GROUP_ROSTER_DERIVED rejection for homeroom-bound.

POST /subject-groups/:id/students/move (standalone only, atomic): 1. Reject if source or target has homeroomId IS NOT NULL. 2. Validate target.studyPlanSubjectId = source.studyPlanSubjectId — moves are within the same Subject only. 3. Move the assignments transactionally.

Teacher-change semantics (no side effects)

  • PATCH /homerooms/:id with new homeroomTeacherId: single-column update; no history insert, no notification, no schedule probe, no archiving guard. The previous teacher's declaredSubjects is not mutated (declaredSubjects is additive only).
  • PATCH /subject-groups/:id with new teacherId: same — flat column update. The optional addToDeclaredSubjects payload appends to the new teacher's declared list (gated by teachers.employment:write, fail-soft).

Swagger considerations

  • Both detail DTOs extend AggregateResponseDto. JSDoc on controller methods is FE-facing copy (per memory feedback_swagger_jsdoc_is_public) — backend internals (transaction shape, cascade materialisation logic) stay in method bodies.
  • Document the addToDeclaredSubjects opt-in flag clearly in the create + PATCH DTO JSDoc — FE needs to know to prompt the admin first.
  • Add oneOf schemas for the list-response tree levels (department wrapper / curriculum wrapper / grade wrapper / item) — Scalar autoinspection will infer most of this from the DTO class hierarchy.
  • Error examples: include HOMEROOM_NAME_CONFLICT (HTTP 409) and STUDENT_ALREADY_IN_HOMEROOM (HTTP 409) on POST; SUBJECT_GROUP_ROSTER_DERIVED (HTTP 409) on roster ops against homeroom-bound SGs; SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE (HTTP 409) on DELETE.
  • DELETE /homerooms/:id/students carries a request body — explicitly document this in Swagger (some HTTP clients drop bodies on DELETE; FE must use a client that preserves them, or we fall back to POST /homerooms/:id/students/remove if FE tooling resists. Decide at implementation time).

6. RBAC seed plan

Seed file Delta
permission_entities (rbac-catalogue.ts ENTITIES) Add { key: 'homerooms', label: 'Homerooms', description: 'Administrative groupings of students per grade', sortOrder: 10 } and { key: 'subject_groups', label: 'Subject Groups', description: 'Teaching units bound to a study-plan subject', sortOrder: 11 }
permission_scopes (rbac-catalogue.ts SCOPES) Add homerooms: [{ key: 'composition', label: 'Composition', description: 'Homeroom structure, roster, and subject-teacher assignments', sortOrder: 1 }] and same for subject_groups
permission_actions (rbac-catalogue.ts ACTIONS) Add 'homerooms.create', 'homerooms.delete', 'subject_groups.create', 'subject_groups.delete' — each { scopeKeys: ['<entity>.composition'] }
scope_field_mappings (rbac-catalogue.ts SCOPE_FIELD_MAPPINGS) { entityKey: 'homerooms', scopeKey: 'composition', tableName: 'homerooms', fields: HOMEROOM_SCOPES.composition } + analogous row for subject_groups. Fields per scope = the persistable columns enumerated in §4 (name, departmentId, curriculumId, gradeId, baseRoomId, homeroomTeacherId, customFields for Homeroom; name, studyPlanSubjectId, teacherId, homeroomId, customFields for SubjectGroup).
Role grants (roles.ts ROLE_PERMISSIONS) Following the matrix in §RBAC Design:
• Admin / HR / Secretary: R/W + create + delete on both
• Principal / Internal Teacher / External Teacher / Internal Staff / External Staff / Admissions Officer: R only on both
• Student: R (self-record only — record-level filtering in service layer)
• Parent (Referent): R (linked-child records only)
• Accountant: no access
*_SCOPES runtime constants (src/common/constants/scope-fields.ts) Add HOMEROOM_SCOPES = { composition: ['name', 'departmentId', 'curriculumId', 'gradeId', 'baseRoomId', 'homeroomTeacherId', 'customFields'] as const } and SUBJECT_GROUP_SCOPES = { composition: ['name', 'studyPlanSubjectId', 'teacherId', 'homeroomId', 'customFields'] as const }. Register in ENTITY_SCOPE_REGISTRY. rbac-catalogue.drift.spec.ts will assert these match the seed.
CUSTOM_FIELD_ENTITY_KEYS (entity-keys.ts) Append EntityKey.HOMEROOMS and EntityKey.SUBJECT_GROUPS

teachers.employment scope extension: add declaredSubjects to TEACHER_SCOPES.employment + corresponding seed mapping. No new scope; this is an Employment-scope field.

Cross-cutting: declaredSubjects side-effect guard. When composition.addToDeclaredSubjects is non-empty on a Homeroom/SubjectGroup write, the service: 1. Compiles the requesting user's permissions for teachers.employment (read from request.permissions). 2. If access < WRITE: skip the side-effect silently (log info) and let the Homeroom/SG operation complete. The FE prompt was an opt-in; missing permission downgrades to "noted but not persisted". 3. If access >= WRITE: normalise (trim, dedupe, case-insensitive) the new subjects against the existing Teacher.declaredSubjects array; append novel ones in the same transaction.

Record-level visibility for Student / Parent (V1): - Student: SELECT * FROM homerooms h JOIN homeroom_assignments ha ON ha.homeroom_id = h.id WHERE ha.student_id = :requestingUserStudentId AND h.tenant_id = :tenantId AND h.academic_year_id = :currentAY and analogously for SubjectGroups via subject_group_assignments. Returns only the records the student is enrolled in. - Parent (Referent): same query but joined through Referent.linkedStudents so the result spans all children the Referent is linked to. - Both implemented at the service-layer query (Layer 4 in the RBAC model). The handler is the same; the service branches on request.user.activeProfileType.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Single-scope entities use configuration (Departments, Grades, Rooms, Curricula, Academic Years, School, EvaluationScales) Naming our single scope composition instead configuration is a catch-all bucket for "admin settings"; Homerooms and SubjectGroups are operational data (roster + teaching), not configuration. composition is semantically honest Slight inconsistency in scope naming across the catalogue; will need a doc note in 04-rbac.md
Reads use scope-grouped DTOs that pass through FieldFilterInterceptor GETs marked @AggregateResponse() — DTOs extend AggregateResponseDto; the interceptor bypasses scope-stripping The natural response shape (department → curriculum → grade → items[] with derived numStudents and embedded students[]/subjects[]) doesn't decompose into scope groups. Same precedent as src/academic-years/ Derived fields aren't gated by FieldFilterInterceptor at the response layer — the ScopeGuard at entry remains the authorization boundary. Bit-rot assertion catches drift
Person-entity scopes are multi-faceted (Student has 7, Teacher has 5) Both new entities have a single scope No legitimate use case for splitting Homeroom/SG visibility in V1 (Teachers + Staff see all; Students/Parents see own/child via record-level filter, not scope split) Future Principal-sees-headers-only or Substitute-sees-only-today UX will need a scope split + migration. Documented as a future iteration
Teacher.declaredSubjects would normally require teachers.employment:write via FieldWriteGuard on every mutation We allow the side-effect on Homeroom/SG create+update with a service-layer fail-soft guard The "record this choice" prompt is part of the assignment UX; a separate PATCH call to /teachers/:id would break atomicity and force the FE into two-call sequencing Privilege-creep risk is bounded (info-only field); admins typically have teachers.employment:write anyway. Documented in §6
"One row per logical concept" — usually a single Postgres UNIQUE expresses the invariant "Exactly one SubjectGroup per Subject per Student per AY" is enforced at service layer, not via DB UNIQUE Would require denormalising subjectId onto subject_group_assignments (currently you reach Subject via subject_group_assignments → subject_groups → studyPlanSubjectId → StudyPlanSubject.id). The denormalisation creates a consistency burden if Subjects move in the StudyPlan Service-level check at write time; e2e test covers concurrency. Documented in §11
US-37's "Tutor Teacher" is a separate assignment surface We fold tutor-teacher into Homeroom.homeroomTeacherId directly Aligns with user's mental model (a Homeroom HAS a homeroom_teacher; tutor and homeroom-teacher are the same role). Simplifies US-37 scope when it lands US-37 will need a small spec amendment to acknowledge the field already exists

8. Pushback log

US says Conflicts with Proposed instead Status
US-33 Notes: "A Homeroom can contain students following different Curricula" User's model: Homeroom is curriculum-bound (S1 wizard requires curriculumId; eligible-pool filter is by dept+curriculum+grade) Homeroom binds to a single Curriculum; eligible pool filtered accordingly Resolved — user confirmed
US-33.1 S2/S3/S5: Subject Group composition keyed on homeroom_role = with_homeroom | none Department flag HomeroomRole enum is being retired; no Department-level mode flag in user's model Mode is determined per-Homeroom — if a Homeroom row exists for a given (Dept, Curriculum, Grade) cohort, that cohort uses Homeroom-bound mandatory SGs; otherwise mandatory SGs are standalone. Mixed mode within a Department is allowed Resolved — user explicit
US-33.1 S1: Subject Groups "auto-generated from Curriculum configuration" as a system pass User's model: SGs are created synchronously by Admin via wizard (homeroom-bound) or explicit POST (standalone) No autonomous "generation" pass; SGs are explicit POST operations Resolved — user confirmed by describing creation flows
US-33 S8 (post-timetable reassign with history) and US-33.1 S12 (same) US-38 (timetable) doesn't exist; no history tables, no draft/published state probe Stub hasPublishedTimetable(AY) as false; only the "replace, no history" branch is wired. History tables deferred Resolved — non-goal in §1
US-33.1 S4: "students with overdue or unfilled fields can be assigned manually" with a manuallyResolved flag Adds a flag column for a v1 we can avoid V1 blocks assignment if any student in the cohort has pending curriculum selection; admin resolves the selection first Resolved — non-goal in §1
US-33.1 S6: "Admin can split an oversized Subject Group" with auto-derived names Server-side split operation is complex; redundant with explicit POST No split endpoint; admin creates separate SGs via repeated POST /subject-groups Resolved — non-goal in §1
US-33 S9: auto-remove HomeroomAssignment on Student.status change Lifecycle hook in students.service.ts; deferred to keep this iteration tight Manual removal by Admin for now; revisit with a future "lifecycle hooks" spec Resolved — non-goal in §1
US-33.1 S8: Subject cancellation cascade (removes SG assignments, invalidates curriculum selection, notifies Referent + Admin) Hooks into US-32 cancellation surface which doesn't expose the right event Deferred; revisit when US-32 grows a cancellation event Resolved — non-goal in §1
US-33.1 S14: Subject removal locked post-timetable Guard lives in Curriculum module; depends on US-38 Deferred until US-38 lands Resolved — non-goal in §1

9. Deferrals

  • Pre/post timetable history & confirmation flows (US-33 S7-S8, US-33.1 S11-S12) — depends on US-38 timetable existence probe. Follow-up: address in the US-38 iteration spec.
  • Auto-removal of HomeroomAssignment on Student status non-Enrolled (US-33 S9) — requires a lifecycle hook in students.service.ts. Follow-up: revisit with the next "student lifecycle hooks" iteration; track via memory entry project_homeroom_lifecycle_hooks_deferred (to write on PR merge).
  • Subject cancellation cascade (US-33.1 S8) — depends on US-32 emitting a "Subject cancelled" event we can subscribe to. Follow-up: when US-32 grows the event surface (currently it lands in the curriculum-selection sub-resource only).
  • manuallyResolved flag on assignments for overdue curriculum selections (US-33.1 S4 second half) — V1 blocks instead. Follow-up: revisit if admins report friction; trivial column add when needed.
  • Subject Group split UX (US-33.1 S6) — admin uses repeated POSTs. Follow-up: bundle with future "subject group composition tools" iteration if Italian-school workflow demands it.
  • Subject removal post-timetable lock (US-33.1 S14) — guard in Curriculum module. Follow-up: lands with US-38.
  • Custom Slot Types and other US-35 / US-36 surfaces — entirely out of scope for this iteration; the original US-35/36 design conversation is paused pending this iteration's completion. Follow-up: resume the US-35/36 design after homerooms + SGs land.
  • Multi-scope visibility split (e.g. principal-sees-headers-only) — single composition scope today. Follow-up: revisit if a role's actual access pattern legitimately needs it.
  • Pagination on list endpoints — not paginated in V1 per user's explicit decision. Follow-up: revisit if list sizes grow (IB-style departments could push subject_groups counts into the hundreds).
  • Hour-based teacher budget displayStudyPlanSubject.weeklyHours is Float? (nullable). The teacher-selector's "allocated/non-allocated" budget will display "—" when hours are missing; falling back to yearHours / weeksInAY is a follow-up nicety. Track if admins report friction.
  • All side-effects of teacher-assignment changes — listed exhaustively in §1 Non-goals. Specifically: no history-row insert on teacher swap (US-37 S6-S7), no hour-budget re-validation, no overlap/conflict detection across the teacher's other assignments, no notification fan-out (FE-visible alert), no archiving guard ("Teacher cannot be archived while assigned" — US-37 S8), no draft-timetable invalidation (US-38 warning-cases table). Follow-up: bundle with US-37 + US-38 iterations as those build out the missing infrastructure.
  • Subject-teacher slot deletion on Homeroom — there's no DELETE for a homeroom-bound child SG (it dies with the Homeroom). To "unassign" a mandatory subject's teacher, the Admin clears the field via PATCH /subject-groups/:id with teacherId: null. Documented in API surface notes.

10. Open questions

All resolved. (Empty per template gate requirement.)


11. Verification plan

  • Unit specs:
  • src/homerooms/homerooms.service.spec.ts — wizard cascade (1 Homeroom + N HomeroomAssignment + N SubjectGroup in one transaction); name uniqueness per (Grade, AY); student exclusivity per AY; mandatory-subject-only filter on subjectTeachers; declaredSubjects fail-soft guard; curriculum immutability on update; roster add/remove cascade to homeroom-bound child SG assignments; move atomicity (transaction rolls back on any per-student validation failure); move grade+curriculum match enforcement; teacher swap is a flat update (no history row, no notification — asserted by spying on side-effect callsites and confirming zero calls).
  • src/subject-groups/subject-groups.service.spec.ts — standalone create (no homeroomId); "one SG per Subject per Student per AY" service-layer invariant; curriculum-selection-pending block; deletion blocked when assignments exist; teacher SetNull behavior; roster ops rejected on homeroom-bound SGs; DELETE blocked on homeroom-bound SGs; move requires same studyPlanSubjectId; PATCH on homeroom-bound restricts mutable field set.
  • src/homerooms/queries.spec.ts — grouped tree shape; filters narrow correctly; sort order (dept → curriculum by name → grade by ordinalPosition); numStudents derived correctly.
  • src/subject-groups/queries.spec.ts — same suite shape, with the additional assertion that homeroomId IS NULL is enforced in list.
  • src/permissions/rbac-catalogue.drift.spec.ts — auto-extended; assert HOMEROOM_SCOPES.composition matches seed mapping fields.
  • src/departments/departments.service.spec.ts — extend with maxStudentsPerClass round-trip + setup-wizard write path.
  • src/curriculum/curriculum.service.spec.ts — refactor existing weeklyLessons/yearLessons specs to use new names; assert no leftover references.

  • E2E specs:

  • test/homerooms.e2e-spec.ts — covers US-33 Scenarios 1 (create + assign), 2 (base room + capacity + designation warn), 3 (name uniqueness), 4 (unassigned overview via filter), 5 (overview filters), 6 (late enrollment is identical create), 7 (replace without history), 10 (delete blocked by downstream — stubbed; cannot test today against missing US-36/US-37). Eligible-students picker: GET /homerooms/eligible-students returns only students enrolled in the target (dept, curriculum, grade) AND not currently in any Homeroom; assert exclusion of an already-assigned Student; status≠ENROLLED Student excluded; curriculum-mismatched Student excluded. Roster sub-resource: add student via POST /homerooms/:id/students (golden path + duplicate-409 + ineligible-pool rejection); remove via DELETE /homerooms/:id/students (golden path + not-in-homeroom 404); cascade assertion on homeroom-bound child SG rosters after add/remove. Move sub-resource: move within same Grade+Curriculum (golden); cross-Grade rejected with HOMEROOM_MOVE_GRADE_MISMATCH; cross-Curriculum rejected with HOMEROOM_MOVE_CURRICULUM_MISMATCH. Teacher swap: PATCH homeroomTeacherId returns 200 with new ID; assert no audit/history row created (probe homeroom_assignment_history doesn't exist or is empty).
  • test/subject-groups.e2e-spec.ts — covers US-33.1 Scenarios 1 (mandatory groups materialise via Homeroom wizard, optional groups via standalone POST), 4 (curriculum-selection-pending block), 5 (assignment per Subject), 7 (move between groups for same Subject — invariant holds), 9 (overview filters), 10 (late enrollment), 11 (replace without history), 13 (prerequisite gate stubbed). Eligible-students picker: GET /subject-groups/eligible-students?studyPlanSubjectId=… returns full eligible cohort; assert mandatory-subject students appear; assert option-block-selected students appear; assert students without the subject in their study plan are excluded; alreadyAssigned flag flips correctly after assignment; sort order has unassigned first; assignedSubjectGroupId and homeroomName populated correctly. Roster sub-resource for standalone SGs: add/remove golden path + duplicate rejection; homeroom-bound SG roster ops rejected with SUBJECT_GROUP_ROSTER_DERIVED. Move sub-resource: move within same studyPlanSubjectId (golden); mismatch rejected. PATCH restricted on homeroom-bound: teacherId mutates fine; attempting to PATCH name or studyPlanSubjectId rejected with SUBJECT_GROUP_FIELD_IMMUTABLE. DELETE: standalone deletes fine; homeroom-bound rejected with SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE.
  • test/teachers.declared-subjects.e2e-spec.ts — covers the inline declaredSubjects mutation path: (a) admin with teachers.employment:write adds subject during homeroom create AND on subsequent PATCH /subject-groups/:id teacher swap → Teacher row updated; (b) role without that scope adds subject during homeroom create → Homeroom created, Teacher untouched.
  • test/departments.max-students-per-class.e2e-spec.ts — covers the new field on POST/PATCH/setup-wizard.
  • Follow feedback_e2e_isolation_patterns: stamp Date.now()-suffixed names on Homeroom/SG creation to avoid collisions; prisma.deleteMany cleanup for the singleton-shaped setup state in beforeAll/afterAll.

  • Manual verification:

  • Spin a fresh tenant via npx prisma db seed, log in as admin, walk the Homeroom wizard end-to-end with 5 students and a curriculum that has 3 mandatory subjects + 1 optional. Verify the resulting tree response includes 1 Homeroom with 3 child SGs; the optional subject does NOT auto-materialise.
  • Create a standalone Subject Group for an optional subject across 2 different Homerooms; verify the detail response shows homeroomName per student correctly.
  • Toggle the "Add to declared subjects" prompt on creation; verify the Teacher record reflects the addition only when the role has teachers.employment:write.
  • Migrate down/up cycle locally to verify the HomeroomRole drop and the weeklyLessons rename succeed cleanly with seeded data.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-25
  • Chat reference: Approved during planning walkthrough on 2026-05-25 — Homeroom + Subject Group flows agreed iteratively (terminology pinned, wizard semantics, GETs grouped tree, RBAC composition scope, aggregate response pattern, detail-page roster + move + teacher-swap endpoints, all side-effects of teacher changes deferred); OptionBlock rename extension confirmed during plan phase; implementation plan approved at ~/.claude/plans/good-now-plan-for-jazzy-neumann.md.

Until this section is filled, no implementation code is written.