Skip to content

date: 2026-07-26 slug: attendance-temporal-program-contract status: Approved # ratified by Fabio 2026-07-26 (solo review + §2b amendment batch through #20); per-spec statuses flip one at a time as each enters implementation clickup_us: n/a — internal architecture program (out of the 2026-07-26 attendance audit + temporal-foundation design conversation) epic: attendance related_specs: - all eleven 2026-07-26-attendance-temporal-*-design.md specs bind to this contract supersedes: docs/superpowers/specs/2026-07-26-attendance-day-shape-snapshot-iteration-2-design.md (premise: backward derivation)


Attendance temporal foundation — program contract

This is the shared-conventions contract for the 11-spec temporal-foundation program. Every spec in the program is written against it. It exists because the specs are authored in parallel: any cross-spec interface (column name, function signature, enum, envelope shape, default rule) that two specs must agree on is fixed HERE, once. A spec may argue a contract rule is wrong — in its §8 Pushback log — but must still design against the contract value until the contract is amended. A spec may never silently redefine a contract item.

1. The program in one paragraph

Every attendance history bug traces to deriving dated facts from a present-tense, mutable template. The fix: valid-time (effective-dated) domain data + a pure resolver, with the day manifest demoted to a rebuildable cache.

ExpectedAttendance(D, S) =
  enrolment ∩ placement ∩ department calendar
  ∩ timetable version ∩ group membership ∩ activity audience   — all effective on D

Writes stay local (close one interval, open another). Non-overlap is a database property (EXCLUDE USING gist), not a write-path discipline. Valid time lives in domain tables; transaction time lives in audit_logs (pragmatic bitemporality — full bitemporal domain schema rejected as disproportionate).

Two designs were rejected — do not resurrect: scheduler-triggered same-day snapshots (correctness must never depend on a job having run; date < today is a predicate, not an event) and forward-generated obligation rows (roster edits become transactional fan-out over hundreds of future rows, and ScheduledActivityAudience is a live-resolved SELECTOR that composes with temporal facts but terribly with materialized rows).

2. The eleven specs — order, files, scope ownership

