Skip to content

Timetables — Manual Management, Diagnostics & Automatic Generation

Two producers of ScheduledLesson rows: (1) an admin placing lessons manually via the CRUD surface; (2) POST /timetables/generate calling out to a CP-SAT solver that produces a full DRAFT in one shot. Both producers feed the same diagnostics engine; the same publish gate applies to both. The manual flow is template-free (iteration-3): day/week templates are inputs to automatic generation only — the manual grid, the diagnostics engine, and the attendance consumer never touch them.

Specs: 2026-06-08-timetable-manual-management-design.md (base) + 2026-06-11-timetable-manual-management-iteration-2-design.md (window-aware engine + lifecycle) + 2026-06-12-timetable-generation-design.md (automatic generation) + 2026-07-07-subject-period-durations-design.md (per-subject durations, tick coordinates) + 2026-07-09-timetable-manual-management-iteration-3-design.md (template decouple, break slots + duties — supersedes every envelope-coupled statement about the manual flow in the older specs) + 2026-07-13-timetable-activities-design.md (named non-curriculum activity slots + attendance parity) + 2026-07-13-remove-day-bounds-design.md (removes the day-bounds concept + the OUTSIDE_DAY_BOUNDS diagnostic entirely — supersedes every day-bounds statement in the iteration-3 spec). Builds on chapter 14 (homerooms / subject groups); the timetable-templates/ module remains as a generation-only input catalog.


1. Mental model

Four models (prisma/schema.prisma):

  • Timetable — one per build attempt, scoped to (tenant, academicYear). Lifecycle TimetableStatus { DRAFT, PUBLISHED, ARCHIVED }. Many DRAFTs may coexist; at most one PUBLISHED per (tenant, AY) — enforced by a partial-unique index (timetables_tenant_ay_published_unique, raw SQL in the migration; see chapter 12).
  • ScheduledLesson — one placed lesson: a SubjectGroup starting at a coordinate (weekday, startTick) on the free day axis, in an optional Room. Schedule-first-room-later: roomId may be omitted at create (and cleared via PATCH { roomId: null }); a roomless lesson fires LESSON_ROOM_MISSING (ERROR) so publish forces every lesson to be roomed. "Repeats on" is a create-time fan-out: POST …/lessons takes weekdays: DayOfWeek[] and writes one row per weekday at the same startTick (all-or-nothing — any coordinate collision rejects the whole batch); nothing is stored as a set, so PATCH/DELETE still address a single row (contrast ScheduledActivity, which stores weekdays[] on one row).
  • ScheduledBreak (+ ScheduledBreakAudience, ScheduledBreakDuty) — a placed break/lunch slot (BreakKind { BREAK, LUNCH }) with a stored durationMinutes, an optional room (canteen), an audience (union of department / curriculum / grade / track / homeroom / student selector rows — resolved live to a student set at read time, never materialized), and duties (supervising teachers; real occupancy that consumes the teacher hour budget). POST …/breaks takes weekdays: DayOfWeek[] too, fanning out one break per weekday, each with its own deep-copied audience + duty set.
  • ScheduledActivity (+ ScheduledActivityAudience, ScheduledActivityTeacher) — a named non-curriculum slot (morning meet, "Friday Activities", assembly) with a stored durationMinutes and a weekdays[] repeat set (one uniform time across all its weekdays), an optional room, a live-resolved roster (kind-discriminated audience selector rows: EVERYONE | CURRICULUM | GRADE | TRACK | HOMEROOM | STUDENT, union, never materialized) and supervising teachers (real occupancy, optional, with weekday coverage). Unlike a break, an activity is an obligation for its roster (fills student occupancy) and behaves like a subject group for attendance (chapter 19). See the Activities section below.

Operational student eligibility (2026-08-28). Timetable authoring and live activity/break expansion use only Student.status = ENROLLED rows from the timetable's academic year. The status/year fence wraps the whole selector union, so PRE_ENROLLED, LEFT, and GRADUATED students cannot re-enter through a direct selector or stale-open curriculum/homeroom relation. Cohort matching retains the display tier: an effective placement wins, while an enrolled pre-year row with no governing placement keeps its planned head pair. This rule is timetable-local; student directories and historical surfaces stay status-neutral, while attendance and record authorization keep their stricter effective-placement-as-of-date rule.

Audience selectors collapse to their effective set at write time. The FE builds a break/activity audience through a cascading picker (department → curriculum → grade) and posts back the whole drilled path as the flat selector union. A naive union would resolve the broadest row (a department swallows every grade under it) — the opposite of what was drilled into. So on create/update the service collapses the union along the department → curriculum → grade cascade only (resolveEffectiveBreakAudience / resolveEffectiveActivityAudience in timetables.service.ts, keyed by the pure rule in audience-effective.ts): a department is dropped when a curriculum or grade under it (same departmentId) is also present. Curriculum and grade are peers (neither prunes the other), and a dep/cv/grade selector whose department can't be confirmed nested is kept (never dropped on a guess). Everything else is additiveHOMEROOM, TRACK, STUDENT and EVERYONE never prune a dep/cv/grade selector and are never pruned; they contribute their own students on top of the effective dep/cv/grade set (so a department + two homerooms resolves to all of the department's students plus the two classes, not just the classes). Only the effective rows are stored, so the raw read-side union resolvers (resolveBreakAudiences / resolveActivityAudiences) and every downstream reader (diagnostics, views, attendance) inherit the narrowed roster with no read-path change. The collapse can never empty a non-empty audience (it only drops a department that has a surviving cv/grade), so it never trips the empty-audience floor. This is a write-time workaround until the API shape carries granularity explicitly — deleting the two resolveEffective* calls reverts to the raw union; rows created before it shipped keep their union until re-saved.

The coordinate is a start tick: startTick counts 5-minute steps from midnight (96 = 08:00; DTO bounds 0..287) on one absolute axis shared by the FE's free grid, the diagnostics engine, and the solver. There are no slots and no envelopes in the manual flow — the FE renders a free 5-minute grid. A lesson's effective duration is slotDuration ?? resolvedDuration ?? the periodDuration cascade: an optional per-slot slotDuration override (nullable Int, any positive minutes — no multiple-of-5 or max constraint; the day axis is the only bound) resizes one placed lesson; resolvedDuration is the cascade value frozen when the version was published (§5.0 — system-written, never a request field, always null on a DRAFT); when both are null the duration derives from the cascade (cell → subject → grade → curriculum, whose root is NOT NULL) — that fallback is total, so every lesson always has a duration. The cascade is the default, one value per subject group; the override lets a unit mix lengths (e.g. a 6 h subject as 5×1 h + a single 2 h block). A curriculum edit re-resolves only the lessons that carry neither stored value — an overridden slot keeps the admin's number, a published slot keeps its freeze. The override is written only by manual create/PATCH — automatic generation always leaves it null (the solver emits uniform per-group durations). Break/lunch slots are different: their duration is stored on the row (a break has no curriculum to derive from). Geometry helpers live in src/common/utils/period-math.ts (TICK_MINUTES, weeklyHoursToMinutes, resolveEffectivePeriodDuration, canTileExactly); src/common/utils/day-envelope.ts survives as a generation-only consumer.

@@unique([timetableId, subjectGroupId, weekday, startTick]) prevents placing the same group twice at one start tick. Breaks deliberately have no coordinate unique — overlapping breaks in one room are legitimate (canteen lunch shifts). (Migrations: 20260708150000_start_tick_rename renamed the column from the slot-era period_ordinal_position; 20260709205707_manual_flow_template_decouple backfilled Curriculum.periodDuration to 60 + SET NOT NULL and added the three break tables + day_bounds (the latter dropped 2026-07-13 by 20260713151952_remove_day_bounds), all RLS-covered.)

A subject-group base room is a default, not a constraint. SubjectGroup.baseRoomId is exposed by the placement plan so the FE can preselect it for a new lesson. The lesson's own roomId remains authoritative: an admin may choose any other in-tenant room, and doing so neither creates a diagnostic nor blocks publish. Automatic generation likewise receives the full capacity + subject-room-set compatible room domain; it does not collapse that domain to the base room. Combined-class members may therefore carry different defaults; a combined plan row exposes a default only when its members' non-null defaults agree.


2. Structural floor vs soft diagnostics

