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 viab,HOUR_BUDGETonb) 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 renamedCOMBINED_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 (
homeroomIdnull andoptionBlockIdnull) 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'smandatoryGroupIds/blockChildGroupIds(both filter on non-nullhomeroomId === hr.id), so it is absent fromHOMEROOM_COVERAGEoutright. AmaxSelections>1option-block-child member, however, still sits in a grade cohort constrained byOPTION_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 viaCurriculumRule, not an option block; cross-grade Art is a plain subject). Decision: v1 fences all option-block-child members — §5 step 4b widens frommaxSelections=1to any non-nulloptionBlockId(code renamedCOMBINED_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-1bmust 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'smandatory_group_ids, soHOMEROOM_COVERAGE's exact-tiling still closes (sharedMinutes+ per-membersoloMinutes= each member's cell), and the base-room objection is moot (homeroom-bound SGs cannot carry abaseRoomId—subject-groups.service.tswrite-guards it). Only option-block children stay fenced (x_child == bblock-sync /OPTION_BLOCK_CLASHgenuinely 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 formerCOMBINED_CLASS_MEMBER_NOT_STANDALONEcode is dropped,COMBINED_CLASS_MEMBER_WINDOW_MISMATCHadded. Four US-69 scope calls settled in chat: multi-combination (S3) deferred (single FK kept — noCombinedLessonContributorjunction); cross-department kept fenced;sharedWeeklyHoursstays 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 oneScheduledLessonanchored on the combination (subjectGroupIdXORcombinedClassId), 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-classCOMBINEDgroup under the real id and pair-tiles paired maxSel=1 block children. What survives from this spec: theCombinedClassmodel + single-FK membership, curriculum-anchored explicitsharedWeeklyHourswith 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.homeroomIdwas 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_MISMATCHstays, 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:
- 2026-06-16 — seam chosen (two linked single-subject sections over the escape hatch and over IB-level narrowing); same-grade, implicit hours.
- 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.
- 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-1bcombine — 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_CELLare 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 onGET /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:
departmentis derived from members and becomesnullfor 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:
- 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 rejectsharedMinutes % duration ≠ 0(COMBINED_CLASS_SHARED_HOURS_NOT_DIVISIBLE). - Fit report (
GET /timetable-assignments, new alert categoriesCOMBINED_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. - 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) andCOMBINED_NO_COMMON_BAND(members' teaching-band tick domains don't intersect). - 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 asCurriculumSubjectHours.weeklyHours; all arithmetic viaweeklyHoursToMinutes),createdAt,updatedAt, relationsubjectGroups SubjectGroup[]. NogradeId(members may span grades) and nodepartmentId(derived from members; same-department is a validation invariant, not a stored fact). SubjectGroupgains nullablecombinedClassIdFK →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.combinedClassesand 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 derivedsoloWeeklyHoursfor 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_CONFLICT409).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 ≥ 2 → COMBINED_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 department → COMBINED_CLASS_MEMBER_DEPARTMENT_MISMATCH
6. member rosters pairwise disjoint → COMBINED_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 == 0 → COMBINED_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=…) exposecombinedClassIdper lesson — the FE groups properly co-located lessons into one card / one merged register. - The attendance recordable-cell surface carries the same
combinedClassIdpassthrough. 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) | none — admin/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=1breaks the solver'sx_child == bblock-sync outright;maxSelections>1would require recomputingOPTION_BLOCK_CLASHcohort 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 weeklyHoursbudget 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 theCombinedLesson+CombinedLessonContributorjunction US-69 names (per-contributor hours + cross-lesson budget); revisit on real demand. sharedWeeklyHoursdefault-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 tomin(cellMinutes)when omitted is a trivial later add.Bcombined-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_headnarrowing, 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_MISCOUNTminutes identity;ROOM_SPLIT;EXCEEDS_CELLafter a simulated cell edit; capacity summing; daily-cap dedupe;STUDENT_CONFLICTfires on roster drift; degenerate cases (2026-07-08): a 1-member and a 0-member combination raiseCOMBINED_CLASS_DEGENERATEWARNING, 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_DIVISIBLEon 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 − sharedMinutesper member), recomputedrosterClashPairsover 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 tickstart_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 incombined-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;combinedClassIdpassthrough is presentational.docs/REFERENCE.md— module map + file-index rows.- Memory — update
project_combined_classes_designafter 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.