Skip to content

Timetable — Manual Management & Diagnostics (V1)

Builds the timetable as a first-class, hand-managed entity: the Timetable + ScheduledLesson data model, lifecycle (DRAFTPUBLISHED), lesson CRUD, the six read-views, and a diagnostics engine that validates a draft against the full hard-constraint set. The Python constraint solver and automatic generation from 2026-05-26-timetable-generation-design.md are out of scope — when generation lands it becomes another producer of the same ScheduledLesson rows and reuses this engine. Diagnostics are advisory during editing and authoritative at publish.


1. Problem distillation

  • A K-12 admin needs to build and manage a weekly timetable by hand for an academic year: place each SubjectGroup into the PERIOD slots of its grade's effective WeekTemplate, assign a room, and freely move / add / remove lessons.
  • A timetable is a persistent first-class entity with a lifecycle (DRAFTPUBLISHED). Multiple drafts may coexist per AY; at most one may be PUBLISHED per AY at any time.
  • Edits never validate. The admin can create / move / delete lessons into any well-formed slot, including states that violate hard constraints. This keeps drag-around editing ergonomic (intermediate conflicts are allowed).
  • A diagnostics engine computes the full hard-constraint set as a structured report on demand. Findings are two-tiered: ERROR (a physically-impossible or incomplete timetable) and WARNING (schedule hygiene / policy). Diagnostics are surfaced as i18n codes + params — the BE never renders prose; the FE localizes.
  • Publish is the gate. DRAFT → PUBLISHED is rejected while any ERROR diagnostic stands. WARNINGs never block. A PUBLISHED timetable is therefore guaranteed valid + complete against the error-tier constraints.