# File (docs/superpowers/specs/) Depends Delivers Product impact
D 2026-07-26-attendance-temporal-d-structural-audit-contract-design.md recordStructuralChange transaction-time contract none
A 2026-07-26-attendance-temporal-a-timetable-valid-time-design.md D immutable schedule history (effectiveFrom/effectiveUntil) publish confirmation gains a server-defaulted effective date
B 2026-07-26-attendance-temporal-b-expected-schedule-resolver-design.md A historical slot geometry past boards stop moving with revisions
C1 2026-07-26-attendance-temporal-c1-enrolment-placement-design.md D, A (schema only — nextSchoolDay reads Timetable.effectiveFrom/effectiveUntil) StudentPlacement history + dated enrolment exit + shared temporal utilities none
C2 2026-07-26-attendance-temporal-c2-membership-valid-time-design.md C1, D dated rosters (the uniqueness→EXCLUDE swap) none
C3 2026-07-26-attendance-temporal-c3-dependency-closure-design.md C2, D complete expectation inputs (combined-class links, audience closure, teacher assignment) none
E 2026-07-26-attendance-temporal-e-expected-attendance-resolver-design.md B, C3 authoritative expected attendance + defaults wiring + hard-delete guard responses carry the resolved application date
R 2026-07-26-attendance-temporal-r-register-lifecycle-design.md E ~~save vs submit, reopen, lock~~ DEFERRED — not in this version (§2a)
F 2026-07-26-attendance-temporal-f-manifest-projection-design.md E reduced: pure perf-cache slice only, conditional on B/E measurements evidence/sealing slice DEFERRED with R (§2a)
G 2026-07-26-attendance-temporal-g-amendments-divergence-design.md D, F ~~governed historical corrections + divergence worklist~~ DEFERRED — not in this version (§2a)
H 2026-07-26-attendance-temporal-h-scheduler-primitive-design.md ~~ops-only scheduler primitive, never load-bearing~~ DEFERRED until an operational consumer exists (§2b #16) — revive default-off; probe keys on cell presence, not submission

2a. Version scope ruling (Fabio, 2026-07-26)

This version of the attendance implementation ships D→A→B→C1→C2→C3→E only (plus F's pure perf-cache slice if — and only if — B/E's measurements demand it). R, G, and F's evidence/sealing slice are DEFERRED and are not to be taken into consideration for this version. H (the scheduler primitive) was subsequently also deferred at the sign-off review — until an operational consumer exists; on revival it ships default-off and the 10:00 constant stands (§2b #16). Concretely:

  • No register state. No submit/reopen/lock, no OPEN/SUBMITTED/LOCKED anywhere, no ATTENDANCE_REGISTER_SEALED in any thrown path (the code stays reserved-unused, §11). Attendance remains immediate per-cell upserts with as-of-D write authorization; "was attendance taken" is answered by cell presence + the audit trail, never by a submission ceremony.
  • No past-roster modifications. Past expectation is immutable by construction: every write path is forward-only, so what a past board resolves to can never change. Cells (facts) on past days stay editable under as-of-D authorization; expected rosters on past days are permanently as-resolved. The governed amendment surface (G) stays on the shelf, demand-driven.
  • With G deferred there is no divergence worklist as a safety net, so the two otherwise-ungoverned past-changers are closed as mandatory guards in this version: C1 clamps the ADMISSION effective default to >= schoolToday (§7), and E adds the retro-calendar guard (department calendar / CLOSING period writes touching dates < schoolToday refuse with TEMPORAL_BACKDATE_FORBIDDEN).
  • D's reserved registry keeps attendance_register.* and .amended as reserved-unused. The R/G/F specs remain on the shelf as designed (status Draft, deferral banner) — they are the pickup point if product demand for register ceremony or historical corrections ever materializes.

Exclusive scope ownership (a model/concern appears in exactly one spec's Data-model plan; other specs may only consume it):

  • Timetable.effectiveFrom/effectiveUntilA (School.temporalEpoch was also A's; retired 2026-07-29, §6)
  • StudentPlacement (new model), Student.exitDate, src/common/temporal/ shared utilities (nextSchoolDay, resolveEffectiveDate) → C1
  • HomeroomAssignment, SubjectGroupAssignment, StudentCurriculumSelection intervals → C2
  • SubjectGroupTeacher intervals, combined-class membership temporality (SubjectGroup.combinedClassId link), audience-input closure verification → C3
  • ExpectedScheduleResolver (module src/attendance/expected/) → B; ExpectedAttendanceResolver extension → E
  • Register lifecycle state (submit/reopen/lock) → R — F may only consume seal events, never define them (DEFERRED, §2a)
  • Manifest/cache tables + fate of AttendanceDaySlotF (perf-cache slice only this version, §2a)
  • Amendment commands + divergence worklist → G (DEFERRED, §2a)

2b. Sign-off amendment batch (Fabio, 2026-07-26 review)

Fabio's solo review of the 12 documents produced this amendment batch, folded into the contract in one edit before per-spec approval. Each row states the ruling and the section where it landed; specs align to these, not to their pre-review text, wherever the two differ.

# Amendment Landed in
1 First AY publication: when the tenant/AY frontier is NULL (keyed to the AY, never to "first publication of a lineage"), effectiveFrom defaults to schoolToday §5
2 Every lesson's resolved duration is frozen whenever content enters PUBLISHED (not only at archive), incl. an application-level backfill of the currently-live version. Amended 2026-07-27 (implementation review): the freeze lands in a NEW column ScheduledLesson.resolvedDuration, never in slotDuration §5
3 findTimetableVersionEffectiveOn is the canonical by-date lookup — no parallel reimplementations §5
4 ~~School.temporalEpoch is stamped at School-row creation~~ — WITHDRAWN 2026-07-29: the column is deleted §6
5 ~~A missing epoch resolves conservatively as RECONSTRUCTED~~ — WITHDRAWN 2026-07-29: no epoch, no marker §6, §8
6 Current reads resolve as-of schoolToday; a future-dated open row is a pending change, exposed separately (forward-head). CURRENT_ROW = { validUntil: null } is replaced by dated + forward-head helpers. FE-visible — the "zero FE impact" claim is withdrawn §3, §13
7 Pure-open heterogeneous batches may compute per-cohort X values; responses report applications[] §7
8 Live homeroom-tutor resolution is a deliberate v1 authorization policy: the current tutor's authority applies historically. Recorded here; designed in C3 (C3)
9 Same-day cutoff: effective-today MOVE / REMOVE / LEAVING is permitted only before the earliest affected obligation starts and while no affected cell exists; otherwise refuse. Cell-existence alone is not the cutoff — superseded by #19 (retired), then #20 (reinstated, cell-existence-only) §7
10 StudentPlacement intervals are the authoritative enrolment-presence episodes; enrollmentDate / exitDate / status are current-workflow projections, never history. Amended 2026-09-03: dated reads carry one status limb, status ≠ PRE_ENROLLED, beside the interval — pre-enrolment has no dated event, so the interval cannot express "never participated"; exits stay interval-dated §7
11 Ever-effective timetable content (ScheduledLesson + audience + supervisor rows) is protected from parent cascades (subject group / combined class / homeroom / student / teacher deletes) — A states the invariant, E enforces the guard §5, §10
12 Empty [X, X) intervals are excluded from every "has ever been effective" delete predicate §3, §10
13 Aggregate-root deletion vs interval close clarified: close is the ordinary path; root hard-delete is refused once anything of it was ever effective; pre-effective setup mistakes remain erasable §10
14 Coordinated temporal cutover — no mixed-version writes; C2/C3/E reach production together §16
15 Student appliedFrom may live inside the relevant scope block of scope-grouped person DTOs §13
16 H deferred until an operational consumer exists; revive default-off; the 10:00 constant stands §2, §2a
17 The attendance write gate stops being status-only: it asks whether a schedule source governs D (an archived version can legitimately govern today after unpublish) §5 (B designs)
18 C1 head-pointer ruling: Student.departmentId/gradeId are forward-head metadata — "the terminal/planned placement after all currently scheduled changes have taken effect" (write coordination, planning, imports, validating subsequent scheduled moves) — and must never answer "where is this student now?". Current placement is always derived from the placement interval effective on schoolToday (shared effectiveOn(date) predicate for lists/filters/counts/policies, batched loader for response mapping, findPlacementOn only for singular ops); the boundary takes effect through the query predicate — no scheduler, no boundary-day resync, no documented exception (both rejected: a resync makes correctness depend on a job; the exception grants destination-scoped users access too early and revokes source-scoped access too early). Pending placement stays discoverable after refresh (additive pendingPlacement on student detail) §3, §13 (C1 designs)
19 No user-supplied validFrom in v1 command DTOs (placement / membership / leaving): the effective date is always server-computed per §7 — the FE ships no date picker; requested survives only as a reserved internal parameter of resolveEffectiveDate. Consequence: requested = schoolToday is unsendable, so the same-day cutoff (#9) is retired as unreachable in v1 (returns as specced if requested dates are ever exposed). Explicitly reverses the earlier C1 ruling "optional forward validFrom accepted" → server-only. Carve-outs: A's timetable-publish forward-only override survives (publication is inherently a dated act, §5); ADMISSION keeps validFrom = today per its clamp (additive — no recorded fact contradicted; known side effect: a mid-day admission shows unmarked-expected for the day's earlier slots) — reversed by #20 §3, §7
20 User-supplied validFrom REINSTATED in v1 command DTOs (reverses #19; final): the removal made the admin's intent inexpressible ("apply from today" vs "apply from next school day"). Four-row semantics — omitted → §7 server default (nextSchoolDay); today → whole-day change (date granularity = 00:00), allowed only while no affected attendance cell exists on schoolToday (cell-existence-only — deliberately supersedes #9's dual condition; the started-but-unmarked-slot race is an accepted cost, on the record); future → scheduled change (pending semantics, #6/#18); past → rejected, TEMPORAL_BACKDATE_FORBIDDEN (backdating stays with G's deferred past-roster surface). The FE still needs no date picker — omitted remains the default path, so the API carries the expressiveness and the UI can expose it later without contract change. Publication adopts the today row too: an explicit publish effectiveFrom = schoolToday is allowed iff no attendance cell exists on schoolToday within the affected timetable's scope §3, §5, §7
21 Concurrency primitives (plan-review ruling, 2026-07-27): (a) a shared/exclusive AY-day lock (Postgres advisory transaction locks keyed on (tenantId, academicYearId, date)) — attendance cell writes take the shared lock and move resolution + validation inside the write transaction; every effective-today mutation (publish-today, placement / membership / teacher / combined-class changes with validFrom = schoolToday) takes the exclusive lock before probing cells, so the cell probe is race-free by construction; an adversarial race test proves a cell write and an incompatible effective-today change cannot both commit. Primitive lives in src/common/temporal/, first shipped by A. (b) Interval boundary writes are race-guarded by compare-and-swap: capture the row id and its current boundary, then update through a predicate that reasserts the captured validUntil (NULL for an open-row close; the exact finite date for a pending-boundary amendment); zero updated rows → 409 and full rollback including any head update — EXCLUDE prevents overlap, not lost updates. (c) Hard-delete guards run in the same transaction as the delete with the root row locked FOR UPDATE; timetable publication locks referenced anchors compatibly (FOR SHARE) before making content effective, closing the guard's TOCTOU window §7, §10
22 LEAVING closes every membership family: homeroom, subject-group and curriculum-selection episodes close at the same X as the placement close; readmission re-opens nothing automatically (placement only — memberships are re-added deliberately). Lands with C2 (the families become temporal there); C1's exit command carries the forward seam §7
23 validFrom on a genuinely non-temporal edit is rejected 400: commands that move no interval boundary (for example C3's reorders) refuse an irrelevant validFrom rather than silently ignoring it. C2 selection edits are no longer an example: the selection is versioned as one aggregate, including choices (#25) §7
24 One pending transition per membership identity in v1: homeroom = student; subject-group = (student, curriculum subject, AY); curriculum selection = student. A pending transition may be amended, retargeted or cancelled before it becomes effective, but a command may not stack an independent later transition. Such attempts return TEMPORAL_PENDING_CHANGE_EXISTS. Pending-only boundaries may be adjusted because they have never governed a day; every adjustment remains actor-stamped in audit_logs. Past/effective facts remain immutable §3, §7, §13
25 A curriculum selection is one versioned aggregate: (curriculumId, trackId, choices) travels together. Changing any part of the currently-effective aggregate closes it and opens the replacement at one X; class-membership pruning shares that X. Re-editing a not-yet-effective pending selection updates/replaces that pending plan rather than creating another transition. This supersedes C2's earlier header-only identity and removes the "choice now, class later" split §3, §7, §13
26 Selection pending state is self-describing: group details keep pendingChanges[], but the student selection read exposes pendingSelection with the planned curriculum, track, choices and effectiveFrom. A generic {kind, on} entry is insufficient on a student-scoped endpoint because it cannot identify the planned state after refresh §13
27 Selection timing is school-controlled: an authorized non-referent school-management writer may supply validFrom; referents may edit selection content but may not choose or reschedule its effective date. Referent writes omit the field and use the server default, or inherit an existing pending selection's date; an explicitly supplied field is rejected, never ignored. This follows the existing CurriculumSelectionWritePolicy rather than introducing a role allowlist §7, §13

3. Interval convention (binding)

  • Half-open [validFrom, validUntil). A row is effective on date D iff validFrom <= D AND (validUntil IS NULL OR D < validUntil).
  • Move rule: old.validUntil = X; new.validFrom = X — with X computed once per command and reused for both boundaries (computing each side independently opens a gap or overlap on a cross-department move).
  • validFrom NOT NULL in storage; optional at the API boundary with four-row request semantics (§7, §2b #20): omitted → server default; today → gated whole-day change; future → scheduled change; past → rejected. Always returned in responses (appliedFrom, §13). The FE may ship without a date picker — omitted is the default path.
  • validUntil nullable; NULL = open (no scheduled end). Open ≠ current: a row is currently effective only if additionally validFrom <= schoolToday (§2b #6).
  • Read semantics (binding, §2b #6) — three reads, never conflated: effective-on-D (dated: validFrom <= D < validUntil∥∞ — resolvers, boards); current (operational rosters / pickers / counts: effective on schoolToday — a row with validFrom > schoolToday is pending and never appears here); forward-head (admin planning: the open row with the greatest validFrom, surfacing pending changes separately from current state). C2 replaces its CURRENT_ROW = { validUntil: null } helper with dated + forward-head helpers accordingly.
  • Pending-transition cardinality (binding, §2b #24): v1 permits at most one not-yet-effective transition per membership identity. The transition may contain the paired boundaries of a move, but it may not be followed by a second future transition. Pending plans may be amended or cancelled because they have never governed a day; transaction-time evidence of every amendment remains in audit_logs. Once a boundary has taken effect, it is history and is not rewritten.
  • Selection aggregate (binding, §2b #25): curriculum, track and choices are one valid-time version. A current selection edit replaces the aggregate at one X; edits to an already-pending aggregate amend that pending plan rather than opening another future version. Any subordinate membership pruning uses the same X.
  • An empty interval [X, X) matches no date. Empty-interval rows are permitted as retained evidence (e.g. a timetable version superseded before it ever became effective) but are never resolvable. Empty intervals are likewise excluded from every "has ever been effective" predicate (delete guards, §10; §2b #12).
  • Column names: validFrom/validUntil (valid_from/valid_until) on all membership/placement tables. The Timetable uses effectiveFrom/effectiveUntil (effective_from/effective_until) — deliberately different words, because the timetable also carries the transaction-time pair publishedAt/supersededAt and the four columns must never be conflated (see §5).
  • DB type: DateTime @db.Date (matches AttendanceRecord.date, Department.calendarStartDate, Period.startDate). API serialization: YYYY-MM-DD strings, validated with @IsDateOnly() (src/common/validators/is-date-only.validator.ts).
  • All "today"/date arithmetic is school-clock. The clock/timezone primitive is hoisted to src/common/temporal/school-clock.ts before any temporal code lands (A's first task — timetables must never import from the attendance module; plan-review ruling 2026-07-27): the historical homes (loadSchoolTimezone in src/attendance/attendance.queries.ts, schoolToday in src/attendance/attendance-clock.ts:32) become re-exports/consumers. Never new Date() date-truncated.

4. Exclusion constraints (binding recipe)

Prisma cannot express EXCLUDE; each lands as raw SQL in the migration (ch12 audit applies) plus a db-constraints.e2e assertion. One migration in the program (the first to need it — C1's) runs CREATE EXTENSION IF NOT EXISTS btree_gist;.

Recipe (constraint names end _excl; identity columns only — tenancy is RLS's job, and every id is a globally unique uuid):

ALTER TABLE homeroom_assignments
  ADD CONSTRAINT homeroom_assignments_concurrent_excl
  EXCLUDE USING gist (
    student_id WITH =,
    daterange(valid_from, valid_until, '[)') WITH &&
  );

The uniques being replaced translate "one X" → "one concurrent X":

Table Today Becomes
homeroom_assignments studentId @unique one concurrent homeroom per student
subject_group_assignments @@unique([studentId, subjectGroupId]) one concurrent membership per (student, SG)
subject_group_assignments @@unique([studentId, curriculumSubjectId, academicYearId]) one concurrent SG per (student, subject, AY)
student_curriculum_selections studentId @unique one concurrent selection per student
subject_group_teachers @@unique([subjectGroupId, teacherId]) one concurrent assignment per (SG, teacher)
student_placements (new) one concurrent placement per student

Interval columns are decorative until the swap lands — this is the non-deferrable core of the program.

5. Timetable valid time (A's seam)

  • publishedAt/supersededAt are transaction time (when we acted); effectiveFrom/effectiveUntil are valid time (when it governs). effectiveFrom = nextSchoolDay ≠ publishedAt. All four stay; a doc comment on the model must state the distinction so nobody "deduplicates" them.
  • Resolution-by-date over a lineage: for any date D, the governing version is the one with effectiveFrom <= D < effectiveUntil (NULL = open). Status is never consulted to answer a date questionPUBLISHED/ARCHIVED remain workflow states only.
  • Publication default: effectiveFrom = schoolToday(tz) + 1 calendar day (strictest rule in §7 — applying a revision to "today" can invalidate registers already taken). Admin may override forward only, never backward (v1). First-publication exception (§2b #1): when the tenant/AY frontier is NULL — no version has ever been effective for that academic year; keyed to the AY, never to "first publication of a lineage" — the default is schoolToday, so the very first schedule governs the current day immediately. Publish-today (§2b #20): an explicit effectiveFrom = schoolToday override is additionally allowed — the four-row today semantics apply to publication too — iff no attendance cell exists on schoolToday for any group governed by the affected timetable's scope; otherwise TIMETABLE_EFFECTIVE_DATE_INVALID. "Forward only" therefore reads "today or forward, never past".
  • Same-day republish: the outgoing never-effective version keeps an empty interval [X, X) (retained evidence, never resolvable). A designs whether the UI surfaces it.
  • The retention iteration (approved spec + in-tree code: same-lineage ARCHIVED copies and a stable published id) supplies the immutable-content half. A adds the valid-time half and supersedes retention's original duration representation as described below — verify the in-tree state before designing, do not trust the older spec's slotDuration wording. Amendment (§2b #2): the resolved duration is frozen on every lesson whenever content enters PUBLISHED, not only at archive — incl. an application-level backfill of the currently-live version — otherwise curriculum duration edits can still retime past days before the first republish. Amendment 2 (2026-07-27, from A's implementation review): the freeze has its own column, ScheduledLesson.resolvedDuration (migration 20260727150000_scheduled_lesson_resolved_duration). It must never be stamped into slotDuration. That column is the admin's authored override and its NULL is the only marker distinguishing an authored length from a derived one; stamping there made every override indistinguishable from a default and — because copyTimetableContent carries slotDuration — froze each lineage's durations permanently at its first publish, so revision drafts stopped seeing curriculum edits. Resolution order is slotDuration ?? resolvedDuration ?? cascade. Revision drafts and duplicates DROP the freeze (they must derive live again); the ARCHIVED retention copy CARRIES it (it must record what the version cost while it was live, not what the curriculum says at archive time). The ambiguity created by stamping into slotDuration is not recoverable after the fact — this is a hard rule for every later spec, not a preference.
  • findTimetableVersionEffectiveOn(db, tenantId, academicYearId, date) (timetables.queries.ts, owned by A) is the canonical by-date lookup — every consumer (B, E, write gates) calls it; no parallel reimplementations (§2b #3). Consequently the attendance write gate stops being status-only (loadPublishedTimetableId): after an unpublish, an archived version can legitimately govern today — the gate asks whether a schedule source governs D (§2b #17, B designs; the old error code survives only if FE compatibility requires it).
  • Ever-effective content is delete-protected transitively (§2b #11): ScheduledLesson, audience and supervisor rows of a version whose interval has ever been effective must not be reachable by parent cascades (subject group / combined class / homeroom / student / teacher deletes). A states the invariant as part of its immutability contract; E's parent-delete guard enforces it (§10).
  • Backfill: existing ARCHIVED versions get effectiveFrom/effectiveUntil reconstructed from the school-tz dates of publishedAt/supersededAt; the live published row gets effectiveFrom = school-tz date of publishedAt, effectiveUntil = NULL. (This reconstruction used to be reported as RECONSTRUCTED completeness; the marker is retired — §8. The backfill itself is unchanged and already applied.)

6. Epoch (School.temporalEpoch) — RETIRED 2026-07-29

The epoch is deleted from the program. Section retained because ~20 citations across the eleven specs point at "§6"; this is what they now resolve to. See §8 for the sibling retirement — the two stand or fall together.

Ruled by Fabio 2026-07-29: attendance has never been released to dev or prod, so no date precedes any tenant's recorded schedule history in a way anyone will ever ask about. The epoch existed only to answer "is D older than our observed history", which has no askers.

What was removed: the column School.temporalEpoch, its stamping in the school-creation path, loadTemporalEpoch, and the backfill. Migration 20260729140000_drop_temporal_epoch.

Why the deletion is safe rather than merely cheap: the epoch was never load-bearing. ExpectedScheduleResolver read it solely to compute the §8 label; resolution never branched on it. A date no version covers resolves to an empty schedule — that was already the behaviour, with or without an epoch.

Withdrawn with it: §2b #4 (stamp at School-row creation) and §2b #5 (missing epoch → RECONSTRUCTED).

7. Effective-date defaults (command-specific, prospective, school-tz)

Never a universal "today". Resolution is requested ?? default inside resolveEffectiveDate, with four-row request semantics (§2b #20) on every placement / membership / leaving command:

  • validFrom omitted → the command's server-computed default below;
  • validFrom = schoolToday → a whole-day change (date granularity means 00:00), allowed only while no affected attendance cell exists on schoolToday — otherwise 422 TEMPORAL_BACKDATE_FORBIDDEN ({ requested: today, earliest: nextSchoolDay });
  • validFrom future → a scheduled change (pending semantics, §2b #6/#18);
  • validFrom past → rejected, TEMPORAL_BACKDATE_FORBIDDEN — backdating is G's (the deferred amendment surface), not v1's.

For the curriculum-selection route, explicit timing is a non-referent school-management control (§2b #27). Referents may change selection content but may not supply or reschedule validFrom; omission uses the normal server default, or preserves the date of an existing pending selection being edited.

Command Default validFrom / boundary
Pre-year setup (AY not started) department.calendarStartDate
New admission (mid-year student create) Student.enrollmentDate ?? schoolToday, clamped to >= schoolToday (§2a mandatory guard — a past enrollmentDate must not change past expectation while G is deferred)
New membership (add to homeroom/SG/selection) nextSchoolDay
Move / remove both boundaries at the same nextSchoolDay
Timetable publication school-tz next calendar day (§5)
Leaving (enrolment exit) nextSchoolDay

Shared utilities, introduced by C1 in src/common/temporal/ (new cross-cutting concern — C1's spec must flag it as such per ch16):

// smallest X > after inside [calendarStartDate, calendarEndDate], not in a
// CLOSING Period, weekday operative for (department, grade) per the timetable
// version effective on X. Undefined at year end → calendarEndDate + 1 day.
nextSchoolDay(db, tenantId: string, departmentId: string, gradeId: string,
              after: IsoDate): Promise<IsoDate>

type EffectiveDateCommand =
  | 'SETUP_PRE_YEAR' | 'ADMISSION' | 'MEMBERSHIP_ADD'
  | 'MEMBERSHIP_MOVE' | 'MEMBERSHIP_REMOVE' | 'LEAVING';

resolveEffectiveDate(db, input: {
  tenantId: string; command: EffectiveDateCommand;
  departmentId: string; gradeId: string;
  requested?: IsoDate;                      // validated, else default
  enrollmentDate?: Date | null;             // ADMISSION only
}): Promise<IsoDate>

Compute once per command for any command that closes an interval; a cross-department move uses one date for both sides (source cohort's nextSchoolDay). Pure-open batches (bulk assignment that opens intervals and closes nothing) may compute per-cohort X values for heterogeneous cohorts (§2b #7); the response's applications[] reports each applied date.

Same-day gate (§2b #20 — supersedes #9's dual condition and #19's retirement). The validFrom = schoolToday row is gated by cell existence alone: the command refuses only if an affected attendance cell already exists on schoolToday. The earlier dual condition (#9 — additionally requiring that the earliest affected obligation had not started) was considered and dropped at sign-off: the started-but-unmarked-slot race (a change landing mid-slot, before the teacher records) is an accepted cost, on the record. No scheduler involved — the check is a point-in-time predicate inside the command, run under the §2b #21 exclusive AY-day lock while attendance cell writes hold the shared side, so probe-then-commit cannot race a cell write. ADMISSION (a pure open) keeps validFrom = today per its clamp: additive same-day is benign — no recorded fact is contradicted; known side effect: a mid-day admission shows unmarked-expected for the day's earlier slots.

Enrolment episodes (§2b #10). StudentPlacement intervals are the authoritative enrolment-presence episodes: a student is enrolled-present on D iff a placement interval covers D; readmission opens a new interval. Student.enrollmentDate / exitDate / status remain current-workflow projections and are never consulted as history — C1's enrolment predicate is defined on placements.

Amendment 2026-09-03 (participation fences, spec). The rule above stands for exits: a departed student is on the register for every day their interval covers and on none after it. It is amended for pre-enrolment: PRE_ENROLLED holds an open placement and flips to ENROLLED by a plain column write, so the interval cannot say "never participated". Every dated roster read therefore carries participationHistoryStatusWhere() (status ≠ PRE_ENROLLED) beside its effectiveOn(D) predicate, and every today-anchored list carries the live participatingStudentWhere() (status = ENROLLED). expected/ reads Student.status for exactly that one value and nothing else.

8. Completeness marker (B's seam, extended by E) — RETIRED 2026-07-29

ExpectationCompleteness is deleted. Section retained for the same reason as §6 — existing "§8" citations resolve here.

The marker was risk #1's mitigation: while B made the schedule historical but left rosters live, a board that said nothing would ship a lie that read like a fix. That reasoning was correct for the B→E window and expires with it. Once E lands, schedule and rosters both resolve as-of D, so every answer is what FULL meant — and a three-valued enum with one reachable value is not a marker.

The arithmetic that closed it (Fabio, 2026-07-29):

Value Fate
RECONSTRUCTED unreachable — no pre-history dates exist (§6)
SCHEDULE_ONLY dies with E by construction, as this section always said
FULL the only survivor ⇒ a constant, not information

Removed: the enum, ExpectedSchedule.completeness, the EXPECTATION_COMPLETENESS_API_DESCRIPTION Swagger copy, and the completeness field on all five read/write DTOs (AttendanceWriteResponse, AttendanceGroupBoard, AttendanceGroupRows, InconsistenciesResponse, PendingRegisterResponse). FE-visible removal — coordinated like any other FE break.

Resolver names are unaffected and stay binding: B exposes resolveExpectedSchedule(db, tenantId, academicYearId, date, schoolToday) (slot geometry); E exposes resolveExpectedAttendance(...) (per-slot expected student sets). Both live in src/attendance/expected/, queries in named functions per the queries.ts convention.

Consequence for E: E no longer "extends" anything here. It emits no marker, adds no DTO field, and must not reintroduce one. If a future phase again makes part of an answer less grounded than the rest, that phase mints its own marker with its own reachable values — it does not revive this enum.

9. Structural audit envelope (D's seam)

No schema migration: the envelope lives inside the existing audit_logs.data JSON. Signature + envelope keys are binding; D refines everything else:

recordStructuralChange(tx: Prisma.TransactionClient, input: {
  tenantId: string;
  actor: { userId: string | null; name: string; role: string };
  entityType: string;    // snake_case domain noun, e.g. 'homeroom_membership'
  entityId: string;
  action: string;        // '<entityType>.<past-tense-verb>', e.g. 'homeroom_membership.moved'
  before: Prisma.JsonValue | null;   // null on create
  after: Prisma.JsonValue | null;    // null on hard removal (rare — see §10)
  effectiveOn?: string;  // IsoDate — the valid-time application date, when effective-dated
  requestContext?: Prisma.JsonValue;
}): Promise<void>
// stored data payload: { v: 1, before, after, effectiveOn }

Callers: A (publish/supersede), C1–C3 (every interval open/close — incl. the placement intervals closed/opened by enrolment exit and readmission: the enrolment event alone does not audit the changed interval); R (lifecycle transitions) and G (amendments) are reserved consumers whose specs are deferred per §2a. Written inside the caller's transaction, exactly like AuditService.record today.

10. Hard-delete guard (E's seam; convention binding program-wide)

  • Deleting a row whose interval is closed (validUntil set) → refused, error TEMPORAL_ROW_IMMUTABLE. History is never destroyed by a delete.
  • Deleting a row whose interval is open → converted to an interval close at the command's default date (§7). The row survives as history.
  • Precedent: ARCHIVED timetables already block delete.
  • "Has ever been effective" — the predicate behind every guard: the interval is non-empty and validFrom <= schoolToday. Empty [X, X) intervals never trip a guard (§2b #12); a pre-effective setup mistake (interval opens only in the future, nothing ever resolved from it) may still be erased outright.
  • Aggregate-root deletion vs interval close (§2b #13): ending participation is an interval close (MOVE / REMOVE / LEAVING, §7); root hard-delete (student, subject group, homeroom, teacher, combined class) is refused with TEMPORAL_ROW_IMMUTABLE once the root owns — or is referenced by — any ever-effective interval or ever-effective timetable content (§5), with the pre-effective escape above. C1 aligns the student delete path with E's predicate.
  • Cascade hazard each C-spec must address: several membership tables cascade on academicYearId/parent delete (e.g. HomeroomAssignment.homeroom Cascade, AY cascades). Where the parent delete is legitimately allowed (DRAFT-only AY delete, setup full-replace), history loss must be explicitly accepted in the spec's Divergence ledger — not discovered later.

11. Reserved error codes

TEMPORAL_ROW_IMMUTABLE, TEMPORAL_BACKDATE_FORBIDDEN, TEMPORAL_INTERVAL_OVERLAP (surfaced exclusion violation), TEMPORAL_PENDING_CHANGE_EXISTS (one-pending-transition v1 rule; declared by C1 and reused by C2), TIMETABLE_EFFECTIVE_DATE_INVALID (A), ATTENDANCE_REGISTER_SEALED (R — reserved-unused this version, §2a). Every new code ships with messages: { en, it } per ch06 + the backend-error- i18n convention. The 23P01 exclusion-violation mapping to TEMPORAL_INTERVAL_OVERLAP is implemented once (one shared mapper — E's error-ownership split ruling) and consumed by every module that writes intervals.

TEMPORAL_INTERVAL_OVERLAP covers both concurrent-interval failures (ruled 2026-07-27): the DB-detected overlap (23P01, two intervals that would coexist) and the CAS-detected lost update (§2b #21b — the predicate close matched zero rows because another transaction already moved the boundary). Both are 409, both mean "someone else changed this interval underneath you, re-read and retry"; no distinct TEMPORAL_CONCURRENT_MODIFICATION code is minted. C1 declares it; C2/C3/E consume it — the choice is settled, so no plan may hedge on it.

12. What this version deliberately cannot do (fail visibly; amendment surface DEFERRED)

Backdate roster corrections; same-day transfers between periods; retroactive timetable revisions; correcting historical enrolment/leaving dates; retroactive department-calendar / CLOSING-period edits; backdating expectation via a past enrollmentDate (ADMISSION default clamped, §7). Each fails with a specific error code (§11), never silently succeeds-approximately. Per §2a the governed amendment surface (G) that would permit these is deferred out of this version — the operations are simply not possible until it ships; error copy must not point users at an amendment flow that does not exist. ("Reassigning a submitted register" drops from the list: with R deferred there are no submitted registers.)

13. Response-shape conventions

  • Temporal write responses carry the resolved application date as appliedFrom: 'YYYY-MM-DD' (even when server-defaulted — the FE must be able to show "takes effect Monday").
  • Roster/list reads of current state resolve as-of schoolToday (§3 read semantics); forward-dated rows surface separately as pending changes (forward-head reads). This is FE-visible: the earlier "FE impact of C1–C3 is zero by design" claim is withdrawn (§2b #6) — the pending-change surface is new FE contract, flagged and coordinated like any other FE break.
  • Student temporal write responses may carry appliedFrom inside the relevant scope block (e.g. assignment) of scope-grouped person DTOs rather than at top level (§2b #15).
  • Pending state must survive refresh — a write-response-only appliedFrom is insufficient when defaults are forward-dated (the admin reloads, sees the old placement, and reasonably assumes the move failed). A forward-dated placement is exposed on the student detail read as an additive pendingPlacement { departmentId, gradeId, effectiveFrom } inside assignment (§2b #18). C2's group reads expose pendingChanges[]; the selection read instead exposes a self-describing pendingSelection carrying the planned curriculum, track, choices and effectiveFrom (§2b #26).

14. Spec-authoring rules (every writer)

  • Template: docs/superpowers/templates/new-feature-design.md — every section filled; ledger sections use "None — <reason>" when empty; ≥3 patterns-survey rows linking to real repo files; frontmatter status: Draft.
  • New tenant-bearing model ⇒ FIVE drift guards (rls-coverage, tenanted-models, tenant-reset coverage, label-coverage, db-constraints e2e) — ch12's three are not the full list (precedent: the day-shape snapshot).
  • Migration sections run the ch12 hazard checklist explicitly.
  • RBAC: read/update are never action names; PATCH uses @RequireScopes('write').
  • Swagger/DTO JSDoc is FE-facing public copy — no backend internals.
  • Open questions: minimized, each with a recommended answer; user resolves at sign-off.
  • Disagreement with this contract → §8 Pushback log, design still follows the contract.

15. Seam checklist (for cross-spec verification)

  1. Column names and types exactly as §3/§5/§6 — no synonyms (startsOn, activeFrom, …).
  2. Every table in §4's swap table is temporalized by exactly its §2 owner.
  3. nextSchoolDay/resolveEffectiveDate signatures identical everywhere; introduced once (C1), consumed elsewhere.
  4. No expectation-completeness marker exists anywhere — no enum, no DTO field. Retired 2026-07-29 (§6, §8); a spec that emits one has drifted.
  5. recordStructuralChange signature + {v, before, after, effectiveOn} payload identical in D and every consumer.
  6. R exclusively owns submit/reopen/lock; F consumes seal events only; G routes reopens through R. (Moot this version — R/G deferred per §2a; holds for the shelved designs.)
  7. No spec depends on a deliverable of a spec later in §2's order; H is consumed by nothing for correctness.
  8. The cannot-do list (§12) is consistent across specs; every item is a visible refusal, deferred to the shelved amendment surface (§2a), and no error copy references a flow that does not ship this version.
  9. Teacher assignment is treated as write authorization input (canWriteStudentCellsrc/attendance/attendance-authority.ts), not display evidence (risk #2).
  10. Error codes only from §11's reserved set (or new ones added to a spec's § with en+it messages).

16. Deployment cutover (binding, §2b #14)

The temporal swaps (C1's placement backfill; C2/C3's uniqueness→EXCLUDE swaps plus interval columns) deploy as a coordinated cutover: either a strict expand-contract sequence in which every deployed intermediate state is write-safe, or an explicit maintenance window in which the affected admin writes are paused while schema and code switch together. No mixed-version writes: an old pod writing head-only rows (no interval/episode) into a temporalized table is data corruption, not degraded service — "old pods 500 briefly" (C2's earlier acceptance) and "head-only writes during the window" (C3's) are both rejected. C2, C3 and E reach production together: the delete-guard gap and episode-less write paths are acceptable only as repo implementation order, never as a deployed state.

17. Addendum 2026-07-30 — §7-bis pre-year immediacy + the lived-day amendment

Ruled and approved 2026-07-30 (docs/superpowers/specs/2026-07-30-pre-year-immediate-temporal-defaults-design.md). The sections above are point-in-time records and stay as written; this addendum is the binding delta.

§7-bis — pre-year, every omitted default is today. When the resolving department's calendarStartDate is still ahead of the school-clock today, resolveEffectiveDate short-circuits every omitted-validFrom default to today: pre-year there is no taken register to protect, and the prospective defaults turned every configuration edit into a booked transition that never read back as current. Explicit dates are untouched (past → 422 TEMPORAL_BACKDATE_FORBIDDEN, today-or-later → verbatim). The SETUP_PRE_YEAR command is retired — its condition is subsumed — and ADMISSION's enrollmentDate is honoured only once the year has started (pre-year it stays recorded domain data while the interval opens today).

Lived-day amendment (§10, §2b #12/#13). "Ever been effective" in the delete guards becomes "has ever been lived": non-empty AND validFrom ≤ today AND overlapping [floor, today], where floor is the row's academic year's earliest department calendarStartDate (loadCalendarFloors; null floor — a year with no departments — means nothing has been lived). Consequences:

  • Pre-year, an interval effective today protects nothing — configuration churn (rosters, placements, whole parents) deletes freely until the first day school could actually have run.
  • Deliberate widening: a closed interval that never overlapped the calendar (pre-year churn residue, validUntil ≤ floor) claims no history even when the check runs mid-year.
  • Grain asymmetry: defaults resolve per-department (§7-bis); the guard floor is per-year (MIN across the year's departments) — one started department makes the whole year's effective intervals eligible as history.
  • The row-level gates (assertRowDeletable, closeInsteadOfDelete) keep the stricter closed-non-empty rule for now — no live callers; align when one appears.
  • hasEverEffectivePlacement (the students route's courtesy fast-fail and the enrollmentDate-edit gate) follows the same lived predicate.

Deployment note (2026-07-30; amended 2026-08-03). Data written under the old defaults sits booked at the year's first school day and collides with §7-bis (the sync refuses a pending selection on a different date; roster commands inherit the booked date instead of applying today) — and the C1/C2 conversion backfills anchored every pre-existing row the same way, so after the release a fully-configured pre-year tenant reads every roster as a pending change applying day one. The retrofit was originally the manual tools/reanchor-pre-year-intervals.sql ("run once per environment"), which was never run anywhere; it now ships as migration 20260803120000_reanchor_pre_year_pending_changes (2026-08-03), which every environment applies exactly once at deploy: in pre-year years, booked closes then pending opens are clamped to school-clock today across the six interval tables (deploy-cutover semantics; non-empty closed future anomalies are skipped and WARNed, never touched). The tool script survives for ad-hoc inspection only.

18. Addendum 2026-07-30 — supersede on roster commands + sync no-op tolerance

Ruled and approved 2026-07-30 (docs/superpowers/specs/2026-07-30-pending-change-supersede-design.md). The sections above stay as written; this addendum is the binding delta to §2b #24's write rule. The cardinality invariant itself is untouched: at most one not-yet-effective transition per membership identity at every commit point.

Supersede (homeroom + subject-group roster commands). A second independent future transition is refused — unless the command explicitly supersedes (supersede: true on add/remove/move and both create compositions), which atomically replaces the outstanding plan inside the command's transaction: a booked join is cancelled whole to [d, d) (a move's source reopens), a booked leave is re-anchored onto the command's boundary so the leave and the new open compose one move-shaped transition (the leave composition arises on homeroom add/create, whose gate reads the FORWARD membership; on the SG axis the one-concurrent gate refuses a current membership outright and the same composition rides moveStudents, whose writer re-dates the source close inline). Replace, never stack — no command, flagged or not, may leave two future boundary dates on one identity. The response reports every replaced plan (superseded[]), including the flag-free same-group amendments; the audit trail records both halves. A superseding re-anchor that pulls a booked close onto today is a same-day close and takes §2b #20's gate. Supersede never bypasses eligibility. Referent surfaces carry no flag; student_placement deliberately keeps refuse-only semantics (its move surface already amends/re-dates; the exit ruling stands).

Forward-sync no-op tolerance. applyClassAssignmentToSelection refuses a rival-dated pending selection only when it would actually write: consistency is checked against every version governing from the command boundary X forward (the current version over [X, Y) when X < Y, the pending from its own date). A class the whole forward timeline already agrees with is a NOOP, not a conflict. The surviving 409s are honest — a genuinely inconsistent booked selection (resolved on the selection surface, which amends/re-dates its own pending), and X before a booked opening with nothing governing between (a gap only a second future version could cover).

Closed corruption path. The homeroom plain roster opens (create-with-roster and the add arm) now refuse — or, flagged, re-anchor — a foreign booked leave at a rival date, which previously could write a second future boundary that poisoned every later normalization with the invariant 409. The SG axis is structurally immune twice over: the one-concurrent gate flags any current membership before a plain open (a booked leave implies one), and the from-homeroom batch's already-attending skip covers every pending shape — which is also why the batch deliberately has no flag.

19. Addendum 2026-07-30 — the calendar thaw (retro-calendar guard replaces the DRAFT gate)

Ruled 2026-07-30, chat sign-off. The sections above stay as written; this addendum is the binding delta to the calendar-immutability premise that E and C3 recorded.

The status gate shrinks to ARCHIVED. Setup mints the academic year directly ACTIVE, so the DRAFT-only gate on PATCH /departments/:id (dates + period arrays) and on the /departments/:id/periods sub-resource froze every post-setup calendar forever — an accident of gate composition, not a ruling. Both paths now refuse ACADEMIC_YEAR_NOT_EDITABLE only when the year is ARCHIVED. C3's dependency-closure rows #3/#4 ("calendar bounds / periods already immutable — DRAFT-only write gate") are superseded by this addendum: the closure they verified is carried by the guard below, not by status.

§5.5a is the rule, not a backstop. The retro-calendar guard E shipped (src/departments/retro-calendar-guard.ts) becomes the sole temporal gate on calendar writes: a bound may move only if no day before the school-clock today changes meaning (affected-day set = symmetric difference, half-open on the moved boundary's side); CLOSING periods may not be created, moved or deleted touching a past day, judged on both the old and the new range; TERM periods stay exempt. Consequences the FE can rely on: before the year starts the whole calendar is freely editable; once lived, the start is immovable automatically (any move crosses a lived day — the same boundary that freezes attendanceMode, so the two freezes cannot disagree) while the end and future CLOSING periods stay editable. No amendment flow this version (§12) — the refusal remains final.

Grace-period check narrows to a moved start. The department-side pre-year-completeness mirror (assertYearStartFollowsGracePeriod) fires only when calendarStartDate actually changes. A legacy year already violating gracePeriodEnding < MIN(calendarStartDate) is never blocked from editing its end date or period set by a start the patch does not touch — the "repairs stay possible" principle both mirror checks already followed.

20. Addendum 2026-07-30 — admin-authoritative commands: data-sensitive dateless default + replace by default

Ruled and approved 2026-07-30 (docs/superpowers/specs/2026-07-30-admin-authoritative-temporal-semantics-design.md). The sections above stay as written; this addendum is the binding delta to §7's dateless defaults and to §2b #24's write rule (superseding §18's supersede-flag mechanism, which shipped and was removed in the same tree).

§7 amendment — the dateless default is data-sensitive. An omitted validFrom resolves to the earliest date an explicit request would be accepted, judged by the command's OWN affected register:

  • Pure opens (roster adds/creates with no shrink, SG teacher-free create, the from-homeroom batch, bulk curriculum assignment, a first selection open, re-dating a never-effective plan) have no affected set and apply today outright — no lock, no probe.
  • Closes and shrinks (removes, moves, exits, teacher changes, combined-class member changes, a selection PATCH that closes the governing version, a homeroom add that re-anchors a booked foreign leave) resolve inside the command transaction via earliestAcceptableDate (src/common/temporal/earliest-acceptable-date.ts): take the exclusive AY-day lock on today, probe with the same predicate the explicit-today gate uses (§2b #20 — the two must not drift), and answer today when the affected register is clean, the next school day once a cell exists. A pre-transaction resolution would race a teacher's shared-lock cell write (§2b #21a), which is why the in-tx placement is binding.
  • Multi-cohort commands aggregate per affected unit and take the chronological MAX (combined classes per distinct grade), exactly as the explicit path already ruled.

§7's per-command table survives as the explicit path and the dirty-day target (resolveEffectiveDate keeps backdate/ADMISSION/verbatim semantics); §7-bis becomes the degenerate case — pre-year no register can exist, so every dateless command lands on today with a vacuous probe. Referent surfaces are untouched: a referent's dateless selection submission still inherits the booked plan's date or the calendar's own prospective default (curriculum-selection.service.ts keys the split on the same predicate as its validFrom guard, so DEPARTMENT-parametric managers count as timing authorities).

§2b #24 amendment — admin commands never block on a booked plan. The one-outstanding-transition invariant is retained writer-side (at most one not-yet-effective transition per identity at every commit point), but it is no longer a refusal surface: a command that collides with a booked plan replaces it, newest wins, inside its own transaction, and reports every replaced plan in the response's superseded[] (entity-typed: homeroom_membership / subject_group_membership / student_placement / curriculum_selection). The §18 supersede flag is removed — replacement is the unconditional behavior; §18's no-op tolerance and eligibility rulings stand (replacement never bypasses eligibility). The placement axis is included: a move dated after a booked change replaces it (replaceBookedPlacementMove — booked row erased to [d, d), the predecessor close travels to the new date), and an exit while a plan is booked replaces it (replaceBookedPlacementForExit); the same-target re-date keeps its amend arm. The class→selection forward sync is admin-wins: an incompatible pending selection at a rival date is cancelled whole to [d, d) and the class-implied fill lands at the command's own boundary, the cancellation reported and audited.

Audit outcome — TEMPORAL_PENDING_CHANGE_EXISTS retired from the admin surface. The code survives at exactly three sites, all corruption invariants over data no command can produce any more: the homeroom/SG normalize guards (pendingConflict / sgPendingConflict) and loadSelectionAggregateState's two-futures guard. No route promises it; FE-facing swagger no longer mentions it.

Standing deferral. Stage 2 (timeline coexistence — letting two future boundaries stand where the read surfaces could report them) stays deferred; this addendum deliberately keeps the singular pendingChanges[] / pendingPlacement / pendingSelection read shapes.

21. Addendum 2026-08-03 — publish dateless default is data-sensitive (§20 extended to the timetable seam)

Ruled in chat and approved 2026-08-03 (plan: docs/superpowers/plans/2026-08-03-publish-effective-today-when-compatible.md). Motivating symptom: a lesson ADDED to a published timetable and republished mid-morning waited for tomorrow, so its class rendered schoolDay: false all day while the admin looked at a slot they had just placed.

§5 amendment — the dateless publish default. An omitted effectiveFrom on publish/republish resolves per §20's rule — the earliest date an explicit request would be accepted:

  • First publication of the (tenant, AY) (NULL frontier): schoolToday, no lock, no probe — unchanged (§2b #1).
  • Frontier beyond today: the frontier — unchanged. No cell can exist on a future date (the write path refuses them), so there is nothing to probe.
  • Otherwise: schoolToday iff no attendance cell recorded on schoolToday conflicts with the incoming content, else the next calendar day. A fallback, not a refusal — the dateless command never throws for this.

The conflict predicate is anchor survival (findCellAnchorsIncompatibleWith, src/timetables/timetables.queries.ts), judged against the INCOMING content (the revision being promoted, or the draft being first-published) on today's weekday:

  • Lesson cells carry stable entity ids: the cell survives iff the incoming content holds a lesson at the same (subjectGroupId, startTick) / (combinedClassId, startTick). A combined-class cell matches only its own anchor — dissolving the combination strands its cells even when the slot geometry survives.
  • Activity cells carry a ScheduledActivity ROW id, which is version content: copies mint new rows, so ids never travel. They match structurally — the cell's snapshot activityName + startTick against incoming activities whose weekdays include today's. A renamed or moved activity therefore reads as a conflict; the conservative fallback is never wrong, only later.
  • Scoped to the academic year being published; cell existence alone, per §2b #20's standing ruling (the obligation-started variant stays dropped).

Explicit effectiveFrom = schoolToday uses the SAME predicate (no-drift rule, as §20 already demands of earliestAcceptableDate): it now refuses — TIMETABLE_EFFECTIVE_DATE_INVALID / CELLS_EXIST_TODAY, unchanged code and reason key — only when a conflicting cell exists, where it previously refused on any cell. §5's publish-today clause reads accordingly: "iff no attendance cell recorded on schoolToday conflicts with the incoming content". Lock discipline is unchanged and binding: exclusive AY-day lock BEFORE the probe, held to commit, in both the dateless and the explicit arm (§2b #21).

Untouched on purpose:

  • Unpublish keeps closing the retained interval at max(tomorrow, oldFrom) — today stays governed by the retained copy. The asymmetry is deliberate: an unpublish has no incoming content to be compatible with, and uncovering a day that carries cells would strand them against nothing.
  • The frontier floor, BEFORE_MINIMUM, forward-only overrides, the same-day-republish empty interval [X, X), and §20's command-side rules all stand as written.

22. Addendum 2026-08-06 — the SG axis reads the FORWARD membership (homeroom parity)

Ruled and approved 2026-08-06 (docs/superpowers/specs/2026-08-06-subject-group-forward-eligibility-design.md, QA-sourced). The sections above stay as written; this addendum is the binding delta to §18's SG-axis parenthetical and its "closed corruption path" paragraph.

The asymmetry is retired. §18 recorded that on the SG axis the one-concurrent gate refuses any current membership (a booked leave implies one), routing cross-group composition through moveStudents and leaving the SG module without a reanchorPendingHomeroomLeave twin. That stance produced the QA dead-end (a student removed from a course — leave booked — was unofferable and unaddable elsewhere the same day, unlike homerooms) and contradicted §20's replace-by-default reading: a booked leave is a plan, not an occupation. As of this addendum the SG one-concurrent invariant — the picker's existingSgForSubject, the write-gate's conflict pass, the from-homeroom batch's skip, and the course-blueprint selected counts — reads the FORWARD membership (forwardSubjectGroup, twin of forwardHomeroom): a current member or a booked join is spoken for; a booked leave admits.

The write side gains the missing twin. reanchorPendingSubjectGroupLeave re-anchors an admitted foreign booked leave onto the command's one boundary BEFORE the open (the exclusion constraints are non-deferrable), on all three roster-admitting surfaces (single create, single add, from-homeroom batch — which thereby loses its structural-immunity rationale and gains the same in-tx dateless resolution + §2b #20 same-day gate as the singles). Replaced plans report in superseded[] with was.outgoingGroupId; a booked join into the amended group itself re-dates whole, source close travelling with it (homeroom's move-shaped dance, now verbatim on the SG axis). The dateless default for adds/creates is accordingly no longer unconditionally "today": it is data-sensitive per §20 whenever the command supersedes a booked leave (affected set = commanded students whose foreign leave sits at a rival date).

moveStudents stands unchanged as the atomic one-call composition — no longer the only lawful one.