Attendance — the day-shape snapshot, back-fill, and write concurrency¶
1. Problem distillation¶
AttendanceRecordfreezes the student's fact; nothing freezes the day. The row snapshots ~25 columns of teaching context and survives any reorg. But every read that renders a day re-derives the day's slot set and rosters from current structure, so a past date is not reproducible.- The published timetable is mutable in place.
publishRevisioncopies the draft's content onto the published row with a stable id and hard-deletes the draft (prisma/schema.prisma:2417-2424), andloadPublishedTimetableId(src/attendance/attendance.queries.ts:233) selectsstatus: 'PUBLISHED'with no date awareness. One timetable per year, rewritten on each revision. - Back-fill is therefore blocked — the concrete defect this iteration is named for. Creating a cell resolves its anchor with
scheduledLesson.findFirst({timetableId, anchor, weekday, startTick}). After a revision moves or deletes that lesson, a cell for a past date can no longer be created:SCHEDULED_LESSON_NOT_FOUND. Back-filling a missed register is the most common admin correction in a school, and revising a timetable mid-year is routine — so the two collide by design, not by accident. - Past days also re-render. The same live lookup means a February day opened in March projects onto March's slot set and March's rosters: different
items[], different anchors, differenttoRegister, and for a DAILY department, statuses projected onto slots that did not exist that day. - The batch write has a read-modify-write race. The existing-cell read sits outside the transaction (
src/attendance/attendance.service.ts:~315, tx opens at:330), so two simultaneous takes on the same cell both decide "create", the second hitsattendance_cell_unique, and the global filter turns P2002 into a generic 409 that loses the whole batch. Two teachers on a co-taught lesson is the realistic trigger.
Success criteria (observable behaviour that proves this works):
- A cell can be created for a past date whose lesson has since been moved or deleted by a timetable revision, and its snapshot names the lesson as it was on that date.
- A past date's GET /attendance/rows returns the same items[] (ticks, labels, anchors, teacher names, rosters) before and after a timetable revision.
- A day that has at least one authored cell always has a frozen shape, written atomically with the first cell.
- Two concurrent batch writes touching the same cell both return 201; the cell holds the later value and the audit log holds two rows.
- A past date with neither a frozen shape nor a live lesson fails with a specific, truthful error instead of SCHEDULED_LESSON_NOT_FOUND.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No temporal validity on Timetable itself. Revisions keep mutating the published row in place; attendance defends itself rather than reshaping the timetable module.
- No new endpoint, no change to any response DTO. The day-shape snapshot is internal — the FE contract is untouched.
- No roster/homeroom history. Slot rosters get frozen; group (class) membership stays live — see §9.
- No healing of existing history. A day whose shape was already lost cannot be reconstructed; this stops the drift from ship date forward.
- No justification/giustificazione, no attendance-code table, no VOID status, no minimum-attendance computation — all §9.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
prisma/schema.prisma AttendanceRecord (:551) |
The whole denormalization stance: freeze the context in columns at write time, soft-FK everything but tenant/year/student, never re-sync. AttendanceDaySlot is the same idea one level up — the cell freezes the student's fact, the slot freezes the slot's shape. |
The cell is written by a human action; the slot row is written by the system as a side effect of one. Provenance columns become frozenAt/frozenSource rather than recordedBy*. |
src/attendance/attendance-day.queries.ts DaySlotRow (:67) + loadDaySlots (:148) |
The persisted row is literally DaySlotRow + a date. The in-memory type the pipeline already consumes becomes the table's column list, so no consumer downstream of loadDaySlots changes at all. |
DaySlotRow carries no periodLabel (that is grade-relative and computed per read in resolvePeriodLabels) and no department/grade/mode (resolved per student). Neither needs freezing — see §4. |
docs/superpowers/specs/2026-07-15-timetable-revision-republish-design.md |
The stable-id republish is the thing that destroys the old shape; this spec is its downstream consumer. Establishes that publishedAt marks the last content swap. |
That spec deliberately chose in-place mutation for a stable id. We are not reopening it — we snapshot on the attendance side instead (§7). |
src/attendance/attendance-day-model.ts |
The AuthoredCell vs ProjectedSlot type split already makes "aggregate over facts, never over the view" hard to get wrong. Extend the same discipline: the frozen shape is a fact, the live timetable is only its constructor. |
Pure module, no persistence. The resolver added here is a query-layer concern, not day-model logic. |
src/timetables/ ScheduledLesson + the anchor XOR CHECK (scheduled_lessons_anchor_xor) |
The three-way nullable anchor with a DB CHECK enforcing exactly-one. AttendanceDaySlot reuses the shape verbatim so the anchor round-trips between timetable, manifest and cell without translation. |
Postgres treats NULLs as distinct in unique indexes, so the anchor triple cannot go straight into a unique key. Resolved with an app-computed anchorKey (§4). |
src/audit-log/ AuditService.record(tx, …) |
Writing a durable side record inside the caller's transaction, never fail-soft — exactly the shape of "freeze the day atomically with the first cell". | Audit rows are append-only per mutation; the manifest is write-once per (tenant, date) and idempotent thereafter. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | AttendanceDaySlot.tenantId required (Class S). RLS policy tenant_isolation_attendance_day_slots in the migration + entry in src/prisma/rls-coverage.ts + STRICTLY_TENANTED_MODELS in src/prisma/tenanted-models.ts. |
Tenant-bearing model — ch12 "New tenant-bearing model checklist" is mandatory. |
| Academic-year scope | yes | academicYearId required, filtered on every read alongside tenantId + date. |
Matches AttendanceRecord; a date alone is ambiguous across a year boundary in a multi-year tenant. |
| RBAC entity key | none (existing ATTENDANCE) |
No EntityKey delta. The model is never directly exposed. |
It is infrastructure behind assembleGroupDays and the write path, both already gated on attendance.read / attendance.register:write. |
| Scopes | none | attendance.register unchanged. |
No new read or write surface. |
| Actions | none | take / manage_communications unchanged. |
Same. |
| Service base | custom | New AttendanceDayShapeService (src/attendance/attendance-day-shape.service.ts) — resolve + freeze. Not a CRUD service; no BaseTenantedCrudService. |
There is no CRUD surface. Mirrors AttendanceBoardService / AttendanceInsightsService, which are also plain injectables. |
queries.ts shape |
named functions in the existing attendance-day.queries.ts |
loadFrozenDaySlots(db, {tenantId, academicYearId, date}), freezeDaySlots(tx, rows), hasFrozenDay(db, …). loadDaySlots keeps its current signature and becomes the constructor the freeze path calls. |
House rule: named functions only, no repository classes. Keeps the manifest queries next to the day queries they replace. |
| Error codes | one new | ATTENDANCE_DAY_SHAPE_UNAVAILABLE (422, params: { date, startTick }) — a past date with neither a frozen slot nor a live lesson at that anchor. |
Today this surfaces as SCHEDULED_LESSON_NOT_FOUND (404), which is a lie: the lesson is not missing, the day is unrecoverable. Distinct code so the FE can say "this day's schedule is no longer on record". |
| DTO conventions | n/a — no DTO change | The manifest is never serialized. AttendanceDayItemDto, AttendanceRegisterCellDto, AttendanceGroupCardDto and every counter are byte-identical. |
The whole point: correctness fix with zero FE contract movement. |
| File-backed sub-resources | n/a — no files involved | ||
| Custom fields | n/a — infrastructure model, not a person/domain entity | ||
| Profile completeness | n/a — unrelated to person records |
4. Data model plan¶
Schema deltas¶
- New model
AttendanceDaySlot(@@map("attendance_day_slots")) — one row per (tenant, academic year, date, slot). Columns, in three groups: - Identity / scope:
id,tenantId(FKRestrict),academicYearId(FKRestrict),date @db.Date,startTick Int. - Anchor (mirrors
ScheduledLesson):kind AttendanceSlotKind(new enumLESSON | ACTIVITY), nullablesubjectGroupId/combinedClassId/activityId(soft FKs, no relation), plusanchorKey String @db.VarChar(60)— the app-computed"sg:<uuid>" | "cc:<uuid>" | "act:<uuid>"discriminator that makes the anchor indexable. - Frozen shape (the persisted
DaySlotRow):durationMinutes Int,label String @db.VarChar(200),roomId String?,roomName String? @db.VarChar(200),teacherIds String[] @db.Uuid,teacherNames String[],studentIds String[] @db.Uuid. - Provenance:
frozenAt DateTime @default(now()),frozenSource AttendanceShapeSource(new enumWRITE | READ) — which trigger captured it, so a reader can judge whether the shape predates a revision. - Soft FKs throughout the anchor and roster columns, matching
AttendanceRecord: a subject group, activity, room or student may be hard-deleted afterwards and the frozen day must survive intact. OnlytenantandacademicYearare hardRestrict. - Not frozen, deliberately:
periodLabel(grade-relative, recomputed per read byresolvePeriodLabelsfrom the frozen tick set — same input, same answer),attendanceMode(a property ofDepartment, already frozen oncecalendarStartDatepasses), and department/grade context (resolved per student; a combined lesson spans several grades, so a singulargradeIdon the slot would be wrong).
Migration shape¶
- Additive. One
CREATE TABLE, twoCREATE TYPE, one unique index, two secondary indexes, one CHECK, one RLS policy. No column dropped, no column altered, no existing row touched. - Data backfill: none. Freezing past days from the current timetable would fabricate shapes for dates whose real shape is already gone, and would stamp them
frozenAt = migration time, which reads as authoritative. The lazy read path produces the same approximation but stamps it honestly. Stated as a limitation, not papered over. - Hazards from the chapter 12 checklist:
- New tenant-bearing model → RLS policy in the migration (
ENABLE+FORCE+tenant_isolation_attendance_day_slots, predicate copied from the latest RLS migration) and therls-coverage.tsclass entry and thetenanted-models.tsSTRICTLY_TENANTED_MODELSentry. Three drift guards fail otherwise. - Raw-SQL CHECK
attendance_day_slots_anchor_xor→ one row in the ch12 raw-SQL table + the constraint name intest/db-constraints.e2e-spec.ts. - New enums are additive
CREATE TYPE, no value added to an existing enum, so no transaction hazard. - No
NOT NULLadded to an existing table, no rename — the two classic destructive shapes are absent.
Indexes and uniqueness¶
@@unique([tenantId, date, startTick, anchorKey], map: "attendance_day_slot_unique")— the freeze is idempotent against this key (createMany({ skipDuplicates: true })), so concurrent first-touches converge instead of racing.anchorKeyexists precisely because the three nullable id columns cannot form a unique key in Postgres (NULLs compare distinct).@@index([tenantId, academicYearId, date])— the only read pattern: "the whole day".@@index([tenantId, date, anchorKey])— the write-path anchor lookup for a past date.- CHECK
attendance_day_slots_anchor_xor: exactly one ofsubject_group_id,combined_class_id,activity_idis non-null, andanchor_keyagrees with whichever it is.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| — | — | — | — | — |
No API surface. No route added, changed or removed; no request or response DTO altered. Two internal behaviour changes, both invisible to the contract:
assembleGroupDaysresolves the day's slots throughAttendanceDayShapeServiceinstead of callingloadDaySlotsdirectly. Fordate < schoolTodayit returns the frozen shape (materializing it first if absent); fordate >= schoolTodayit returns the live timetable unchanged.POST /attendance/recordsfreezes the day inside its existing transaction, and resolves each entry's anchor against the frozen shape when the date is past.
The only externally observable deltas are the intended ones: back-fill stops 404-ing, past days stop moving, and one new error code can appear on a write.
Swagger considerations¶
ATTENDANCE_DAY_SHAPE_UNAVAILABLEjoins the error table inattendance.swagger.tsfor both write routes, withparams: { date, startTick }.- Nothing else regenerates — no controller signature moves, so the OpenAPI diff is one error example.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none — no new scope |
PermissionAction (rbac-catalogue.ts) |
none — no new action |
ScopeFieldMapping (rbac-catalogue.ts) |
none — the model is never field-filtered |
| Role grants (roles.ts) | none — no role reaches AttendanceDaySlot directly |
*_SCOPES runtime constant |
none — no seed change, so no drift-check delta |
No reseed required, in any environment.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Reads are side-effect-free | A GET on a past date with no frozen shape writes the manifest |
The set of days whose shape matters is not knowable in advance, and the alternative is losing it. Reframed so it is principled rather than a hack: for a past date the manifest is the source of truth and the live timetable is only its constructor — the read materializes its own source, it does not mutate domain state. | The write is a single idempotent createMany({skipDuplicates}) on the unique key, so concurrent reads converge and a retry is free. It never fires for date >= today, never for an already-frozen day, and never changes a frozen row. |
| One read pipeline, no persistence in the read path | The read path now has a conditional write | Same as above. Confined to AttendanceDayShapeService; assembleGroupDays calls one method and cannot tell the difference. |
One extra SELECT per assembly for past dates (the has-frozen check folds into the load). Today's dates are unaffected. |
Cross-module side effects go through afterCommitQueue |
We add no cross-module hook at all — publishRevision is untouched |
Freezing on publish would have to run inside the timetables transaction (after commit is too late — the content is already overwritten), creating a synchronous, non-fail-soft timetables → attendance dependency and a module cycle. Ch16 flags exactly this as off-axis. Lazy freezing covers the realistic cases: any day with a register is frozen by its own first write. |
A day that was never written and never opened before a revision freezes to an approximate shape on its first later touch. frozenSource: READ + frozenAt make that legible rather than silent. |
AttendanceRecord is the only snapshot in the module |
A second, system-written snapshot table | The cell answers "what did a human assert about this student"; the manifest answers "what was this student scheduled for". Conflating them is what produced the bug. | One more table to keep in the RLS/tenanted registries. ~40 rows per school day, ~8k rows per school-year per tenant — negligible. |
§6: "a past date with neither a frozen slot nor a live lesson → ATTENDANCE_DAY_SHAPE_UNAVAILABLE" |
Implementation split the case in two. A manifest that has slots is a complete account of the day, so an anchor absent from it answers the ordinary 404 SCHEDULED_LESSON_NOT_FOUND / SCHEDULED_ACTIVITY_NOT_FOUND; the 422 is reserved for a past date with no shape on record at all. |
The spec's single rule made the new code's own copy false: it says the schedule "is no longer on record" — a lie when the manifest is sitting right there and simply does not contain that tick. It also broke the FE's uniform handling of a stale anchor, which is the same event whether the date is past or present. | Neither branch consults the live timetable, so the protection the rule existed for is unchanged. A day whose shape was lost and whose weekday now derives to nothing still 422s; a day whose shape was lost but which derives to something now 404s at the old tick — indistinguishable from a wrong tick, and already covered by "does not heal history". |
Batch write = one createMany + N update |
Per-cell upsert inside the transaction |
The current split decides create-vs-update from a read taken outside the transaction, which is a race by construction. | N round-trips instead of 1 + N-updates, with N ≤ a class roster. The audit action label (.created vs .updated) is still derived from the in-transaction pre-read, so under an exact race the label may say created for a row that became an update; the value history is still complete and correct. Commented at the site. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| No US — this work originates from the 2026-07-26 engineering design review, not from product. | n/a | n/a | Resolved |
| (Review finding, carried forward) "fix the retroactive-context problem" could be read as also freezing homeroom/group membership per date. | HomeroomAssignment (prisma/schema.prisma:2154) has no from/to — fixing it properly is a temporal-history change to a model owned by another module with many other consumers. |
Freeze slot rosters only (which is what determines who was scheduled and therefore who is owed a cell). Group membership stays live; the cell already snapshots homeroomId/homeroomName, so historical attribution is recoverable for any day that was actually taken. Deferred as its own iteration — §9. |
Resolved |
(Review finding) A VOID status was raised alongside back-fill. |
Adding an enum value forces a decision in classifyStudentDay — a day of only voided cells would return [], breaking the "never empty" contract established 2026-07-26. |
Out of scope. It is a real gap but an independent one, and folding it in would put a semantic change to the day model inside an infrastructure fix. §9. | Resolved |
9. Deferrals¶
- Roster/homeroom temporal history —
HomeroomAssignmenthas no validity dates, so a past date's board still groups students by current class. Bounded by this spec: slot rosters are frozen, and any taken cell carries its ownhomeroomName, so only untaken past days of transferred students are misattributed. Follow-up: its own spec; touches homerooms, students and command-center, not just attendance. - Freeze-on-publish hook — would close the "never touched before the revision" gap completely. Deferred to avoid a
timetables → attendancemodule cycle (§7). Revisit iffrozenSource: READrows turn out to be common in production. VOID/ cancelled cell status — a mis-anchored cell is currently permanent and occupies the unique key forever. Independent semantic change; needs aclassifyStudentDaydecision. Follow-up: next attendance iteration.- Justification (giustificazione) — legally required, parent-signed, deadline-bearing. Correctly homed on
AttendanceDayEvent(day-scoped, not cell-scoped). Follow-up: the comms-loop spec, which owns that table. - Tenant-defined attendance-code table — the fixed 7-value
AttendanceStatusenum will not survive multi-jurisdiction K-12 indefinitely. Mitigated meanwhile by keeping all interpretation in pure functions (attendance-status-rules.ts,carryForward/carryBackward,classifyStudentDay), so the eventual swap stays mechanical. Follow-up: revisit when the second jurisdiction lands. - Minimum-attendance / monte ore computation — unblocked by this spec for PERIOD departments (cells carry
periodStartTime/periodEndTime) and, with the manifest, now also derivable for DAILY. Not built here. Follow-up: its own spec. - Healing existing history — days whose shape was already lost before this ships cannot be reconstructed. No follow-up; documented in ch19 as a known boundary.
10. Open questions¶
- Freeze on publish, or lazily? — Resolved: lazily. Publish-time freezing must run inside the timetables transaction and creates a module cycle; lazy freezing on first write covers every day that has a register, which is the set of days where the shape is load-bearing.
- Freeze
date <= todayordate < today? — Resolved: the write path freezes whatever date it is writing (including today); the read path freezes onlydate < schoolToday. A write is the moment a human asserts something against a schedule, so that schedule is what must be preserved. A read of today needs no freeze — today's timetable is still authoritative for today. - Per-grade or whole-day manifest? — Resolved: whole day.
loadDaySlotsalready loads every lesson of the weekday in one pass; freezing a subset would leave the manifest partial and force a "is this day fully frozen?" question with no cheap answer. - Does the manifest need
periodLabel? — Resolved: no. It is grade-relative rank over the day's distinct lesson ticks; the frozen tick set is the same input, soresolvePeriodLabelsyields the same answer without storing it.
None open. Ready for sign-off.
11. Verification plan¶
- Unit specs
src/attendance/attendance-day-shape.service.spec.ts(new): future date → live, never frozen; past date with a manifest → frozen rows, live timetable not queried; past date without one → materialized from live and persisted; freeze is idempotent (second call writes nothing); today's date on the read path → live, no freeze.src/attendance/attendance.service.spec.ts: a write freezes the day inside the transaction; a past-date entry resolves its anchor from the manifest when the live lesson is gone; a past date with neither raisesATTENDANCE_DAY_SHAPE_UNAVAILABLE; the upsert path emits.createdvs.updatedaudit actions correctly.src/attendance/attendance-board.service.spec.ts: existing tests must pass unchanged — the proof that no contract moved.- E2E specs
test/attendance-admin.e2e-spec.ts— the money test: take a register on the seeded Monday; move theScheduledLessonto a different tick (simulating a revision); then assert (i)GET /attendance/rowsfor that Monday returns the same items, ticks and anchors as before, and (ii)POST /attendance/recordssucceeds for a second student at the original tick, with the snapshot naming the original lesson. Follows the file's existing self-provisioned fixture anddeleteManycleanup; new table added toafterAll.test/attendance-admin.e2e-spec.ts— two concurrentPOST /attendance/recordsfor the same cell: both 201, final value is one of the two, two audit rows.test/db-constraints.e2e-spec.ts—attendance_day_slot_unique,attendance_day_slots_anchor_xorand the RLS policy exist in the DB.src/prisma/rls-coverage.drift.spec.ts/tenanted-models.drift.spec.ts— pass with the two new registry entries.- Manual verification: publish a timetable, take a DAILY register for a past school day, start a revision that deletes the lesson, publish it, then re-open that day's rows and add a cell for a student who was missed. Both must behave as if the revision had not happened.
Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline (sis_e2e reset per run, serial --maxWorkers=1).
Documentation deliverables (part of this iteration, not a follow-up)¶
docs/19-attendance.md— new section on the day-shape snapshot: the cell/slot symmetry, the resolution rule, the freeze triggers, and the honest boundary (history is not healed). Update §3 school-day derivation and the routes/error table with the new code.ATTENDANCE.md— §1 gains a sixth idea ("the day is frozen too"); §4assembleGroupDayspseudocode routes through the resolver; §5 endpoint 7 gains the freeze step; §8 gains the error code; §9 file index gains the service, the queries and the model; re-stamp the header.docs/REFERENCE.md— theattendance/module row gains the manifest; the §6 file index gains a row for "back-fill a register / touch day-shape freezing".docs/12-migrations.md— one row in the raw-SQL constraint table forattendance_day_slots_anchor_xor.- No FE guide: nothing in the contract breaks. The behaviour change (back-fill now works) is worth a line in the release notes, not a guide.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-26
- Chat reference: approved in chat 2026-07-26, immediately after the design review of the attendance module that produced the three findings in §1. Both flagged divergences (read-path materialization; no freeze-on-publish hook) were called out explicitly at sign-off and accepted, as were the §9 deferrals — notably roster/homeroom temporal history and
VOID.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.