Success criteria (observable behavior that proves this works): - Admin creates an empty DRAFT timetable for an AY, then adds ScheduledLesson rows one at a time; no edit is ever rejected on a constraint (only on the structural floor: cross-tenant FK, coordinate uniqueness, slot-must-be-a-real-PERIOD-slot). - GET /timetables/:id/diagnostics returns { canPublish, violations[] } where each violation is { category, severity, params }category is the i18n key, params carries IDs + display names + interpolation values, no rendered string. - Every lesson mutation (create/move/relocate/delete) returns the whole-timetable diagnostics inline (ScheduledLessonMutationResultDto), so the drag-and-drop FE gets authoritative red-cell feedback after each action with no follow-up call. - A draft with a teacher double-booked, a class gap, an over-capacity room, or a mismatched hour budget reports the corresponding ERROR and canPublish = false. A draft whose only issues are >4 periods/day, a non-contiguous subject, or a split-room double reports those as WARNING and canPublish = true. - PUT /timetables/:id/status to PUBLISHED succeeds only when canPublish = true; otherwise 422 TIMETABLE_PUBLISH_BLOCKED with the error-tier violations. Demotion to DRAFT is always allowed. - The six views (grade / homeroom / subject-group / room / teacher / student) render the lessons without further computation.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No automatic generation / constraint solver — the entire 2026-05-26-timetable-generation Python-service pipeline (snapshot construction, CP-SAT, unsat-core, infeasibility rendering) is a separate future iteration. - No soft-constraint quality scoring — the gen spec's soft objective (S1–S10: teacher day-span, doubles preference, room preferences, even distribution, teacher presence, …) is the solver's objective function, not a validity concern. Surfacing it as diagnostics would contradict a clean-to-publish gate. Excluded. - No teacher/student-facing public read pipelines — deriving published-timetable views with their own RBAC for non-admins is a separate iteration. The six views here are admin-side. - No per-lesson teacher override — teacher(s) are read through SubjectGroupTeacher; a substitute on a single lesson is deferred. - No async generation, partial-locks, multi-candidate — all solver-engine concerns, n/a here.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/command-center/completeness.* The live-computed diagnostics shape: a pure queries.ts summarizer over a loaded snapshot, surfaced read-only, no stored result rows. Our computeDiagnostics mirrors completeness exactly. Completeness counts entity readiness; we evaluate temporal placement constraints. Different domain, same architecture.
src/timetable-templates/timetable-templates.queries.ts The input model + reusable pure helpers: computeSlotStartEndTimes, parseHHmmToMinutes, resolveEffectiveAssignment, loadAssignmentTreeForAcademicYear, DAYS_OF_WEEK_ORDERED. Wall-clock + effective-template resolution for every lesson comes from here. Templates are tenant-flat config; timetables are AY-scoped runtime artifacts with a status lifecycle.
docs/superpowers/specs/2026-05-26-timetable-generation-design.md §4–§5, §1.5.5 The Timetable + ScheduledLesson schema, the AY-scoped + single-PUBLISHED lifecycle, the six-view endpoint, the lesson-CRUD endpoints, and the constraint label vocabulary (H1/H2/H4/H5/H6/H7/H8/H9/H12/H13 → our category codes). The gen spec's creation path is a solver call; ours is manual. The gen spec deferred ALL edit validation; we add the diagnostics layer. The gen spec pre-rendered violation prose; we emit codes + params.
docs/superpowers/specs/2026-05-25-timetable-templates-design.md (AlertDto { code, params }) The "alerts are i18n keys + payload, FE renders the copy" convention — our Violation is the same shape generalized with a severity tier. Also the "never block writes, surface on read" stance. Templates' lunch alert is a single code; we have a 13-code catalogue split across two severities.
src/subject-groups/subject-groups.queries.ts + src/homerooms/ Roster derivation: homeroom-bound mandatory groups draw their roster from HomeroomAssignment; option-block / standalone groups from SubjectGroupAssignment. Drives resolveStudentCount and STUDENT_CONFLICT. These modules own the roster; we only read it.
src/academic-years/ AY-scoped lifecycle + single-active partial-unique index pattern (status='ACTIVE'); our (tenantId, academicYearId) WHERE status='PUBLISHED' is the same raw-SQL stanza. AY has 3 states; timetable V1 has 2.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes tenantId denormalized onto both Timetable and ScheduledLesson; every query filters by it via standard middleware. Standard multi-tenant invariant.
Academic-year scope yes Timetable.academicYearId FK; ScheduledLesson inherits AY via its timetable (no own column). List filters by AY; writes target the timetable's own AY. Generation/management is per-AY.
RBAC entity key new — TIMETABLES + TIMETABLES: 'timetables' in src/common/constants/entity-keys.ts. Not added to CUSTOM_FIELD_ENTITY_KEYS (runtime artifact, no custom fields). Distinct domain concept; not a sub-resource of any existing entity. (Note: this defines the entity the gen spec also planned — neither is implemented yet; this spec is the first to land it.)
Scopes configuration Single descriptor-only field-group scope on TIMETABLES (matches DEPARTMENTS/ROOMS/SCHOOL/TIMETABLE_TEMPLATES). The route decorator takes an access level, not the scope name: reads use @RequireScopes(EntityKey.TIMETABLES, 'read'), non-action writes use @RequireScopes(EntityKey.TIMETABLES, 'write') (verified against subject-groups.controller.ts; 'configuration' is the seeded field-group, not a decorator arg). read/update are implicit per [[feedback_rbac_actions_convention]] / [[feedback_rbac_no_update_action]]. ADMIN-only in v1 — public / teacher / student views are deferred (see §9), so no non-admin grant ships; matches TIMETABLE_TEMPLATES (admin-only). No per-field visibility split needed.
Actions create, delete, publish Three actions, matching the sibling-module convention (subject_groups, evaluation_scales, timetable_templates all seed create/delete): POST/duplicate@RequireAction(EntityKey.TIMETABLES, 'create'); DELETE /:id@RequireAction(..., 'delete'); PUT /:id/status@RequireAction(..., 'publish'). Sub-resource lesson edits and rename are scope-gated (@RequireScopes(..., 'write')), like subject-groups add/remove-students. read/update stay implicit. No generate action (gen spec had one; n/a here). Explicit create/delete/publish keys for audit/seed clarity and codebase consistency.
Service base custom — not BaseTenantedCrudService TimetablesService: lesson CRUD is CRUD-shaped but the diagnostics engine, the publish gate, and the view-projection endpoint dominate. Mirrors CurriculumService / EvaluationScalesService / command-center precedent. Base service is single-entity per-scope field projection; we have a parent + child + a computed report + a tree-shaped view.
queries.ts shape yes — *Include consts + named functions + pure summarizers timetableDetailInclude, lessonForViewInclude; loadTimetableOrThrow, loadLessonsForView(view, targetId), buildDiagnosticsSnapshot(tenantId, timetableId); pure computeDiagnostics(snapshot) composed of per-category pure sub-functions (each unit-testable in isolation, computeLunchAlertsForTree precedent). Standard convention; the pure/loader split is what makes the engine testable.
Error codes new See §5. Generation-specific codes from the gen spec (*_SOLVER_*, *_PRECHECK_*) are not introduced. Per-code granularity is the convention.
DTO conventions standard CreateTimetableDto, UpdateTimetableDto, TimetableResponseDto, CreateScheduledLessonDto, UpdateScheduledLessonDto, ScheduledLessonResponseDto, ScheduledLessonMutationResultDto (lesson + recomputed diagnostics envelope), TimetableLessonsViewDto, TimetableDiagnosticsDto, TimetableViolationDto, UpdateTimetableStatusDto, TimetableViewQueryDto. No scope sub-DTOs (single scope). Standard.
File-backed sub-resources n/a — no files attached to timetables.
Custom fields n/a — runtime artifact, not admin-extensible.
Profile completeness n/a — not a person entity.

