AMENDMENT 2026-07-29 —
School.temporalEpochis DELETED. This spec owned that column (contract §2a exclusive scope). It was dropped by migration20260729140000_drop_temporal_epochafter Fabio ruled the completeness marker retired: attendance was never released, so no date precedes a tenant's recorded schedule history. Everything else A delivered is untouched — theTimetablevalid-time columns, both CHECKs,findTimetableVersionEffectiveOn, the AY-day lock,resolvedDuration. Read §6 of the contract, not this spec, for the epoch's status; passages below that stamp or read it are historical.
Timetable valid time — effectiveFrom/effectiveUntil + School.temporalEpoch (temporal program, spec A)¶
1. Problem distillation¶
- The timetable knows when its records changed, never when reality changed. The retention iteration (in tree:
Timetable.supersededAt,revisionNumber,archivePublishedVersioninsrc/timetables/timetables.queries.ts:332) keeps every superseded version's exact content, but the pairpublishedAt/supersededAtis transaction time — a Wednesday republish that starts governing Thursday is indistinguishable from one that governed since Wednesday 14:03. No query can answer "which version governed date D". - Every consumer that needs "the schedule of date D" resolves by status instead.
loadPublishedTimetableId(src/attendance/attendance.queries.ts:287) picksstatus: 'PUBLISHED'with no date awareness; ch19 §3.1 documents the resulting drift and theAttendanceDaySlotsnapshot built to contain it. The program (contract §1) replaces derive-from-mutable-present with valid time: this spec adds the valid-time pair to theTimetableand the pure resolution rule over a lineage; spec B builds the schedule resolver on it. - Publication must become a dated act. Today
setStatus/publishRevisionflip content "now", mid-day — which is exactly how a revision invalidates registers already taken today. The contract fixes the default: a published version starts governing the next school-tz calendar day; the admin may push that date further forward, or — since the sign-off review (contract §2b #20) — explicitly pull it to today, gated on no attendance cell existing onschoolTodayyet. Never to the past. - Pre-history needs an honest marker. Backfilled intervals are reconstructions from transaction time.
School.temporalEpochrecords the date observed valid-time history begins, so the resolvers (B, E) can reportRECONSTRUCTEDfor dates before it instead of presenting a guess as fact.
Success criteria (observable behavior that proves this works):
- PUT /timetables/:id/status (publish) and POST /timetables/:id/publish-revision responses carry effectiveFrom and its contract-§13 twin appliedFrom (always the same value); with no override it is the school-tz next calendar day — or schoolToday on the AY's first-ever publication (frontier NULL, contract §2b #1); an explicit override of schoolToday succeeds while no attendance cell exists on schoolToday and fails with TIMETABLE_EFFECTIVE_DATE_INVALID { reason: 'CELLS_EXIST_TODAY' } once one does (§2b #20); an override earlier than the allowed minimum fails with TIMETABLE_EFFECTIVE_DATE_INVALID { reason: 'BEFORE_MINIMUM' }.
- After a republish committed today at 14:00 (effective tomorrow), the new named query findTimetableVersionEffectiveOn(db, tenantId, ayId, today) returns the retained ARCHIVED copy, and its timetableId-scoped content is the outgoing schedule; the same call for tomorrow returns the stable published id. Status appears nowhere in the query.
- A same-day republish of a not-yet-effective version leaves the outgoing copy with an empty interval [X, X) — present as evidence, returned by no date resolution.
- The DB itself rejects a DRAFT row carrying an interval and a PUBLISHED row missing effective_from (CHECK constraints, asserted in test/db-constraints.e2e-spec.ts).
- Existing rows are backfilled per contract §5; every existing School row gets temporalEpoch = deploy date; a School row created after this ships is born with its epoch set.
- Deleting a timetable whose lineage has governed at least one past-or-current day is refused with TEMPORAL_ROW_IMMUTABLE.
- Publish, republish and unpublish each write one recordStructuralChange event (contract §9) with effectiveOn.
- GET /timetables returns the same rows it returns today (archives stay hidden behind the existing revisionOfId: null filter, src/timetables/timetables.service.ts:187); the only response change is additive.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No resolver. resolveExpectedSchedule and every attendance read-path rewire belong to spec B; the fate of AttendanceDaySlot belongs to F (contract §2 ownership). This spec ships the data and one named query.
- No membership/placement intervals (C1–C3) and no src/common/temporal/ — that module is C1's exclusive scope; the one date helper A needs stays inside src/timetables/.
- No EXCLUDE constraint on timetables. The contract §4 swap table does not include it, and the frontier rule (§4 below) makes an overlap unconstructible through the API; btree_gist arrives with C1's migration.
- No read surface for archived versions and no UI for the version timeline — reaffirms the retention iteration's deferral to the budget-module spec.
- No backdating. Any correction of an effective date already reached belongs to G (contract §12).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
2026-07-26-timetable-revision-republish-iteration-2-design.md (in tree) |
The entire retention machinery this spec builds on: archivePublishedVersion + stampResolvedDurations (src/timetables/timetables.queries.ts:332,218), the stable published id, the FOR UPDATE lock ordering in publishRevision (src/timetables/timetables.service.ts:294), archives hidden from every DTO. A only adds the valid-time pair to the rows retention already creates. |
Retention answers "what did version N contain", never "which version governed date D" — its own §1 declared validity windows a non-goal, which the program reverses (see §7). |
2026-07-26-attendance-day-shape-snapshot-design.md |
The failure catalogue valid time must beat: past days re-rendering onto today's schedule, loadPublishedTimetableId's status-only lookup, write-time freeze as the honest fallback. Also the "verify five drift guards" precedent for migrations. |
It snapshots per-day evidence; A records the domain's own history. No new model here, so the snapshot's five-guard checklist collapses to n/a (§4). |
src/attendance/attendance-clock.ts + loadSchoolTimezone (src/attendance/attendance.queries.ts:276) |
The school-clock discipline (contract §3): schoolToday(timezone, now) is the only legal way to ask "what day is it", and School.timezone is the source. The publication default and the unpublish boundary are computed with it. |
Lives in the attendance module; A imports the pure function cross-module rather than duplicating it (one-way import, no cycle — attendance already imports from timetables' queries). C1 may later re-home it under src/common/temporal/. |
src/audit-log/audit.service.ts (resolveActor, line 26) |
Actor snapshotted once per request, event written inside the caller's transaction — the exact calling convention contract §9 fixes for recordStructuralChange, which A consumes on publish/republish/unpublish. |
A binds to D's contract signature only; where D physically lands the function (audit-log module, per §9's "exactly like AuditService.record") is D's call. |
src/tenants/tenants.queries.ts (provisionTenantWithAdmin, line 50; school wipe, line 241) |
The provisioning + dev-reset surface temporalEpoch must be reconciled with: provisioning creates Tenant/User/Staff/UserRole — no School row — and reset deletes the School row. |
Contract §6's "set at provisioning" cannot be literal (see §8 Pushback); the epoch is stamped where the School row is actually born. |
docs/12-migrations.md raw-SQL constraint table (e.g. curriculum_subject_hours_window_chk, timetables_open_revision_unique) |
The recipe for constraints Prisma cannot express: hand-written SQL in the migration, a row in ch12's table, a name asserted in test/db-constraints.e2e-spec.ts. A's two CHECKs follow it. |
Fits cleanly. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | New columns ride existing RLS-covered tables (timetables, schools); every new query filters tenantId and runs inside the request's tenant transaction. |
No new model ⇒ no rls-coverage.ts / tenanted-models.ts delta. |
| Academic-year scope | yes | Resolution is per (tenantId, academicYearId) — a lineage lives inside one AY (archives inherit academicYearId, verified archivePublishedVersion). findTimetableVersionEffectiveOn takes academicYearId explicitly. |
Matches how findAll/attendance already resolve the year first. |
| RBAC entity key | existing TIMETABLES |
No delta to entity-keys.ts. |
Valid time is a property of an existing action (publish), not a new capability. |
| Scopes | existing timetables.configuration |
Routes keep @RequireScopes(EntityKey.TIMETABLES, 'read'/'write') / @RequireAction(..., 'publish' \| 'delete') as today. |
No new field-filtered surface: timetable routes are @AggregateResponse(), so the additive DTO field needs no scope-field mapping. |
| Actions | existing publish, delete |
The effective-date override rides the publish routes; the delete gate rides the existing delete action. read/update are not action names (they never are). |
|
| Service base | custom (TimetablesService) |
Same as the retention iteration: the module predates BaseTenantedCrudService and is not migrated here. |
Out of scope. |
queries.ts shape |
named functions in src/timetables/timetables.queries.ts |
New: findTimetableVersionEffectiveOn(db, tenantId, academicYearId, date), computeEffectiveFrontier(tx, tenantId, academicYearId), lineageHasGovernedHistory(db, tenantId, id, today), hasEverEffectiveContentReferencing(db, tenantId, anchor: { kind: 'subjectGroup' \| 'combinedClass' \| 'homeroom' \| 'student' \| 'teacher'; id: string }, today) (the §2b #11 predicate, consumed by E's parent-delete guard), countAttendanceCellsOn(db, tenantId, date) (the §2b #20 publish-today gate — a count over attendance_records; querying attendance's table from timetables' queries file crosses no TS module boundary, so the existing one-way attendance→timetables import direction is preserved). Changed: archivePublishedVersion input gains effectiveFrom/effectiveUntil; publishRevision's locked-parent select adds effective_from. |
Mandatory-queries.ts convention; no repository classes. |
| Error codes | one new + one reserved-code declaration | TIMETABLE_EFFECTIVE_DATE_INVALID params { effectiveFrom: string; minimum: string; reason: 'BEFORE_MINIMUM' \| 'CELLS_EXIST_TODAY' } (reason added at the §2b #20 review so the FE can distinguish "too early" from "cells already recorded today"); TEMPORAL_ROW_IMMUTABLE params { entity: string; id: string } (program-reserved, contract §11 — A is the first spec in §2 order with a delete path, so A declares it; A passes entity: 'timetable'). The params shape is program-wide: ErrorParamsMap types each code exactly once, and the richer { entity, id } is what E's hard-delete guard (E §5.4) needs — E and the C-specs consume A's declaration with their own entity values, never re-declare. Both get en_US/it_IT rows in src/common/i18n/error-messages.catalog.ts + examples in error-examples.ts. |
Contract §11 reserves both names. |
| DTO conventions | request field + response field | UpdateTimetableStatusDto gains optional effectiveFrom (@IsDateOnly, src/common/validators/is-date-only.validator.ts); new PublishRevisionDto { effectiveFrom? } for the currently body-less POST :id/publish-revision; TimetableResponseDto gains effectiveFrom: string \| null (format: 'date'); the two publish responses use TimetablePublishResponseDto extends TimetableResponseDto adding appliedFrom: string = effectiveFrom — contract §13's temporal-write carrier (see §8). |
Contract §3: date-only values serialize as YYYY-MM-DD; contract §13: temporal write responses carry appliedFrom. |
| File-backed sub-resources | n/a | No files involved. | |
| Custom fields | n/a | Timetables carry no custom fields. | |
| Profile completeness | n/a | Not a person entity. |
4. Data model plan¶
Schema deltas¶
Timetable.effectiveFrom DateTime? @db.Date @map("effective_from")— valid time: first date this version's content governs.Timetable.effectiveUntil DateTime? @db.Date @map("effective_until")— valid time, exclusive: first date it no longer governs;NULL= open.School.temporalEpoch DateTime? @db.Date @map("temporal_epoch")— first date with observed (non-reconstructed) valid-time history for this tenant (contract §6).- Model doc comment on
Timetable(contract §5 requires it verbatim in spirit):publishedAt/supersededAtare transaction time (when we acted);effectiveFrom/effectiveUntilare valid time (when the version governs).effectiveFrom = next school-tz calendar day ≠ publishedAt. All four columns stay — they must never be "deduplicated". - No new model, no new enum, no relation change.
The valid-time semantics (the design core)¶
Interval shape per status (enforced by CHECK, below):
| Status | effective_from |
effective_until |
Meaning |
|---|---|---|---|
| DRAFT | NULL | NULL | Not a version; governs nothing. Set on the transition out of DRAFT, cleared on the transition back. |
| PUBLISHED | set | NULL | The open interval — governs from effectiveFrom until superseded/unpublished. |
| ARCHIVED | set | set | A closed version [from, until); from = until is the empty interval — retained evidence that never governed and never resolves (contract §3/§5). |
Resolution-by-date — the central rule. For any date D (IsoDate string), the governing version in (tenantId, academicYearId) is the row with effective_from <= D AND (effective_until IS NULL OR D < effective_until). Status is never consulted — and the query cannot even accidentally consult it: DRAFTs self-exclude because NULL <= D is not true. Named query:
// src/timetables/timetables.queries.ts — B consumes this (attendance already imports
// from timetables.queries — one-way, no cycle); the signature is a seam and the name is
// ratified as the canonical program-wide lookup (contract §2b #3 — no parallel
// reimplementations). C1 is deliberately NOT a consumer: src/common/temporal/ cannot
// import a domain module, so C1's temporal.queries.ts re-implements the effectiveness
// predicate locally (C1 §7).
export async function findTimetableVersionEffectiveOn(
db: Db,
tenantId: string,
academicYearId: string,
date: string, // IsoDate 'YYYY-MM-DD'
): Promise<{
id: string;
effectiveFrom: Date;
effectiveUntil: Date | null;
revisionNumber: number | null;
} | null>;
The returned id may be the stable published id or an ARCHIVED copy's id — deliberately. Because copyTimetableContent gives every retained version its own full ScheduledLesson/ScheduledBreak/ScheduledActivity rows keyed by timetableId (each carrying the duration frozen for that version), the slot geometry of date D is simply the content of whichever row this query returns. That property is what makes spec B a query-composition exercise instead of a reconstruction one. Duration freezing happens at publish time (§2b #2, below) in the dedicated ScheduledLesson.resolvedDuration column, so the PUBLISHED row's content is already self-contained — archive copies inherit the frozen values rather than re-deriving them.
The freshly-republished window (mandated worked example): republish committed today at 14:00, incoming effectiveFrom = tomorrow. The retained copy holds [oldFrom, tomorrow); the published row holds [tomorrow, NULL). Resolution for today matches only the retained ARCHIVED copy — today's register keeps rendering against the schedule the school actually ran today, even though the published row's content already changed. Tomorrow resolves to the published id. A status-based lookup gets this wrong for the entire remainder of publish day; the date-based one cannot. (An explicit gated today-republish (§2b #20) instead closes the outgoing at [oldFrom, today) and hands today to the incoming version — safe precisely because the gate guarantees no cell was recorded against the outgoing schedule today.)
Write-path rules — the frontier invariant. Non-overlap of intervals within (tenant, AY) is guaranteed by construction, not by EXCLUDE:
frontier(tenantId, ayId)= max over the AY's timetable rows ofGREATEST(effective_from, COALESCE(effective_until, effective_from))— the first date not yet claimed by any version (NULLwhen no version ever existed). Named querycomputeEffectiveFrontier, executed inside the publish transaction after the row locks.- Every publish (first publish via
setStatus(PUBLISHED)and republish viapublishRevision) setseffectiveFrom = FwithF = requested ?? default, wheredefault = max(schoolTomorrow, frontier)andschoolTomorrow=schoolToday(School.timezone, now)+ 1 calendar day (contract §7 row "Timetable publication"; computed once per command). An explicitrequestedmust satisfyrequested >= max(schoolToday, frontier)— "today or forward, never past" (contract §2b #20; ruling chain on the record: the rule was forward-only-tomorrow until the sign-off review extended it to gated-today). Whenrequested = schoolToday, the publish-today gate must additionally pass: no attendance cell exists onschoolToday—countAttendanceCellsOn(db, tenantId, schoolToday) = 0, the affected scope being the tenant-day sincetimetables_tenant_ay_published_uniqueallows one governing version per (tenant, AY). Cell-existence only: there is no obligation-started check — the dual condition was considered and dropped at sign-off; the started-but-unmarked-slot race (publishing today mid-slot before any teacher records) is an accepted cost, on the record. Failures refuse withTIMETABLE_EFFECTIVE_DATE_INVALID { effectiveFrom, minimum, reason }—reason: 'BEFORE_MINIMUM'(withminimum = max(schoolToday, frontier)) orreason: 'CELLS_EXIST_TODAY'(withminimum = schoolTomorrow— today is no longer available). First-publication exception (contract §2b #1, ruled 2026-07-26): whenfrontierisNULL— no version has ever been effective in(tenant, AY); keyed to the AY frontier, never to "first publication of a lineage" —default = schoolToday, so the very first schedule governs the current day immediately; the publish-today gate is trivially satisfied there, since no version ever governed a day and so no schedule-anchored cell can exist. Every case where a register could already exist (prior lineage, unpublish boundary, pending version) has a non-NULL frontier and keeps the tomorrow default — today is reachable there only by the explicit, gated request. - Duration freezing at publish, in its own column (contract §2b #2 — B's ask, accepted at review; column split added 2026-07-27):
stampResolvedDurationsruns on every transition into PUBLISHED — the first publish and the incoming content of a republish — so a version's lessons carry their frozen duration from the first moment they can govern a day.archivePublishedVersioninvokes it only as anonlyMissinggap-filler for pre-column lineages. Without the publish-time freeze, curriculum duration edits could still retime past days of the live version before its first republish.
The freeze target is a new nullable column, ScheduledLesson.resolvedDuration — never slotDuration. Resolution order is slotDuration ?? resolvedDuration ?? cascade: authored beats frozen beats live. The separation is not stylistic. slotDuration is the admin's authored override and its NULL is the only record that a length was derived rather than chosen; freezing into it makes an authored 43′ indistinguishable from a frozen 50′, and — because copyTimetableContent carries slotDuration — pins every later revision of the lineage to the curriculum as it stood at the first publish. That ambiguity cannot be undone once written, so this is a hard constraint on every downstream spec, not a preference.
Three consequences that follow from it and must be implemented together:
- copyTimetableContent drops resolvedDuration by default (duplicates, revision drafts, and the revision being promoted must all derive live again) and carries it only under an explicit carryResolvedDuration flag.
- archivePublishedVersion is that flag's only caller: the retained copy inherits the publish-time freeze. Re-deriving at archive time would date the freeze to the archive instant, letting a curriculum edit made while the version was live rewrite what that version is recorded as having cost. The archive-time stampResolvedDurations call survives only as an onlyMissing gap-filler for lineages published before the column existed.
- Unpublish (retain-and-close) clears the freeze on the demoted row via clearResolvedDurations, strictly after the archive copy has taken it. This gives the invariant a DRAFT never carries a freeze, which the rest of the module relies on.
resolvedDuration is internal: it appears in no DTO. Clients get the effective durationMinutes and the authored slotDuration, which together carry everything they need.
- Republish closes the outgoing version at exactly F (move rule, contract §3: one X for both boundaries): retained copy [oldFrom, F), published row reopens [F, NULL). Since frontier >= oldFrom always, the retained interval is never negative; it is empty ([oldFrom, oldFrom)) exactly when F = oldFrom — which is the default whenever the outgoing version had not yet taken effect. The contract's same-day-republish rule (publish this morning effective X, republish this afternoon → outgoing keeps [X, X)) is this case, not a special branch. If the admin instead overrides F > oldFrom over a pending version, the outgoing governs the interim [oldFrom, F) — the standing instruction runs until the replacement starts. No earlier archive's boundary is ever touched: ARCHIVED intervals are written once and immutable.
- Unpublish (setStatus(DRAFT) on a PUBLISHED row, today a bare status flip at src/timetables/timetables.service.ts:1921) becomes retain-and-close: compute U = max(oldFrom, schoolTomorrow) once; retain an ARCHIVED copy [oldFrom, U) via archivePublishedVersion (content copy + duration stamp, exactly as republish); reset the row to status: DRAFT, effectiveFrom: null, effectiveUntil: null, revisionNumber: null. Today stays governed by the retained copy (a register taken this morning keeps its schedule); a never-effective version collapses to empty evidence. The whole step runs in one transaction with the row locked FOR UPDATE. An open revision draft, if any, keeps pointing at the lineage id — unchanged behavior.
- Delete gate (contract §10 convention, adapted — deviation logged in §8): DELETE /timetables/:id is refused with TEMPORAL_ROW_IMMUTABLE { entity: 'timetable', id } when the lineage (id = :id OR revision_of_id = :id) contains any row with effective_from IS NOT NULL AND effective_from <= schoolToday AND (effective_until IS NULL OR effective_until > effective_from) — i.e. a non-empty interval that has begun. This closes the cascade escape hatch the retention iteration accepted (deleting the published parent cascades every archive): once a lineage has governed a single day, its history is undeletable through the API. Never-governed lineages (drafts, pending versions with only empty-interval archives) stay deletable — see §7. The existing assertNotArchived refusal on directly deleting an ARCHIVED row (TIMETABLE_READ_ONLY) is untouched.
- Ever-effective content protection (contract §2b #11, ruled 2026-07-26) — the invariant A's immutability contract now states: ScheduledLesson, ScheduledBreak/ScheduledActivity rows and their audience/supervisor children belonging to a version whose interval has ever been effective must never be removed by a parent cascade — the FK paths from schedule content to its anchors (subject group, combined class, homeroom, student, teacher) all onDelete: Cascade today, so deleting a live anchor silently guts archived schedules. The relational approach: the cascades are deliberately retained, not severed — switching them to Restrict would break the pre-effective escape (never-effective drafts and empty-interval archives must keep cleaning up mechanically) and the setup-time full-replace paths. Enforcement is instead runtime, at every anchor's delete site: E's parent-delete guard (E's exclusive scope, contract §10) refuses the root delete with TEMPORAL_ROW_IMMUTABLE when ever-effective schedule content references it, which makes the retained cascades unreachable for protected content. No migration work lands in A for this; A contributes the referencing predicate (lineageHasGovernedHistory generalized to "content of an ever-effective version references anchor X") as a named query E consumes. Residual bypass risk is ledgered in §7.
- Concurrency: within a lineage every boundary write happens under the published row's FOR UPDATE lock (existing pattern). Across lineages, two concurrent publishes in one AY are already serialized by the timetables_tenant_ay_published_unique partial unique + TIMETABLE_PUBLISH_CONFLICT pre-check; a lineage can only start publishing after the previous PUBLISHED row is gone from that state, so its frontier read observes every committed boundary.
Epoch (School.temporalEpoch) — write points only (read semantics are B's, contract §8):
- Migration backfill:
UPDATE schools SET temporal_epoch = CURRENT_DATE WHERE temporal_epoch IS NULL— the deploy date (server-clock date is acceptable for a one-time marker; ±1 day of skew is insideRECONSTRUCTED's honesty budget). - School-row creation: the create branch of the
SchoolService.bulkSyncupsert (src/school/school.service.ts:185— the onlyschool.create/upsertinsrc/, verified) stampstemporalEpoch = schoolToday(dto.timezone, new Date()). A new tenant's School row is born during setup, before any timetable can exist, so post-program tenants never have reconstructed history — the contract §6 intent (see §8 for the "at provisioning" wording). - Seeds: the fixture School constants (e.g.
prisma/seed/e2e-fixtures.ts:152upsert) gain a deterministictemporalEpochpredating the seeded AY, so seeded tenants read as fully observed. - Dev-only
POST /tenants/:id/resetdeletes the School row (src/tenants/tenants.queries.ts:241); the epoch is re-established when the wizard recreates it. Documented, accepted (dev-only). - Missing epoch (NULL after backfill, or a School row predating the stamp): B and E resolve every date as
RECONSTRUCTED(contract §2b #5). A's backfill plus the create-branch stamp make the state transient, but the conservative read is the contract — A only guarantees the write points.
Structural audit (contract §9; A consumes D's seam — signature and { v: 1, before, after, effectiveOn } payload are binding, all else refinable by D):
| Event | action |
before |
after |
effectiveOn |
|---|---|---|---|---|
| First publish | timetable.published |
null |
{ name, revisionNumber, effectiveFrom } |
F |
| Republish | timetable.republished |
{ archiveId, revisionNumber, effectiveFrom, effectiveUntil } (outgoing) |
{ revisionNumber, effectiveFrom } (incoming) |
F |
| Unpublish | timetable.unpublished |
{ archiveId, revisionNumber, effectiveFrom, effectiveUntil } |
null |
U |
entityType: 'timetable', entityId = the lineage's stable id (D's D1 anchor rule — retain-then-swap keeps it across republish). Vocabulary source: D's reserved-names registry (src/audit-log/structural-audit.constants.ts) is the single authoritative table — A imports its constants and never inlines the strings (D §5.2's review-reject rule). D §5.2's timetable row is amended in this same seam-repair pass to exactly the event set above: entityType timetable, verbs .published/.republished/.unpublished, one event per logical command (a republish is one .republished event, never a .superseded + .published pair — the same one-event-per-command rule D itself applies to .moved); D's original reservation (timetable_version, pair emission, no unpublish verb) is superseded by that amendment. Payloads are version-metadata projections, not full row snapshots — the content evidence is the archived rows, so a full Timetable row copy adds nothing forensic; this is a D-§4 projection exception ledgered in D §7 alongside D4 (student_enrolment). Calls go inside the existing publish transactions; the actor snapshot follows AuditService.resolveActor (src/audit-log/audit.service.ts:26). TimetablesModule gains the audit-log module import.
Version numbering fix implied by unpublish-retention: setStatus first-publish currently coalesces revisionNumber: tt.revisionNumber ?? 1 ("unpublish must not consume a version", timetables.service.ts:1893-1897). With unpublish now retaining a numbered version, re-publish assigns 1 + max(revisionNumber over the lineage's rows) ?? 1 so the retained copy and the re-published row never share a number. §7 logs the reversal.
Migration shape¶
- Additive, one migration (suggested name
timetable_valid_time_and_temporal_epoch): three nullabledatecolumns, data backfill, then two CHECK constraints — in that order, so the CHECKs land on already-conforming rows. - Data backfill (contract §5, verbatim semantics):
- ARCHIVED rows:
effective_from= school-tz date ofCOALESCE(published_at, created_at),effective_until=GREATEST(effective_from, school-tz date of superseded_at). School-tz conversion via(ts AT TIME ZONE 'UTC' AT TIME ZONE s.timezone)::datejoiningschools s ON s.tenant_id = t.tenant_id; a follow-upUPDATE ... WHERE effective_from IS NULLwith'UTC'covers tenants without a School row. TheGREATESTclamp andCOALESCEare belts —supersededAt >= publishedAtby construction and archived rows carrypublishedAtsince retention, but the CHECK must hold for every historical row the Railway dev DB may contain (ch12 "stale data" P3009 lesson). - The PUBLISHED row per (tenant, AY):
effective_from= school-tz date ofpublished_at,effective_until = NULL. Reconstruction attributes a mid-day switch day to the incoming version (registers taken after the old switch rendered the new content); the residual ±1-day ambiguity — including the theoretical midnight-straddle between the twonew Date()calls inpublishRevision— is pre-epoch by definition and reported asRECONSTRUCTEDby B. - DRAFT rows: untouched (NULL/NULL).
schoolsbackfill as above.- Live-version duration freezing (§2b #2): the currently-PUBLISHED version per (tenant, AY) gets every lesson's derived duration written into
resolvedDurationat deploy — the 4-level cascade resolution lives in TS (stampResolvedDurations), so this runs as a one-time application-level backfill step in the same deploy (tools/stamp-resolved-durations.ts, script not raw SQL), exactly as the retention iteration stamped archives. It fills NULLs only, and re-checks each timetable underFOR UPDATEinside its own transaction so a lineage unpublished mid-sweep is skipped rather than stamped back into a frozen draft. Until it runs, the live version's past days are still retimeable by curriculum edits — which is the hole the amendment closes. - Hazards from the chapter-12 checklist, walked:
- #1 NOT NULL without DEFAULT — no: all three columns nullable.
- #2 unique — none added.
- #3/#5 drop/rename — none.
- #4/#6 type/enum changes — none.
- #7 FK — none.
- #8 destructive recreate — n/a; verify the generated SQL is pure
ADD COLUMN+ raw appended SQL. - New CHECKs on populated tables (the P3009 stale-data class, ch12 "Recovering a wedged deploy"): mitigated by ordering backfill before
ADD CONSTRAINTin the same migration and by the backfill's clamps covering every representable legacy shape. - Not a new tenant-bearing model, so the five drift guards — rls-coverage, tenanted-models, tenant-reset coverage, label-coverage, db-constraints e2e — are n/a as registrations; the fifth still applies as an assertion duty: both CHECK names go into
test/db-constraints.e2e-spec.tsand ch12's raw-SQL table (two new rows), per the "adding a raw constraint means both" rule.
Indexes and uniqueness¶
- Two CHECK constraints (raw SQL; Prisma cannot express CHECK):
timetables_effective_shape_chk:(status = 'DRAFT' AND effective_from IS NULL AND effective_until IS NULL) OR (status = 'PUBLISHED' AND effective_from IS NOT NULL AND effective_until IS NULL) OR (status = 'ARCHIVED' AND effective_from IS NOT NULL AND effective_until IS NOT NULL)— the per-status table above, made mechanical. Deliberate side effect: the not-yet-implemented AY-archival cascade (therevisionOfId IS NULLARCHIVED producer) is forced to choose a closing boundary when it is finally built.timetables_effective_order_chk:effective_from IS NULL OR effective_until IS NULL OR effective_from <= effective_until—=permitted: the empty interval is legal evidence.- No new index. Resolution and frontier scans run per
(tenantId, academicYearId)over a lineage of at most a few dozen rows, already covered by the existing@@index([tenantId, academicYearId]). - No uniqueness change:
timetables_tenant_ay_published_uniqueandtimetables_open_revision_uniqueare untouched and remain the write-path serializers the frontier rule leans on.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| PUT | /timetables/:id/status |
@RequireAction(EntityKey.TIMETABLES, 'publish'), @AppliesPolicy(TimetablesPolicy) (unchanged) |
UpdateTimetableStatusDto + optional effectiveFrom (@IsDateOnly; rejected with 400 VALIDATION_FAILED when status: 'DRAFT' — an unpublish takes no date in v1) |
TimetablePublishResponseDto (= TimetableResponseDto + effectiveFrom, + appliedFrom per contract §13) |
| POST | /timetables/:id/publish-revision |
unchanged | new optional body PublishRevisionDto { effectiveFrom?: string } (route is body-less today — additive) |
TimetablePublishResponseDto (= TimetableResponseDto + effectiveFrom, + appliedFrom per contract §13) |
| DELETE | /timetables/:id |
unchanged | — | 204; new 409 TEMPORAL_ROW_IMMUTABLE when the lineage has governed history |
| GET | /timetables, /timetables/:id |
unchanged | — | TimetableResponseDto gains nullable effectiveFrom (additive) |
Read-path notes (mandated): the non-admin policy pin to status: PUBLISHED (src/timetables/timetables.policy.ts) is a visibility rule and stays — archives remain invisible on every route. The status-based date lookups that this spec obsoletes — loadPublishedTimetableId and loadOperativeWeekdaysForGrade (src/attendance/attendance.queries.ts) — are B's to rewire onto findTimetableVersionEffectiveOn, including the attendance write gate, which stops being status-only and asks whether a schedule source governs the target date (contract §2b #17: after an unpublish, the retained archive can legitimately govern today); until B lands, today's attendance board keeps its known republish-window approximation (ch19 §3.1), now with the correct answer sitting in the data. Empty-interval archives are not surfaced in any UI (A's call per contract §5): archives have no read surface at all (retention iteration), and the timetable.republished audit event is the visible evidence.
Swagger considerations¶
ApiSetTimetableStatusandApiPublishRevision(src/timetables/timetables.swagger.ts) descriptions gain the FE-facing effective-date contract: publishing takes effect fromeffectiveFrom(default: the next calendar day in the school's timezone; the academic year's very first publication takes effect today); the date may be set to today — accepted only while no attendance has been recorded today — or to any later day, never to the past; the response'seffectiveFromis what to show on the confirmation ("takes effect Monday"). No transaction/lock/column internals in the copy.- New 422 example for
TIMETABLE_EFFECTIVE_DATE_INVALID(params{ effectiveFrom, minimum, reason }— one example perreason, so the FE sees both the "too early" and the "attendance already recorded today" shapes) and 409 example forTEMPORAL_ROW_IMMUTABLE(params{ entity, id }) insrc/common/constants/error-examples.ts;messages { en_US, it_IT }rows insrc/common/i18n/error-messages.catalog.ts(ch06 + backend-error-i18n) — the catalog copy interpolatesreasonsemantics into two distinct message texts. TimetableResponseDto.effectiveFrom:@ApiProperty({ format: 'date', nullable: true }), copy: "First day this timetable governs the schedule; null while a draft."effectiveUntilis deliberately not exposed — the DTO only ever renders DRAFT/PUBLISHED rows, where it is always null.TimetablePublishResponseDto.appliedFrom:@ApiProperty({ format: 'date' }), copy: "Resolved application date — always equal toeffectiveFrom; carried under this name on every temporal write response across the platform." Publish responses only; GET reads are not temporal writes and carryeffectiveFromalone.- Documentation deltas shipped with the implementation: ch18 §5.1 (publish/republish gain the effective-date rule; retention gains valid time), ch19 §3.1 (its "date-aware resolution was considered and dropped" paragraph is superseded by the program — pointer to this spec; full rewrite belongs to B/F), ch12 raw-SQL table (+2 rows), and a small FE-guide note under
docs/fe-guides/for the publish-response delta + new error code.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none — publish/delete grants already exist for admin |
*_SCOPES runtime constant |
none — timetable routes are @AggregateResponse(), no scope-grouped field surface changes, so scope-fields.ts stays untouched and the RBAC drift check stays green |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Retention iteration-2 §1 non-goal: "No validity windows and no date-aware resolution", echoed by ch19 §3.1 | Reversed — this is the program's point (contract §1/§5). | The premise changed: the 2026-07-26 attendance audit concluded every history bug is derive-from-mutable-present; the contract supersedes the day-shape-iteration-2 premise. Retention (content immutability) is kept and completed with valid time, exactly as contract §5 frames it. | Ch19 §3.1's rationale paragraph becomes stale the moment this ships and must be marked superseded (B/F own the rewrite). The manifest's fate is F's decision, not re-litigated here. |
Publish takes effect immediately (today's setStatus/publishRevision swap content mid-day) |
Publication is dated: content governs from effectiveFrom (default next school-tz calendar day), never from the commit instant. |
Contract §5/§7 — applying a revision to "today" is how registers already taken get invalidated. | Ruling chain on the record: v1 as first specced allowed no same-day fix at all (AY's first publication excepted, §2b #1); the sign-off review then extended it (§2b #20) — an urgent same-day fix can take effect today via an explicit effectiveFrom = today, gated on no attendance cell existing on schoolToday. Once a cell exists, today is gone: the change waits for tomorrow, and correcting an already-governed day stays with the deferred amendment surface (G, §2a). FE must render "takes effect <date>" instead of assuming immediacy. |
setStatus unpublish is a bare status flip that "destroys no content … must not consume a version" (comment at timetables.service.ts:1893-1897) |
Unpublish retains an ARCHIVED copy, closes its interval at max(oldFrom, schoolTomorrow), nulls the draft's revisionNumber; re-publish consumes the next number. |
A governed version that stops governing is history; without retention, editing the now-DRAFT row would rewrite what date-resolution returns for past days. | One extra content copy per unpublish; the comment and its unit spec are rewritten. Unpublish keeps publishedAt on the row (transaction time, "never cleared" per the schema comment) — only valid time is cleared. |
| Delete of a published row cascades its whole lineage — accepted as the retention iteration's "escape hatch" | Refused (TEMPORAL_ROW_IMMUTABLE) once the lineage has governed ≥1 day; still allowed for never-governed lineages, cascading their empty-interval archives. |
Contract §10: history is never destroyed by a delete. | Governed lineages become undeletable through the API forever (pruning was already deferred). Deleting a never-governed lineage discards empty-interval evidence rows — accepted: they attest to versions that never ran. Parent cascades that remain legitimately destructive: DRAFT-only AY delete (a DRAFT AY cannot host a publish — TIMETABLE_PUBLISH_AY_NOT_ACTIVE — so no governed intervals exist under it) and the dev-only tenant reset; both accepted here per contract §10's instruction to surface cascade history-loss in this ledger. |
AttendanceDaySlot snapshot as the only past-day schedule source (ch19 §3.1) |
A second, authoritative source appears: the domain's own valid-time history. | The manifest freezes evidence per day; valid time records the schedule's own history. Both are wanted (retention iteration §8 argued the same). | Until F decides the manifest's fate, two mechanisms coexist; B's completeness marker is what keeps the gap honest. |
DB onDelete: Cascade paths from anchors (subject group / combined class / homeroom / student / teacher) into schedule-content rows |
Retained as-is; protection for ever-effective content is E's runtime parent-delete guard (§2b #11), so the cascades only ever fire for never-effective content. | Severing the FKs (Cascade→Restrict) would break the pre-effective escape (never-effective drafts, empty-interval archives) and the setup-time full-replace cleanup; the guard sits at every API delete site instead. |
Raw SQL or a future guard-less code path could still cascade protected content — same residual class as every other application-enforced invariant in the module; accepted and stated here so it is never "discovered". |
8. Pushback log¶
| US says (here: contract says) | Conflicts with | Proposed instead | Status |
|---|---|---|---|
§6: temporalEpoch "set at provisioning for new tenants (src/tenants/ touch)" |
Provisioning creates no School row — provisionTenantWithAdmin (src/tenants/tenants.queries.ts:50) inserts Tenant/User/Staff/Role rows only; the School row is born at the wizard SCHOOL step (SchoolService.bulkSync upsert, the only creation point in src/). A column on schools cannot be written at provisioning. |
Stamp the epoch at School-row creation (still strictly before any timetable/attendance history can exist — the §6 intent holds: post-program tenants never have reconstructed history). The actual src/tenants/ touch is documentary: the dev-only reset deletes the School row and therefore the epoch. |
Resolved at review, 2026-07-26 — contract §6 amended (§2b #4): epoch stamped at School-row creation, exactly as designed here |
§5 first bullet: "effectiveFrom = nextSchoolDay ≠ publishedAt" vs §7 table: timetable publication default = "school-tz next calendar day" |
Internal contract inconsistency — and nextSchoolDay does not exist until C1, which runs after A (§2 order), so A could not call it even if it were the rule. |
Next calendar day, per §7's explicit table and this spec's mandate. A publish landing on a weekend simply has its first operative day later — harmless, since resolution is an interval test, not a per-day row. §5's bullet reads as shorthand for "effectiveFrom ≠ publishedAt". | Open (wording nit for the contract; designed to §7) |
§13: "Temporal write responses carry the resolved application date as appliedFrom" |
A's mandate and §5 fix the timetable's field name as effectiveFrom on the publish response; carrying both duplicates one value under two names on one DTO. |
Emit both, per §14's "design still follows the contract": the two publish responses use TimetablePublishResponseDto carrying appliedFrom (the §13 program-wide carrier) alongside effectiveFrom (the entity's own column name, §5's deliberate timetable naming) — same value, one-line duplication, and B/E writers reading §13 find exactly the field it promises. GET reads are not temporal writes and carry effectiveFrom alone. A §13 clarifying note ("on the timetable, appliedFrom duplicates effectiveFrom") remains desirable but is no longer needed for compliance. |
Resolved (design emits the §13 appliedFrom alongside effectiveFrom — contract followed as written) |
§5: "Same-day republish: the outgoing never-effective version keeps an empty interval [X, X)" — stated as a special rule |
Nothing — but treating it as a special case would invite a second code path. | Derived, not special-cased: retained interval is always [oldFrom, F) with F >= frontier >= oldFrom; the default F = max(schoolTomorrow, frontier) makes it empty precisely when the outgoing never took effect. One branch-free rule satisfies the contract sentence. |
Resolved (design detail, no contract change) |
§10: deleting an open-interval row converts to an interval close at the command's default date; deleting a closed-interval row (validUntil set) is refused with TEMPORAL_ROW_IMMUTABLE |
DELETE /timetables/:id targets an aggregate root (a lineage), not a membership row. The open-interval convert-to-close already exists as its own command — unpublish (§4) — so converting the delete would duplicate that path under a destructive verb; and deleting a never-governed lineage cascades its closed empty-interval ARCHIVED rows, which §10 rule 1 nominally refuses. |
Stricter than §10 on open intervals: refuse outright (TEMPORAL_ROW_IMMUTABLE) once the lineage has governed ≥1 day — an admin wanting the close semantics uses unpublish. Weaker than §10 on empty intervals only: a never-governed lineage's [X, X) evidence rows cascade with it (accepted in §7's delete row). |
Resolved at review, 2026-07-26 — contract §10 amended (§2b #13: aggregate-root deletion vs interval close distinguished; §2b #12: empty intervals excluded from every ever-effective predicate). Designed behavior stands as written |
9. Deferrals¶
- Schedule resolver + attendance rewiring (
resolveExpectedSchedule,loadPublishedTimetableId/loadOperativeWeekdaysForGradecall sites,completenessemission) — B's exclusive scope; A ships the query B composes. Follow-up: spec B. - Membership/placement valid time and the
EXCLUDEswap — C1–C3; the timetable deliberately needs no gist exclusion (frontier rule, §4). Follow-up: specs C1–C3. - Manifest fate / sealed evidence — F. Ch19 §3.1's full rewrite rides B/F, not A.
- Backdating & boundary corrections (retroactive revisions, pulling an effective date backward, editing a reached boundary) — the deferred amendment surface (G, contract §2a/§12); v1 fails these with
TIMETABLE_EFFECTIVE_DATE_INVALID, and the error copy must not point at an amendment flow that does not ship.TEMPORAL_BACKDATE_FORBIDDENstays unused by A (it belongs to §7's membership-command validation). - Version-history read surface / diff / pruning — unchanged deferral to the budget-module spec (retention iteration §9).
- AY-archival cascade — still unimplemented;
timetables_effective_shape_chknow forces it to close intervals when built. Follow-up: whatever spec builds it. - Unpublish-while-revision-open oddity (the open draft keeps pointing at a now-DRAFT parent;
publishRevisionon it is already degenerate today) — pre-existing behavior, unchanged by A. Follow-up: revisit at next timetable lifecycle iteration. - Notification on republish — the
TODO(notify, deferred)inpublishRevisionstays.
10. Open questions¶
- Unpublish: retain-and-close (designed) or forbid once effective? Alternative: reject
setStatus(DRAFT)wheneffectiveFrom <= today("a governing timetable can only be replaced, never withdrawn"), which avoids the retained-copy write but removes an existing capability. Resolved at review, 2026-07-26: retain-and-close confirmed — it preserves the capability, reusesarchivePublishedVersionverbatim, and produces exactly the history shape resolution needs. - Expose
effectiveFromon all timetable reads, or only on the two publish responses? Resolved at review, 2026-07-26: exposed on ALL timetable reads (one nullable additive field onTimetableResponseDto) — the FE shows "governs since <date>" without a second fetch. - First-publish effective-date default — B's cross-spec ask (B §8 / B §10 Q3). Resolved at review, 2026-07-26: the exception is adopted and the contract amended (§2b #1) — when the tenant/AY frontier is
NULL(no version has ever been effective for that academic year; keyed to the AY frontier, never to "first publication of a lineage"), the default becomeseffectiveFrom = schoolToday. The frontier clamp and the forward-only override stayed untouched at that ruling, so every case where a register could already exist (prior lineage, unpublish boundary, pending version) still defaults to tomorrow. Designed into §4's publish rule; B's §5.3a regression row is superseded accordingly. (Coda: §2b #20 later widened the override itself to gated-today — see §4; the tomorrow default and the frontier clamp still stand.)
11. Verification plan¶
- Unit specs —
src/timetables/timetables.service.spec.ts: - publish default: no override →
effectiveFrom= school-tz tomorrow (fake timezone west of UTC in the evening — the ch19 §14 desync case — asserting school-tz, not server-tz); the AY's first-ever publication (frontierNULL) →effectiveFrom=schoolToday(§2b #1), and any subsequent publish reverts to the tomorrow default; - override validation:
requested < max(schoolToday, frontier)→TIMETABLE_EFFECTIVE_DATE_INVALIDwithreason: 'BEFORE_MINIMUM'andminimumin params;requested = frontieraccepted (when>= schoolToday); - publish-today gate (§2b #20): explicit
requested = schoolTodaywith zeroattendance_recordsonschoolToday→ accepted,effectiveFrom = today; with one cell present →TIMETABLE_EFFECTIVE_DATE_INVALID { reason: 'CELLS_EXIST_TODAY', minimum: schoolTomorrow }; explicit today over a pending outgoing version (frontier = tomorrow) →BEFORE_MINIMUM(the frontier clamp wins); publish-today then same-day republish-today → outgoing keeps the empty interval[X, X)(the §4 branch-free rule, unchanged by the gate); - duration freezing: first publish and republish each write the derived duration into
resolvedDurationon every lesson entering PUBLISHED (§4, §2b #2); the archive inherits it and its own stamp is anonlyMissinggap-filler; unpublish clears it. Migration20260727150000_scheduled_lesson_resolved_duration; - republish boundaries: retained copy gets
[oldFrom, F);F = oldFrom(pending outgoing) → empty interval; published row reopens[F, NULL)with incrementedrevisionNumber; - unpublish: archive
[oldFrom, max(oldFrom, tomorrow)), row reset to DRAFT/NULL/NULL/null-revisionNumber; re-publish numbers past the retained copy; - delete gate: governed lineage →
TEMPORAL_ROW_IMMUTABLE; standalone draft and never-governed pending lineage → allowed; recordStructuralChangecalled inside the tx with the §4 action/effectiveOntable (mock, D's signature; action strings imported from D'sstructural-audit.constants.ts, never inlined).src/timetables/timetables.queries.spec.ts:findTimetableVersionEffectiveOn— the freshly-republished window (today → archived id, tomorrow → published id), gap dates → null, DRAFT rows never returned, empty intervals never returned;computeEffectiveFrontierover open/closed/empty mixes;hasEverEffectiveContentReferencing— true for an anchor referenced by an ever-effective version's content, false for anchors referenced only by drafts or empty-interval archives (§2b #11/#12);countAttendanceCellsOn— counts only the given tenant-day (other tenants and other dates excluded).src/school/school.service.spec.ts:bulkSynccreate branch stampstemporalEpoch; update branch never touches it.- E2E specs —
test/timetables.e2e-spec.ts: - publish → response
effectiveFrom= tomorrow (YYYY-MM-DDstring) withappliedFromequal to it (contract §13); republish with a forward override → 200 with that date; backdated override → 422 (reason: 'BEFORE_MINIMUM'); - publish-today (§2b #20): explicit
effectiveFrom = todaywith no cells recorded today → 200 governing today immediately; after seeding one attendance cell on today → 422 withreason: 'CELLS_EXIST_TODAY'(cell cleaned up inafterAllper the shared-tenant isolation rule); - after a republish,
GET /timetablesreturns the same single lineage row as before (no-consumer-change criterion re-asserted with valid time present); - delete of the governed lineage → 409
TEMPORAL_ROW_IMMUTABLE. test/db-constraints.e2e-spec.ts: both CHECK names exist; a DRAFT insert witheffective_fromset and an ARCHIVED insert witheffective_until < effective_fromare rejected;[X, X)ARCHIVED insert is accepted.- Manual verification: publish on a Rome-tz tenant late evening UTC and confirm the response date is Rome's tomorrow;
SELECT status, revision_number, effective_from, effective_until FROM timetables WHERE tenant_id = … ORDER BY effective_from NULLS LASTafter a publish → republish → unpublish sequence shows contiguous, non-overlapping intervals with one open row at most.
Patterns: chapter 09 (testing) + feedback_e2e_isolation_patterns.md — the timetables e2e shares the seeded tenant with the four attendance specs (the day-shape iteration learned this the hard way), so every ARCHIVED row and every effectiveFrom-bearing fixture this spec creates is cleaned up in afterAll.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-07-27
- Chat reference: 2026-07-26 temporal-program review conversation — batch approval of the reviewed program ("approved, but proceed one spec at a time from now on"), with statuses flipping as each spec enters implementation; A entered implementation 2026-07-27 immediately after D shipped ("go on with the next plan"). The 2026-07-27 plan-review amendments (AY-day lock, school-clock hoist, public-barrel reversal,
stampResolvedDurationscounts, committed-baseline preflight, A owns the e2e fixture repair) are folded into the plan and binding on this spec.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.