Skip to content

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 ScheduledLesson rows, the FE merged them by combinedClassId, 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 from x_child == b to pair-tiling x_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 with weeklyMinutes = cell − shared (dropped when ≤ 0). The combined:/solo: synthetic namespace, combinedExpansion, and ingest fan-out are deleted — an assignment's groupId is 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 on b). 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.py block-children entries gain a pair form ({groupId} | {combinedGroupId, soloGroupId}); model.py swaps 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 via COMBINED_NO_COMMON_BAND precheck 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: subjectGroupIdnullable; new nullable combinedClassId FK → CombinedClass (onDelete: Cascade — deleting the combination deletes its lessons, per chat 2026-07-12); DB CHECK constraint "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) nullable combinedClassId + 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.combinedClassId decoration → replaced by the lesson's own anchor; GenerationSnapshot.combinedExpansion.

Derived quantities (never stored — unchanged doctrine)

  • union roster = ∪ members' SubjectGroupAssignment rosters (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: ALTER scheduled_lessons (subject_group_id DROP NOT NULL; ADD combined_class_id + FK + CHECK + unique index), ALTER attendance_records (two nullable columns). No backfill (greenfield discipline; existing combined data — if any — is dev-only and droppable per standing preference).
  • Hazards (ch12): CHECK addition 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; CombinedClass keeps @@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: SubjectGroupRefunit: 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

  • TeachingUnitRefDto documented once, referenced from lesson + plan DTOs; kind enum drives FE switching.
  • Lesson-create anchor XOR documented in the operation description (400 on both/neither).
  • COMBINED_CLASS_* prose block in combined-classes.swagger.ts updated: 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_BUDGET per-unit identities; STUDENT_CONFLICT/STUDENT_GAP membership-aware occupancy; OPTION_BLOCK_SYNC expanded-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/Name stamped.
  • table/list specs — kind discriminator 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/table returns the combined row; /combined-classes accepts 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_design updated 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.