Skip to content

Combined classes (co-taught sections) — resumed post-SPD

AMENDED 2026-07-08 (adversarial architecture review, ratified in chat): (1) SPD's coordinate is now the absolute start tick (from midnight) — the §3 reflow-ripple branch is dead and the co-location predicate is trivially frame-uniform; the solver pseudo-group needs no frame translation. (2) New degenerate-membership clause (§3/§4): a combination whose membership decays below 2 (SG deleted by the curriculum sweep or manually) is inert + WARNING, never auto-deleted. (3) §2's stale auto-seed premise corrected (standalone SGs are created manually; no auto-seed on offer). (4) The "no Python constraint changes" claim is now firm for the admissible envelope: a second-pass verification against the shipped SPD solver (2026-07-08) confirmed the maxSel=1 block encoding (x_child == b, children covered only via b, HOUR_BUDGET on b) is incompatible with the pseudo/solo split — so §5 fences option-block-child members out of membership. (This fence was widened during plan-writing from maxSel=1 to all option-block children, and the code renamed COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD — see the plan-phase banner immediately below for the reasoning; §5 step 4b carries the final rule.) Co-taught option-block membership is deferred (§9). (5) SPD is verified as landed (generation snapshot + Python solver already reworked to minutes/envelopes, all helpers present) — the plan doc is unblocked and specifies against real code, not a design.

AMENDED 2026-07-08 (plan phase, ratified in chat): the pseudo-group verification the plan's worked example was to prove came back early against the shipped solver. Plain-standalone members (homeroomId null and optionBlockId null) rewrite cleanly into a shared pseudo-group + per-member solo groups with zero Python changes and zero cohort/coverage surgery — because a standalone member never enters any homeroom's mandatoryGroupIds/blockChildGroupIds (both filter on non-null homeroomId === hr.id), so it is absent from HOMEROOM_COVERAGE outright. A maxSelections>1 option-block-child member, however, still sits in a grade cohort constrained by OPTION_BLOCK_CLASH, and the pseudo/solo split would move the co-pick occupancy it clashes against — real snapshot-level cohort/clash recompute that no current use case exercises (IB HL/SL is level-based via CurriculumRule, not an option block; cross-grade Art is a plain subject). Decision: v1 fences all option-block-child members — §5 step 4b widens from maxSelections=1 to any non-null optionBlockId (code renamed COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD), and §9 defers all co-taught option-block membership. The admissible envelope is exactly the plain-standalone group, which is all the two real use cases need.

