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. NoScheduledLessonschema 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.maxSelectionscan exceed 1. Iter-1'sOPTION_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 → PUBLISHEDtransition; 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 disjoint → no 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 ACTIVE → 422 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¶
TimetableStatusgainsARCHIVED(third value, afterDRAFT/PUBLISHED). Nothing else changes — noScheduledLessoncolumn, 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)→ oneCurriculumSubjectHourscell → one window;CurriculumSubject→ itsCurriculumSubjectRoomset.
Migration shape¶
- Folds into iter-1. Iter-1 is unimplemented, so there is no standalone iteration-2 migration — the
add_timetablesmigration iter-1 will create simply emitsTimetableStatuswith three values from the start. If iter-1 were already live, this would be an additiveALTER 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 ofDRAFTandARCHIVEDrows per(tenant, AY), rejecting a secondPUBLISHED.
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 } | null — null = 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
categoryvalues (OPTION_BLOCK_CLASH,ROOM_NOT_IN_SUBJECT_SET) and the redefinedHOMEROOM_GAP.params.uncoveredIntervalsdocumented under theTimetableViolationDto.paramsper-category note. effectiveWindowon 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=1 → warn 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 wiring —
ARCHIVEDvalue + 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
INFOtier). - Cascade-delete guards —
SubjectGroup/Homeroomdelete 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): windowsOverlaptruth table; disjoint-window lessons at the same slot produce noTEACHER_/ROOM_/STUDENT_/CLASS_CONFLICT; overlapping-window ones still do.HOMEROOM_GAPcalendar-interval: full tiling (Art [Sep–Jan] + Music [Feb–Jun]) → clean; partial (Art [Sep–Dec] only) → oneuncoveredIntervalsentry; empty slot → whole span uncovered.OPTION_BLOCK_SYNCfires only formaxSelections=1unsynced;OPTION_BLOCK_CLASHfires only formaxSelections>1overlapping 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, blockmaxSelections. - 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_GAPERROR + publish422). maxSelections=1unsynced block +maxSelections>1overlapping block → respective ERRORs.- Room-set violation → ERROR; capacity-only subject unaffected.
- Live PUBLISHED edit that creates a teacher conflict →
422rollback, 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.