Skip to content

Timetable — Manual Management & Diagnostics (Iteration 2)

A build-readiness pass over the signed-off iter-1 spec. Iter-1 (2026-06-08-timetable-manual-management-design.md) is structurally sound and remains the base; this iteration closes four domain gaps found by stress-testing the design against the live schema, plus one lifecycle clarification. No ScheduledLesson schema change — every change is in the diagnostics engine, the snapshot loader, the service-layer write path, or one enum value. Because iter-1 was never implemented, these deltas fold into iter-1's first build rather than migrating on top of it.


1. Problem distillation

Iter-1 mapped the timetable cleanly but carried five unexamined assumptions that the real schema contradicts. This iteration fixes them:

  • Teaching windows. CurriculumSubjectHours.{taughtAllYear, teachingStartDate, teachingEndDate} (PYP units of inquiry — a subject taught only part of the year, DB-CHECK-enforced). Iter-1's engine is time-blind: a half-year subject's lesson occupies its slot all year and a fall-only subject falsely satisfies year-round coverage. Windows must become a real timetabling constraint — two half-year subjects tiling one slot is legal; a slot covered only in fall is an uncovered-spring gap.
  • Multi-select option blocks. OptionBlock.maxSelections can exceed 1. Iter-1's OPTION_BLOCK_SYNC ("all children at one slot") is correct only for pick-one. Pick-many needs the inverse.
  • Live-timetable validity. Iter-1 validates only at the DRAFT → PUBLISHED transition; lesson edits never re-validate. So a PUBLISHED (live) timetable can be edited into an ERROR state.
  • Subject room sets. CurriculumSubjectRoom (the subject↔specialized-room link gym/music/lab need) exists in the schema — the generation spec wrongly assumed it doesn't. Iter-1 checks only capacity. A subject with a room set MUST be placed within it.
  • Lifecycle gaps. Iter-1 is silent on AY-status gating and on what happens to a timetable when its AY is archived.

Success criteria (observable behavior that proves this works): - Two half-year subjects (Art [Sep–Jan], Music [Feb–Jun]) placed at the same homeroom slot → no conflict, no gap, publishable. The same slot with only Art [Sep–Jan] → HOMEROOM_GAP reporting the uncovered Feb–Jun interval, canPublish=false. - A teacher/room/student in two slot-overlapping lessons whose teaching windows are disjointno TEACHER_/ROOM_/STUDENT_CONFLICT. - A maxSelections=1 block with its subject groups split across different slots → OPTION_BLOCK_SYNC ERROR. A maxSelections>1 block with two subject groups overlapping → OPTION_BLOCK_CLASH ERROR. Both clear when placed correctly. - A subject with ≥1 CurriculumSubjectRoom placed in a room outside that set → ROOM_NOT_IN_SUBJECT_SET ERROR. A subject with no room set → any capacity-sufficient room passes. - Editing a lesson on a PUBLISHED timetable in a way that introduces an ERROR → 422 TIMETABLE_EDIT_WOULD_VIOLATE, change rolled back, timetable unchanged. The same edit on a DRAFT → applied, non-blocking. - PUT …/status to PUBLISHED while the AY is not ACTIVE422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE.