The single most important split:

  • Structural floor — a small set of hard invariants enforced at write time; a violation rejects the mutation. These are: cross-tenant FK integrity (room — when provided, lessons room later — / subject group / audience refs / duty teachers must be in-tenant), coordinate uniqueness for lessons (409 SCHEDULED_LESSON_COORDINATE_CONFLICT), the day axis — the interval must not spill past midnight, tickToMinute(startTick) + durationMinutes ≤ 1440 (422 SCHEDULED_LESSON_INVALID_SLOT for lessons via assertLessonOnDayAxis, 422 SCHEDULED_BREAK_INVALID_SLOT for breaks) — break-audience well-formedness (≥1 selector row, 422 SCHEDULED_BREAK_AUDIENCE_EMPTY; exactly one ref per row, else VALIDATION_FAILED), and direct audience-student eligibility (ENROLLED in the timetable AY, else 422 SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE). The floor keeps the data coherent.
  • Soft diagnostics — everything pedagogical (conflicts, capacity, minute budgets, gaps, …). These never block a DRAFT edit. They are computed and returned, and they gate publish, but authoring stays frictionless.

This is why you can build a half-finished, conflict-ridden DRAFT freely, and only the publish gate forces resolution.


3. The diagnostics engine

A pure function computeDiagnostics(snapshot): Violation[] in src/timetables/timetables.diagnostics.ts. It takes a plain in-memory DiagnosticsSnapshot (no Prisma types leak in) built by buildDiagnosticsSnapshot(...) in timetables.queries.ts, which resolves per lesson: startMinute/endMinute/durationMinutes (tick × 5 + the periodDuration cascade — total, no null geometry anywhere) plus wallStart/wallEnd strings; alongside come the placed breaks (each with its live-resolved audience student set and duty teachers), the teacher pool (with budgetMinutes = (totalHoursPerWeek + extraHours) × 60, null when unset), rosters, teaching windows, subject-room sets, and option-block maxSelections. All overlap math runs on the numeric minute intervals.

The engine's internal Violation is { category, severity, params, variant? } — a machine key plus a denormalized params payload. That shape is renderer input and never reaches the wire: renderViolationCards(violations) (src/timetables/diagnostics-cards.ts) maps every violation onto a self-contained localized card at the service edge (the same input/wire split as ValidationErrorInput vs the slim validation leaves, docs/06 §2). canPublish(violations) is true iff no ERROR-tier violation exists (computed on the raw engine output).

Violation cards (the wire shape)

{
  "category": "TEACHER_CONFLICT", // stable machine key (grouping/filtering — not display copy)
  "severity": "ERROR", // ERROR | WARNING
  "title": { "en_US": "Teacher double-booked", "it_IT": "Docente sovrapposto" },
  "messages": {
    "en_US": "Anna Bianchi is scheduled in two places at once on Monday",
    "it_IT": "…",
  },
  "refs": { "teacherId": "…" }, // optional headline entity ids (navigate/highlight)
  "items": [
    // optional detail lines (one per clashing lesson/break/…)
    {
      "messages": { "en_US": "Math 9A — 08:00–09:00", "it_IT": "…" },
      "refs": { "lessonId": "…", "subjectGroupId": "…" },
    },
  ],
}

