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_SEALEDin 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< schoolTodayrefuse withTEMPORAL_BACKDATE_FORBIDDEN). - D's reserved registry keeps
attendance_register.*and.amendedas 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/effectiveUntil→ A (School.temporalEpochwas also A's; retired 2026-07-29, §6)StudentPlacement(new model),Student.exitDate,src/common/temporal/shared utilities (nextSchoolDay,resolveEffectiveDate) → C1HomeroomAssignment,SubjectGroupAssignment,StudentCurriculumSelectionintervals → C2SubjectGroupTeacherintervals, combined-class membership temporality (SubjectGroup.combinedClassIdlink), audience-input closure verification → C3ExpectedScheduleResolver(modulesrc/attendance/expected/) → B;ExpectedAttendanceResolverextension → E- Register lifecycle state (submit/reopen/lock) → R — F may only consume seal events, never define them (DEFERRED, §2a)
- Manifest/cache tables + fate of
AttendanceDaySlot→ F (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 dateDiffvalidFrom <= D AND (validUntil IS NULL OR D < validUntil). - Move rule:
old.validUntil = X; new.validFrom = X— withXcomputed once per command and reused for both boundaries (computing each side independently opens a gap or overlap on a cross-department move). validFromNOT 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.validUntilnullable;NULL= open (no scheduled end). Open ≠ current: a row is currently effective only if additionallyvalidFrom <= 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 onschoolToday— a row withvalidFrom > schoolTodayis pending and never appears here); forward-head (admin planning: the open row with the greatestvalidFrom, surfacing pending changes separately from current state). C2 replaces itsCURRENT_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 sameX. - 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 useseffectiveFrom/effectiveUntil(effective_from/effective_until) — deliberately different words, because the timetable also carries the transaction-time pairpublishedAt/supersededAtand the four columns must never be conflated (see §5). - DB type:
DateTime @db.Date(matchesAttendanceRecord.date,Department.calendarStartDate,Period.startDate). API serialization:YYYY-MM-DDstrings, 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.tsbefore 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 (loadSchoolTimezoneinsrc/attendance/attendance.queries.ts,schoolTodayinsrc/attendance/attendance-clock.ts:32) become re-exports/consumers. Nevernew 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/supersededAtare transaction time (when we acted);effectiveFrom/effectiveUntilare 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 witheffectiveFrom <= D < effectiveUntil(NULL = open). Status is never consulted to answer a date question —PUBLISHED/ARCHIVEDremain 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 isschoolToday, so the very first schedule governs the current day immediately. Publish-today (§2b #20): an expliciteffectiveFrom = schoolTodayoverride is additionally allowed — the four-row today semantics apply to publication too — iff no attendance cell exists onschoolTodayfor any group governed by the affected timetable's scope; otherwiseTIMETABLE_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
slotDurationwording. 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(migration20260727150000_scheduled_lesson_resolved_duration). It must never be stamped intoslotDuration. 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 — becausecopyTimetableContentcarriesslotDuration— froze each lineage's durations permanently at its first publish, so revision drafts stopped seeing curriculum edits. Resolution order isslotDuration ?? 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 intoslotDurationis 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/effectiveUntilreconstructed from the school-tz dates ofpublishedAt/supersededAt; the live published row getseffectiveFrom = school-tz date of publishedAt,effectiveUntil = NULL. (This reconstruction used to be reported asRECONSTRUCTEDcompleteness; 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:
validFromomitted → 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 onschoolToday— otherwise 422TEMPORAL_BACKDATE_FORBIDDEN({ requested: today, earliest: nextSchoolDay });validFromfuture → a scheduled change (pending semantics, §2b #6/#18);validFrompast → 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 (
validUntilset) → refused, errorTEMPORAL_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_IMMUTABLEonce 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
appliedFrominside 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
appliedFromis 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 additivependingPlacement { departmentId, gradeId, effectiveFrom }insideassignment(§2b #18). C2's group reads exposependingChanges[]; the selection read instead exposes a self-describingpendingSelectioncarrying the planned curriculum, track, choices andeffectiveFrom(§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; frontmatterstatus: 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/updateare 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)¶
- Column names and types exactly as §3/§5/§6 — no synonyms (
startsOn,activeFrom, …). - Every table in §4's swap table is temporalized by exactly its §2 owner.
nextSchoolDay/resolveEffectiveDatesignatures identical everywhere; introduced once (C1), consumed elsewhere.- No expectation-completeness marker exists anywhere — no enum, no DTO field. Retired 2026-07-29 (§6, §8); a spec that emits one has drifted.
recordStructuralChangesignature +{v, before, after, effectiveOn}payload identical in D and every consumer.- 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.)
- No spec depends on a deliverable of a spec later in §2's order; H is consumed by nothing for correctness.
- 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.
- Teacher assignment is treated as write authorization input
(
canWriteStudentCell—src/attendance/attendance-authority.ts), not display evidence (risk #2). - 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 (
MINacross 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 theenrollmentDate-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:
schoolTodayiff no attendance cell recorded onschoolTodayconflicts 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+startTickagainst incoming activities whoseweekdaysinclude 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.