Non-goals (explicitly not this iteration): - Notifying impacted users when a live (PUBLISHED) timetable is edited — deferred (the validation gate ships; the notification does not). - Per-student gap detection for pick-many option blocks — a student is inherently free during option subjects they didn't pick; HOMEROOM_GAP stays homeroom-collective, not per-student. - AY-archival cascade wiring — the ARCHIVED enum value + read-only semantics ship; the actual flip is a no-op hook until the AY-archive transition lands (deferred to rollover, per project_academic_year_delete_and_archive_scope). - Cascade-delete guards — deleting a SubjectGroup/Homeroom still silently strips its lessons from any timetable (accepted as-is; not guarded). - Per-lesson stored window / per-lesson room override — windows and room sets are curriculum facts, read through, never stored on the lesson. - The CP-SAT generation engine — still deferred (iter-1 §9).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-06-08-timetable-manual-management-design.md (iter-1, signed off) The entire base: Timetable/ScheduledLesson model, six views, the buildDiagnosticsSnapshot → computeDiagnostics pure-loader split, the ERROR/WARNING tiering, the always-on mutation envelope, the publish gate. This iteration only edits the engine + snapshot + write path. Iter-1 is time-blind, pick-one-only on option blocks, validates only at publish, and checks only room capacity — exactly the four gaps closed here.
src/timetable-templates/timetable-templates.queries.ts The effective-template resolution (resolveEffectiveAssignment, loadAssignmentTreeForAcademicYear, computeSlotStartEndTimes) the snapshot already uses; unchanged by this iteration. No notion of teaching windows or the academic calendar — both new inputs the snapshot loads alongside.
Department-scoped academic calendar ([[project_department_scoped_calendar]]; Period.departmentId, Department.calendar{Start,End}Date) The coverage span for calendar-interval HOMEROOM_GAP: a homeroom → its department → its calendar [start, end]; teaching windows are DB-validated ⊆ this span. The calendar models which dates are in session; we only read its outer span per homeroom to bound coverage interval math.
src/curriculum/CurriculumSubjectHours (window + hours cell), CurriculumSubjectRoom (room set), OptionBlock (maxSelections) The three curriculum facts each lesson resolves: its (taughtAllYear, start, end) window, its subject's room-id set, its block's maxSelections. All read-only into the snapshot. Curriculum owns these; the timetable consumes them. A subject group → exactly one (curriculumSubject, grade) → exactly one hours cell → exactly one window (no ambiguity).
src/command-center/completeness.* Still the precedent for "pure summarizer over a loaded snapshot, surfaced read-only" — the calendar-interval coverage check is one more such pure sub-function. Counts entity readiness, not temporal interval coverage; same architecture, new predicate.

3. Architecture mapping

Deltas to iter-1's §3 only. Every unlisted row is unchanged from iter-1.

Primitive Apply? How Justify
Tenant scope unchanged
Academic-year scope unchanged
RBAC entity key unchanged (TIMETABLES) No new entity.
Scopes unchanged (configuration) No new scope. All iteration-2 changes are engine/service logic on the existing route surface.
Actions unchanged (create/delete/publish) No new action. The AY-active gate and live-edit gate are service-layer checks on existing routes, not new permissions.
Service base unchanged (custom TimetablesService) Gains the PUBLISHED-edit transactional validate-or-rollback path + the publish-time AY-active check. The diagnostics pair already lives here; the live-edit gate reuses it.
queries.ts shape extended buildDiagnosticsSnapshot loads 3 new inputs: per-lesson derived window (CurriculumSubjectHours(curriculumSubjectId, gradeId)), per-subject room-id set (CurriculumSubjectRoom), per-homeroom calendar span (Department.calendar{Start,End}Date); per-block maxSelections onto the option-block grouping. The pure engine stays DB-free; all new facts enter via the snapshot.
Error codes new — 2 TIMETABLE_EDIT_WOULD_VIOLATE (422), TIMETABLE_PUBLISH_AY_NOT_ACTIVE (422). See §5. New failure modes; per-code granularity is the convention.
DTO conventions extended ScheduledLessonResponseDto/…View gain effectiveWindow: { start, end } | null; TimetableViolationDto.params grows the new categories' shapes. No new DTO files. FE renders a window badge + the new violations.
Data model enum value TimetableStatus + ARCHIVED. No table/column/FK change. Lifecycle completeness; folds into iter-1's enum at first build.
File-backed sub-resources n/a
Custom fields n/a
Profile completeness n/a

4. Data model plan

Schema deltas

  • TimetableStatus gains ARCHIVED (third value, after DRAFT/PUBLISHED). Nothing else changes — no ScheduledLesson column, no new table, no new FK.
  • No per-lesson window or room column. Both are derived at snapshot time from curriculum (single source of truth). Confirmed: SubjectGroup → one (curriculumSubjectId, gradeId) → one CurriculumSubjectHours cell → one window; CurriculumSubject → its CurriculumSubjectRoom set.

Migration shape

  • Folds into iter-1. Iter-1 is unimplemented, so there is no standalone iteration-2 migration — the add_timetables migration iter-1 will create simply emits TimetableStatus with three values from the start. If iter-1 were already live, this would be an additive ALTER TYPE "timetable_status" ADD VALUE 'ARCHIVED' (safe, non-destructive).
  • Data backfill: none.
  • Hazards (chapter 12): none beyond iter-1's (the partial-unique WHERE status='PUBLISHED' stanza is unaffected by the new value).

Indexes and uniqueness

  • Unchanged from iter-1. The WHERE status='PUBLISHED' partial unique continues to admit any number of DRAFT and ARCHIVED rows per (tenant, AY), rejecting a second PUBLISHED.

Diagnostics engine — catalogue delta

The engine signature (buildDiagnosticsSnapshot → computeDiagnostics, pure, three consumers) is unchanged. Catalogue changes (iter-1 §4 is the base; deltas only):