The FE renders title[lang] / messages[lang] verbatim — no param mapping. Copy lives in src/common/i18n/diagnostic-messages.catalog.ts (DIAGNOSTIC_MESSAGES, keys diagnostic.<CATEGORY>[.<variant>] + .title + diagnostic.item.* + a diagnostic.default fallback for a category the registry doesn't know). One CARD_RENDERERS row per category extracts text params (durations pre-formatted via formatDurationMinutes, weekday/break-kind enums resolved through the :value label modifier), the card refs, and the item lines. variant selects shape-specific sentences (TEACHER_CONFLICT.duty_lesson/.duty_duty, ROOM_CONFLICT.break_lesson, ROOM_CAPACITY.breaks, TEACHER_AVAILABILITY.duty, STUDENT_CONFLICT.break_lesson; the solver stamps generation on every infeasibility core). diagnostics-cards.drift.spec.ts keeps engines ↔ registry ↔ catalog in exact-set lockstep (it also scans solver/app/model.py) and enforces the DIAGNOSTIC_TEXT_PARAMS bijection.

Cards also ride inside three error payloads via COLLECTION_ENRICHMENTS passthrough rows (docs/06 §2): TIMETABLE_GENERATION_PRECHECK_FAILED (sibling skippedSubjectGroups), TIMETABLE_GENERATION_INFEASIBLE and TIMETABLE_GENERATION_TIMEOUT (siblings skippedSubjectGroups + solveMetadata; on TIMEOUT violations is always [], so the base message stands) — leaves ship as-is and the envelope composes from the card messages. TIMETABLE_PUBLISH_BLOCKED deliberately stays count-only (publish is invoked from the diagnostics screen, which already shows the cards). (The former TIMETABLE_EDIT_WOULD_VIOLATE passthrough was removed with the live-edit path — published content is now revision-gated, §4/§5.1.)

Breaks and duties in the conflict checks. A break blocks its room for lessons (break × lesson in one room = ROOM_CONFLICT) but break × break in one room is fine — ROOM_CAPACITY instead checks each transitively-overlapping break cluster per (room, weekday) against the room capacity using the union of the participants (a student in two shifts counts once). A student in a lesson during a break they participate in is a STUDENT_CONFLICT. A duty is real teacher occupancy: duty × lesson and duty × duty overlaps are TEACHER_CONFLICT, a duty on a day off is TEACHER_AVAILABILITY, and duty minutes count toward TEACHER_BUDGET_EXCEEDED.

Activities enter the existing categories as a third occupancy family (see the Activities section for the full semantics): STUDENT_CONFLICT (activity×lesson/×activity/×break variants — an activity is an obligation), TEACHER_CONFLICT + TEACHER_AVAILABILITY (supervisors are real occupancy), ROOM_CONFLICT (activity×lesson in one room; activity×activity/×break co-locate), ROOM_CAPACITY (per-activity + union of a co-located non-lesson cluster), STUDENT_GAP (activities fill occupancy and, whole-roster, bridge), SAME_DAY_CONTIGUITY (whole-roster activity bridges), TEACHER_BUDGET_EXCEEDED (+activityMinutes bucket). One new WARNING ACTIVITY_SCOPE_EMPTY (an activity whose live-resolved audience matches no students). Activities are invisible to HOUR_BUDGET, option-block, subject-room-set, base-room, LESSON_ROOM_MISSING, and TEACHER_DAILY_CAP.

Catalogue — 9 ERROR + 6 WARNING, window-aware, minutes-based

Category Tier Fires when
TEACHER_CONFLICT ERROR one teacher in two interval-overlapping occupancies (lesson×lesson, duty×lesson, duty×duty)
ROOM_CONFLICT ERROR one room double-booked (lesson×lesson or break×lesson; break×break allowed)
STUDENT_CONFLICT ERROR a student double-booked (lesson×lesson on the student's own roster, or lesson × a break they participate in)
OPTION_BLOCK_SYNC ERROR a maxSelections = 1 block's children don't share the identical (weekday, startTick) set (pick-one ⇒ alternatives co-START; same weekly lesson count falls out). Durations and windows may diverge — a shorter alternative simply ends earlier (relaxed 2026-07-23, spec 2026-07-23-option-block-sync-start-only-design.md; durations stay in the card payload for display). Each child's occupancy is expanded (own solo lessons ∪ its active combination's lessons) — a correct pair-tiled combined layout passes with no exemption
OPTION_BLOCK_CLASH ERROR a maxSelections > 1 block has two different children overlapping in time (a co-pick would clash). Occupancies are expanded the same way; two children covered by ONE shared combined meeting (same lesson row) are not a clash
ROOM_CAPACITY ERROR lesson roster exceeds Room.maximumCapacity; for breaks, the union roster of an overlapping cluster in one room exceeds it (one violation per cluster)
ROOM_NOT_IN_SUBJECT_SET ERROR a subject with a non-empty CurriculumSubjectRoom set is placed outside it (gym/lab/music)
LESSON_ROOM_MISSING ERROR a lesson has no room yet (schedule-first-room-later); one violation per roomless lesson, so publish forces full rooming. Every other room-keyed check (ROOM_CONFLICT lesson×lesson, ROOM_CAPACITY, ROOM_NOT_IN_SUBJECT_SET, SAME_ROOM_DOUBLE) skips roomless lessons and defers here
TEACHER_AVAILABILITY ERROR a lesson or duty on a weekday outside the teacher's daysOn
HOUR_BUDGET WARNING Σ placed durationMinutes ≠ the teaching unit's weekly budget. Advisory both directions (under demoted 2026-07-16, over followed 2026-07-23): never blocks publish — non-60′ periods make both directions systematic (a "2h" cell scheduled as 2×55′ lands short; as 2×65′ it lands over); the sign of deltaMinutes tells which way. Params expectedMinutes/placedMinutes/deltaMinutes. Per-unit budgets: plain SG → cell minutes; member of an active combination → cell − shared (solo remainder); the combined unit itself → sharedMinutes. Units with no lessons in the timetable are out of scope (an empty draft publishes). The solver still enforces the budget as hard-exact, so generated drafts never carry it
STUDENT_GAP WARNING a student's same-day lessons are separated by free time not fully tiled by break slots the student participates in (demoted from ERROR in iteration-3 — without envelope bands the admin owns the day's shape; overlaps stay STUDENT_CONFLICT's job)
TEACHER_BUDGET_EXCEEDED WARNING teaching + duty minutes exceed the teacher's weekly budget weeklyHoursToMinutes(totalHoursPerWeek + extraHours) (both whole-minute Floats since 2026-08-21, e.g. 18.5 = 1110 min); skipped when totalHoursPerWeek is unset; params carry the {teachingMinutes, dutyMinutes, totalMinutes, budgetMinutes} split so the FE can show what eats the budget
TEACHER_DAILY_CAP WARNING a teacher exceeds 4 lessons on one day (lesson-count on purpose — duty load is visible via the budget split instead)
SAME_DAY_CONTIGUITY WARNING a group's same-day placements aren't back-to-back; a break covering the group's entire roster bridges the pair, a partial-audience break does not
SAME_ROOM_DOUBLE WARNING an adjacent "double" of one group switches room mid-block

(Deleted in iteration-3: DAY_OVERFLOW and NO_EFFECTIVE_TEMPLATE — both were template-era checks. OUTSIDE_DAY_BOUNDS — which had replaced the day-shape signal — was itself removed 2026-07-13 with the day-bounds concept. The combined-class categories are listed in their own section below.)

Teaching windows (iteration-2)

A subject not taught all year (CurriculumSubjectHours.taughtAllYear = false, with teachingStartDate/teachingEndDate, PYP units of inquiry) carries a derived window per lesson — never stored. Two lessons with disjoint windows do not conflict even over the same interval (windowsOverlap short-circuits the overlap checks), so two half-year subjects legitimately tile one stretch; a null window is active all year. (The iteration-2 window-segment machinery fell out with STUDENT_GAP's demotion — the gap check is now a plain per-(student, weekday) pass where interval-overlapping pairs are skipped, which makes disjoint-window tiling neither a gap nor a conflict.)


4. Always-on envelope + live-edit validation

Lesson/break create fans out over weekdays[], so it returns a plural batchScheduledLessonBatchResultDto = { lessons[], diagnostics } / ScheduledBreakBatchResultDto = { breaks[], diagnostics } (one entry per requested weekday). PATCH/DELETE …/lessons|breaks keep the singular ScheduledLessonMutationResultDto = { lesson?, diagnostics } / ScheduledBreakMutationResultDto = { break?, diagnostics } — the affected row (omitted on delete). Either way the payload carries the whole-timetable diagnostics recomputed after the edit, canPublish included, so the FE never issues a separate diagnostics request. The N inserts + the single recompute run inside one mutateAndDiagnose lock; content edits are DRAFT-only (branch below), so a batch either applies wholesale on a draft or is rejected wholesale.

mutateAndDiagnose (shared by lesson, break AND activity mutations) locks the timetable row FOR SHARE, re-reads the status in-tx (the authoritative, TOCTOU-safe gate against a concurrent publish, which takes FOR UPDATE), then:

  • DRAFT — apply the write, then recompute (non-blocking authoring).
  • PUBLISHEDreject with 409 TIMETABLE_EDIT_REQUIRES_REVISION. A published timetable's content is frozen: edits go through a revision (§5.1) so consumers never observe mid-edit state, and a multi-step reorg — which may pass through transiently-invalid states — is expressible (impossible under the old apply-and-rollback rule). ARCHIVED — reject with 409 TIMETABLE_READ_ONLY.

GET …/diagnostics runs the same computeDiagnostics for an on-demand report. Break PATCH semantics: scalars are sparse; audience and dutyTeacherIds, when present, are full-replace. duplicate copies the lessons and every break with its audience + duty rows.


5. Lifecycle & publish gate

PUT …/status toggles DRAFT ↔ PUBLISHED only (ARCHIVED is rejected at the DTO — see below) and is the first publish of a never-published draft. Publishing requires, in order:

  1. The timetable is not ARCHIVED (409 TIMETABLE_READ_ONLY).
  2. It is not a revision draft (409 TIMETABLE_NOT_A_REVISION) — a revision is promoted via publish-revision (§5.1), never by flipping its own status.
  3. The academic year is ACTIVE (422 TIMETABLE_PUBLISH_AY_NOT_ACTIVE) — draft prep on a non-active year is fine; only publish is gated.
  4. Zero ERROR violations (422 TIMETABLE_PUBLISH_BLOCKED).
  5. No other PUBLISHED timetable in the year (409 TIMETABLE_PUBLISH_CONFLICT — pre-checked, with the partial-unique index as backstop).

A successful publish stamps Timetable.publishedAt = now().

Publication notification. A real first transition to PUBLISHED emits timetable.published after commit; an idempotent PUBLISHED-to-PUBLISHED request emits nothing. Audience is resolved against the new version's effectiveFrom, not the request date: lesson and combined-class teachers/rosters, supervised activity teachers/audiences, and break/lunch duties/audiences all participate. Active linked teacher users and active referents of involved students are deduplicated by user; a dual-role user retains both teacher and linked-child context in one destination-neutral payload. The stable timetable/year IDs, revision, effective date, and recipient-relevant teacher/student/department IDs let FE choose a destination later without a backend route discriminator.

5.0 Valid time — which version governs which dates (2026-07-27)

Publication is a dated act. publishedAt/supersededAt say when we acted (transaction time); effectiveFrom/effectiveUntil (@db.Date, half-open [from, until)) say which dates a version governs (valid time). The two are not duplicates and neither pair may be collapsed into the other — the default effective date can be tomorrow (contract §21: it is, whenever today's register conflicts with the incoming content), so effectiveFrom != date(publishedAt) in general.

Spec: 2026-07-26-attendance-temporal-a-timetable-valid-time-design.md; program contract §3/§5/§6, dateless default §21.

Interval shape is tied to status by the raw CHECK timetables_effective_shape_chk — DRAFT NULL/NULL, PUBLISHED set/NULL (open), ARCHIVED set/set. A second CHECK, timetables_effective_order_chk, keeps from <= until; equality is legal and means the empty interval [X, X): retained evidence of a version superseded before it ever governed a day. It matches no date.

Resolution never consults status. findTimetableVersionEffectiveOn(db, tenantId, ayId, date) (exported from the src/timetables barrel) is the one canonical by-date lookup — DRAFTs self-exclude because NULL <= D is never true, and empty intervals self-exclude because until > D fails for every D >= from. It may return the stable published id or an archived copy's id: the geometry of date D is whichever row governs D.

The effective date of a publish, resolved once per command inside the transaction. "No date sent" includes effectiveFrom: null@IsOptional() skips validation for null too, so the service reads null as absent everywhere (resolvePublishEffectiveFrom and the unpublish no-date guards all test == null, never === undefined; a null that slipped into the explicit branch once reached Prisma as new Date("nullT00:00:00.000Z") — a 500):

Case Result
no date sent, no version ever effective in this AY today (school timezone) — the first-publication exception; no probe, no cell can exist
no date sent, frontier beyond today the frontier — no probe, no cell can exist on a future date
no date sent, otherwise today if no cell recorded today conflicts with the incoming content, else tomorrow — a fallback, never a refusal (contract §21)
a date earlier than the minimum 422 TIMETABLE_EFFECTIVE_DATE_INVALID, reason: 'BEFORE_MINIMUM'
today, and no recorded cell conflicts accepted
today, and a recorded cell conflicts 422 …, reason: 'CELLS_EXIST_TODAY'

"Conflicts" is anchor survival (findCellAnchorsIncompatibleWith, timetables.queries.ts), judged against the incoming content on today's weekday: lesson cells by stable (subjectGroupId | combinedClassId, startTick); activity cells structurally by snapshot (activityName, startTick), because activity rows are version content and their ids never survive a copy. Only today's cells matter — past days stay governed by the closed interval, and the write path refuses future dates. The register is the only thing a schedule swap can strand; when it strands nothing, the edit is live immediately — the dateless default and the explicit-today gate share this one predicate by §21's no-drift rule.

Dates are school-timezone calendar days, never the server's. Both today-arms probe under an exclusive AY-day advisory lock (lockAyDayExclusive, src/common/temporal/) which attendance's cell writes take on the shared side, keyed on the cells' own date — so publish-today and a register take serialize instead of both observing an empty day. Responses carry effectiveFrom and, on publish, the identical appliedFrom (the program-wide name for a resolved application date) — with a data-sensitive default the FE must render appliedFrom rather than assume either day.

Unpublish is retain-and-close, not a status flip: the outgoing version is retained as an ARCHIVED copy closing at max(effectiveFrom, tomorrow) — today stays governed, so a register taken this morning keeps the schedule it was taken against — and only the live row's valid time is cleared (publishedAt is transaction time and is never cleared). A version that never became effective collapses to [X, X). The demoted row also drops its resolvedDuration freeze (clearResolvedDurations, strictly after the archive copy has carried it away) — it is a DRAFT again, and a DRAFT derives live.

Delete is gated by governed history. A lineage that has governed at least one school day can no longer be deleted: 409 TEMPORAL_ROW_IMMUTABLE. Never-governed lineages — standalone drafts, pending versions whose only archives carry empty intervals — stay deletable. Unpublish is the alternative: the version stays on record and stops governing future days.

Durations freeze at publish, in their own column. ScheduledLesson carries two nullable duration columns and they are not interchangeable:

Column Written by Meaning of NULL
slotDuration the admin, via lesson create/PATCH no override — derive the length
resolvedDuration stampResolvedDurations only, on every transition into PUBLISHED not frozen — a DRAFT, or a lesson whose slotDuration already decides it

resolvedDuration is internal — it is not in any DTO and never reaches a client. The API exposes the effective durationMinutes and the authored slotDuration; together those answer "how long is it" and "did someone set that deliberately", which is the whole of what a client needs. Putting the freeze on the wire would publish a field whose meaning is "which publish this content came from" and invite a round-tripped PATCH to turn it into a real override.

Read order is slotDuration ?? resolvedDuration ?? cascade: authored beats frozen beats live. Live derivation ends at publish, so a curriculum edit can no longer re-cost a day that has already been governed; a revision draft drops the freeze (copyTimetableContent) and derives live again, so edits made since the last publish do land. The ARCHIVED retention copy is the one copy that carries the freeze — it must record what the version cost while it was live, not what the curriculum says at archive time.

The split is load-bearing rather than cosmetic. slotDuration's NULL is the only marker distinguishing an authored length from a derived one; the first cut of this freeze stamped into that column, which made every override indistinguishable from a default and propagated frozen values into every later revision. Never widen the stamp back onto slotDuration — the ambiguity it creates cannot be undone. One-time application-level backfill for lineages published before the column existed: tools/stamp-resolved-durations.ts (privileged DB URL, sweeps every tenant, fills NULLs only).

ARCHIVED is set only by the AY-archival cascade (no client transition — the status DTO uses @IsIn([DRAFT, PUBLISHED])). An ARCHIVED timetable is read-only: every edit and transition throws 409 TIMETABLE_READ_ONLY. The AY-archival hook is a no-op until AY archiving lands.

5.1 Revision & republish (staged modify-then-publish)

A PUBLISHED timetable's content is frozen (§4) — consumers read it live (attendance resolves the register by status:'PUBLISHED'; read-views read its rows), so an in-place edit would leak mid-reorg state. To change it:

  • POST …/revision (create action) deep-copies the published timetable's content (lessons/breaks/activities + sub-rows, via copyTimetableContent) into a new DRAFT R with R.revisionOfId = published.id. At most one open revision per published — the partial unique timetables_open_revision_unique (WHERE status = 'DRAFT'); idempotent — an already-open revision is returned. The FE edits R through the ordinary lesson/break/activity CRUD (addressed by R's own id, frictionless DRAFT rules).
  • POST …/publish-revision (publish action) is the atomic retain-then-swap: lock R FOR UPDATE (content edits take FOR SHARE on it), then the published parent FOR UPDATE; re-validate under the lock that R is still an open revision (guards the discard-during-publish race); run the publish gate on R's content; then archivePublishedVersion(published)clearTimetableContent(published)copyTimetableContent(R → published) → stamp publishedAt + the next revisionNumberhard-delete R, all in one transaction. The published id is stable, so consumers flip old→new at commit and any reference to the id survives.
  • Discard = DELETE …/:R (ordinary draft delete); the published timetable is untouched.

Version retention (2026-07-26). The version a republish replaces is no longer destroyed. archivePublishedVersion copies it to a new row with status: ARCHIVED and the same revisionOfId — one lineage pointer, the open DRAFT ahead of the published row and the retained versions behind it. Consequences worth knowing:

  • ARCHIVED now has two producers, told apart by that pointer: revisionOfId != null is a superseded version; null is a standalone timetable archived with its academic year. Reusing the status rather than adding SUPERSEDED costs nothing at the API (the FE's status union is unchanged) and inherits assertNotArchived — a retained version is read-only, terminal and undeletable, which is exactly right.
  • The retained copy carries the published row's publishedAt plus a fresh supersededAt, so it records the window it was live in, and a revisionNumber (1-based per lineage; first publish sets 1, each republish increments the published row). Since 2026-07-27 it also carries its own valid-time window [effectiveFrom, effectiveUntil) — and that one IS a resolution mechanism (§5.0): a republish closes the outgoing interval at exactly the boundary the incoming one opens at, one date per command, so the lineage stays contiguous and gap-free. (The transaction-time window remains documentation only.)
  • Its lessons inherit the resolvedDuration frozen when that version was published (the copy passes carryResolvedDuration); stampResolvedDurations then runs onlyMissing purely as a gap-filler for lineages published before the column existed. Re-deriving here instead would date the freeze to the archive instant, so a curriculum edit made while the version was live would rewrite what that version is recorded as having cost. See §5.0 for the two-column rule.
  • Growth is a full content copy per republish, retained indefinitely. ARCHIVED blocks direct delete, and once the lineage has governed a day the published parent is protected by TEMPORAL_ROW_IMMUTABLE, so effective history has no API removal path. Deleting the parent cascades only for a lineage that never governed.
  • There is no read surface yet (supersededAt / revisionNumber are not in any DTO). Retention exists ahead of the budget module that will diff and cost versions; that module owns the shape of its own reads. Frozen teacher attribution is likewise not part of this — teachers live on the SubjectGroup, so an archived version's "who taught this" is still live data.

findAll (the list) hides revision drafts and retained versions with one revisionOfId: null filter — a revision is reachable only via the published timetable's openRevisionId, a retained version only by its own id. For the same reason findOpenRevision matches on status: 'DRAFT' as well as the pointer, and timetableSummaryInclude filters revisions to DRAFT: without those, a retained version reads as a pending change. The DTO exposes publishedAt, hasPendingChanges (= an open revision exists), openRevisionId, and revisionOfId. A successful revision swap emits the same timetable.published kind with changeType: REPUBLISHED, resolved from the copied incoming content on its new effective boundary and deferred through PostCommitCoordinator; discard, rejected, and no-op paths emit nothing.


6. Read views & placement plan

GET …/lessons takes a faceted filter: one optional multi-value param per kind — departmentId | curriculumId | gradeId | homeroomId | subjectGroupId | combinedClassId | roomId | teacherId | studentId (repeat the param or send a comma-separated list, per the platform filter convention) — and returns TimetableLessonsViewDto { targets, lessons, breaks, activities }. Values of one param union (a slot matches any of them); different params intersect — slot-level AND, a slot must independently match every kind present (QA-driven 2026-08-05: homeroomId + teacherId = that teacher's slots in that class, replacing the single-view + targetId pair, which is now a 400 via the whitelist pipe). The response echoes targets: {view, id, displayName}[] — catalog order across kinds, request order (deduped) within a kind, each entry tagged with the kind it was selected under; all params omitted = the whole-school read (every lesson and break of the timetable, targets: []; the old top-level view echo field is gone). There are no sequence grids (removed with the templates, FE-breaking as agreed) — the FE renders a free 5-minute grid; lessons carry their own wallStart/wallEnd/durationMinutes. Every lesson carries unit: TeachingUnitRefDto { kind: SUBJECT_GROUP | COMBINED_CLASS, id, displayName, subjectName } (the old subjectGroup ref + combinedClassId passthrough are gone); a combined lesson's teachers are the deduped union of its members'. The homeroom facet is roster-derived (the lessons of the SubjectGroups its rostered students attend); department filters through subjectGroup.grade.departmentId, curriculum through subjectGroup.curriculumSubject.curriculumId; combined lessons satisfy each facet wherever any member matches it — per facet, so under an AND different members may satisfy different facets (student via the contributing membership, teacher via the union teacher set, room/grade/department/curriculum via any-member match).

Breaks per facet (findBreaksForView): the whole-school read returns all; the teacherId facet keeps the breaks where a selected teacher holds a duty; roomId matches the break's room; every student-anchored facet keeps the breaks whose live-resolved audience intersects that facet's student cohort. Facets apply sequentially over the row set (slot-level AND); the audience resolution runs once, up front, when any student-anchored facet is present. findActivitiesForView mirrors this with roster ∩ cohort / supervising teacher / room. The teacher facet means “assigned on at least one activity weekday”; each returned teacher ref carries its effective weekdays[], so the client can render the per-day split without duplicating the activity. Lesson WHERE composition plus both selectors live in timetable-view.queries.ts and are shared by the grid service and option projector.

Grid filter options — anchored surface=timetables. The seven federated option endpoints for departments, grades, curricula, homerooms, rooms, teachers, and students require timetableId. TimetableViewProjector loads that anchor through TimetablesPolicy, makes its academicYearId authoritative, and runs the same structural selection engine as this read. Surviving lessons contribute unit department/grade/curriculum, effective-roster homerooms, exact scheduled room, and effective course teachers (including combined members). Activities and breaks contribute structural facts from their resolved audiences, exact rooms, and supervisors/duties. Non-admin DRAFT/ARCHIVED anchors remain 404; missing timetable READ is 403. Structural/teacher options remain projections of surviving timetable cells. Student options are different by design: they are authoring candidates in the anchored AY, intersected with StudentsReadPolicy, restricted to ENROLLED, and optionally narrowed by display department/grade. They are not intersected with students already used by the timetable. Existing filters remain left-to-right rather than self-excluding facets.

Duty-derived record visibility. A teacher assigned to a break/lunch duty is related, for record-level student reads, to every strictly placed student selected by that break's effective STUDENT | DEPARTMENT | GRADE | CURRICULUM | TRACK | HOMEROOM audience while the timetable governs the school-clock date. Effective placement is the outer candidate universe for all six legs, so a direct ref or stale-open membership cannot grant access before entry or after exit. The inverse communications-recipient lookup uses the same six selector legs. EVERYONE is not a break selector and is not part of this relationship. This relationship is independent of the break's stored weekday because record visibility is date-scoped by the governing timetable window, not by whether that particular duty occurs on the queried weekday. Canonical policy details: chapter 04 and the 2026-08-28 eligibility design.

Placement planGET …/placement-plan (same faceted grammar as the lessons read; echoes the same kind-tagged targets too; combined classes join the scope per facet via any-member match — AND of subjectGroups: { some: <facet predicate> }) returns one row per teaching unit: { kind, unit, subjectName, department, curriculum, grade, teachers, studentCount, weeklyHours, expectedMinutes, slotDurationMinutes, placedMinutes, remainingMinutes, baseRoom?, combinedClassId? }. Minutes-first: placedMinutes = Σ each placed lesson's effective duration (slotDuration ?? cascade) — summed per unit (via sumPlacedMinutes*), since a slotDuration override lets durations vary within a unit; slotDurationMinutes in the row is the cascade default used to seed a new slot. The aggregate deliberately ignores resolvedDuration (a groupBy cannot express the coalesce): the plan steers editing, which happens on DRAFTs, where nothing is frozen. The residue is that a PUBLISHED version whose curriculum moved after publish is costed here at live-cascade minutes while its board renders the frozen ones — advisory surface, non-editable target. A combined class is its own row (kind: COMBINED_CLASS, expectedMinutes = sharedMinutes, union studentCount/teachers, derived department, subjectName/curriculum/grade null); each member keeps a contributor row whose expected minutes shrink to the solo remainder (cell − shared) and carries combinedClassId so the FE can explain the reduction. This is the FE's "what's left to place" panel and pairs with HOUR_BUDGET's per-unit budgets.


7. RBAC

Admin authors; reads are tenant-wide. One entity EntityKey.TIMETABLES with a single descriptor-only configuration scope (flat DTOs → timetables is in FLAT_DTO_ENTITIES, no FIELD_MAPPINGS row) and four actions create / delete / publish / generate. The ADMIN role auto-grants via ALL_WRITE/ALL. Every role holds timetables.configuration READ — the staff roles by default, and referent + student by explicit grant (2026-07-22; see prisma/seed/roles.ts, alongside academic_years.configuration as the other baseline-everyone READ). No role holds WRITE except admin.

Read routes are gated by scope, not role (2026-07-22 decision — "everyone should reach the timetable; the FE decides what's shown"). GET /, GET /:id, and GET /:id/lessons (all nine filter kinds, whole-school or narrowed) carry @RequireScopes(TIMETABLES,'read') + @AppliesPolicyDimensions(TimetablesPolicy) and no @RequireRoles, so any authenticated user holding the READ scope reaches them. TimetablesPolicy is hand-built with a role-agnostic WHERE (not definePolicy, whose resolver would fail-close unlisted roles to NEVER_MATCH): admin / platform-admin → every status (the authoring surface); everyone else → status: PUBLISHED (DRAFTs and the revision drafts that shadow a published timetable are authoring state and stay admin-internal). The service ANDs this WHERE into every query via loadOrThrow/findAll, so a non-admin requesting a DRAFT id gets TIMETABLE_NOT_FOUND.

Write / action / planning routes stay admin-only. Create / patch / delete / duplicate / revision / publish-revision / lesson-break-activity CRUD / status carry @AppliesPolicy(TimetablesPolicy), which composes @RequireRoles(...policy.roles) = @RequireRoles('admin') (policy.roles is ['admin'] — it gates these routes, not the reads). The two planning reads — GET /:id/diagnostics (conflict report) and GET /:id/placement-plan ("what's left to place") — are read-scoped but are authoring tools, so they carry an explicit @RequireRoles('admin') + @AppliesPolicyDimensions(TimetablesPolicy) (dimensions-only keeps the policy WHERE + boot drift check while the tighter role gate holds). Introducing publish and generate also required adding them to the ACTION_NAMES union in src/permissions/interfaces/decorators.interfaces.ts (the TS type @RequireAction validates against).


8. Automatic generation

Templates are generation-only inputs (iteration-3): the day/week template catalog and the per-grade effective-template resolution (resolveEffectiveTemplatesForAcademicYear + EffectiveTemplate, relocated to src/timetables/generation/effective-templates.ts) are consumed exclusively by this pipeline. Nothing outside generation/ may depend on them. The generation flow stays dormant but fully wired; when it re-enters the product, templates will be configured inside its own creation flow.

Pipeline

POST /timetables/generate (@RequireAction(TIMETABLES, 'generate'), admin-only) runs:

  1. SnapshotbuildGenerationSnapshot(prisma, tenantId, academicYearId) assembles segments, day envelopes (per operative (template, weekday): start/end/default duration/breaks/teaching minutes), groups (with weeklyMinutes, durationByWeekday from the cascade, compatible rooms, windows), coverage cohorts (each a distinct student course-set, deduped — the compactness unit), option-block cohorts, teacher availability, and pre-computed roster-clash pairs. (The old slotOverlapPairs are gone — the absolute minute axis makes cross-template overlap a plain interval intersection.)
  2. Pre-checksrunPreChecks(snapshot) runs 8 pure checks (see table below). Any violation → 422 TIMETABLE_GENERATION_PRECHECK_FAILED (data.violations as rendered cards + the skippedSubjectGroups sibling). Solver is never called.
  3. Solver callSolverPort.generate(toSolverRequest(snapshot, config)). If the solver service is unreachable or returns a non-2xx → 502 TIMETABLE_GENERATION_SOLVER_UNREACHABLE.
  4. INFEASIBLE422 TIMETABLE_GENERATION_INFEASIBLE (data.{ violations, skippedSubjectGroups, solveMetadata }). The solver's unsat-core refs are enriched with { id, displayName } from the snapshot, stamped variant: 'generation', and rendered as cards through the shared categories' .generation sentences (§3).
  5. TIMEOUT422 TIMETABLE_GENERATION_TIMEOUT (data.{ violations: [], skippedSubjectGroups, solveMetadata } — same shape as INFEASIBLE; the violations list is always empty on timeout, so the envelope keeps the base message).
  6. FEASIBLE → assignments arrive as { subjectGroupId, weekday, startMinute, roomId } where subjectGroupId may carry a combined-class id (the snapshot handed the solver the combination under its real id); the ingest maps each assignment directly to one row — a combined id lands as { subjectGroupId: null, combinedClassId }, anything else as a plain SG row (no fan-out). startTick = startMinute / TICK_MINUTES (a non-multiple startMinute throws 500 before the persist transaction — it can only mean solver drift). Persist into a new DRAFT in the phase-W transaction (insertTimetable + scheduledLesson.createMany), then immediately run computeDiagnostics on the persisted draft. If any ERROR violations: roll back the entire transaction and throw 500 (solver/diagnostics-engine drift is a loud bug, not a user-recoverable error). On zero ERRORs: return 201 { timetable, diagnostics, skippedSubjectGroups, solveMetadata }.

Transaction shape (2026-08-10): the route is @NoTenantTx. R opens a short withTenantGuc transaction for academic-year resolution, the captured school day, and the structural snapshot; prechecks then produce the solver request and a canonical structural fingerprint. S awaits the solver with no database transaction or pooled connection held. R and S are side-effect-free and outside coordinator scope. W runs postCommit.runScoped(() => withTenantGuc(...)): it re-resolves the year, rebuilds with the phase-R school day, re-runs prechecks before mapping, and compares the canonical fingerprint before writing. Any year mismatch, new precheck violation, or structural fingerprint mismatch returns 409 TIMETABLE_GENERATION_STALE; the write transaction rolls back and nothing is persisted. Phase-R precheck failure remains 422.

Skipped subject groups — STANDALONE groups with no grid cell (reason: HOURS_NULL) or whose weekly minutes no combination of their available lesson durations sums to (reason: HOURS_NOT_EXPRESSIBLE, via canTileExactly) are excluded from the snapshot before the solver sees them. Fractional hours are no longer skipped per se — 1.5 h is two 45′ lessons. They appear in skippedSubjectGroups in both success and failure responses and can be placed manually afterwards.

Governing principle

The diagnostics engine is the single authority on validity. The solver's 7 hard-constraint families (HOUR_BUDGET, STUDENT_GAP, TEACHER_CONFLICT, ROOM_CONFLICT, STUDENT_CONFLICT, OPTION_BLOCK_SYNC, OPTION_BLOCK_CLASH) correspond to the engine's checks — three of them the solver keeps stricter than the engine: HOUR_BUDGET and STUDENT_GAP are engine-WARNINGs the solver keeps hard, and OPTION_BLOCK_SYNC is engine-relaxed to co-start (2026-07-23) while the external solver still encodes full identical-interval co-slotting (plus the strict OPTION_BLOCK_CHILD_MISMATCH pre-check) until its own relaxation lands — stricter-than-the-gate is the safe direction, so generated drafts always pass. The 3 WARNING checks (TEACHER_DAILY_CAP, SAME_DAY_CONTIGUITY, SAME_ROOM_DOUBLE) and quality preferences are mirrored in the soft objective. The post-ingest diagnostics pass is the safety net: any solver/BE constraint drift is caught before an admin ever sees the draft.

Band fit is solver-internal now. The solver's x-domains only contain start ticks whose interval fits inside one teaching band of the group's template envelope, so a generated lesson can never straddle a template break — but the diagnostics engine no longer mirrors this (DAY_OVERFLOW was deleted with the template decouple; the manual flow has no day-shape diagnostic at all since day bounds were removed 2026-07-13). Subject-group base rooms do not participate in generation validity: compatible room domains derive only from capacity + subject-room-set filtering. Note the solver also still enforces HOUR_BUDGET and STUDENT_GAP as hard families while the engine reports them as WARNINGs — generated drafts are simply stricter than what publish requires.

Segment model

The year is partitioned at teaching-window + department-calendar boundaries into day-granularity elementary segments (computeSegments in generation.queries.ts). Solver variables are weekly; conflict constraints are stated once per segment (over the groups active in that segment), so disjoint-window groups never conflict and overlapping-window groups always do. Per-student compactness (STUDENT_GAP) is checked per segment. See §4 of the spec for the full formalization.

Option-block cohort scoping

The generation snapshot and the diagnostics engine key option-block cohorts identically:

maxSelections Cohort key Constraint
= 1 (blockId, gradeId) SYNC — engine: all children co-started (identical start sets; durations may diverge, 2026-07-23). Solver: still full co-slot (one b variable per cohort) until its relaxation lands
> 1 (blockId, gradeId) CLASH — no two children may overlap in time

Both cohort keys are grade-scoped. The maxSelections = 1 key was re-scoped from (blockId, homeroomId) to (blockId, gradeId) in the 2026-07-08 decoupling (homerooms no longer partition option-block cohorts). The keying is applied identically in the diagnostics engine's checkOptionBlockSync / checkOptionBlockClash and the solver.

Pre-checks

Pure checks in generation.prechecks.ts run before the solver (8 categories). Each fires as ERROR severity.

Category Fires when
SUBJECT_HOURS_INVALID Any group in the snapshot has null/zero weekly minutes, or minutes that canTileExactly can't express with the group's available lesson durations (skippable STANDALONE groups with no cell were dropped at classification — this catches groups that must block generation instead)
NO_EFFECTIVE_TEMPLATE A schedulable group has no resolved week template
ROOM_UNPLACEABLE A group has no compatible rooms (after capacity + subject-room-set filtering)
TEACHER_CAPACITY A teacher's assigned weekly minutes exceed capacity in any segment; capacity = Σ over daysOn of the largest teachingMinutes envelope among the templates their groups use that day. Combined members already carry reduced minutes; a paired maxSel=1 block child keeps its FULL cell on the wire, so this check subtracts its combination's shared quota (the COMBINED group carries those minutes) to avoid double-counting
OPTION_BLOCK_TEACHER_DUPLICATE A maxSelections=1 cohort has one teacher appearing in two or more different child groups (co-placement would create a teacher conflict)
OPTION_BLOCK_CHILD_MISMATCH A maxSelections=1 cohort's children have mismatched weekly minutes, teaching windows, or durationByWeekday (equal durations are what lets children swap seamlessly per lesson)
COMBINED_NO_COMMON_ROOM A combined class's members share no compatible room (the COMBINED group's room-intersection is empty)
COMBINED_NO_COMMON_BAND A combined class's members' teaching bands never intersect on any shared weekday (the intersection template has no operative day)

The solver service

solver/ (repo root) — a stateless FastAPI + OR-Tools CP-SAT Python microservice. Endpoints: GET /health (no auth), POST /v1/generate (bearer secret, TIMETABLE_SOLVER_SECRET). One-shot: receives a generation snapshot, returns { status: FEASIBLE|INFEASIBLE|TIMEOUT, assignments?, violations?, solveMetadata }; assignments carry startMinute on the absolute axis.

Budget split: the timeLimitSeconds config (default 300 s, from TIMETABLE_SOLVER_TIME_LIMIT_SECONDS) is split 75% to the main solve, ~25% reserved for deletion-based unsat-core minimization on the INFEASIBLE branch. Fixed seed 42 for reproducibility.

Variables (tick-grid packing): x[(group, weekday, tick)] (placement BoolVar over the allowed start ticks — those where the lesson's duration_by_weekday interval fits inside ONE teaching band of the template's envelope, weekdays filtered by teacher days_on), y[(group, room, weekday, tick)] (room assignment BoolVar over every capacity + subject-room-set compatible room), b[(cohortKey, weekday, tick)] (block-unit BoolVar for maxSelections=1 cohorts over the children's domain intersection). A placement covers ticks t..t+dur/5−1. HOUR_BUDGET is Σ x·duration == weeklyMinutes per group — feasibility now comes from per-group budgets, not from any tiling identity (the old HOMEROOM_COVERAGE exact-tiling family is gone). Groups carry kind: STANDALONE | BLOCK_CHILD | COMBINED and combined_group_id; pair-tiling extends the block-sync family for a maxSelections=1 child paired to a combination: at every tick of the block domain x_child(t) + x_cc(t) == b(t) (the child and its combination tile the block unit together — the child keeps its FULL cell budget through the block-unit sum while the combination's own HOUR_BUDGET fixes the shared share, so the child's solo minutes are the arithmetic remainder), and the combination places nothing outside its paired cohorts' block domains (multi-cohort pairing → the intersection). STUDENT_GAP enforces per-coverage-cohort compactness: for each (cohort, weekday, segment), Σ placed − Σ adjacencies ≤ 1 under one assumption literal. Because a cohort's lessons never overlap (guaranteed by STUDENT_CONFLICT + OPTION_BLOCK_CLASH), runs = placed − adjacencies, so ≤ 1 means exactly one contiguous run = no teaching gap (an adjacency is two placements where one ends exactly where the next begins, with a fixed break band normalized as adjacent). All 7 hard families are enforced via assumption literals so an infeasibility core maps back to {category, entityRefs} pairs. The BE enriches entity ids with { displayName } before returning to the FE. The test suite verifies solver output through an independent mirror oracle (solver/tests/mirror.py) that re-derives every predicate from scratch, including band geometry and the combined pair rules.

Soft objective (10 terms): 3 warning-mirror terms (teacherDailyCap, sameDayContiguity, sameRoomDouble) + 7 quality terms (teacherDaySpan, doublesPreference, departmentRoomPreference, subjectGroupRoomStability, evenWeeklyDistribution, teacherPresence, soloIsolatedPeriods). (The two homeroom-keyed terms homeroomRoomStability + homeroomBaseRoomPreference were removed in the 2026-07-08 decoupling.) Weights are passed per-request from the BE (DEFAULT_SOFT_WEIGHTS in solver.port.ts); zero weight skips the term entirely. Under packing, adjacency = one lesson ending exactly where the next starts (a fixed break band between them still counts), and the contiguity-flavoured terms count extra runs instead of rank holes; evenWeeklyDistribution balances minutes per day; doublesPreference qualifies groups by weekly lesson count (weeklyMinutes / duration). Room-preference semantics: departmentRoomPreference penalizes only rooms of a different department (a NULL-department "general-purpose" room is favored, not penalized); subjectGroupRoomStability minimizes the distinct rooms a single SG uses across its lessons.

The BE talks to the solver via SolverPort (DI token SOLVER_PORT): HttpSolverTransport in production (reads env at construction), FakeSolverTransport in tests (mailer-style seam — inject via the DI token, set .nextResponse before each test).

Env vars

TIMETABLE_SOLVER_URL                  # URL of the solver service (e.g. http://localhost:8000)
TIMETABLE_SOLVER_SECRET               # Shared bearer secret; solver fails closed (401) if unset
TIMETABLE_SOLVER_TIME_LIMIT_SECONDS   # Total budget in seconds (default: 300); applies to both the main solve and the infeasibility-core pass

9. Key files

src/common/utils/period-math.ts                     # TICK_MINUTES, weeklyHoursToMinutes, resolveEffectivePeriodDuration, canTileExactly
src/common/utils/day-envelope.ts                    # DayEnvelope geometry — GENERATION-ONLY consumer since the template decouple
src/timetables/audience-effective.ts                # pure break/activity audience collapse: dep→cv→grade cascade only (homeroom/track/student/everyone additive), write-time workaround
src/timetables/activity-teacher-coverage.ts         # global-sentinel expansion, per-weekday membership, and parent-weekday reconciliation
src/timetables/timetables.diagnostics.ts            # pure window-aware minutes engine (the heart): 9 ERROR + 6 WARNING + combined checks
src/timetables/timetables.queries.ts                # CRUD + copyTimetableContent/clearTimetableContent/findOpenRevision + buildDiagnosticsSnapshot + cascade/response loaders + break graph + activity graph + resolveBreakAudiences/resolveActivityAudiences + placement-plan queries
src/timetables/timetable-view.queries.ts            # shared lesson/activity/break faceted selection engine used by grid reads and filter projection
src/timetables/timetable-view-projector.service.ts  # public timetable-owned distinct facet projection for anchored /filters calls
src/timetables/timetables.service.ts                # lifecycle, structural floor, mutateAndDiagnose (PUBLISHED frozen), startRevision + publishRevision (atomic swap), break + activity CRUD, publish gate, grid projection + placement plan
src/timetables/timetables.controller.ts             # 20 routes (incl. breaks + activities CRUD, placement-plan, POST generate)
src/timetables/timetables.policy.ts                 # admin-only record access
src/timetables/dto/                                 # timetable / lesson / break / activity / view / diagnostics / placement-plan / mutation-envelope DTOs
src/timetables/generation/effective-templates.ts    # resolveEffectiveTemplatesForAcademicYear + EffectiveTemplate (generation-only)
src/timetables/generation/generation.queries.ts     # buildGenerationSnapshot: segments, day envelopes, groups (minutes + durations), cohorts, clash pairs
src/timetables/generation/combined-rewrite.ts       # applyCombinedClassRewrite: first-class COMBINED group + in-place member reduction (maxSel=1 children keep the full cell)
src/timetables/generation/generation.prechecks.ts   # runPreChecks: 8 pure pre-check categories
src/timetables/generation/solver.port.ts            # SolverPort interface + HttpSolverTransport + FakeSolverTransport + DEFAULT_SOFT_WEIGHTS + toSolverRequest
src/timetables/generation/generation.service.ts     # pipeline: snapshot → pre-checks → solver → tick ingest → verify
src/timetables/generation/dto/                      # GenerateTimetableDto, GenerationResultDto, SolveMetadataDto, SkippedSubjectGroupDto
solver/app/main.py                                  # FastAPI app: /health + /v1/generate (bearer auth, body-size guard)
solver/app/model.py                                 # CP-SAT tick-grid packing: x/y/b vars over allowed ticks + 7 hard families via assumption literals
solver/app/objective.py                             # 12-term soft objective (3 warning-mirror + 9 quality), run-counting adjacency
solver/app/solve.py                                 # budget split, FEASIBLE extraction (startMinute), INFEASIBLE unsat-core minimization
solver/app/schemas.py                               # Pydantic request/response schemas (DayEnvelope, GenerateRequest, GenerateResponse, …)
solver/tests/mirror.py                              # independent oracle: re-derives every hard predicate over concrete placements

To add a diagnostics check: add a check* function to timetables.diagnostics.ts, thread any new snapshot field through buildDiagnosticsSnapshot in timetables.queries.ts, and unit-test it in timetables.diagnostics.spec.ts (the engine is fully unit-covered — every category has a test). To change the publish gate: TimetablesService.setStatus. To add a view: extend the shared predicates/selectors in timetable-view.queries.ts, the target resolver and placement-plan predicate in the service, and the facet projector when the new kind contributes options. To add a pre-check: add a check* function to generation.prechecks.ts and call it from runPreChecks. To change solver weights: DEFAULT_SOFT_WEIGHTS in solver.port.ts.

Attendance is a consumer (chapter 19): the take-attendance path resolves a lesson's wall interval via resolvePeriodSlot(startTick, cascade) (total, template-free) and derives a grade's operative weekdays from the published timetable's placed lessons or any attendance-bearing activity whose roster includes a student of the grade (loadOperativeWeekdaysForGrade) — a weekday with no placed lesson AND no activity for the grade is not a school day for it. Activities are a take anchor alongside lessons/combined classes.

Combined classes (first-class teaching units)

A combined class links 2+ SGs as taught together (see 14 - Homerooms & Subject Groups for the entity). Since iteration 3 (2026-07-12) the shared meeting is one lesson row anchored on the combination itself — there is no co-location simulation anywhere.

Dual anchor. ScheduledLesson.subjectGroupId XOR combinedClassId (DB CHECK scheduled_lessons_anchor_xor; coordinate uniqueness is per anchor — @@unique([timetableId, subjectGroupId|combinedClassId, weekday, startTick])). POST …/lessons accepts exactly one of the two ids (class-validator @ExactlyOneAnchor → 400 on none/both); PATCH/DELETE address a combined lesson by its own lessonId like any lesson. Deleting the combination cascades its lessons (onDelete: Cascade); the members' solo lessons survive. duplicate copies the anchor verbatim. A combined lesson's duration resolves through any member's cascade (the combine-time gate guarantees agreement); its teachers are the members' deduped union; its roster is the union live-resolved.

Diagnostics. The whole v1 co-location machinery is deleted: the predicate/exemption helpers, COMBINED_SHARED_MISCOUNT, and COMBINED_CLASS_ROOM_SPLIT (both structurally impossible — one shared meeting is one row), plus the conflict exemptions, ROOM_CAPACITY summing, and TEACHER_DAILY_CAP dedupe (plain accumulation is exact when the meeting is one row: conflict checks treat a combined lesson with its union roster/teachers; capacity checks the union roster once). What remains: per-unit HOUR_BUDGET (combined unit → sharedMinutes; active member → cell − shared; see the catalogue), the expanded option-block occupancy (a block child's occupancy = its solo lessons ∪ its active combination's lessons — SYNC compares expanded tick-sets with no exemption, CLASH counts a combination's lessons for a paired child but never flags two children covered by the SAME shared meeting), COMBINED_SHARED_EXCEEDS_CELL (ERROR — only via a post-combine curriculum edit), and COMBINED_CLASS_DEGENERATE (WARNING — a <2-member combination is inert; its lessons stay placed and the warning flags cleanup). The snapshot carries combinedClasses (members + sharedMinutes) and a memberToCombined map.

Layered duration gate — members must resolve to equal lesson durations. Enforced decidable-earliest-first: combine-time 409 (curriculum-resolved), fit report (GET /timetable-assignmentsCOMBINED_DURATION_MISMATCH / COMBINED_SHARED_NOT_DIVISIBLE / COMBINED_CLASS_DEGENERATE, template-tailed), then the generation prechecks (COMBINED_NO_COMMON_ROOM / COMBINED_NO_COMMON_BAND).

Generation — first-class COMBINED group. generation/combined-rewrite.ts::applyCombinedClassRewrite (called inside buildGenerationSnapshot) adds, for each active combination (≥2 fully schedulable members), one group under the real cc.id with kind: 'COMBINED': weeklyMinutes = shared, union roster/teachers, ∩ compatible rooms (refiltered by the summed roster), riding a synthetic intersection week template cc:<ccId> whose per-weekday bands are the intersection of the members' real templates' bands. Members stay in the group list under their own ids with combinedGroupId = cc.id:

  • standalone + maxSelections>1 block children are reduced in place to cell − shared (fully shared → dropped from the group list);
  • maxSelections=1 block children keep their FULL cell — the solver's block-unit budget runs on the first child's minutes and pair-tiling (x_child + x_cc == b over the block domain) makes the solo remainder fall out arithmetically;
  • null-minutes block children are left untouched (SUBJECT_HOURS_INVALID owns that failure).

There is no synthetic id namespace and no combinedExpansion — the solver addresses the combination by its real id and the ingest maps each assignment directly to one row (a combined id → one combined-anchored lesson; no fan-out). Roster-clash pairs need no special casing: the COMBINED group carries the union roster and a null optionBlockId, so it pairs with co-picking maxSelections>1 siblings through the ordinary recompute (pinned by a spec). Option-block-child members are admitted — the v1 fence and its error code are deleted.

Activities (named non-curriculum slots)

Specs: 2026-07-13-timetable-activities-design.md (v1) + 2026-08-12-teacher-attendance-surface-iteration-3-design.md (single-department) + 2026-08-26-timetable-two-step-roster-iteration-2-design.md (break track/student selectors + weekday-specific activity supervisors). An activity is a named slot that isn't a curriculum subject — a morning meet, "Friday Activities", an assembly. It is timetable-scoped (break-style: self-contained rows, copied by duplicate, gone when the timetable is deleted), NOT AY-scoped, there is exactly one kind (no per-homeroom fan-out — the morning-meet-as-domain-concept was deferred; see the v1 spec §8/§9), and since 2026-08-12 it belongs to exactly one department — a cross-department activity is modeled as two activities. The single department is what makes the register's attendanceMode unambiguous (ch19); the 2026-08-12 migration backfilled it (alphabetical-first pick) and trimmed out-of-department audience rows on existing activities (signed off — no production usage).

Model. ScheduledActivity { name, departmentId (required, Cascade), weekdays: DayOfWeek[], startTick, durationMinutes (stored), roomId? } + ScheduledActivityAudience (kind-discriminated selector rows — six kinds, the DEPARTMENT kind was retired with the column) + ScheduledActivityTeacher { teacherId, weekdays: DayOfWeek[] } (supervisors, optional). On the child row, an empty stored array is the backward-compatible global sentinel: the teacher covers every parent activity weekday. A non-empty array is an explicit subset. Responses never expose the sentinel; every teacher ref returns the effective weekdays[] expanded against the parent. The parent weekdays[] array is the repeat set at one uniform time — a different Friday time is a second activity (divergence from the one-coordinate-per-row norm; the diagnostics/attendance code expands per weekday). Raw-SQL CHECKs: scheduled_activities_axis_chk (5′ grid, inside the day, non-empty weekdays) and scheduled_activity_audiences_kind_ref_chk (EVERYONE ⇒ no ref, every other kind ⇒ exactly its own). No coordinate unique — overlap wrongness is diagnostics' job. All three tables are RLS-covered + in tenanted-models.ts.

Roster. resolveActivityAudiences (in timetables.queries.ts) live-resolves the union of the audience rows to a student set in ONE query, keyed per kind: EVERYONE → the activity department's placed students, GRADE → gradeId, CURRICULUM → StudentCurriculumSelection.curriculumId, TRACK → …trackId, HOMEROOM → HomeroomAssignment, STUDENT → the studentevery kind fenced to ENROLLED students in the timetable AY and to the activity's department (write-time validation pins the ref, not its members, so a student who moved department drops out here). Never materialized — moves re-resolve on the next read. The register does NOT use this expander: attendance expands as-of-D through expandAudienceOn (ch19/spec E), which carries the same department fence.

CRUD. POST/PATCH/DELETE /timetables/:id/activities[/:activityId] — admin-only (@RequireScopes(TIMETABLES,'write'), no new RBAC keys), riding the same mutateAndDiagnose envelope as breaks/lessons ({ activity?, diagnostics }; DRAFT never blocked, PUBLISHED rolls back on any new ERROR). Create requires departmentId; every audience ref must belong to it (SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT 422), and a PATCH that changes the department revalidates the stored audience when none is provided — a department switch cannot strand out-of-department refs. Direct studentId refs additionally must identify an ENROLLED student in the timetable AY (SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE 422); activity department validation uses the student's display cohort on the captured school date rather than a future forward-head move. audience and either teacher input full-replace on PATCH. New clients send teachers: [{ teacherId, weekdays? }]; omitting an assignment's weekdays means all parent days. Legacy teacherIds[] remains accepted with the same all-days meaning, but teacherIds and teachers are mutually exclusive. Explicit child weekdays must be a unique, non-empty subset of the parent. A parent weekday-only PATCH intersects explicit subsets (deleting an assignment that becomes empty), does not widen them when a day is added, and leaves global assignments global. Structural floor: in-tenant refs, day axis (SCHEDULED_ACTIVITY_INVALID_SLOT), non-empty audience (@ArrayMinSize(1) at the DTO → 400; the service SCHEDULED_ACTIVITY_AUDIENCE_EMPTY 422 is the bypass backstop), everyone-XOR-ref and invalid teacher coverage (VALIDATION_FAILED). duplicate copies activities with their audience + teacher rows, including exact stored coverage (copyTimetableContent carries departmentId); GET …/lessons returns activities[] filtered per view by activitiesForView (whole-school = all, teacher = supervisor match, room = room match, student-anchored views = roster ∩ cohort).

Diagnostics. Activities are obligations (see the catalogue note above) — the snapshot carries activities: SnapshotActivity[] (one entry per row × weekday), fed by toSnapshotActivities. Each occurrence carries only teachers whose assignment covers that weekday, so conflicts, availability, and teacher minute budgets follow the per-day supervisor split. The out-of-band edit hole (editing a placement's diagnostics-relevant fields is covered by mutateAndDiagnose, but there is no definition layer to edit out-of-band — activities are self-contained rows) does not apply here as it does for curriculum edits.

Attendance parity is the load-bearing decision — every activity behaves like a subject group for attendance (chapter 19): the slot is a third take anchor, and since single-department (2026-08-12) its register runs under one attendance mode — the department's — with its own ACTIVITY cohort admitting supervisors (ch19 §7.1).