Timetable — Manual Management & Diagnostics (V1)¶
Builds the timetable as a first-class, hand-managed entity: the
Timetable+ScheduledLessondata model, lifecycle (DRAFT→PUBLISHED), 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 from2026-05-26-timetable-generation-design.mdare out of scope — when generation lands it becomes another producer of the sameScheduledLessonrows 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
SubjectGroupinto the PERIOD slots of its grade's effectiveWeekTemplate, assign a room, and freely move / add / remove lessons. - A timetable is a persistent first-class entity with a lifecycle (
DRAFT→PUBLISHED). Multiple drafts may coexist per AY; at most one may bePUBLISHEDper 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) andWARNING(schedule hygiene / policy). Diagnostics are surfaced as i18n codes + params — the BE never renders prose; the FE localizes. - Publish is the gate.
DRAFT → PUBLISHEDis rejected while anyERRORdiagnostic stands.WARNINGs never block. APUBLISHEDtimetable 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):
CascadeonScheduledLesson.timetableId+subjectGroupIdis intentional (a placement is meaningless without its timetable/group; avoids dangling FKs).RestrictonroomId— 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 (mirrorAcademicYear.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].dayTemplate → computeSlotStartEndTimes(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 ERROR → 422 TIMETABLE_PUBLISH_BLOCKED with { violations: <ERROR-tier only> }. Else if another timetable in the AY is PUBLISHED → 409 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 }.subjectGroupIdis 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 }.lessonpresent 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.paramsdocumented asRecord<string, unknown>with a per-categorynote; FE keys offcategory+severityand interpolatesparams. (Future: tighten to a discriminated union per category.)PUT /timetables/:id/statusis polymorphic by HTTP status:200 TimetableResponseDto/409/422 TimetableDiagnosticsDto-shaped error body.GET /timetables/:id/lessonsis a single endpoint discriminated by theviewquery enum; uniformTimetableLessonsViewDtoresponse (FE renders via a select). Per gen spec §13.3.- Lesson mutations (#7/#8/#9) return
ScheduledLessonMutationResultDto(lesson + recomputed whole-timetable diagnostics);DELETEreturns200with that body (not204). Document the envelope so Scalar doesn't infer a bare lesson response. - Error examples for all six codes wired into
error-examples.tsper [[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-generationpipeline. 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
INFOtier 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_ROOMerror) — 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
paramsunion — V1 documentsRecord<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-conflict409, invalid-slot422; constraint placements never rejected); duplicate copies all lessons into a newDRAFT; status transitions (publish blocked by ERROR422; publish-conflict409; demote always allowed; single-PUBLISHED invariant).timetables.queries.spec.ts—buildDiagnosticsSnapshotresolution (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.ts— the heart: one focused block percategoryover synthetic snapshots. Asserts each predicate fires/doesn't and the exactparamsshape. Critical cases: option-block siblings at one slot are not aCLASS_CONFLICTand count as one coverage unit;STUDENT_CONFLICTsuppressed when subsumed by aCLASS_CONFLICT; cross-templateTEACHER_CONFLICTby wall-clock;TEACHER_AVAILABILITY/HOUR_BUDGETskipped when unconfigured; contiguity transparency to intervals/lunch;canPublishtrue with only warnings.-
rbac-catalogue.drift.spec.ts— verifies thetimetablesentity +configurationscope +create/delete/publishactions +TIMETABLE_SCOPESregistry 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;DELETEreturns200with 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+ publish422. Build a warnings-only fixture (>4/day, non-contiguous, split double) and assertcanPublish=true+ publish succeeds.-
Follow [[feedback_e2e_isolation_patterns]] —
Date.now()-stamped names,deleteManyinafterAll(order: scheduled_lessons → timetables). -
Manual verification: seed a fixture school; create a draft; build a homeroom's week; walk
GET …/diagnosticsas 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.