4. Data model plan

Schema deltas

enum TimetableStatus {
  DRAFT
  PUBLISHED

  @@map("timetable_status")
}

/// A hand-managed (future: generated) weekly timetable for one AY. Multiple
/// DRAFTs may coexist; at most one PUBLISHED per (tenant, AY) — enforced by a
/// partial-unique index (raw SQL, Prisma can't express partial unique).
model Timetable {
  id             String          @id @default(uuid()) @db.Uuid
  tenantId       String          @map("tenant_id") @db.Uuid
  tenant         Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  academicYearId String          @map("academic_year_id") @db.Uuid
  academicYear   AcademicYear    @relation(fields: [academicYearId], references: [id], onDelete: Cascade)
  name           String          @db.VarChar(150) /// admin-provided; FE default "Draft <timestamp>"
  status         TimetableStatus @default(DRAFT)
  createdAt      DateTime        @default(now()) @map("created_at")
  updatedAt      DateTime        @updatedAt @map("updated_at")

  lessons ScheduledLesson[]

  @@index([tenantId, academicYearId])
  @@map("timetables")
}

/// One placed lesson: a SubjectGroup occupying a (weekday, periodOrdinalPosition)
/// slot of its grade's effective WeekTemplate, in a room. The slot is addressed
/// by COORDINATE, not by TimeSlot FK — stable across slot reordering, resolved at
/// read time via subjectGroup → grade → effectiveWeekTemplate → day[weekday] → slot.
model ScheduledLesson {
  id                    String       @id @default(uuid()) @db.Uuid
  tenantId              String       @map("tenant_id") @db.Uuid
  tenant                Tenant       @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  timetableId           String       @map("timetable_id") @db.Uuid
  timetable             Timetable    @relation(fields: [timetableId], references: [id], onDelete: Cascade)
  subjectGroupId        String       @map("subject_group_id") @db.Uuid
  subjectGroup          SubjectGroup @relation(fields: [subjectGroupId], references: [id], onDelete: Cascade)
  weekday               DayOfWeek    @map("weekday")
  periodOrdinalPosition Int          @map("period_ordinal_position") /// the TimeSlot.ordinalPosition of a PERIOD slot in the group's effective day template
  roomId                String       @map("room_id") @db.Uuid
  room                  Room         @relation(fields: [roomId], references: [id], onDelete: Restrict)
  createdAt             DateTime     @default(now()) @map("created_at")
  updatedAt             DateTime     @updatedAt @map("updated_at")

  @@unique([timetableId, subjectGroupId, weekday, periodOrdinalPosition])
  @@index([tenantId, timetableId])
  @@index([tenantId, subjectGroupId])
  @@index([tenantId, roomId, weekday, periodOrdinalPosition])
  @@map("scheduled_lessons")
}

Side edits (back-relations only): Tenant + timetables Timetable[] + scheduledLessons ScheduledLesson[], AcademicYear + timetables Timetable[], SubjectGroup + scheduledLessons ScheduledLesson[], Room + scheduledLessons ScheduledLesson[].

Notable model decisions: - Drop the gen spec's generatedAt + solveWallClockMs — solver artifacts, meaningless for manual management. - roomId required (onDelete: Restrict). Every lesson has exactly one room (gen spec H14). A "place now, room later" workflow is deferred; if it lands, make roomId nullable + add a LESSON_NO_ROOM error. Restrict prevents deleting a room still referenced by a published/draft lesson. - No teacherId on the lesson — teacher(s) read through SubjectGroupTeacher; a post-edit teacher reassignment flows through automatically. - No optionBlockId on the lesson — option-block membership derived: lessons whose subjectGroup links to the same OptionBlock sharing a slot. - Coordinate, not timeSlotId — same rationale as gen spec §4 (stable across slot reordering; matches the resolution the views already perform).

Migration shape

  • Additive. Two new tables, one new enum. No existing column changes, no backfill.
  • Hazards (chapter 12): Cascade on ScheduledLesson.timetableId + subjectGroupId is intentional (a placement is meaningless without its timetable/group; avoids dangling FKs). Restrict on roomId — a room hosting lessons can't be deleted out from under them. Partial-unique (tenantId, academicYearId) WHERE status='PUBLISHED' needs a raw SQL stanza appended to the generated migration (mirror AcademicYear.status='ACTIVE').