Snapshot enrichment: - SnapshotLesson.window: { start: Date, end: Date } | nullnull = all-year. Resolved from the lesson's hours cell: taughtAllYear ⇒ null; else { teachingStartDate, teachingEndDate }. - SnapshotLesson.subjectRoomIds: string[] — the CurriculumSubjectRoom room-id set for the lesson's subject (empty ⇒ "any room"). - SnapshotHomeroom.calendarSpan: { start: Date, end: Date } — the homeroom's department calendar span (coverage bound). - SnapshotOptionBlock.maxSelections: number — onto the block grouping the engine already builds. - windowsOverlap(a, b) primitive: true when both are all-year, or their [start, end] date intervals intersect (all-year = the full calendar span for intersection purposes).

Overlap-check change (4 existing ERROR checks): TEACHER_CONFLICT, ROOM_CONFLICT, STUDENT_CONFLICT, and CLASS_CONFLICT gain a window term — two lessons conflict iff same weekday ∧ wall-clock overlap ∧ windowsOverlap. Disjoint-window lessons at the same slot/room/teacher are no longer conflicts.

HOMEROOM_GAP — now calendar-interval coverage (ERROR, redefined): for each homeroom PERIOD slot, collect the lessons placed there with their effective date intervals (all-year ⇒ the homeroom's full calendarSpan; windowed ⇒ [start, end]); union the intervals; if the union ≠ calendarSpan, report the uncovered sub-interval(s). params gains uncoveredIntervals: [{ start, end }]. (A wholly empty slot reports the entire span as one uncovered interval.)

Option-block checks — OPTION_BLOCK_SYNC rescoped + OPTION_BLOCK_CLASH added (both ERROR):

category Applies when Predicate params
OPTION_BLOCK_SYNC block maxSelections = 1 The block's subject groups are not all on the identical (weekday, periodOrdinalPosition) slot set { optionBlock:{id,displayName}, maxSelections:1, childPlacements:[{subjectGroup:{id,displayName}, slots:[{weekday,periodOrdinalPosition}]}] }
OPTION_BLOCK_CLASH block maxSelections > 1 Any two of the block's subject groups overlap (same weekday ∧ wall-clock overlap ∧ windowsOverlap) { optionBlock:{id,displayName}, maxSelections, overlappingPair:[{subjectGroup:{id,displayName}, weekday, slots:[…]}, …] }

The iter-1 universal OPTION_BLOCK_SYNC is retired in favor of these two. The CLASS_CONFLICT "option-block siblings = 1 occupancy" exemption is retained but now only ever fires for maxSelections=1 (synced siblings legitimately stack); for maxSelections>1, siblings never co-slot, so an option child sharing a homeroom slot with a mandatory subject or a non-sibling is a normal CLASS_CONFLICT.

New room check — ROOM_NOT_IN_SUBJECT_SET (ERROR):

category Predicate params
ROOM_NOT_IN_SUBJECT_SET The lesson's subject has subjectRoomIds.length > 0 and the lesson's roomId ∉ subjectRoomIds { subjectGroup:{id,displayName}, room:{id,displayName}, allowedRooms:[{id,displayName}] }

Subjects with an empty room set are unconstrained beyond ROOM_CAPACITY. This is a diagnostic ERROR, not a write-time rejection — draft editing stays non-blocking; it blocks publish and live edits.

Net catalogue: 12 ERROR + 3 WARNING. Iter-1's 10 ERROR (TEACHER_/ROOM_/CLASS_/STUDENT_CONFLICT, OPTION_BLOCK_SYNC, ROOM_CAPACITY, TEACHER_AVAILABILITY, HOUR_BUDGET, HOMEROOM_GAP, NO_EFFECTIVE_TEMPLATE) — OPTION_BLOCK_SYNC is rescoped (not removed) — plus OPTION_BLOCK_CLASH and ROOM_NOT_IN_SUBJECT_SET = 12. WARNING tier unchanged (TEACHER_DAILY_CAP, SAME_DAY_CONTIGUITY, SAME_ROOM_DOUBLE).


5. API surface

No new endpoints. Behavioral changes on existing iter-1 routes:

# Verb Path Change
7/8/9 POST/PATCH/DELETE /timetables/:id/lessons[/:lessonId] On a PUBLISHED timetable: the mutation runs in a transaction, recomputes whole-timetable diagnostics, and if any ERROR results, rolls back and returns 422 TIMETABLE_EDIT_WOULD_VIOLATE with the offending ERROR violations. WARNINGs never block. On a DRAFT: unchanged (non-blocking; structural floor only). On an ARCHIVED: read-only — every mutation rejected 409 TIMETABLE_READ_ONLY before any work.
11 PUT /timetables/:id/status Promotion to PUBLISHED additionally requires the timetable's AY to be ACTIVE; else 422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE (checked before the diagnostics gate and the single-published conflict). Demotion to DRAFT unchanged. No client-facing transition to ARCHIVED (only the AY-archival cascade sets it).
all mutation responses ScheduledLessonResponseDto/…View carry effectiveWindow; TimetableViolationDto.params carries the new category shapes.

Error codes (new)

Code HTTP When
TIMETABLE_EDIT_WOULD_VIOLATE 422 A lesson mutation on a PUBLISHED timetable would introduce ≥1 ERROR; rolled back. Body carries the ERROR-tier violations.
TIMETABLE_PUBLISH_AY_NOT_ACTIVE 422 Promote to PUBLISHED while the AY is not ACTIVE.
TIMETABLE_READ_ONLY 409 Any mutation (lesson CRUD, rename, status, delete) targeting an ARCHIVED timetable.

Swagger considerations

  • New category values (OPTION_BLOCK_CLASH, ROOM_NOT_IN_SUBJECT_SET) and the redefined HOMEROOM_GAP.params.uncoveredIntervals documented under the TimetableViolationDto.params per-category note.
  • effectiveWindow on the lesson DTOs ({ start, end } | null, null = all-year).
  • Three new error examples wired into error-examples.ts (drift spec enforces one per code).

6. RBAC seed plan

Seed file Delta
all RBAC seed files None. No new scope, action, mapping, or grant. Every iteration-2 change is engine/service logic on iter-1's existing timetables.configuration + create/delete/publish surface (ADMIN-only).

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Iter-1: time-blind engine; a placed lesson is "in effect" with no date dimension. Each lesson carries a derived effective window; all four overlap checks gain a window term; HOMEROOM_GAP becomes calendar-interval coverage. CurriculumSubjectHours teaching windows (PYP units) are a real, DB-enforced domain fact iter-1 ignored. Coverage now needs the department calendar span as a snapshot input + interval-gap math (bounded: sort + scan per slot).
Iter-1 / gen-spec H12: universal OPTION_BLOCK_SYNC (all children one slot). Parameterize by maxSelections: sync at 1, pairwise-disjoint at >1 (OPTION_BLOCK_CLASH). maxSelections>1 is in scope; pick-many students need their picks at different times — an a-priori guarantee for any valid selection. Pick-many blocks consume distinct slots and leave inherent per-student free periods during non-picked options (accepted; not flagged).
Iter-1: all edits non-blocking; validity enforced only at the publish transition. A PUBLISHED timetable's edits are transactionally validated; an edit that introduces an ERROR is rolled back. A live timetable must stay valid; "non-blocking" is for the draft authoring phase, not the published artifact. Editing a live timetable is now gated (can only change it in valid-preserving ways); demote-to-draft remains the escape hatch for free editing.
Gen-spec premise: "no subject↔specialized-room link in the data layer" (justified dropping H11). We use CurriculumSubjectRoom as a hard placement constraint (ROOM_NOT_IN_SUBJECT_SET). That link now exists in the schema (gym/music/lab). The gen-spec premise is stale. A subject with a room set is constrained to it; subjects without one stay capacity-only.
Iter-1: lifecycle = DRAFT/PUBLISHED only; silent on AY status. Publish gated on AY.status='ACTIVE'; ARCHIVED added, set only by AY-archival cascade. Lifecycle completeness; a timetable shouldn't outlive its AY's active phase or go live on a non-active year. The archival flip is a no-op hook until AY-archival exists (deferred).

8. Pushback log

Source says Conflicts with Proposed instead Status
Iter-1 §4: HOMEROOM_GAP = "a PERIOD slot is unfilled" (occupied ⇒ covered, window-blind). A fall-only subject falsely satisfies year-round coverage; the spring hole is invisible. Calendar-interval coverage — union of placed lessons' windows must equal the homeroom's calendar span. Resolved (chat 2026-06-11).
User's first option-block note: "maxSelections=1warn if two siblings share a slot." Self-superseded by the simpler symmetric rule. maxSelections=1 → siblings must share a slot (ERROR if not); maxSelections>1 → siblings must never overlap (ERROR if they do). Resolved (chat 2026-06-11) — second statement wins.
Gen-spec §1.5.5 H11 / §9: subject↔specialized-room link doesn't exist; H11 dropped. CurriculumSubjectRoom is in the live schema. Treat a non-empty subject room set as a hard placement constraint. Resolved (chat 2026-06-11).
Iter-1: cascade-delete of SubjectGroup/Homeroom silently strips published lessons (flagged as an integrity risk). User accepts as-is; no guard this iteration. Resolved — accepted (chat 2026-06-11).

9. Deferrals

  • Notify impacted users on live-timetable edits — the validation gate ships; the notification does not. Follow-up: the notifications subsystem (US-40/41 territory).
  • AY-archival cascade wiringARCHIVED value + read-only semantics ship; the flip is a no-op until the AY-archive transition lands. Follow-up: [[project_academic_year_delete_and_archive_scope]] rollover work.
  • Per-student gap detection for pick-many options — inherent free periods during non-picked options are not flagged. Follow-up: revisit if schools want per-student schedule-density hints (likely an INFO tier).
  • Cascade-delete guardsSubjectGroup/Homeroom delete still strips lessons silently. Follow-up: revisit if it bites in practice.
  • CP-SAT generation engine — still the deferred other half (iter-1 §9, gen-spec). Reuses this model + (a different) engine.

New project_*.md memory entries at sign-off.


10. Open questions

None — all resolved (chat 2026-06-11). Resolution trail: 1. Teaching-window coverage granularity → calendar-interval (precise), not slot-occupied (window-blind). 2. Option-block semantics → flat "any k of N"; sync at maxSelections=1, pairwise-disjoint at >1; both ERROR. 3. Live-edit policy → validate-or-rollback on PUBLISHED (ERROR blocks); DRAFT stays non-blocking; notification deferred; cascade deletes accepted. 4. Subject room set → hard ERROR when the set is non-empty; any room otherwise. 5. Lifecycle → publish only on ACTIVE AY; ARCHIVED set only by AY-archival cascade; no other logic.


11. Verification plan

  • Unit specs (timetables.diagnostics.spec.ts — extends iter-1's):
  • windowsOverlap truth table; disjoint-window lessons at the same slot produce no TEACHER_/ROOM_/STUDENT_/CLASS_CONFLICT; overlapping-window ones still do.
  • HOMEROOM_GAP calendar-interval: full tiling (Art [Sep–Jan] + Music [Feb–Jun]) → clean; partial (Art [Sep–Dec] only) → one uncoveredIntervals entry; empty slot → whole span uncovered.
  • OPTION_BLOCK_SYNC fires only for maxSelections=1 unsynced; OPTION_BLOCK_CLASH fires only for maxSelections>1 overlapping pairs (and not for window-disjoint pairs).
  • ROOM_NOT_IN_SUBJECT_SET: non-empty set + outside room → ERROR; empty set → skipped; in-set → clean.
  • Unit specs (timetables.queries.spec.ts): snapshot resolves per-lesson window, subject room set, homeroom calendar span, block maxSelections.
  • Unit specs (timetables.service.spec.ts): PUBLISHED edit that introduces an ERROR → rollback + TIMETABLE_EDIT_WOULD_VIOLATE; same edit on DRAFT → applied; publish on non-ACTIVE AY → TIMETABLE_PUBLISH_AY_NOT_ACTIVE; any mutation on ARCHIVED → TIMETABLE_READ_ONLY.
  • E2E specs (timetables-diagnostics.e2e-spec.ts / timetables.e2e-spec.ts):
  • Window tiling end-to-end (two half-year subjects share a slot → publishable; drop one → HOMEROOM_GAP ERROR + publish 422).
  • maxSelections=1 unsynced block + maxSelections>1 overlapping block → respective ERRORs.
  • Room-set violation → ERROR; capacity-only subject unaffected.
  • Live PUBLISHED edit that creates a teacher conflict → 422 rollback, timetable verified unchanged.
  • Publish on a DRAFT-status AY → 422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE.
  • Follow [[feedback_e2e_isolation_patterns]].

Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-11
  • Chat reference: brainstorm 2026-06-11 — build-readiness review of the signed-off iter-1 spec surfaced four domain gaps + a lifecycle gap; each resolved in chat (teaching windows → derived + calendar-interval coverage; option blocks → sync@1/disjoint@>1; live-edit → validate-or-rollback on PUBLISHED; subject room set → hard ERROR; lifecycle → publish-on-ACTIVE + ARCHIVED-on-AY-archival). Approved by Fabio ("y") in chat.

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