AMENDED 2026-07-08 (US-69 alignment review, ratified in chat): product's US-69 "Combine Subject Groups into a Shared Lesson" was cross-checked against this spec. The standalone-only fence wrongly excluded US-69's headline case — two homerooms sharing a subject (Preschool 1 & 2): a homeroom is only the roster source for its mandatory SGs, so Art-1a + Art-1b must be combinable. The fence is therefore widened from "plain standalone" to "not an option-block child" — homeroom-bound (MANDATORY) members are now admissible. A second solver read (2026-07-08) confirms this needs zero Python constraint-family changes: for a homeroom-bound member the shared pseudo-group is listed as a coverage term in each participating homeroom's mandatory_group_ids, so HOMEROOM_COVERAGE's exact-tiling still closes (sharedMinutes + per-member soloMinutes = each member's cell), and the base-room objection is moot (homeroom-bound SGs cannot carry a baseRoomIdsubject-groups.service.ts write-guards it). Only option-block children stay fenced (x_child == b block-sync / OPTION_BLOCK_CLASH genuinely resist the pseudo/solo split). New combine-time gate: members must share the shared portion's teaching window (per-segment homeroom coverage must be tileable) — the former COMBINED_CLASS_MEMBER_NOT_STANDALONE code is dropped, COMBINED_CLASS_MEMBER_WINDOW_MISMATCH added. Four US-69 scope calls settled in chat: multi-combination (S3) deferred (single FK kept — no CombinedLessonContributor junction); cross-department kept fenced; sharedWeeklyHours stays explicit (no default-to-min); teacher stays out of the co-location predicate, but the pseudo's teacher set is the deduped union of members' teachers — identical teachers collapse to one occupancy (one teacher teaches the shared lesson), differing teachers co-occupy. This is an iteration on the uncommitted feature recorded inline (matching the prior same-day amendments in this file), not a new spec doc.

⚠️ REPRESENTATION SUPERSEDED (2026-07-12) by 2026-07-12-combined-classes-iteration-3-design.md. Iteration 3 makes the combined class a first-class teaching unit and reverses this spec's core seam at the representation layer: a shared meeting is now one ScheduledLesson anchored on the combination (subjectGroupId XOR combinedClassId), not N co-located per-SG rows. The entire co-location machinery (predicate/exemptions/summing/dedupe, COMBINED_SHARED_MISCOUNT, COMBINED_CLASS_ROOM_SPLIT), the solver pseudo-group encoding (combined:*/solo:* ids, combinedExpansion, ingest fan-out), and the option-block-child fence (COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD) are deleted — the solver now schedules a first-class COMBINED group under the real id and pair-tiles paired maxSel=1 block children. What survives from this spec: the CombinedClass model + single-FK membership, curriculum-anchored explicit sharedWeeklyHours with derived solo remainders, the combine-time validation envelope (minus the fence), the degenerate-membership clause, and the fit-report alerts.

⚠️ PARTIALLY SUPERSEDED (2026-07-08) by 2026-07-08-homeroom-course-decoupling-design.md. The US-69 homeroom-member widening described in the banner immediately above was reverted by the homeroom/course decoupling: SubjectGroup.homeroomId was dropped, so there are no homeroom-bound members to admit. The admissible-member fence is once again simply "not an option-block child" — which now coincides with "standalone", since every SG is standalone. COMBINED_CLASS_MEMBER_WINDOW_MISMATCH stays, but it now guards the shared lesson existing in every segment a member is taught (not per-homeroom coverage tileability — homeroom tiling is gone, replaced by per-student compactness). Everything else in this spec (the co-locate seam, explicit hours, degenerate-membership clause, pseudo/solo solver rewrite) is unaffected.

0. The seam, recapped, and what changed since the 2026-06-16 draft

The core decision survives untouched: don't fuse — co-locate. Every section stays single-subject. Physics-HL and Physics-SL (or Art-G9 and Art-G10) remain the ordinary SubjectGroups they already are; a thin CombinedClass link declares them taught together. A shared slot is N co-located per-SG ScheduledLesson rows, never one merged lesson. Enrollment, grading (landed, SG-anchored — the seam paid off exactly as designed), attendance (landed, lesson-anchored), rosters, eligibility, and sync never branch on subject.

Three brainstorm rounds moved everything around that core:

  1. 2026-06-16 — seam chosen (two linked single-subject sections over the escape hatch and over IB-level narrowing); same-grade, implicit hours.
  2. 2026-07-07 (round 1, §13 addendum of the old spec) — envelope widened to multi-grade, same-department; hours made explicit with the curriculum as single source of truth; wall-clock co-location; solver support pulled into scope; then paused pending the generation refactor.
  3. 2026-07-07 (round 2, this spec) — re-derived against the approved subject-period-durations spec (SPD): all arithmetic re-based from period counts to minutes, the duration cascade adds a member-agreement gate, the micro-tick solver makes the combined encoding cheap, and the attendance stance is settled (per-section registers, FE merges).

1. Problem distillation

  • Two or more single-subject sections must be declarable as taught together so the timetable engine accepts a combined layout (shared room+time for the common core, per-section extras) instead of flagging it as a set of conflicts.
  • The sections are ordinary standalone SGs the school creates and rosters via the existing SG workflow (creation is manual — there is no auto-seed on offer; see curriculum-structure-sync.ts); the feature adds one admin action — the link — plus one number: how much of the week they share.
  • The member envelope is any ≥2 non-option-block-child SGs of one department and the active AY — homeroom-bound mandatory SGs included (a homeroom is only the roster source, so Art-1a + Art-1b combine — US-69's Preschool 1 & 2 case), same grade (IB HL/SL) or different grades (small-school multigrade Art). Members stay grade-pure: this is cross-grade combination, not cross-grade membership.
  • Hours are explicit and curriculum-anchored: the admin declares sharedWeeklyHours (clock-hours, same unit as the grid cell); each member's solo remainder is derived (cellMinutes − sharedMinutes), never stored. No hour deviation is expressible — if the school runs different hours, they edit the curriculum.
  • The engine (post-SPD: reflow coordinates, minutes identities, micro-tick solver) learns that combined ≠ parallel: conflict exemptions for properly co-located siblings, a minutes-identity check on the shared portion, capacity summing, and a generation encoding that produces combined layouts.

Success criteria (observable behavior that proves this works): - Admin can link ≥2 non-option-block-child SGs of one department/AY (homeroom-bound included) — same or different grades — with a sharedWeeklyHours, via /combined-classes; teacher/referent/secretary get 403; every envelope violation is a typed 409/422. - A timetable placing the IB layout — the shared core co-located (identical resolved wall-clock interval, same room) + HL's solo remainder — produces no ROOM_CONFLICT/TEACHER_CONFLICT/OPTION_BLOCK_SYNC ERROR and is publishable; the same layout with a co-location shortfall raises COMBINED_SHARED_MISCOUNT and blocks publish. - A cross-grade combination (Art-G9 2h + Art-G10 3h, shared=2h) is authorable, generable, and publishable; G10's derived 1h solo is enforced by the unchanged HOUR_BUDGET minutes identity. - ROOM_CAPACITY checks the shared room against the summed co-located roster; a combined class that overflows only jointly is caught. - Generation (post-SPD tick solver) returns FEASIBLE combined layouts via the pseudo-group encoding; no Python constraint-family additions — for a standalone member the pseudo/solo transform never touches HOMEROOM_COVERAGE; for a homeroom-bound member the shared pseudo is listed as a coverage term in each participating homeroom's mandatory_group_ids, so exact-tiling still closes with no new constraint family (verified against the shipped solver 2026-07-08). Only option-block-child members are fenced out (§5) — the one case the pseudo/solo split genuinely can't encode. - A duration mismatch between members (HL 60' vs SL 40') is rejected at combine time when curriculum-resolved, and surfaces on the fit report / generation prechecks when template-tailed. - Attendance needs zero backend domain changes: each member lesson carries its own roster's cells; read surfaces expose combinedClassId so the FE can render one merged register card.

Non-goals: - Cross-grade membership (a student rostered into another grade's section) — eligibility/selection feature, explicitly out (unchanged deferral). - Cross-department members — v1 fences at one department (product decision 2026-07-07). - Multi-teaching / team-teaching semantics — teacher sets may differ per member; nothing requires co-teaching beyond the existing SubjectGroupTeacher rows. The generation pseudo-group's teacher set is the deduped union of the members' teachers, so identical teachers collapse to a single occupancy (one teacher teaches the shared lesson) while differing teachers co-occupy it — but sharing is never validated or forced (US-69 Scenario 5's "same teacher(s)" is honored when the data already agrees, not imposed). - The B combined-forward creation wizard — link-existing stands; FE may label a linked set "Combined class". - Backend merged register — attendance stays per-section; the merge is FE presentation (product decision 2026-07-07). - Per-lesson shared/solo tagging — shared-ness remains derived from co-location; the declared sharedWeeklyHours makes the expectation explicit, placement stays untagged.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
2026-06-16-combined-classes-design.md (superseded draft + §13 addendum) The whole seam: link entity, SubjectGroup.combinedClassId (SetNull), N co-located lessons, RBAC reuse, link-existing UX, module location Same-grade envelope, period-count arithmetic, implicit hours, pre-SPD engine facts — all re-derived here
2026-07-07-subject-period-durations-design.md (SPD, Approved) weeklyHoursToMinutes + minutes identities; reflow resolver (resolved wall-clock intervals per lesson); the layered feasibility gate (write-time when decidable → fit report → precheck → diagnostics); micro-tick solver encoding; fit-report alert pattern SPD's cascade resolves durations; this spec only consumes effective durations and adds a cross-member agreement rule
src/subject-groups/ (SG CRUD, SubjectGroupsPolicy, standalone semantics) Entity-key/scope reuse, standalone-SG rule, aggregate DTO shape, admin/department_head allowlist + policy narrowing The link is a relation across SGs — small dedicated CRUD, not a scoped-field add
src/curriculum/curriculum-structure-sync.ts (SG guard/clear on curriculum edits; standalone SGs are created manually — no auto-seed on offer) The feature creates nothing but the link; the sweep is also the verified membership-decay vector the degenerate clause (§3) handles The 2026-06-16 draft's "auto-seed" premise is stale — corrected 2026-07-08
2026-07-03-backend-error-i18n-design.md Every new ErrorCode ships catalog entries (en_US/it_IT), self-contained messages, ERROR_TEXT_PARAMS
src/timetables/timetables.diagnostics.ts Violation engine, pair scans, occupancyKey; the per-check delta table below is written against its post-SPD shape Pre-SPD slot/count semantics are being rewritten by SPD — this spec specifies against the SPD design, not current code

On-axis / off-axis (ch16 §4–§5): entity + CRUD + RBAC-reuse are paved paths. The off-axis pieces — wall-clock co-location as a positive obligation (a minimum of coincidence, not just absence of conflict) and the solver pseudo-group rewrite of the payload — are called out in §4/§8 with rationale. Both lean on primitives SPD already invents (reflow intervals, tick occupancy), so the off-axis surface here is the combination of them, not new machinery.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes CombinedClass.tenantId; members must share tenant; RLS coverage extends to the new table standard
Academic-year scope yes CombinedClass.academicYearId; reads filtered to active AY; members must share AY AY-local teaching arrangement
RBAC entity key existing SUBJECT_GROUPS no new key; endpoints decorated with the SG entity key (same trick as pickers/boards) composition concern of subject groups
Scopes / Actions existing composition scope + create/read/write/delete @AggregateResponse() flat DTO — no field-filtering plumbing mirrors subject-levels riding curricula.configuration
Service base custom small service CombinedClassesService in src/subject-groups/combined-classes/ thin link + validations, aggregate reads
queries.ts shape combined-class includes + snapshot deltas SnapshotLesson.combinedClassId; snapshot gains combinedClasses: { id, name, sharedMinutes, memberSubjectGroupIds }[]; same delta on the generation snapshot engine needs membership + the declared minutes
Error codes new COMBINED_CLASS_* family (§5) with i18n catalog entries 409/422, typed, message-complete error-i18n convention
DTO conventions Create/Update/Response under combined-classes/dto/, @AggregateResponse() admin/manager-only flat read consistent with boards/pickers
File-backed sub-resources / custom fields / profile completeness n/a not a person entity, no files

Diagnostics engine delta — full audit (post-SPD catalogue)

Co-location predicate (one shared helper): lessons of all members of one combined class, same weekday, identical resolved wall-clock interval (identical start and end, which implies equal duration), same room. Identity, not overlap: partial overlap gets no blessing. Teacher is deliberately not part of the predicate. Under SPD's final coordinate model (absolute start ticks from midnight, amended 2026-07-08) resolved intervals live on one global axis for every template — the predicate is a plain tick + duration + room comparison with no frame translation, even across grades on different week templates. Implement combined-awareness as a decoration pass: annotate the snapshot's lessons with their properly-co-located set once, and let each check below consume the annotation rather than re-deriving the predicate.

Check (post-SPD) Verdict Why
TEACHER_CONFLICT / ROOM_CONFLICT exempt sibling pairs that are properly co-located per the predicate the shared meeting is the declared intent; partial overlaps still conflict — no blanket immunity
COMBINED_SHARED_MISCOUNT new ERROR per member: Σ minutes of its lessons properly co-located with all siblings ≠ sharedMinutes. The solo remainder needs no check — HOUR_BUDGET's unchanged minutes identity (Σ placed == cellMinutes) forces it arithmetically
COMBINED_CLASS_ROOM_SPLIT new ERROR sibling lessons overlapping wall-clock in different rooms — a subcase of the miscount kept as the actionable diagnosis
COMBINED_SHARED_EXCEEDS_CELL new ERROR sharedMinutes > cellMinutes for a member — only reachable via a post-combine curriculum edit (combine-time 409 prevents creating it); self-heals the other direction (cell raised → solo grows → HOUR_BUDGET pressure)
OPTION_BLOCK_SYNC moot — §5 step 4b fences all option-block-child members out of membership, so no combined member is ever in an option block; any "exclude combined members from the sync set" reduces to a no-op (kept only as a defensive comment) combined members are plain standalone by rule
ROOM_CAPACITY sum rosters of properly co-located siblings against the shared room (count the meeting once) two co-located rows are one physical meeting; per-lesson counting misses joint overflow
TEACHER_DAILY_CAP (WARNING) dedupe a properly co-located set to one occupancy per teacher one physical meeting
CLASS_CONFLICT none fires per homeroom; properly co-located siblings live in different homerooms (roster-disjointness is enforced), so no single homeroom ever sees two lessons at one tick — even now that homeroom-bound members are admitted
STUDENT_CONFLICT none — deliberately unexempted a student rostered in two siblings sits in one room for two subjects at once: a real modeling error. Combine-time roster-disjointness (§5) guards creation; this check catches post-combine roster drift
OPTION_BLOCK_CLASH none fires per (block, grade) cohort on maxSelections>1 co-pick overlap — if two combined siblings share such a block, a co-picking student genuinely clashes; the ERROR is correct protection
ROOM_NOT_IN_SUBJECT_SET none per-lesson; the shared room must satisfy every member's non-empty subject-room set — correct pressure (consequence: viable shared rooms = intersection)
SUBJECT_GROUP_NOT_IN_BASE_ROOM none (engine); combine-time compatibility rule instead (§5) homeroom-bound members carry no base room (write-guarded in subject-groups.service.ts), so this only constrains standalone members with a pinned room: those must share one base room, else shared slots are unsatisfiable by construction; post-combine base-room edits are caught by the unchanged check
TEACHER_AVAILABILITY, HOMEROOM_GAP, HOUR_BUDGET, NO_EFFECTIVE_TEMPLATE, DAY_OVERFLOW, SAME_DAY_CONTIGUITY, SAME_ROOM_DOUBLE none per-lesson / per-homeroom-day structural checks; combined lessons participate like any standalone lesson (SEQUENCE_MISALIGNMENT no longer exists — dropped by SPD's 2026-07-08 tick amendment)
COMBINED_CLASS_DEGENERATE new WARNING (2026-07-08) a combination with < 2 members (post-decay); see the degenerate-membership clause below — the combination is inert while this fires

Reflow-ripple note — RESOLVED (2026-07-08): SPD landed absolute tick coordinates, so a lesson's start is stored, not derived from neighbors — an unrelated edit in one participating homeroom cannot shift a shared lesson's resolved time. Co-location can only break through direct edits to a sibling lesson (move/delete/duration-affecting curriculum change), all of which surface as COMBINED_SHARED_MISCOUNT/ROOM_SPLIT.

Homeroom-member coverage note (added 2026-07-08, US-69 alignment): when a member is homeroom-bound (MANDATORY), the generation rewrite lists the shared pseudo-group's id in that member's homeroom mandatory_group_ids, so HOMEROOM_COVERAGE tiles the homeroom day with pseudo(sharedMinutes) + solo(cellMinutes − sharedMinutes) + other subjects. HOMEROOM_COVERAGE reads its terms as a plain id list and looks each up in group_domain regardless of the group's own homeroomId/template (solver/app/model.py:461), so one pseudo placement is a coverage term in all participating homerooms at once — the co-location semantics fall out with no new Python family. The pseudo rides the synthetic intersection template (bands ⊆ every member's real template), so every tick it can occupy is a valid cover tick in each homeroom; the rest of each day is filled by that homeroom's solo + other lessons. At ingest the pseudo fans back into ordinary per-member homeroom rows, so post-generation diagnostics (HOMEROOM_GAP, coverage) see normal lessons in each homeroom. Precondition — this only stays satisfiable when the members share the shared portion's teaching window (else the pseudo is inert in a segment the homeroom still needs covered); enforced at combine time (§5 step 3).

Degenerate membership (added 2026-07-08)

The ≥2-member floor is enforced only at create/PATCH, but membership decays without combined-classes code running: the curriculum SG-clear sweep (guardAndClearSubjectGroupsFor* in curriculum-structure-sync.ts) hard-deletes empty standalone SGs on curriculum edits, and manual SG deletion unlinks silently (the FK is on SubjectGroup). A combination with < 2 members is inert + WARNING (ratified 2026-07-08; auto-delete rejected — it would add synchronous cross-module coupling from curriculum/SG deletes, the exact audit-#5 pattern):

  • Inert: grants no conflict exemptions, no capacity summing, no daily-cap dedupe; COMBINED_SHARED_MISCOUNT / ROOM_SPLIT / EXCEEDS_CELL are not evaluated for it (this resolves the vacuous-quantifier ambiguity of "co-located with all siblings" at zero siblings — the checks simply don't run).
  • COMBINED_CLASS_DEGENERATE — new WARNING diagnostics category (params: combinedClassId, name, memberCount) + the matching fit-report alert on GET /timetable-assignments, so the admin sees it before any timetable work.
  • Cleanup is manual: the admin deletes the combination (or re-links members). Response DTOs must tolerate the state: department is derived from members and becomes null for a memberless combination.

Layered duration gate (mirrors SPD's divisibility layering)

HL and SL are separate CurriculumSubject rows, each with its own effective periodDuration through the SPD cascade. Unequal durations make identical intervals impossible — the combination could never co-locate. Enforcement is layered, decidable-earliest-first:

  1. Combine time (409) — only when every member's duration is curriculum-resolved (non-null before the template tail): reject unequal durations (COMBINED_CLASS_DURATION_MISMATCH) and reject sharedMinutes % duration ≠ 0 (COMBINED_CLASS_SHARED_HOURS_NOT_DIVISIBLE).
  2. Fit report (GET /timetable-assignments, new alert categories COMBINED_DURATION_MISMATCH, COMBINED_SHARED_NOT_DIVISIBLE) — template-tailed members resolved against their grades' assigned templates; per-weekday defaults may legitimately differ across the members' week templates.
  3. Generation precheck — same two conditions over the generation snapshot, plus COMBINED_NO_COMMON_ROOM (compatible-room intersection, re-filtered by the summed roster, is empty) and COMBINED_NO_COMMON_BAND (members' teaching-band tick domains don't intersect).
  4. Diagnostics — whatever slips through lands as COMBINED_SHARED_MISCOUNT (co-location simply never satisfiable).

4. Data model plan

Schema deltas

  • New model CombinedClass: id, tenantId (FK Tenant, Cascade), academicYearId (FK AcademicYear, Cascade), name (VarChar 150, required), sharedWeeklyHours Float (clock-hours, same unit and whole-minutes discipline as CurriculumSubjectHours.weeklyHours; all arithmetic via weeklyHoursToMinutes), createdAt, updatedAt, relation subjectGroups SubjectGroup[]. No gradeId (members may span grades) and no departmentId (derived from members; same-department is a validation invariant, not a stored fact).
  • SubjectGroup gains nullable combinedClassId FK → CombinedClass, onDelete: SetNull (deleting the combination unlinks members; the SGs survive; deleting a member SG drops it from the combination automatically — which can leave the combination degenerate, handled per §3's inert + WARNING clause). The single nullable FK is the ≤1-combination-per-section guarantee — membership carries no attributes (solo hours are derived), so no junction table.
  • (in-memory only) SnapshotLesson.combinedClassId; DiagnosticsSnapshot.combinedClasses and the generation-snapshot equivalent.

ScheduledLesson representation (unchanged decision): a shared slot is N co-located per-SG rows, never one combined-owned lesson — every per-SG invariant (coordinate uniqueness, minutes budget, roster, attendance anchoring) survives verbatim. Solo lessons are ordinary rows; nothing is tagged.

Derived quantities (never stored)

  • sharedMinutes = weeklyHoursToMinutes(sharedWeeklyHours)
  • per member: soloMinutes_i = weeklyHoursToMinutes(cell_i.weeklyHours) − sharedMinutes (≥ 0 by the combine-time constraint; response DTOs expose it as derived soloWeeklyHours for the FE)

Migration shape

  • Additive: new table + one nullable FK column + indexes; no backfill. Sequenced strictly after SPD's destructive migration (this feature consumes SPD's engine; there is no meaningful intermediate state). Hazards (ch12): none — nullable column add, SetNull FK, unique over an empty table.

Indexes and uniqueness

  • CombinedClass: @@index([tenantId, academicYearId]), @@unique([academicYearId, name]) (P2002 → COMBINED_CLASS_NAME_CONFLICT 409).
  • SubjectGroup: @@index([combinedClassId]).
  • Member-count floor, standalone, same-department, roster-disjointness, base-room compatibility, hours/duration gates are service-enforced (cross-row, not expressible as DB constraints).

5. API surface

Module src/subject-groups/combined-classes/; controller registered in SubjectGroupsModule; literal path /combined-classes (no route-order collision with /subject-groups/:id).

Verb Path Decorators Request DTO Response DTO
POST /combined-classes @RequireAction(SUBJECT_GROUPS,'create'), @RequireRoles('admin','department_head'), @AggregateResponse() CreateCombinedClassDto { name: string; sharedWeeklyHours: number; subjectGroupIds: string[] } CombinedClassResponseDto
GET /combined-classes @RequireScopes(SUBJECT_GROUPS,'read'), same roles query { departmentId?, gradeId? } (active AY implicit; gradeId filters combinations having a member in that grade) CombinedClassResponseDto[]
GET /combined-classes/:id same as list CombinedClassResponseDto
PATCH /combined-classes/:id @RequireScopes(SUBJECT_GROUPS,'write'), same roles UpdateCombinedClassDto { name?; sharedWeeklyHours?; subjectGroupIds? } (set semantics on members; full envelope re-validated) CombinedClassResponseDto
DELETE /combined-classes/:id @RequireAction(SUBJECT_GROUPS,'delete'), same roles 204 (members unlinked via SetNull; placed lessons untouched — any resulting layout oddity surfaces as ordinary diagnostics)

CombinedClassResponseDto: { id, name, sharedWeeklyHours, academicYear {id}, department {id, name} | null /* derived from members; null when degenerate-memberless (§3) */, members: { id, name, grade {id,name}, subject {id,name}, level {id,name}|null, cellWeeklyHours, soloWeeklyHours /* derived */, effectivePeriodDuration|null, teachers[], studentCount }[] }.

Validation envelope (create + patch, pre-tx, in order): 1. subjectGroupIds.length ≥ 2COMBINED_CLASS_TOO_FEW_MEMBERS 2. every id exists in tenant + active AY → COMBINED_CLASS_MEMBER_NOT_FOUND / COMBINED_CLASS_MEMBER_AY_MISMATCH 3. members share the shared portion's teaching window (so per-segment homeroom coverage is tileable — see the homeroom-member coverage note in §3) → COMBINED_CLASS_MEMBER_WINDOW_MISMATCH. (This replaces the former standalone fence: homeroom-bound MANDATORY members are now admitted; the sole structural exclusion is the option-block-child rule in step 4b. A homeroom is only the roster source, so Art-1a + Art-1b are ordinary combinable SGs — US-69's Preschool 1 & 2 case.) 4. no member already in another combination → COMBINED_CLASS_MEMBER_ALREADY_COMBINED 4b. no member is an option-block child (non-null optionBlockId, any maxSelections) → COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD (solver-compatibility fence, widened 2026-07-08 plan phase). Two failure modes, one fence: a maxSelections=1 child is modeled x_child == b and covered only via the cohort block-unit b, which the pseudo/solo transform (folding the member into a shared group with no x_child) breaks outright; a maxSelections>1 child carries its own vars but stays bound by OPTION_BLOCK_CLASH on its grade cohort, so splitting it into pseudo + solo would require recomputing cohort membership and clash pairs — snapshot surgery no current use case needs. Both real use cases (IB HL/SL — level-based, not an option block; cross-grade Art — plain subject) have plain-standalone members, so the admissible envelope is exactly the standalone group (homeroomId and optionBlockId both null). Co-taught option-block membership is deferred wholesale (§9). 5. all members' grades belong to one departmentCOMBINED_CLASS_MEMBER_DEPARTMENT_MISMATCH 6. member rosters pairwise disjointCOMBINED_CLASS_MEMBER_ROSTERS_OVERLAP (a student in two siblings would sit in one room for two subjects; later drift is caught live by STUDENT_CONFLICT) 7. members with non-null baseRoomId all share the same base room → COMBINED_CLASS_MEMBER_BASE_ROOM_CONFLICT (else shared slots are unsatisfiable by construction). Homeroom-bound members carry no base room (write-guarded), so this bites only standalone members with a pinned room. 8. every member's (subject, grade) cell exists (zero-hours model: presence ⟺ taught) → COMBINED_CLASS_MEMBER_CELL_MISSING 9. sharedWeeklyHours > 0, whole-minutes (class-validator 400, same rule as cells); sharedMinutes ≤ min(cellMinutes_i)COMBINED_CLASS_SHARED_HOURS_EXCEED_CELL 10. layered duration gate step 1 (§3): when all members curriculum-resolved — equal durations → COMBINED_CLASS_DURATION_MISMATCH; sharedMinutes % duration == 0COMBINED_CLASS_SHARED_HOURS_NOT_DIVISIBLE 11. department_head narrowed to members in a department they head (via SubjectGroupsPolicy)

All COMBINED_CLASS_* codes ship i18n catalog entries (en_US/it_IT, self-contained params per ERROR_TEXT_PARAMS) and Swagger error examples (drift-guarded).

Read-surface deltas (attendance + FE merge support)

  • Timetable lesson read views (GET /timetables/:id/lessons?view=…) expose combinedClassId per lesson — the FE groups properly co-located lessons into one card / one merged register.
  • The attendance recordable-cell surface carries the same combinedClassId passthrough. No attendance-domain change: each member lesson keeps its own roster's cells; no student appears twice; minutes accounting is per-section and co-located lessons share one duration by definition.

6. RBAC seed plan

Seed file Delta
PermissionScope / PermissionAction (rbac-catalogue.ts) none — reuse subject_groups.composition + existing actions
ScopeFieldMapping none — aggregate DTO
Role grants (roles.ts) noneadmin/department_head already hold the SG grants
*_SCOPES runtime constant none

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
New Prisma entity → own EntityKey + policy Reuse SUBJECT_GROUPS; no CombinedClassPolicy composition concern; admin/dept-head-only surface record visibility derives from member SGs via SubjectGroupsPolicy
Diagnostics are negative (absence of conflict) COMBINED_SHARED_MISCOUNT is a positive obligation (a required minimum of coincidence) the declared sharedMinutes is a promise the timetable must keep a new check shape; scoped to combined members only
OPTION_BLOCK_SYNC demands identical intervals across block children n/a — all option-block-child members fenced out of membership (§5 step 4b), so combined members are never block children plain-standalone-only envelope keeps the seam simple co-taught option-block alternatives deferred (§9)
conflict checks flag any same-room/teacher overlap exempt properly co-located sibling pairs only shared room+time is the declared intent; identity-not-overlap keeps sloppy placements flagged a mis-declared link can mask a real double-book — mitigated by admin-only + the room-split ERROR
per-lesson capacity / daily-cap counting combined-aware summing / dedup one physical meeting complexity in two checks
curriculum grid is the only hours authority unchanged — upheld: sharedWeeklyHours partitions the cell, never overrides it product decision 2026-07-07 no hour deviation expressible by design
admin's mental object is "one combined class" backend stays N linked sections preserves every single-subject invariant (grades/attendance landed on it) FE label bridges the gap
write-time invariants stay true for the row's lifetime ≥2-member floor is write-time only; membership decays via SG deletion (sweep/manual) fail-soft: degenerate combos go inert + COMBINED_CLASS_DEGENERATE WARNING (added 2026-07-08); auto-delete rejected as audit-#5-style cross-module coupling stale link rows linger until the admin cleans up; exemptions silently stop applying

8. Pushback log

Source says Conflicts with Proposed instead Status
(2026-06-16) "custom subject-groups" escape hatch opaque objects every structured consumer must special-case two+ linked single-subject sections Resolved
(2026-06-16) narrow to IB levels misfits are recurring, not IB-specific generalize the co-teaching link Resolved
(round 1) same-grade envelope small schools combine across grades cross-grade combination, same department; membership stays grade-pure Resolved 2026-07-07
(round 1 draft) implicit hours, inferred shared/solo solver can't infer (it generates); admin has no explicit control explicit sharedWeeklyHours; hard curriculum match (no deviation), solo derived — collapse of the initial "explicit shared + explicit solo" model once deviation was ruled out Resolved 2026-07-07
(round 1, my WARNING recommendation) tolerate hour deviation with a diagnostic curriculum stops being the source of truth deviation inexpressible; edit the curriculum instead Resolved 2026-07-07 (user overruled toward strictness)
(round 2) SPD makes duration a per-subject cascade — members may disagree identical intervals impossible under unequal durations layered gate (409-when-decidable → fit report → precheck → diagnostics), mirroring SPD's divisibility layering Resolved 2026-07-07
(round 2) lesson-anchored attendance ⇒ N registers per joint meeting teacher takes attendance once physically per-section registers, FE merges via combinedClassId passthrough; backend merged register rejected as re-fusing the seam Resolved 2026-07-07

9. Deferrals

  • Cross-grade membership (student in another grade's section) — eligibility relaxation + selection wrinkle; own future spec.
  • Cross-department combinations — revisit only on real demand; calendars/templates are department-scoped, so the cost is real.
  • Members that are option-block children (any maxSelections) — fenced out in v1 (§5 step 4b). maxSelections=1 breaks the solver's x_child == b block-sync outright; maxSelections>1 would require recomputing OPTION_BLOCK_CLASH cohort membership and clash pairs for each pseudo/solo-split member. Lifting either requires solver-adjacent snapshot work; defer until a real school needs a co-taught option-block alternative.
  • Multi-combination membership (US-69 Scenario 3 — a SG contributing hour-slices to several combined lessons, with a Σ shared ≤ total weeklyHours budget check) — deferred (chat 2026-07-08). The single nullable FK keeps ≤1 combination per SG; all three of US-69's concrete cases are single pairings. Lifting it needs the CombinedLesson + CombinedLessonContributor junction US-69 names (per-contributor hours + cross-lesson budget); revisit on real demand.
  • sharedWeeklyHours default-to-min (US-69 Scenario 2's "if unspecified, default to the fewest-hours contributor's full hours") — deferred (chat 2026-07-08). v1 requires the number explicitly; making it optional and defaulting to min(cellMinutes) when omitted is a trivial later add.
  • B combined-forward wizard — FE facade later if link-existing proves too bare.
  • Backend merged register — rejected for v1; revisit only if FE merge proves insufficient.
  • Team-teaching semantics — YAGNI.
  • Co-location placement preferences (e.g. shared core in the morning) — same product exclusion as SPD's position-in-day taste.

10. Open questions

None — all forks resolved in chat 2026-07-07 (rounds 1–2): envelope (cross-grade combination, same department), hours (explicit shared, curriculum match hard, solo derived), enforcement (identical-interval co-location, ERROR miscount, teacher out of the predicate), duration gate (layered), solver (in scope, pseudo-group encoding recommended), attendance (per-section + FE merge).

11. Verification plan

Sequencing: SPD has landed (verified 2026-07-08: periodDuration cascade, envelope templates, absolute-tick coordinates, minutes-based diagnostics, the reworked generation snapshot, and the Python tick solver are all in the tree). The plan doc — docs/superpowers/plans/YYYY-MM-DD-combined-classes.md — is therefore unblocked and anchors to real files. The prior "plan-doc gate" (worked block-child × combined example) is resolved: the second-pass solver read confirmed the maxSel=1 x_child == b machinery is incompatible with the pseudo/solo transform, so such members are fenced out at combine time (§5 step 4b). The plan should still carry the pseudo-group unit worked example — but as construction documentation over the admissible envelope, not a go/no-go gate. If a future iteration lifts the fence (§9), that is when a Python model change gets budgeted.

  • Unit specs:
  • combined-classes.service.spec.ts — the full §5 envelope (each 409/422), set-semantics PATCH, delete unlinks, department_head narrowing, whole-minutes + ≤ min(cell) + layered-gate step 1 (including the template-tailed pass-through).
  • timetables.diagnostics.spec.ts — co-location predicate (identity not overlap, all-members for 3+, cross-grade via absolute-tick intervals); conflict exemptions (properly-co-located only; partial overlap still conflicts; unrelated lessons unaffected); COMBINED_SHARED_MISCOUNT minutes identity; ROOM_SPLIT; EXCEEDS_CELL after a simulated cell edit; capacity summing; daily-cap dedupe; STUDENT_CONFLICT fires on roster drift; degenerate cases (2026-07-08): a 1-member and a 0-member combination raise COMBINED_CLASS_DEGENERATE WARNING, grant no exemptions, and trigger none of the combined ERROR checks; the full IB layout and a cross-grade layout each yield zero ERRORs + canPublish.
  • fit-report specs — COMBINED_DURATION_MISMATCH / COMBINED_SHARED_NOT_DIVISIBLE on template-tailed members.
  • generation.prechecks.spec.ts + snapshot-rewrite specs — pseudo-group construction (union roster/teachers, room intersection + summed-capacity filter, band-intersection tick domain), solo-group emission (cellMinutes − sharedMinutes per member), recomputed rosterClashPairs over the pseudo/solo groups, homerooms/cohorts left untouched (members are standalone, absent from coverage/cohorts), the two new prechecks; ingest expansion round-trip (shared pseudo assignment → N member rows + each solo assignment → its member row, each mapped to the absolute start tick start_minute / TICK_MINUTES).
  • solver pytest — none expected (no Python constraint changes); one regression fixture with a combined pair proving FEASIBLE end-to-end.
  • E2E: combined-classes.e2e-spec.ts — wire contract + RBAC (admin/department_head allowed; teacher/referent/secretary 403) + representative envelope codes (thin by design; the full envelope — homeroom-bound accept, option-block-child reject, window mismatch — is unit-covered in combined-classes.service.spec.ts). timetables.e2e-spec.ts — publish a combined layout green, miscount variant blocks publish. The two-homeroom combined layout (US-69 Preschool 1 & 2: combine both homerooms' mandatory Math SGs → co-located generate + publish) lands with the deferred generation e2e (Task 17), where the solver integration is exercised end-to-end.
  • Manual: seeded IB tenant — link HL+SL with shared=4h, generate, verify co-located core + solo extras, publish, take PERIOD attendance on both sections' cells for one joint meeting.

Documentation deltas (part of the work)

  • docs/14-homerooms-subject-groups.md — "Combined classes" section: link entity, envelope, derived solo hours, recipe.
  • docs/18-timetables.md — co-location predicate, the check-delta table, the layered duration gate, the pseudo-group encoding, the reflow-ripple note.
  • docs/19-attendance.md — one paragraph: combined meetings are N per-section registers; combinedClassId passthrough is presentational.
  • docs/REFERENCE.md — module map + file-index rows.
  • Memory — update project_combined_classes_design after landing.

12. Sign-off

  • Approved by: Fabio Barbieri ("approved, sign off")
  • Date: 2026-07-07
  • Chat reference: 2026-06-16 (seam), 2026-07-07 round 1 (envelope + hours + pause), 2026-07-07 round 2 (post-SPD re-derivation: minutes re-base, duration gate, solver encoding, attendance stance; written-spec review + sign-off same day)

SPD has landed (verified 2026-07-08); implementation is unblocked. Next step is the plan doc per §11 — the spec itself needs no further sign-off.

  • Iteration amendment (2026-07-08, US-69 alignment) approved by: Fabio Barbieri ("sign off, update the plan and implement") — fence widened to "not an option-block child" (homeroom-bound members admitted), multi-combination + cross-department + default-to-min deferred, teacher union/dedup confirmed. See the third amendment banner at the top.