Indexes and uniqueness

  • Timetable: btree (tenantId, academicYearId); partial unique (tenantId, academicYearId) WHERE status='PUBLISHED'.
  • ScheduledLesson: unique (timetableId, subjectGroupId, weekday, periodOrdinalPosition); btree (tenantId, timetableId), (tenantId, subjectGroupId), (tenantId, roomId, weekday, periodOrdinalPosition).

Diagnostics engine (canonical check catalogue)

The engine is buildDiagnosticsSnapshot(tenantId, timetableId) (DB load + resolution) → computeDiagnostics(snapshot) (pure, no DB). The publish gate and GET …/diagnostics both call the pair.

Resolution primitives (snapshot construction): - Wall-clock per lesson. Resolve subjectGroup → grade → effectiveWeekTemplate (via resolveEffectiveAssignment over the grade/department assignment rows for the timetable's AY) → day[weekday].dayTemplatecomputeSlotStartEndTimes(startTime, slots) → index by periodOrdinalPosition. Yields { wallStart, wallEnd } and the slot's PERIOD-rank (index among PERIOD-type slots of that day, used by contiguity / doubles). - Overlap(l₁, l₂)l₁.weekday = l₂.weekday AND [wallStart₁, wallEnd₁) ∩ [wallStart₂, wallEnd₂) ≠ ∅. Cross-template aware (a teacher spanning grades on different templates is compared by wall-clock). - resolveStudentCount(group) / roster: homeroom-bound mandatory group → HomeroomAssignment count/roster of its homeroom; option-block child or standalone group → SubjectGroupAssignment count/roster. - weeklyHours(group) = CurriculumSubjectHours cell keyed (curriculumSubject, grade); null ⇒ unconfigured ⇒ hour-budget check skipped for that group. - Option-block occupancy: at homeroom grain, all sibling lessons of one OptionBlock at the same (weekday, periodOrdinalPosition) count as one occupancy.

Violation = { category: string, severity: 'ERROR' | 'WARNING', params: object }. category is the i18n key; params carries IDs and display names plus interpolation values. No rendered string.

ERROR tier (blocks publish):

category Gen-spec Predicate params
TEACHER_CONFLICT H5 A teacher (any SubjectGroupTeacher row, incl. co-teachers) in two overlapping lessons { teacher:{id,displayName}, weekday, slots:[{periodOrdinalPosition,wallStart,wallEnd}], lessons:[{id,subjectGroup:{id,displayName}}] }
ROOM_CONFLICT H8 One room hosts two overlapping lessons { room:{id,displayName}, weekday, slots:[…], lessons:[…] }
CLASS_CONFLICT H4 (≤1) A homeroom occupied >1× at a slot (option-block-aware) { homeroom:{id,displayName}, weekday, periodOrdinalPosition, lessons:[{id,subjectGroup:{id,displayName}}] }
STUDENT_CONFLICT new A student's resolved roster puts them in two overlapping lessons; suppressed when both lessons are already a CLASS_CONFLICT of that student's homeroom (no double-report). Catches standalone/optional groups { student:{id,displayName}, weekday, slots:[…], lessons:[…] }
OPTION_BLOCK_SYNC H12 A block's children are not all placed on the same slot set { optionBlock:{id,displayName}, childPlacements:[{subjectGroup:{id,displayName}, slots:[{weekday,periodOrdinalPosition}]}] }
ROOM_CAPACITY H9 resolveStudentCount(group) > room.maximumCapacity for a placement { subjectGroup:{id,displayName}, room:{id,displayName}, studentCount, capacity }
TEACHER_AVAILABILITY H7 A lesson placed on a weekday ∉ teacher.daysOn; skipped when daysOn empty (unconfigured) { teacher:{id,displayName}, weekday, lesson:{id,subjectGroup:{id,displayName}} }
HOUR_BUDGET H1 Placed count ≠ weeklyHours (skipped when null) { subjectGroup:{id,displayName}, required, placed, delta } (delta = placed − required; sign = over/under)
HOMEROOM_GAP H4 (≥1) A PERIOD slot of the homeroom's effective template is unfilled { homeroom:{id,displayName}, weekday, periodOrdinalPosition, wallStart, wallEnd }
NO_EFFECTIVE_TEMPLATE new A homeroom/grade with lessons (or expected coverage) has no resolved effective week template { homeroom:{id,displayName}, grade:{id,displayName} }

WARNING tier (advisory, never blocks):

category Gen-spec Predicate params
TEACHER_DAILY_CAP H6 A teacher teaches >4 periods on a weekday { teacher:{id,displayName}, weekday, periodCount, cap: 4 }
SAME_DAY_CONTIGUITY H2 A group's placed PERIOD-ranks on a day are not consecutive (gap = another lesson or empty slot between its periods; intervals/lunch transparent) { subjectGroup:{id,displayName}, weekday, placedPositions:[periodOrdinalPosition] }
SAME_ROOM_DOUBLE H13 A group's back-to-back (consecutive PERIOD-rank) periods on a day are in different rooms { subjectGroup:{id,displayName}, weekday, placements:[{periodOrdinalPosition, room:{id,displayName}}] }

Scoping notes: CLASS_CONFLICT / HOMEROOM_GAP are homeroom-anchored — they apply to homeroom-bound mandatory groups + their option blocks (the mandatory curriculum that fills a class's week). Standalone groups (homeroomId = null) still participate in TEACHER_CONFLICT / ROOM_CONFLICT / STUDENT_CONFLICT / ROOM_CAPACITY but not in class coverage. canPublish = (no ERROR-tier violations).


5. API surface

Endpoints

# Verb Path Decorators Request DTO Response
1 POST /timetables @RequireAction(TIMETABLES, 'create') CreateTimetableDto 201 TimetableResponseDto (empty DRAFT)
2 GET /timetables @RequireScopes(TIMETABLES, 'read') AcademicYearQueryDto 200 TimetableListResponseDto — no pagination V1
3 GET /timetables/:id @RequireScopes(TIMETABLES, 'read') 200 TimetableResponseDto (metadata; lessons via #6)
4 PATCH /timetables/:id @RequireScopes(TIMETABLES, 'write') UpdateTimetableDto (name) 200 TimetableResponseDto
5 POST /timetables/:id/duplicate @RequireAction(TIMETABLES, 'create') 201 TimetableResponseDto (clone → new DRAFT, copies all lessons)
6 GET /timetables/:id/lessons @RequireScopes(TIMETABLES, 'read') TimetableViewQueryDto (view, targetId) 200 TimetableLessonsViewDto — uniform across the six views
7 POST /timetables/:id/lessons @RequireScopes(TIMETABLES, 'write') CreateScheduledLessonDto 201 ScheduledLessonMutationResultDto
8 PATCH /timetables/:id/lessons/:lessonId @RequireScopes(TIMETABLES, 'write') UpdateScheduledLessonDto 200 ScheduledLessonMutationResultDto
9 DELETE /timetables/:id/lessons/:lessonId @RequireScopes(TIMETABLES, 'write') 200 ScheduledLessonMutationResultDto (no lesson)
10 GET /timetables/:id/diagnostics @RequireScopes(TIMETABLES, 'read') 200 TimetableDiagnosticsDto
11 PUT /timetables/:id/status @RequireAction(TIMETABLES, 'publish') UpdateTimetableStatusDto 200 TimetableResponseDto or 409 TIMETABLE_PUBLISH_CONFLICT or 422 TIMETABLE_PUBLISH_BLOCKED
12 DELETE /timetables/:id @RequireAction(TIMETABLES, 'delete') 204 (cascades to lessons)

Write discipline on lesson CRUD (#7/#8) — the structural floor vs. soft diagnostics line: - Rejected at write (structural / well-formedness, NOT constraint validation): cross-tenant FK on subjectGroupId/roomId; coordinate uniqueness (P2002 → 409 SCHEDULED_LESSON_COORDINATE_CONFLICT); the (weekday, periodOrdinalPosition) must resolve to a real PERIOD-type slot of the group's effective template (422 SCHEDULED_LESSON_INVALID_SLOT) — malformed input, not a soft constraint. - Never rejected: every constraint in the §4 diagnostics catalogue. Conflicts, coverage gaps, capacity overflow, etc. are surfaced on read and enforced only at publish.

Mutation responses carry recomputed diagnostics (#7/#8/#9) — always-on. Every lesson mutation returns ScheduledLessonMutationResultDto = { lesson?, diagnostics }: the affected lesson (omitted on DELETE) plus the diagnostics report recomputed over the whole timetable. Diagnostics are global (a single move can clear or create a violation anywhere), so there is no correct local delta — bundling the recompute with the mutation removes a round-trip and the mutate→GET race for the drag-and-drop UX. DELETE therefore returns 200 with the envelope, not 204. GET …/diagnostics (#10) stays for initial load, and the publish gate (#11) reuses the same buildDiagnosticsSnapshot + computeDiagnostics pair — one computation, three consumers. (Escape hatch if per-edit recompute ever bites: an opt-in ?include=diagnostics flag — explicitly deferred; V1 is always-on.)

Publish semantics (#11): - → PUBLISHED: run diagnostics; if any ERROR422 TIMETABLE_PUBLISH_BLOCKED with { violations: <ERROR-tier only> }. Else if another timetable in the AY is PUBLISHED409 TIMETABLE_PUBLISH_CONFLICT (admin demotes it first; no auto-demote, gen spec convention). Else promote. The partial-unique index is the ground-truth backstop. - → DRAFT (demote): always allowed, no gate.

Duplicate (#5): clones the timetable as a fresh DRAFT (name "<original> (copy)") and copies every ScheduledLesson coordinate+room. High-value manual workflow (clone last year / branch a draft). Status resets to DRAFT.

Key DTO shapes

  • CreateTimetableDto{ name?: string, academicYearId?: string } (defaults: FE timestamp name; tenant's active AY).
  • TimetableResponseDto{ id, name, academicYearId, status, lessonCount, createdAt, updatedAt }.
  • TimetableListResponseDto{ items: TimetableResponseDto[], total }.
  • ScheduledLessonResponseDto / ScheduledLessonView{ id, timetableId, subjectGroup:{id,displayName,subjectName}, weekday, periodOrdinalPosition, wallStart, wallEnd, weekTemplateId, room:{id,displayName}, teacher:{id,displayName}|null, homeroom:{id,displayName}|null, optionBlock:{id,displayName}|null, createdAt, updatedAt }.
  • CreateScheduledLessonDto{ subjectGroupId, weekday, periodOrdinalPosition, roomId }. subjectGroupId is the lesson's identity — not editable (changing the subject = delete + create).
  • UpdateScheduledLessonDto{ weekday?, periodOrdinalPosition?, roomId? } (≥1 present; move = weekday+period, relocate = roomId).
  • ScheduledLessonMutationResultDto{ lesson?: ScheduledLessonResponseDto, diagnostics: TimetableDiagnosticsDto }. lesson present on create/update, omitted on delete. Returned by all three lesson mutations (always-on; no opt-in flag in V1).
  • TimetableLessonsViewDto{ timetableId, view, targetId, target:{id,displayName}, slotGrids: SlotGrid[], lessons: ScheduledLessonView[] }. Single-template views (grade/homeroom/subject-group/student) carry one grid; cross-template (teacher/room) may carry several.
  • TimetableViewQueryDto{ view: 'grade'|'homeroom'|'subject-group'|'room'|'teacher'|'student', targetId: string }. View filters per gen spec §13.3.
  • TimetableDiagnosticsDto{ timetableId, canPublish: boolean, violations: TimetableViolationDto[] }.
  • TimetableViolationDto{ category: string, severity: 'ERROR'|'WARNING', params: Record<string, unknown> }.
  • UpdateTimetableStatusDto{ status: 'DRAFT'|'PUBLISHED' }.
  • UpdateTimetableDto{ name?: string }.

Error codes (new)

Code HTTP When
TIMETABLE_NOT_FOUND 404 :id doesn't resolve in tenant
TIMETABLE_PUBLISH_CONFLICT 409 Promote to PUBLISHED while another is PUBLISHED in the same AY
TIMETABLE_PUBLISH_BLOCKED 422 Promote to PUBLISHED with ≥1 ERROR diagnostic; body carries the error-tier violations
SCHEDULED_LESSON_NOT_FOUND 404 :lessonId doesn't resolve under the parent timetable
SCHEDULED_LESSON_COORDINATE_CONFLICT 409 Create/move would violate the coordinate uniqueness
SCHEDULED_LESSON_INVALID_SLOT 422 (weekday, periodOrdinalPosition) is not a PERIOD slot of the group's effective template

Swagger considerations

  • TimetableViolationDto.params documented as Record<string, unknown> with a per-category note; FE keys off category + severity and interpolates params. (Future: tighten to a discriminated union per category.)
  • PUT /timetables/:id/status is polymorphic by HTTP status: 200 TimetableResponseDto / 409 / 422 TimetableDiagnosticsDto-shaped error body.
  • GET /timetables/:id/lessons is a single endpoint discriminated by the view query enum; uniform TimetableLessonsViewDto response (FE renders via a select). Per gen spec §13.3.
  • Lesson mutations (#7/#8/#9) return ScheduledLessonMutationResultDto (lesson + recomputed whole-timetable diagnostics); DELETE returns 200 with that body (not 204). Document the envelope so Scalar doesn't infer a bare lesson response.
  • Error examples for all six codes wired into error-examples.ts per [[feedback_swagger_jsdoc_is_public]].

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) + { entity: 'timetables', scope: 'configuration' }
PermissionAction (rbac-catalogue.ts) + (timetables, 'create'), + (timetables, 'delete'), + (timetables, 'publish') + ACTION_REQUIREMENTS rows mapping each to ['timetables.configuration']
ScopeFieldMapping (rbac-catalogue.ts) none — configuration is descriptor-only
Role grants (roles.ts) ADMIN-only. No explicit edit needed — ADMIN is seeded with nativeScopes: 'ALL_WRITE' / actions: 'ALL', auto-granting (timetables, configuration) read+write + all three actions. No other role is granted anything on timetables in v1 (public/teacher/student views deferred).
*_SCOPES runtime constant + export const TIMETABLE_SCOPES = { configuration: [] as const } as const; in src/common/constants/scope-fields.ts + register timetables: new Set(Object.keys(TIMETABLE_SCOPES)) in the scope registry. Per [[feedback_rbac_drift_check]].

The drift-check spec (rbac-catalogue.drift.spec.ts) re-runs and verifies the new entries.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Gen spec: violations carry a BE-rendered message + freeform entities; a timetable-violation-renderer + template library renders prose. We emit { category, severity, params } only — no BE rendering. category is the i18n key; FE localizes. The FE i18ns all copy ([[reference]] templates-spec AlertDto {code,params} convention is the actual house style; the gen spec was the outlier). Deletes a whole BE subsystem. params shape is freeform-per-category in V1 (documented, not a typed union). FE must key off category.
Gen spec: V1 has zero validation on edits AND no publish gate (admin discretion trusted). We add a full diagnostics engine + a publish gate that blocks on ERROR-tier. Edits stay non-blocking; publish is the gate. This iteration's whole point is manual-management validity. Edits stay ergonomic; the gate guarantees a published timetable is valid+complete. A hand-built timetable can't be published until every ERROR clears (exact hours, full coverage, zero conflicts) — demanding but intended.
Gen spec H4 is one =1 constraint (no-gap AND no-double). We split it into HOMEROOM_GAP (coverage, ERROR) + CLASS_CONFLICT (double-book, ERROR) and add STUDENT_CONFLICT at roster grain for standalone groups. Manual diagnostics need to name which failure occurred; the solver only needs feasibility. Two checks + a roll-up dedup rule (suppress STUDENT_CONFLICT already covered by CLASS_CONFLICT).
Gen spec: H6 (daily cap), H2 (contiguity), H13 (same-room double) are HARD constraints. We demote them to WARNING (advisory, don't block publish). User decision (chat 2026-06-08): these are hygiene/policy, not impossibility; blocking publish on them traps the admin. A published timetable may have a 5-period teacher-day or a split double. Acceptable; surfaced as warnings.
Sync solver microservice (gen spec's headline divergence). n/a — removed. No external service; everything in-process. No generation in this iteration. None — this is a simplification.

8. Pushback log

Source says Conflicts with Proposed instead Status
Gen spec: "No hard-constraint validation runs on edits in V1 — admin discretion is trusted." This iteration's explicit goal of modeling K-12 timetable validity + manual management. Keep edits non-blocking, but add a read-time diagnostics engine + a publish gate. The two specs target different lifecycle phases (gen = produce a draft; this = manage + validate + publish). Resolved (chat 2026-06-08).
Gen spec defers a "publish gate (no validation enforced before publish)". We now have diagnostics, so a gate is cheap and valuable. Publish blocks on ERROR-tier diagnostics; warnings don't block. Resolved (chat 2026-06-08).
Gen spec keeps generatedAt + solveWallClockMs on Timetable. No solver here; both are meaningless for hand-built timetables. Drop both columns. Resolved (chat 2026-06-08).
US-38 (epics.md): "blocking-vs-non-blocking warnings". We need a concrete severity model. Two tiers: ERROR (blocks publish) / WARNING (advisory). This is the blocking-vs-non-blocking split, made concrete. Resolved (chat 2026-06-08).
Implicit: validations should reject bad edits. Rejecting on constraints makes drag-around editing painful and contradicts the codebase's "never block, alert on read" precedent. Reject only the structural floor (FK, coordinate uniqueness, slot-must-be-PERIOD); everything else is read-time diagnostics. Resolved (chat 2026-06-08).

9. Deferrals

  • Automatic generation / CP-SAT solver — the entire 2026-05-26-timetable-generation pipeline. Follow-up: that spec, when scheduled; it will reuse this engine + data model.
  • Soft-constraint quality diagnostics (S1–S10) — solver-objective preferences (teacher day-span, doubles preference, room preferences, even distribution, teacher presence, solo periods, designated-room preference). Not validity; would contradict the clean-to-publish gate. Follow-up: revisit if admins want quality hints, likely as a third INFO tier that never blocks.
  • Teacher/student-facing published-timetable views — own RBAC + rendering for non-admins. Follow-up: separate iteration (TUS/RUS timetable-view territory).
  • Per-lesson teacher override (substitute on a single lesson) — teacher is derived from SubjectGroupTeacher. Follow-up: revisit when substitution/absence management lands.
  • Place-now-room-later (nullable roomId + LESSON_NO_ROOM error) — V1 requires a room per lesson. Follow-up: revisit if the manual workflow proves it.
  • Pagination on GET /timetables — V1 returns all per AY (low cardinality). Follow-up: add when a tenant accumulates many drafts.
  • Typed per-category params union — V1 documents Record<string, unknown>. Follow-up: tighten in Swagger when shapes stabilize.

Most of the above will surface as project_*.md memory entries at sign-off.


10. Open questions

None — all resolved (chat 2026-06-08). Resolution trail: 1. Validation behavior → non-blocking diagnostics (edits never rejected on constraints; structural floor still rejected). 2. Check scope → full hard-constraint set (H1/H2/H4/H5+co-teachers/H6/H7/H8/H9/H12/H13 + STUDENT_CONFLICT); soft S-series and public views excluded. 3. Publish gate + severity → two tiers: ERROR blocks publish, WARNING advisory. TEACHER_DAILY_CAP / SAME_DAY_CONTIGUITY / SAME_ROOM_DOUBLE are the warnings. 4. Violation surface → i18n category + params, no BE-rendered prose. 5. Smaller defaults accepted: roomId required; six views kept; generatedAt/solveWallClockMs dropped; conflicts consider co-teachers; duplicate copies all lessons.


11. Verification plan

  • Unit specs:
  • timetables.service.spec.ts — empty-create; lesson CRUD (structural floor: cross-tenant FK rejection, coordinate-conflict 409, invalid-slot 422; constraint placements never rejected); duplicate copies all lessons into a new DRAFT; status transitions (publish blocked by ERROR 422; publish-conflict 409; demote always allowed; single-PUBLISHED invariant).
  • timetables.queries.spec.tsbuildDiagnosticsSnapshot resolution (effective-template per grade, PERIOD-rank mapping, roster resolution per group flavor, weeklyHours cell lookup) + view-projection functions for the six views.
  • timetable-diagnostics.spec.tsthe heart: one focused block per category over synthetic snapshots. Asserts each predicate fires/doesn't and the exact params shape. Critical cases: option-block siblings at one slot are not a CLASS_CONFLICT and count as one coverage unit; STUDENT_CONFLICT suppressed when subsumed by a CLASS_CONFLICT; cross-template TEACHER_CONFLICT by wall-clock; TEACHER_AVAILABILITY/HOUR_BUDGET skipped when unconfigured; contiguity transparency to intervals/lunch; canPublish true with only warnings.
  • rbac-catalogue.drift.spec.ts — verifies the timetables entity + configuration scope + create/delete/publish actions + TIMETABLE_SCOPES registry entry.

  • E2E specs:

  • timetables.e2e-spec.ts — golden path: create empty draft → add lessons covering a fixture grade's PERIOD slots → diagnostics clean → publish succeeds. Move/change-room a lesson with no rejection. Assert each lesson mutation response carries the recomputed whole-timetable diagnostics reflecting the edit (e.g. a move that clears a conflict shows it gone in the same response; DELETE returns 200 with diagnostics). Duplicate. List/show/views across the six views. Single-PUBLISHED + demote-then-publish flow.
  • timetables-diagnostics.e2e-spec.ts — build fixtures that trip each ERROR (teacher/room/class/student conflict, option-block desync, room over-capacity, teacher-unavailable-day, hour-budget under+over, homeroom gap, no-effective-template) and assert the structured violation + canPublish=false + publish 422. Build a warnings-only fixture (>4/day, non-contiguous, split double) and assert canPublish=true + publish succeeds.
  • Follow [[feedback_e2e_isolation_patterns]] — Date.now()-stamped names, deleteMany in afterAll (order: scheduled_lessons → timetables).

  • Manual verification: seed a fixture school; create a draft; build a homeroom's week; walk GET …/diagnostics as you introduce/clear each violation class; confirm publish flips from blocked to allowed when the last ERROR clears.

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


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-06-08
  • Chat reference: brainstorm 2026-06-08 — read both timetable specs → confirmed scope (manual-management half, solver excluded) → validation model (non-blocking diagnostics) → check scope (full hard-constraint set) → severity model (two tiers, publish blocks on ERROR) → violation surface (i18n codes + params) → edit contract (granular per-lesson CRUD, drag-and-drop) → always-on diagnostics envelope on every mutation. Approved by Fabio in chat.

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