Combined classes iteration 3 — first-class teaching unit¶
The pivot, in one paragraph. v1 shipped combined-ness as a simulation: a shared meeting was N co-located per-SG
ScheduledLessonrows, the FE merged them bycombinedClassId, and a family of diagnostics (COMBINED_SHARED_MISCOUNT,COMBINED_CLASS_ROOM_SPLIT, conflict exemptions, capacity summing, daily-cap dedupe) existed solely to police the simulation. Product direction (chat 2026-07-12): a combined class is a teaching unit — placed, listed, viewed, and attended exactly like a subject group — and its contributors are ordinary SGs whose placeable hours are the derived solo remainder (cell − shared). The combination stays invisible in exactly two places: grades (marks are always contributor-anchored) and per-subject attendance reports (a cell taken on the combined lesson is attributed to the student's own contributor subject at write time). Additionally, the option-block-child member fence is lifted (product overruled the v1 deferral): the solver's block-sync constraint generalizes fromx_child == bto pair-tilingx_combined + x_solo == b.
1. Problem distillation¶
- The FE should never special-case combined classes on scheduling surfaces. v1 forces it to: merge N lesson rows into one card, orchestrate N co-located placements by hand, merge N register cells into one card, and read placement-plan rows whose hours don't match what is actually placeable.
- What product wants: the combined class behaves as one schedulable unit worth
sharedWeeklyHours(union roster, deduped-union teachers), and each contributor behaves as an ordinary SG worth its solo remainder. One shared meeting = one lesson row. - Grades exception: participating in a combined class never creates a new gradeable subject. Marks are always anchored to the contributor SG; combined classes appear on no grade surface. (Grading "from the combined context" is FE navigation, not a BE flow — settled in chat.)
- Attendance: the combined lesson has its own register (it happens at a different time than solo lessons, so separate registers are correct), taken once on the union roster by any teacher of any contributor. But per-subject reporting must see through the combination: each student's cell is attributed to their own contributor subject at write time (the frozen-snapshot columns), so reports need zero combined awareness — ever.
- Option-block members are in scope — a member SG belonging to an option block (any
maxSelections) must be combinable like any other; the v1 fence is deleted.
Success criteria (observable behavior that proves this works):
- Placement plan shows one row for the combined unit (expectedMinutes = sharedMinutes) and
one row per contributor (expectedMinutes = cellMinutes − sharedMinutes; zero-solo
contributors show 0 and nothing left to place).
- POST /timetables/:id/lessons accepts combinedClassId as the anchor; the lesson lands as
one row; all views/mutations return a uniform teaching-unit ref. No co-location for the FE
to orchestrate — COMBINED_SHARED_MISCOUNT/ROOM_SPLIT are structurally impossible and
deleted.
- Attendance: the combined lesson's register is one card (union roster); saving it writes
per-student cells stamped with the student's contributor SG/subject/grade; a
per-subject present-days query over attendance_records returns correct numbers with no
join through combined_classes.
- Grades surfaces are byte-identical to today; a combined id is not addressable anywhere in
src/grades/.
- GET /subject-groups/table (and the SG list surfaces) include combined rows with a kind
discriminator — "combined appears in lists everywhere" (chat 2026-07-12).
- A combination whose members include option-block children is authorable (fence code
deleted), generable (pair-tiling encoding), and publishable.
- Generation emits combined-anchored lessons directly — the ingest fan-out and the synthetic
combined:/solo: id namespace are deleted.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Multi-combination membership (a SG contributing to several combined classes) — single
nullable FK stands.
- Cross-department combinations — fence stands.
- Cross-grade membership (student rostered into another grade's section) — unchanged
deferral; cross-grade combination of grade-pure sections already works.
- Team-teaching semantics; sharedWeeklyHours default-to-min; a combined-forward creation
wizard (/combined-classes CRUD stays the authoring surface).
- A grades write that accepts a combined id and fans marks out — explicitly rejected in chat
2026-07-12 ("grade entry is always on contributor").
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
2026-07-07-combined-classes-design.md (iter 1+2) |
The link entity (CombinedClass + SubjectGroup.combinedClassId), explicit sharedWeeklyHours with curriculum as sole hours authority, the full combine-time validation envelope (§5 there), degenerate-membership clause, derived department/solo |
The representation half — N co-located rows, FE merge, co-location predicate, MISCOUNT/ROOM_SPLIT/exemptions/summing/dedupe — is what this spec deletes |
ScheduledBreak (model, ~line 2300) |
Precedent for a non-SG first-class schedulable row on the timetable, with live-resolved audience (never materialized) — exactly the shape combined lessons take | Breaks store their own durationMinutes; combined lessons derive duration from the members' agreed curriculum cascade |
AttendanceRecord (model, line 536) |
The frozen per-student snapshot row with soft references (subjectGroupId?, curriculumSubjectId?, subjectName?, per-cell gradeId) — the write-time attribution vehicle |
Today every cell of one lesson stamps the lesson's SG; combined lessons stamp per-student contributor context (cells of one lesson legitimately reference different subjects and even different grades) |
src/timetables/generation/combined-rewrite.ts |
The pseudo/solo arithmetic (union roster/teachers, room ∩ + summed-capacity refilter, band-intersection tick domain, cell − shared solo minutes) becomes the literal snapshot model |
The synthetic combined:${id}/solo:${id} namespace, combinedExpansion, and the ingest fan-out all die — group ids are real entity ids and assignments ingest directly |
2026-07-12-table-lists-and-filters-design.md + subject-group-table-response.dto.ts |
/subject-groups/table row/pagination shape that combined rows join |
Combined rows have no single subject/grade/track — the row DTO gains a kind discriminator and those refs go nullable on COMBINED_CLASS rows |
2026-07-03-backend-error-i18n-design.md |
Deleted codes leave the catalog/examples/drift specs in lockstep; no new codes need entries beyond what exists | — |
On-axis / off-axis (ch16 §4–§5): the off-axis piece is the dual-anchor
ScheduledLesson (a polymorphic schedulable unit) — called out in §7 with the
ScheduledBreak precedent as the closest paved path. Everything else rides existing rails:
the validation envelope survives verbatim, attendance attribution reuses the soft-snapshot
columns as designed, and the solver change is a constrained generalization of one constraint
family.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | unchanged — CombinedClass already Class-S RLS; new lesson FK inherits the lesson row's tenancy |
standard |
| Academic-year scope | yes | unchanged — combined reads AY-filtered; lessons live under a timetable's AY | standard |
| RBAC entity key | existing | SUBJECT_GROUPS for /combined-classes (unchanged); TIMETABLES for lessons (unchanged); attendance unchanged at the grant level |
no new key |
| Scopes | existing | no new scope; SG list surfaces expose combined rows under the existing subject_groups read |
composition concern |
| Actions | none | — | read/update implicit per convention |
| Service base | custom (existing services) | deltas land in CombinedClassesService (fence removal), TimetablesService (dual anchor), AttendanceService (union register + stamping), SubjectGroupsService/queries (list inclusion) |
no new module |
queries.ts shape |
teaching-unit projections | lesson selects gain the combined include; a Map<combinedClassId, {members, unionRoster, unionTeachers, sharedMinutes}> loader shared by timetables/attendance/generation |
engine + registers need membership everywhere lessons load |
| Error codes | net deletion | delete COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD (enum + params + catalog + examples + drift); delete diagnostics categories COMBINED_SHARED_MISCOUNT, COMBINED_CLASS_ROOM_SPLIT; keep COMBINED_SHARED_EXCEEDS_CELL, COMBINED_CLASS_DEGENERATE, and the whole combine-time envelope; lesson-anchor XOR violations are class-validator 400s (no new ErrorCode) |
fence lifted; simulation police deleted |
| DTO conventions | TeachingUnitRefDto { kind: 'SUBJECT_GROUP' \| 'COMBINED_CLASS', id, displayName, subjectName: string \| null } |
replaces ScheduledLessonResponseDto.subjectGroup; reused by placement plan and views. The lesson-level combinedClassId passthrough field is deleted (FE-breaking, acceptable) |
one uniform unit ref |
| File-backed sub-resources | n/a | — | not a person entity |
| Custom fields | no | combined classes keep none (v1 decision stands) | thin link |
| Profile completeness | n/a | — | not a person entity |
Diagnostics engine delta (replaces the v1 co-location catalogue)¶
The shared meeting is one row, so combined-awareness stops being a decoration pass and becomes ordinary data loading: the snapshot loads each combined class's membership, union roster, union teacher set, shared minutes, and per-member cell minutes.
| Check | Verdict | Why |
|---|---|---|
co-location predicate + properlyCoLocated helpers |
deleted | nothing to co-locate — one physical meeting is one row |
COMBINED_SHARED_MISCOUNT, COMBINED_CLASS_ROOM_SPLIT |
deleted | structurally impossible through the API |
ROOM_CONFLICT / TEACHER_CONFLICT exemptions |
deleted | no sibling rows to exempt; a combined lesson's teacher occupancy is the deduped union set, conflicting naturally with anything else those teachers teach |
ROOM_CAPACITY summing |
deleted (check unchanged) | the combined lesson's roster IS the union — per-lesson counting is now correct |
TEACHER_DAILY_CAP dedupe |
deleted (check unchanged) | one row = one occupancy |
HOUR_BUDGET |
per-unit identity | combined unit: Σ placed == sharedMinutes; contributor: Σ placed == cellMinutes − sharedMinutes. Same minutes-identity check, new budget derivation |
COMBINED_SHARED_EXCEEDS_CELL |
kept (ERROR) | still reachable via post-combine curriculum edits (shared > cell ⇒ negative solo) |
COMBINED_CLASS_DEGENERATE |
kept (WARNING) | membership still decays via SG deletion; a degenerate combo now owns lessons — they stay placed, the WARNING flags cleanup; roster/attribution derive from whatever members remain |
STUDENT_CONFLICT |
membership-aware roster | a student's occupancy = lessons of their SGs plus lessons of combined classes their SGs contribute to |
STUDENT_GAP (compactness) / coverage cohorts |
membership-aware course-set | a student's group-set (cohort key) includes the combined class ids derived via membership |
OPTION_BLOCK_SYNC (maxSel=1) |
expanded occupancy | a block child's occupancy = its own (solo) lessons ∪ its combined class's lessons; the identical-intervals check runs over the expanded set — a correct combined layout passes, a broken one fails, no exemption |
OPTION_BLOCK_CLASH (maxSel>1) |
expanded pairs | clash pairs expand each block-child member to |
ROOM_NOT_IN_SUBJECT_SET |
intersection rule | a combined lesson's room must sit in the intersection of members' non-empty subject-room sets (same pressure as v1, now evaluated on one row) |
SUBJECT_GROUP_NOT_IN_BASE_ROOM |
combine-time compatibility gate kept; combined lessons checked against the single shared base room when members pin one | unchanged from v1 |
day-bounds / DAY_OVERFLOW / SAME_DAY_CONTIGUITY / SAME_ROOM_DOUBLE / TEACHER_AVAILABILITY / LESSON_ROOM_MISSING publish gate |
none | a combined lesson participates like any lesson; a cross-grade combined lesson must satisfy every member's operative day window (intersection — the v1 band-intersection rule, applied to one row) |
| duration | unchanged gate | the combined lesson's durationMinutes is the members' agreed effective duration — the layered duration gate (combine-time 409 → fit report → precheck) survives verbatim |
| fit report | kept: COMBINED_DURATION_MISMATCH, COMBINED_SHARED_NOT_DIVISIBLE, COMBINED_CLASS_DEGENERATE |
pre-timetable visibility unchanged |
| generation prechecks | kept: COMBINED_NO_COMMON_ROOM, COMBINED_NO_COMMON_BAND |
now also the friendly surface for option-block coupling infeasibility (two pick-1 blocks tied by a combination may have no common ticks) |
Solver encoding (the option-block fence lift)¶
- Combined = a real group in the payload: id = the real
combinedClassId, weeklyMinutes = sharedMinutes, roster = union, teachers = deduped union, rooms = intersection (summed-roster refiltered), tick domain = intersection of members' operative windows. Contributors ride as themselves withweeklyMinutes = cell − shared(dropped when ≤ 0). Thecombined:/solo:synthetic namespace,combinedExpansion, and ingest fan-out are deleted — an assignment'sgroupIdis either an SG id or a combined id and ingests as the matching anchor. - maxSel=1 block child as member: the shipped encoding covers a child only via the block
unit (
x_child == b, HOUR_BUDGET onb). Generalization (ratified in chat 2026-07-12): a split child ties the pair to the block unit —x_combined(t) + x_solo(t) == b(t)per tick (the two can never overlap: shared roster).schemas.pyblock-children entries gain a pair form ({groupId}|{combinedGroupId, soloGroupId});model.pyswaps the equality for sum-tiling on pair entries. A combined class with block-child members from different blocks satisfies each block's tiling simultaneously — this couples the blocks' slot sets (real rigidity; infeasibility surfaces viaCOMBINED_NO_COMMON_BANDprecheck or solver-infeasible). - maxSel>1 block child as member: BE-side only — cohort/clash-pair construction expands the member to {member, combined}, mirroring the diagnostics rule. No Python change beyond the pair schema.
- This is the first combined-driven Python constraint change — accepted deliberately (product overruled the v1 zero-Python fence; recorded in §8).
4. Data model plan¶
Schema deltas¶
ScheduledLesson:subjectGroupId→ nullable; new nullablecombinedClassIdFK →CombinedClass(onDelete: Cascade— deleting the combination deletes its lessons, per chat 2026-07-12); DBCHECKconstraint "exactly one of (subject_group_id,combined_class_id) is non-null" (raw SQL in the migration — Prisma can't express XOR);@@unique([timetableId, combinedClassId, weekday, startTick])(mirror of the SG coordinate-uniqueness);@@index([tenantId, combinedClassId]).AttendanceRecord: new soft (no-FK) nullablecombinedClassId+combinedClassName(VarChar 200) — grouping/audit only. The existing subject columns (subjectGroupId/Name,curriculumSubjectId,subjectName/Code,gradeId/Name) are stamped per-student with the contributor context on combined-lesson cells (see §5).CombinedClass,SubjectGroup.combinedClassId(membership FK, SetNull): unchanged.- (deleted, in-memory)
SnapshotLesson.combinedClassIddecoration → replaced by the lesson's own anchor;GenerationSnapshot.combinedExpansion.
Derived quantities (never stored — unchanged doctrine)¶
- union roster = ∪ members'
SubjectGroupAssignmentrosters (pairwise-disjoint by the combine-time gate, so no double-membership); union teachers = deduped ∪ members'SubjectGroupTeacher; department = derived from members;soloMinutes_i = cell_i − shared.
Migration shape¶
- One migration:
ALTERscheduled_lessons(subject_group_idDROP NOT NULL; ADDcombined_class_id+ FK + CHECK + unique index),ALTERattendance_records(two nullable columns). No backfill (greenfield discipline; existing combined data — if any — is dev-only and droppable per standing preference). - Hazards (ch12):
CHECKaddition validates existing rows (full scan — trivial at dev scale); nullable-column adds are safe; no table recreation. No new tenant-bearing table → no RLS/tenanted-models delta.
Indexes and uniqueness¶
- As listed above;
CombinedClasskeeps@@unique([academicYearId, name]).
5. API surface¶
No new endpoints. Deltas on existing surfaces (FE-breaking changes acceptable per standing preference — no shims):
| Surface | Delta |
|---|---|
POST /timetables/:id/lessons |
body anchor becomes { subjectGroupId? } ⊕ { combinedClassId? } — exactly one (class-validator 400 otherwise); rest unchanged (weekday, startTick, roomId?) |
PATCH /DELETE /timetables/:id/lessons/:lessonId |
unchanged — a combined lesson is addressed by its own lessonId like any lesson |
ScheduledLessonResponseDto |
subjectGroup: SubjectGroupRef → unit: TeachingUnitRefDto (kind, id, displayName, subjectName: string \| null — null on combined); combinedClassId passthrough field deleted; durationMinutes/effectiveWindow populated from the members' agreed values on combined lessons |
Lesson views (GET /timetables/:id/lessons?view=…) |
combined lessons appear wherever a member matches: student view via the student's contributor membership; teacher view via the union teacher set; room/grade/department/curriculum views via any-member match |
Placement plan (GET /timetables/:id/plan…) |
rows become teaching units: combined row (kind: COMBINED_CLASS, expectedMinutes = sharedMinutes, union studentCount, union teachers, derived department, subjectName/grade null) + contributor rows with expectedMinutes = cellMinutes − sharedMinutes; contributor rows keep combinedClassId so the FE can explain the reduced hours |
| Attendance recordable cells / take | a combined lesson projects one register card with the union roster. Authorization: any teacher of any contributor may take/edit it (the teaches-map unions contributor teacher sets). On save, each student's cell stamps the student's own contributor subjectGroupId/Name, curriculumSubjectId, subjectName/Code, and gradeId/Name (per-student — cross-grade combos stamp each student's real grade), plus the new combinedClassId/Name. AttendanceCellDto.combinedClassId stays (context/grouping), but the FE merge instruction dies — one lesson is one card |
| Attendance reports (existing + future per-subject rollups) | zero combined awareness — attribution happened at write time; membership changes never re-attribute history (frozen rows) |
GET /subject-groups/table + SG list surfaces |
rows gain kind: 'SUBJECT_GROUP' \| 'COMBINED_CLASS'; combined rows carry name, derived department, union studentCount, sharedWeeklyHours as the hours figure, and null subject/grade/track/optionBlock; contributor rows keep combinedClassId. Grouped-courses tree keeps the existing member-badge (combinedClassId on leaves) and gains no combined node — a combined class has no curriculum position (deferral, §9) |
/combined-classes CRUD |
unchanged surface; validation envelope drops step 4b (option-block fence — code deleted); DELETE prose updated: cascades the combination's scheduled lessons, unlinks contributors (SetNull), leaves them and their solo lessons alive |
Grades (src/grades/) |
zero changes — combined ids are structurally unaddressable (no SG row exists) |
Swagger considerations¶
TeachingUnitRefDtodocumented once, referenced from lesson + plan DTOs;kindenum drives FE switching.- Lesson-create anchor XOR documented in the operation description (400 on both/neither).
COMBINED_CLASS_*prose block incombined-classes.swagger.tsupdated: fence code removed, DELETE cascade noted. Deleted ErrorCode/categories removed from the examples registry + drift spec in lockstep.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none (aggregate DTOs) |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
Record-level (code, not seed): the attendance take-path teaches-map admits teachers of any
contributor for combined-lesson cells; timetable/attendance read policies resolve a combined
unit's visibility through its members (same trick as SubjectGroupsPolicy narrowing on
/combined-classes today).
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| v1's core seam: "don't fuse — co-locate" (N per-SG rows) | Reversed at the representation layer: one combined-anchored row per shared meeting | the co-location machinery existed only to simulate first-class-ness the FE now requires natively | dual-anchor lessons — every timetable/attendance consumer branches on unit kind (bounded set; precedent: ScheduledBreak) |
ScheduledLesson is SG-anchored (required FK) |
nullable SG FK + XOR CHECK with combinedClassId |
a combined class has no single SG identity, and making it literally a SubjectGroup would poison required curriculumSubjectId/gradeId + the SGA subject-unique (pushback, §8) |
polymorphic anchor; CHECK lives in raw SQL outside Prisma's model |
| attendance cells stamp the lesson's own SG/subject | combined-lesson cells stamp per-student contributor context | per-subject reports must see through the combination; write-time attribution makes reports combined-blind and immune to membership drift | cells of one lesson reference different subjects/grades — by design, documented on the model |
| grades/attendance surfaces mirror each other's anchoring | grades stay contributor-only; attendance takes on the combined lesson | product semantics: one physical meeting has one register; marks belong to the subject | asymmetry between the two domains is intentional and documented |
solver: "children covered only via b" (x_child == b) |
pair-tiling x_combined + x_solo == b for split block children |
product requires option-block members; the v1 fence was a zero-Python cost decision, not impossibility | first combined-driven Python model change; block-coupling rigidity can produce infeasibility (surfaced by prechecks) |
| write-time invariants stay true for the row's lifetime | degenerate clause carried forward — now with owned lessons | fail-soft doctrine unchanged | degenerate combos keep placed lessons until manual cleanup; WARNING flags them |
8. Pushback log¶
| Source says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
"treat a combined course just like any other course, so probably also the db design should change" (chat 2026-07-12) — read literally: combined = a SubjectGroup row |
required SubjectGroup.curriculumSubjectId/gradeId (Restrict FKs), SubjectGroupAssignment's @@unique([studentId, curriculumSubjectId, academicYearId]), and the 2026-06-16 rejection of opaque "custom subject-groups" |
first-class schedulable anchor: CombinedClass stays its own entity; ScheduledLesson gains the dual anchor; "like any SG" is delivered by a uniform teaching-unit projection on every scheduling/attendance/list surface, while grades/enrollment/curriculum structurally cannot see it |
Resolved 2026-07-12 (this spec's model) |
| v1 spec §9: option-block-child members deferred ("solver-adjacent snapshot work") | product: "we can't defer that" | pair-tiling generalization (§3) — x_combined + x_solo == b for maxSel=1; BE-side cohort/clash expansion for maxSel>1 |
Resolved 2026-07-12 (fence lifted, Python change budgeted) |
| product decision 2026-07-07: "backend merged register rejected; FE merges via passthrough" | one physical meeting should be one register | superseded, not contradicted: the register is naturally one card because the meeting is one lesson; per-section-ness is preserved in the stored cells via write-time contributor stamping — the domain never re-fuses | Resolved 2026-07-12 |
| v1: any contributor's own teacher takes their own section's register | one teacher physically runs the shared meeting | any teacher of any contributor may take the combined register (union teaches-map) | Resolved 2026-07-12 (explicit call) |
9. Deferrals¶
- Multi-combination membership — single FK stands; revisit needs the junction model — follow-up: v1 spec §9 entry carries.
- Cross-department combinations; cross-grade membership; team-teaching; default-to-min shared hours — unchanged v1 deferrals.
- A combined node in the grouped-courses tree — the tree is curriculum-structured (dept→grade→cv→track→subject) and a combined class has no curriculum position; leaves keep the member badge, the table is the "everywhere" list surface — revisit on FE demand.
- Combined-aware ROOM_UNPLACEABLE / room-suggestion surfaces — suggestions treat the combined unit's room set (intersection + summed capacity) opportunistically; anything fancier waits for real demand.
10. Open questions¶
None — the four forks (grades affordance, take RBAC, list inclusion, delete cascade) were resolved in chat 2026-07-12; the representation model and option-block encoding were ratified in the same conversation.
11. Verification plan¶
- Unit specs:
combined-classes.service.spec.ts— envelope minus the fence (option-block member now accepted); delete-cascade expectation; the rest carries.timetables.service.spec.ts/timetables.queries.spec.ts— dual-anchor create/patch/ delete (XOR 400s, coordinate uniqueness per anchor), unit-ref projection, membership-aware view filters (student/teacher/grade views admit combined lessons).timetables.diagnostics.spec.ts— deleted checks gone (no MISCOUNT/ROOM_SPLIT/exemption fixtures);HOUR_BUDGETper-unit identities;STUDENT_CONFLICT/STUDENT_GAPmembership-aware occupancy;OPTION_BLOCK_SYNCexpanded-occupancy pass/fail;EXCEEDS_CELL+DEGENERATE(degenerate combo with owned lessons) survive.- generation specs — direct encoding (real ids, no expansion map), pair-tiling payload for block-child members, prechecks including the block-coupling band case; ingest writes combined-anchored rows.
attendance.service.spec.ts/ queries — union register projection; per-student contributor stamping (incl. cross-grade grades on cells); union teaches-map authorization;combinedClassId/Namestamped.- table/list specs —
kinddiscriminator rows, nullable refs on combined rows. - Solver pytest — pair-tiling constraint unit (maxSel=1 split child tiles
b; overlap impossible); one regression fixture: combined pair with a block-child member solves FEASIBLE and mirror-clean. - E2E: lessons CRUD with a combined anchor + publish green; attendance take on a combined
lesson → cells assert contributor subject/grade stamping;
/subject-groups/tablereturns the combined row;/combined-classesaccepts an option-block child; generation e2e with a combined assignment ingesting as one combined-anchored lesson (closes the Task-17 debt from v1). - Manual: seeded IB tenant — link HL+SL, place the shared core once + HL solo, publish, take PERIOD attendance on the shared meeting, verify per-subject attendance query attributes to Physics-HL/Physics-SL respectively.
Documentation deltas (part of the work)¶
docs/14-homerooms-subject-groups.md— rewrite the "Combined classes" section (first-class unit, list inclusion, grades/attendance stance).docs/18-timetables.md— dual anchor, deleted checks, per-unit budgets, expanded option-block rules, solver pair-tiling.docs/19-attendance.md— combined register + write-time contributor attribution.docs/REFERENCE.md— module map + file-index rows.- v1 spec gets a "representation superseded by this doc" banner (mirror of the decoupling
banner pattern); memory
project_combined_classes_designupdated after landing.
12. Sign-off¶
- Approved by: Fabio Barbieri ("signed off")
- Date: 2026-07-12
- Chat reference: 2026-07-12 — FE-transparency redesign; four forks resolved (grades contributor-only, union take-RBAC, lists-everywhere, delete-cascades-lessons); option-block fence lift ratified with the pair-tiling encoding; sign-off same day.