Attendance Register — Day Cells, the Admin Board & Y-set Authority¶
The attendance register (Italian registro) is a legal compliance document: who was present, absent, or in transition, for which lesson, on which day — recorded by an authorized teacher or admin and never silently mutated by structural reorg. Each row is an immutable historical fact carrying a deep snapshot of the teaching context at record time. Value changes are not destructive: every edit appends to the audit log (chapter 20), and the cell's own columns hold only current state.
This chapter documents the admin surface — founded by 2026-07-25-attendance-admin-day-register-design.md and completed by the temporal program (specs A–E: dated schedule + dated memberships, composed by ExpectedAttendanceResolver) — and the teacher surface (GET /attendance/teacher-day + teacher-reachable cohort reads, 2026-08-02-teacher-attendance-surface-design.md; see §7.1). The referent/student surface and the family communication loop remain separate follow-on work; the communication tables ship here read-only.
Builds on the Timetable version whose valid-time interval governs the requested date (chapter 18), on department school-day calendars (departments), and on the audit-log module (chapter 20) — attendance is its first consumer. A retained ARCHIVED version may therefore be the governing schedule; status is never the attendance oracle.
1. Mental model¶
Every cell is period-anchored. One AttendanceRecord is one (student, date, startTick) fact, where startTick is the slot's start in 5-minute steps from midnight — the same coordinate as ScheduledLesson.startTick. There is no null-tick "daily cell": the DAILY/PERIOD distinction is about how many cells a day needs, not about their shape.
- PERIOD mode — the register is taken every period. A slot shows its own cell or nothing.
- DAILY mode — the register is taken once (or a few times, when something changes), and reads project it across the day. Every later cell applies from its own tick onward; the slots before the earliest cell are backfilled.
Mode is a property of the department (Department.attendanceMode), resolved from the lesson's grade → department at write time and frozen onto the row. Different departments in one tenant run different modes simultaneously.
Projection is semantic, not literal, and it runs both ways (carryForward / carryBackward, attendance-day-model.ts). Forward: LATE_ENTRY means "in school from here", so it carries as PRESENT; EARLY_EXIT means "gone from here", so it carries as ABSENT. Backward — for the slots that precede the earliest authored cell — the mapping inverts: LATE_ENTRY backfills as ABSENT (the student was not yet in school before arriving), EARLY_EXIT as PRESENT (they were there until they left). Everything else carries as itself in both directions. A worked DAILY day with cells authored at 08:00 and 10:00 only:
| Slot | Authored | Shown | source |
|---|---|---|---|
| 08:00 | ABSENT |
ABSENT |
RECORDED |
| 09:00 | — | ABSENT |
PROJECTED |
| 10:00 | LATE_ENTRY |
LATE_ENTRY |
RECORDED |
| 11:00 | — | PRESENT |
PROJECTED |
Three display states, and the FE must not conflate them: RECORDED is a stored row (has an id, a time, provenance); PROJECTED is a carry-forward (status only — no id, nothing to edit); source: null with record: null means not taken, which is not the same as absent.
A projected slot is the student's actual state at that tick — the day summary is a separate thing. The strip answers "was S in the room at 11:00?", so a late arrival correctly decays to PRESENT later in the day. It is not the answer to "how was S's day?", which is §4's business and is read off the authored cells instead. Freezing LATE_ENTRY forward would break the first question (and is exactly the shape the day-state machine flags as LATE_ENTRY_WHILE_PRESENT); collapsing the day off the strip breaks the second.
A write always targets a slot of the schedule that governs the cell's date (§3.1) — a lesson (subject-group- or combined-anchored) or an activity (§Activities). The roster of that slot defines who can be recorded; the Y-set (§7) defines who the caller is authorized to record.
Participation (2026-09-03). Rosters are the placement and membership intervals effective on the date ∧ status ≠ PRE_ENROLLED (participationHistoryStatusWhere, src/students/constants/participation-status.ts). A pre-enrolled student holds an open placement but has never participated, and the flip to ENROLLED is a plain column write, so only status can keep them off the register; exits stay interval-dated, so a student who left is on every day before their exit boundary and on none after it. Today-anchored surfaces — the follow-up worklist and summary, the admin justification table, the notifier's due passes, the family to-justify and justification lists — carry the live fence status = ENROLLED (participatingStudentWhere) instead. The cohort-ownership authorization seam (loadAttendanceCohortOwnershipOn) is deliberately status-neutral: a class of only pre-enrolled students is still owned by its department, and a fence there would 403 its manager rather than show an empty group. Known wart: flipping PRE_ENROLLED → ENROLLED mid-year makes the student expected on every placement-covered day before the flip; the remedy is correcting the enrolment date, which re-anchors the placement. Design: 2026-09-03 spec.
2. Data model¶
AttendanceRecord (prisma/schema.prisma), plus enum AttendanceStatus { PRESENT, ABSENT, EARLY_EXIT, LATE_ENTRY, FIELD_TRIP, DAY_TRIP, SUSPENDED }.
The row is a deep snapshot. At create time the service copies the entire teaching context into denormalized columns — student name + identification code, department/grade/homeroom ids and names, subject-group/curriculum-subject/subject name + code, room id + name, the lesson's teacher ids + names, the resolved period start/end, and the attendance mode. None of these are re-synced afterward; the snapshot is the legal record of what was true on the day, not a live join — with one deliberate exception: a cross-anchor re-assertion (§10) re-photographs the whole context, because the row must testify about the assertion event it now carries. Labels and wall bounds are frozen in both modes — a DAILY cell is period-anchored like any other, and the widgets that read a cell back name it from its own snapshot.
Soft FKs. The only hard Restrict foreign keys are tenant, academicYear, and student — the three identities a compliance record can never lose. Everything else (subjectGroupId, curriculumSubjectId, activityId, roomId, homeroomId, departmentId, gradeId, lessonTeacherIds[], justificationId) is a plain column with no FK. This is deliberate: a subject group can be hard-deleted, a timetable re-published, a student moved mid-year — and the historical rows survive untouched, still naming the context by their *Name/*Code columns.
One unique index, declarative in the schema:
The v1 pair of partial uniques (attendance_daily_unique / attendance_period_unique, split on start_tick IS NULL) is gone with the null tick. See chapter 12 — the rename Prisma proposes for this change silently preserves the old partial WHERE predicate, so the migration does DROP + CREATE.
Provenance columns track authorship without a history table: recordedByUserId / recordedByName / recordedAt (set once on create) and lastModifiedByUserId / lastModifiedByName / lastModifiedAt (@updatedAt, refreshed on every edit). Names are snapshotted so a deactivated user still reads correctly.
justificationId (nullable, soft, @@index([tenantId, justificationId])) names the family communication that pre-filled the cell — NULL on every staff-authored one. It is the only handle withdrawal has, and together with lastModifiedByUserId IS NULL it is the whole withdrawal predicate: a pre-filled cell a human has since edited is adopted and survives. See §11.4. A dangling id left by a deleted justification is expected and harmless — nothing joins on it.
Communication-loop tables — AttendanceDayEvent (one per (student, date, eventType), eventType ∈ {ABSENT, LATE_ENTRY}, state machine UNDER_REVIEW → NOTIFIED → FOLLOW_UP → ACKNOWLEDGED | NOT_REQUIRED | CANCELLED | SUPERSEDED) and AttendanceContactLog (append-only contact attempts, cascading from the event). Written since family-loop slice A (2026-08-04) by AttendanceLoopService inside both write transactions — §11.1. NOTIFIED/FOLLOW_UP and the notifiedAt/escalateAt/remindedAt stamps are reserved for the notifications iteration (slice D); NOT_REQUIRED for the justifications one (slice C). dayEvents[] on a row and the follow-up endpoints are live.
3. School-day derivation¶
A cell is recordable only on a date that is both a teaching day of the schedule that governed that date and a school day of the department calendar. isSchoolDay(date, cal) (attendance-calendar.ts) returns true iff:
- the date is within
[Department.calendarStartDate, calendarEndDate], - the weekday is operative — the governing timetable version places ≥1 lesson for the grade on that weekday, or ≥1 attendance-bearing activity whose as-of-date roster includes a student placed in that grade on that date (
loadOperativeWeekdaysForGrade; the manual flow is template-free, so the schedule itself is the source of the week's shape — see chapter 18), and - the date is not inside any
CLOSINGperiod of that department.
Operative weekdays are per grade; the calendar is per department. The board loads all lesson memberships, referenced department calendars and closing periods once (loadBoardSchoolDayInputs: three delegate calls independent of the number of pairs), then folds every group's answer in memory. One board group has no grade identity: the synthetic ACTIVITY cohort, whose roster spans its department's grades (grade.id is blank — and blank must never reach a uuid filter). Its school-day derives at the department level instead. Activity weekdays come from the all-week facts already carried by the expected schedule and expand once against the expected resolver's dated indexes. The single-student write assertion keeps its request-local (department, grade) cache and existing loadOperativeWeekdaysForGrade/calendar helpers.
One branch, since spec E. The version governing D comes from findTimetableVersionEffectiveOn — its valid-time interval, never status — and the derivation runs against that version. B's interim second branch ("no version covers D, so the day's manifest stands in for the weekday derivation") is gone: expectation is resolved on every date now, so a date no version covers has no schedule source at all. Reads render it as "not a school day, nothing owed", short-circuiting without a single query; the write path refuses it at its gate with ATTENDANCE_NO_PUBLISHED_TIMETABLE, before the school-day rule is reached.
The activity half of the derivation moved to as-of-D as well: audiences expand through expandAudienceOn over the resolver's own as-of-date indexes, and "is this student in this grade" reads their placement on D rather than Student.gradeId, which is forward-head metadata (contract §2b #18) and cannot answer for a past day. Sharing the resolver's indexes is also what stops the derivation re-loading the same three facts per (department, grade).
AttendanceBoardService.prepareSchoolDays and AttendanceService.assertSchoolDay mirror the same predicates deliberately — change one and you must change the other, or a day the board renders becomes a day the write path refuses. The board keeps one extra timetableId === null arm with no counterpart on the write side, and that asymmetry is intentional: the board must render an uncovered date, the write path must refuse it.
3.1 The day-shape snapshot — what a past date was scheduled for¶
AttendanceRecord freezes the student's fact; AttendanceDaySlot freezes the slot's shape. That is the whole idea, and the two halves are a pair.
The second half exists because the PUBLISHED timetable is mutated in place on republish: publishRevision copies the draft's content onto the published row with a stable id and hard-deletes the draft. So the row itself cannot tell you what a past day looked like.
The resolution rule (spec E), one line: every date resolves — geometry from the version effective on it, rosters and teachers from the intervals effective on it. The manifest is not read at all; only writes still freeze it.
B's interim rule was "today and the future read the derivation; the past reads its manifest first". Spec E reverses the second half deliberately, and the reversal is the point of the whole program rather than a regression in B: while rosters were live, a manifest was the only thing that could make a past day stable. Now that memberships carry valid time, resolution is stable by construction, and a second stored truth is exactly what the program exists to remove.
"The derivation" is now date-aware. Timetable carries valid time (effectiveFrom/effectiveUntil), so "which version governed date D" is answered by the data rather than by a status lookup — ExpectedScheduleResolver.resolveExpectedSchedule (src/attendance/expected/) composes findTimetableVersionEffectiveOn with the existing loadDaySlots hydration. See ch18 §5.0 and spec A. Spec E extends the same seam to expected students.
What changed the manifest's standing. B's argument for keeping it was exact at the time: a version's interval freezes the timetable's geometry, not the rosters, teacher assignments or entity names the register snapshots — so a past day derived from a retained version still rendered onto today's rosters. Specs C1–C3 closed that gap by giving placements, memberships, teaching assignments and combination links their own valid time, and spec E composes both halves. A past day now renders onto that day's rosters, from data, so the manifest no longer answers a question resolution cannot.
It is therefore write-path evidence only: freezeDay still writes it inside the write transaction, nothing authoritative reads it, and its disposal is spec F's decision rather than this chapter's. Do not re-introduce a read of it "for safety" — that is precisely the second-truth pattern the program removes.
Reads persist nothing. Reading a past day used to materialize its manifest (AttendanceShapeSource.READ) because there was no other way to make the answer stable; retained versions removed that need, so the READ freeze is gone; since spec E the read path does not touch the table at all. Two consequences worth stating plainly:
READis dormant on the write side but live in stored data. Rows stampedREADbefore this change are still the only shape on record for their days and remain authoritative — never delete them, never re-derive them. Disposal is spec F's decision.WRITEfreezes continue, inside the write transaction, before the day's first cell.
In code, since spec E, there is one path and no branch at all:
any date → ExpectedAttendanceResolver.resolveExpectedAttendance(db, tenant, ay, date)
= geometry from the version effective on `date` (spec B)
× rosters / teachers / audiences from the intervals effective on it (C1–C3)
# nothing is persisted; nothing is probed
AttendanceDayShapeService accordingly has no read method any more — the manifest-first resolveDaySlots was deleted with the rest of the pre-resolver stratum (2026-07-29); freezeDay and loadFrozenDaySlots (its idempotence probe) are all that remain. Slots are plain DaySlotRow everywhere, so nothing downstream can tell where a roster came from — deliberately, and the reason the swap onto the resolver was mechanical rather than invasive.
One freeze trigger. WRITE, inside the write transaction, before the day's first cell exists — so every day that has a register has its shape, atomically, and it is the schedule the register was actually taken against. The former READ trigger (first read of an unfrozen past date) is retired: see "Reads persist nothing" above for what its stored rows still mean.
Deliberately not frozen. attendanceMode is a property of Department, already frozen once calendarStartDate passes. Grade and department are resolved per student — a combined lesson spans several grades, so a singular column on the slot would be wrong. There is no period ordinal anywhere: "Period N" was dropped 2026-07-26 (see §14).
One accepted divergence remains. There is no freeze-on-publish hook: it would have to run inside the timetables transaction (after-commit is too late — the content is already overwritten), creating a synchronous timetables → attendance dependency and a module cycle. Valid time made the hook unnecessary anyway — the retained version answers for its own dates.
(The other former divergence, "a read of a past date may write", is gone with the READ freeze. Reads are pure.)
The honest boundary. The tenant's schedule history begins at its first publication, and attendance has no surface predating it — which is why there is no grounding marker on any response and no epoch column (both retired 2026-07-29; see §13). A date no version covers resolves to the empty day rather than to an error: reads render it with nothing owed, and only the write path refuses it.
ATTENDANCE_DAY_SHAPE_UNAVAILABLE no longer exists (retired 2026-07-29 — enum, i18n and Swagger examples all deleted). Its premise was "the day was never frozen and nothing is left to derive it from", and resolution no longer depends on a manifest existing at all. A date that resolves to no slot at the requested anchor answers the ordinary 404 SCHEDULED_LESSON_NOT_FOUND / SCHEDULED_ACTIVITY_NOT_FOUND — a truthful "not on that day" about the ANCHOR instead of a claim about the DAY.
A past date still takes no live fallback: the resolved day is a complete account of that date, so an anchor absent from it was not scheduled then. Re-deriving from whatever is published now would splice today's lessons into a day they were never on, which is the bug the whole program exists to prevent.
4. Classification & counters — the day states, which overlap¶
classifyStudentDay(authored) (attendance-day-model.ts) returns every bucket the student's day matches, in DAY_CLASSIFICATIONS order, never empty. It runs over the authored cells, not the projection: the projection is a rendering device, while the day is what people actually asserted.
notTaken— nothing authored all day.suspended/trip— aSUSPENDED/FIELD_TRIP-DAY_TRIPcell anywhere in the day.absent— every authored cell isABSENT, i.e. the student never appeared.lateEntry/earlyExit— aLATE_ENTRY/EARLY_EXITcell anywhere in the day.present— aPRESENTcell and no transition.
Two rules are load-bearing and easy to get wrong:
absent means never appeared, not "has an absent cell". The DAILY flow opens the day with an ABSENT baseline that a later arrival supersedes; tallying raw statuses would count that student as an absence and as a late arrival on the same day.
Trips and suspension stay exclusive. They are whole-day dispositions where the student is not in the ordinary register at all, so they replace the other buckets rather than pairing with them — pairing them with a tardiness tally double-counts someone who was never in the normal register. This is a product decision, not a structural one: it is two early returns in classifyStudentDay, and deleting them lets the predicates take over if a school wants "late to the trip bus" counted.
The seven counters therefore OVERLAP and do not sum to the roster. A student who arrived late and left early is counted under both lateEntry and earlyExit — deliberately, because "how many late arrivals today" must include them. What still holds:
numStudentsis the denominator. Never the sum.notTakenis the one disjoint bucket, sonumStudents - notTakenis "students with a register".- Every student contributes at least one bucket.
Anything rendering the counters as a stacked bar, a donut or an "X of Y" derived from their sum is wrong — see the FE guide 2026-07-26-attendance-day-summary-and-counters-BREAKING.md.
toRegister is a different unit: it counts owed cells, not students, and only for slots that have already started in school-local time (School.timezone, an IANA zone; attendance-clock.ts resolves "now" and "today" through Intl.DateTimeFormat). So it climbs through the day. A PERIOD group owes one cell per elapsed slot still missing one; a DAILY group owes one take per student, attributed to the first elapsed slot. null means the question does not apply (a future date, or a date no timetable version covers) — which is not the same as 0. Since spec E that is the whole test: the manifest is out of the read path, so a date with no governing version has no schedule source at all.
inconsistencies (on the group card and the board totals, added 2026-07-30 so the board badge does not need a second GET) is a third unit again: it counts the group's §6 contradictions, not students and not cells. It is counted in buildGroupDay over the same authored cells the widget walks, so the card and GET /attendance/inconsistencies agree by construction — and it is deliberately a sibling of counters, not an eighth bucket, so the overlap rules above stay undisturbed.
The board totals cover only school-day groups (2026-08-03). Calendars are per department, so on a partial holiday the old whole-catalogue sum totalled the entire school — every closed group's roster landing in notTaken. getBoard now skips schoolDay: false groups when folding totals: their cards still render (greyed on the FE), but they contribute to none of numGroups, numStudents, the seven counters, toRegister, groupsPendingRegister or inconsistencies. A date nobody has school on answers all-zero totals with toRegister: null.
5. The board — assembleGroupDays¶
AttendanceBoardService.assembleGroupDays (attendance-board.service.ts) is the one read pipeline. The board, the pending list, the day rows and the inconsistency widget are all projections of a single assembly, so the surfaces agree with each other by construction rather than by re-querying.
A group is a class (HOMEROOM), a grade's students who belong to no class (GRADE_GROUP), or — since the teacher surface (2026-08-02) — a teaching unit: one subject group (SUBJECT_GROUP) or one combined class (COMBINED_CLASS). The first two are built by loadGroupCatalog from the students themselves, so a grade with no classless students simply has no grade-group card. A teaching-unit cohort is not in that student-derived catalogue: assembleGroupDays routes it to loadSubjectCohortGroup (attendance-day.queries.ts), which builds ONE synthetic GroupRow directly from the section(s) — identity from the subject group / the combination's first member by name, roster = students placed on the date ∩ membership effective on it — and the rest of the pipeline treats it like any other group. Consequences worth stating: a teaching-unit group's counters and toRegister cover its students' whole day (the same rule as every group); /rows shows whole-day strips by default and can slice items[] to one occurrence with startTick; a memberless-on-date combination answers with an empty roster rather than 404; and an unknown teaching-unit id assembles zero groups (so /rows answers its usual 404).
Filter semantics (DayFilterQueryDto), the three rules the FE must not guess:
- structural ids (
departmentId,gradeId,homeroomId,cohortType+cohortId) andsearchselect whole groups — a group's counts always cover its full roster, even when one student matched; curriculumIdis the exception: it narrows the students inside a group, so a grade group spanning curricula shrinks rather than disappearing;- passing
cohortType+cohortIdturns any widget into its single-group version. That is how the per-class panels are built — there is no separate per-group endpoint; /rowsalone also accepts optionalstartTick(0–287). When present, each student'sitems[]is narrowed to that tick; forSUBJECT_GROUP/COMBINED_CLASS, the item must match both the teaching-unit anchor and tick, so a unit taught more than once that day yields at most one item per student. The day-levelregister[], classifications, events and group card remain whole-day facts. Omitting it preserves the full strip.
search on the follow-up endpoints means something else on purpose: that worklist is a list of students, so free text narrows the students themselves. It uses the shared person-search rule: whitespace-separated terms compose in AND, while firstName, lastName, and identificationCode compose in OR for each term; matching is case-insensitive and blank input adds no filter.
Reads never 409: a date no version covers renders its cards with nothing owed, and a tenant mid-setup is just the case where that is true of every date.
The assembly resolves the date exactly once (ExpectedAttendanceResolver) and passes the answer down. Since spec E its two inputs are both as of the queried date:
- the group catalogue —
loadGroupCatalog(…, date): placement, homeroom membership and curriculum selection all resolved on D. A student who moved groups mid-year therefore appears in their old group on every day before the move and in the new one from it.date === schoolTodaydegenerates to the as-of-today read C2 shipped, so one code path serves both. byStudent— the resolver's per-student slots: geometry from the version effective on D, rosters/teachers/audiences from the intervals effective on D.
Nothing downstream may resolve again. Three read kinds exist and are not interchangeable (contract §3): effective-on-D (this pipeline, and every dated board), current (as-of-schoolToday — what a roster endpoint answers), and forward-head/pending (C2's pendingChanges[]). "Open" is not "current": an open interval may start in the future.
There is no grounding/completeness marker on any attendance response. One existed for the window in which the schedule had become historical but rosters had not (ExpectationCompleteness, three values, on five DTOs); it was retired 2026-07-29 together with School.temporalEpoch — spec E makes rosters dated too, which leaves one reachable value, and attendance was never released, so no date precedes a tenant's recorded schedule history. A date no version covers simply resolves to an empty schedule.
Query envelope (2026-08-25 optimization). A normal covered expected day is capped at 9 Prisma delegate calls total: one governing-version read, one all-week activity graph read, one lesson-geometry read, and at most six dated people/fact reads. Published duration stamps make the normal cascade cost zero; a legacy lesson with neither authored nor resolved duration may spend at most two additional delegates (11 total). Schedule hydration never loads live lesson teachers/rosters or expands activity students. It carries version-owned activity selectors/supervisors once; the requested weekday filters supervisor coverage in memory before the dated resolver obtains teacher ids plus display names in the one effective teacher-episode query. The automatic counting-proxy spec fails on any unlisted delegate call.
The board's school-day cost is now independent of P, the number of distinct (department, grade) pairs: one three-query input batch followed by map lookups and pure activity folds. Exact homeroom and grade-group reads also push their dated placement/membership predicates into the initial student query and select the governing placement there, rather than loading the tenant catalogue and rereading placements. These ceilings are rewiring guards, not latency claims; representative p50/p95 and actual SQL/row counts still belong to the environment-gated performance profile.
5.1 The day rows — items[] is the strip, register[] is the day¶
GET /attendance/rows returns two different views of the same student-day, and they answer different questions:
items[]— one entry per slot this student attends (option blocks make one class's rosters diverge, which is why the day is rendered per student rather than as a class-by-period grid). Each item carries the projected status and itssource. This is the timetable strip. SupplyingstartTickmakes it a single-occurrence slice; teaching-unit cohorts also match their anchor, so another item at the same tick cannot leak into the result.register[]— the cells actually written, oldest first. This is the day summary: a day accumulates events, so a student who arrived late and left early carries both, and the FE renders the pair as one rich attendance cell (P,A,LE 10:15,LE 10:15 · EE 13:00). A DAILY department leads with this; a PERIOD department leads withitems[]and showsregister[]/classifications[]as the highlighted day state.
Both views are built by AttendanceBoardService.buildStudentRows, which takes an already-assembled GroupDay and an optional student narrowing. GET /attendance/rows calls it for the whole group and then applies its optional occurrence slice; the write envelope calls it for the students a write touched and always returns their whole rows (§10). One builder on purpose: the underlying projection and day facts cannot drift between a write response and a read.
register[] cannot be reconstructed by filtering items[] for source === 'RECORDED'. A cell authored at a tick the day no longer schedules — a lesson moved after the register was taken — appears in no item: every slot is a projection of it, and the cell would vanish from the summary while still being the register's truth. So register[] is projected from the records directly (toRegisterCell), named from each cell's own frozen snapshot rather than today's timetable, exactly like the inconsistency widget's cells. It costs zero extra queries: sd.records is already loaded by the shared pipeline.
Both reads surface the cell's observed unit-coverage tag (§10.1) since 2026-07-30: a RECORDED item's record and every register[] cell carry coveredUnitIds / coveredUnitNames from the cell's own snapshot, so the day screen can show "Svolto" without a per-cell history fetch. A PROJECTED item carries empty arrays — units are an authored fact, and the carry-forward is only a rendering device.
6. Inconsistencies — the day-state machine¶
A register can contradict itself: a student marked present after an absence with nothing in between, an early exit recorded while already out. detectDayInconsistencies(authored) (attendance-inconsistency.ts) walks the day's authored cells (projected ones can't contradict anything — they are the previous state) through a three-state machine and flags every illegal transition.
States: IN_SCHOOL (after PRESENT / LATE_ENTRY), OUT_LEFT (after EARLY_EXIT), OUT_ABSENT (after everything else). Trips and suspensions are neutral — they say nothing about being in school and never advance the state. The day's first cell is never a violation: there is no prior state to contradict, which is why a day may legally open with an early exit.
| Previous state | Offending status | Code |
|---|---|---|
OUT_ABSENT |
PRESENT |
PRESENT_AFTER_ABSENCE_WITHOUT_LATE_ENTRY |
OUT_ABSENT |
EARLY_EXIT |
EARLY_EXIT_WHILE_ABSENT |
OUT_LEFT |
PRESENT |
PRESENT_AFTER_EARLY_EXIT |
OUT_LEFT |
EARLY_EXIT |
EARLY_EXIT_AFTER_EARLY_EXIT |
IN_SCHOOL |
ABSENT |
ABSENT_WITHOUT_EARLY_EXIT |
IN_SCHOOL |
LATE_ENTRY |
LATE_ENTRY_WHILE_PRESENT |
Every other pair is legal (OUT_ABSENT → ABSENT/LATE_ENTRY, OUT_LEFT → re-entry, …).
The check runs in both modes: a well-formed DAILY day cannot contradict itself, but an admin can author one, and that is exactly as wrong as in a PERIOD department. Each item names both cells, everyone who recorded either, and the teachers of both lessons — and carries a ready-to-render localized message ({ en_US, it_IT }, attendance-inconsistency-messages.catalog.ts); do not map the code to your own copy. Cells are described from their own frozen snapshot, so a lesson that has since moved or been deleted is still named as it was recorded. /rows legitimately does the opposite — it renders today's schedule, so its labels come from the live slots.
An item's cohortType + cohortId are exactly the /rows selector, and studentId + triggeringCell.startTick locate the cell — so a row in the widget is directly navigable into the group where it happened.
7. Write authority — the Y-set, and who escapes it¶
The single most non-obvious pattern here: write authority is broader than read visibility. The usual assumption "you can only write what you can read" is inverted — a teacher who only sees their own department's records can write a cell for a student they don't otherwise see, if they witnessed the event.
canWriteStudentCell(actor, facts) (attendance-authority.ts) grants a teacher write to a single student's cell iff either of:
- they are a lesson teacher of the targeted slot (
facts.lessonTeacherIds— activity supervisors covering that weekday count), or - they are the student's homeroom (tutor) teacher.
The former third clause — "teaches the student in any lesson that same day" — was deleted 2026-08-02 (teacher-surface ruling): a teacher teaching any lesson of the day must not modify previous or following slots that are not theirs. loadTeachesStudentOnDateMap retired with it. Accepted consequence, on the record: a same-day colleague who witnesses an event can no longer record it into another teacher's slot — the slot's own teacher, the tutor, or a qualified management writer must. Previously-succeeding writes now answer 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT.
Management authority is grant-shaped and access-level-sensitive.
resolveManagementAttendanceScope(ctx, access, action?) uses the qualified
role slice. For READ, admin, Director, Front Office, Administrative Assistant,
and HR Manager are school-wide. For register WRITE+take, only admin, Front
Office, and Administrative Assistant are school-wide; Director and HR do not
qualify and hold no attendance action.
DEPARTMENT parameters become allowed department ids and CURRICULUM parameters
become their active-year owning departments.
Department Principal and Curriculum Coordinator may additionally fall back to
the holder's Teacher-anchored Y-set (or the linked Teacher profile
for a legacy unanchored assignment); a Staff-only anchor has no such limb.
actor.isSchoolWide bypasses the Y-set, while actor.allowedDepartmentIds
bypasses it only when the cell's dated or frozen department is in that set.
Empty and foreign values fail closed.
Parameter authority and the related-teacher Y-set are complete alternatives. The management assignment must independently satisfy the same scope/action requirement before its parameters or profile anchor are considered; another role cannot lend it a grant, and a coordinator assignment missing the requested action cannot lend ids to a Department Principal's qualified action slice.
The check runs in the service, per entry — not in the policy — and is re-run on every PATCH using the record's stored lessonTeacherIds snapshot (the taker's authority over their own cell survives every schedule change) plus the homeroom assignment effective on the record's own date. Both clauses are dated on the cell's own day: lessonTeacherIds comes from the expected-attendance resolution of that date (a teacher whose assignment closed before D keeps back-fill authority over D — they did teach that day — and one added after D gains none over it), and the tutor clause reads the membership effective on D.
The homeroom tutor stays live-resolved — an explicit v1 ruling, not an oversight. Homeroom.homeroomTeacherId is a live pointer, so the current tutor supplies the tutor limb for the student's dated homeroom. Changing a tutor therefore changes the teacher Y-set for window-open dates; it does not bypass the past-date lock, which still requires a management limb covering the cell's department. No interval table for the tutor in v1.
Note the split precisely (contract §2b #8): the membership that finds the tutor is dated — loadStudentSnapshots(…, date) reads the homeroom the student belonged to on D — while the tutor identity on that homeroom is live. Identity live, membership dated, and the same rule governs teacher display names on a resolved slot: the spelling is current, the set is as of the date.
7.1 The teacher surface — GET /attendance/teacher-day¶
The teacher's landing view (2026-08-02, 2026-08-02-teacher-attendance-surface-design.md; completed to the whole day 2026-08-12, iteration 3) answers "what is my day on this date, where is my register duty, and how far along is it" — a slot agenda, not a group board, which is why it is a new endpoint (AttendanceTeacherService.getTeacherDay) rather than a groupBy mode of §5's board. It still obeys §5's law: the date resolves exactly once through ExpectedAttendanceResolver, and everything on the response is a projection of that one resolution.
today[]— the caller's slots on the date, in the resolver's period order: the LESSONs they teach and the ACTIVITIES they supervise on that weekday (kindis the discriminator; both take attendance identically). A combined meeting appears once as the shared slot (cohort: { COMBINED_CLASS, ccId }), never as its member sections; an activity card carriescohort: { ACTIVITY, activityId }. Each card carries the BE-mintedanchor(echo verbatim on writes), the wall clock, room, co-teachers (only supervisors covering that weekday, on an activity), acohortselector for/rows, the department'sattendanceMode(an activity's = its own single department's — the reason activities became single-department, ch18), the unit's ownname(2026-08-04: the SG's name from the same day meta the register header uses, the combined meeting's, or the activity's — the FE's card title) with a nullablesubjectName(nullon a combined meeting, whose members may span subjects, and on an activity, which has no subject), and two independent signals:needsAttendance— the register duty lives on this slot today. PERIOD: every slot. DAILY: only the slot that opens the day for ≥1 of its rostered students — computed over ALL slot kinds, so an activity opening a student's day carries the duty on the activity's own card. The flag is static: completing the register does not clear it (duty ≠ completeness, an explicit ruling).recorded— how many roster students are already covered: by a cell at this slot's tick (PERIOD), or by any authored cell that day (DAILY).rosterSize − recordedis what is still missing.
and, since 2026-08-03, a third:
- inconsistencies — the contradictions this slot triggered: over the card's roster, the hits whose chain-breaking cell (§6's triggeringCell) sits at this card's tick (one detectDayInconsistencies per student, filtered per card by triggeringTick === slot.startTick). Each contradiction badges exactly one card — the one whose cell contradicts the day as recorded before it — so two slots of one group differ. (Until 2026-08-14 the fold was the student's whole-day count on every card of the roster, which rendered one fact once per lesson — the FE-reported duplication that forced the narrowing.) A hit triggered at a slot the caller does not hold, or at a tick no expected slot matches, badges nothing here and surfaces only in the drill-down, which remains the whole-day authority: a card's badge is a lower bound of GET /attendance/inconsistencies for its cohort, never an equality. The admin group card (§4) deliberately keeps the cohort-day count — there is one card per cohort there, so no duplication exists. Costs no extra query — the day's authored cells are already loaded for recorded.
- duties[] (2026-08-12) — the break/lunch slots the caller supervises on the date, time-ordered, carrying the same detail the timetable's slot modal shows (kind, wall clock, room, the full duty-teacher set, audience rows resolved to names across DEPARTMENT | CURRICULUM | GRADE | TRACK | HOMEROOM | STUDENT) so the FE never needs the governing timetableId. Informational only: breaks bear no attendance, so a duty has no anchor, no cohort and no counters. Resolved by loadTeacherDutiesOn from expected.timetableId — the version the resolver pinned as effective on the date — so duties get lesson-identical temporality (§5's replace+report on republish) with no freeze: the day-shape manifest stays evidence-only and never learns about breaks. Empty when no version governs the date.
- otherGroups[] — every other subject group the caller holds a teaching episode for on the date (loadTaughtSubjectGroupIdsOn), minus today's anchored groups and the members of today's combined meetings; name-ordered, with dated roster counts. This keeps every register reachable on days its group does not meet. No activity analog exists on purpose: an activity always meets on its weekdays, so nothing is unreachable.
The optional “what we did today” editor displayed beside a SubjectGroup
attendance page is not attendance state. The frontend reads/writes it through
the class-register routes in chapter 26; no
AttendanceRecord, AttendanceDaySlot, CombinedClass, activity, homeroom, or
grade-group payload gains that field.
- date is optional and defaults to school-today; past dates answer as they were then (the same as-of-D rules as everything else) but take no related-teacher writes since 2026-08-04 (past corrections require a complete school-wide or parameter-management limb — §10 gate 1); future dates are an agenda preview, writable like today.
- A caller with no teacher profile in the active year gets { date, today: [], duties: [], otherGroups: [] } — fail-soft, never an error.
Acting on cells is identical to the admin surface. A card's cohort plus anchor.startTick opens its single occurrence through GET /attendance/rows; writes go through the ordinary POST /attendance/records with the complete slot anchor — subject to the Y-set, which after the clause-3 deletion admits exactly the slots this agenda shows plus tutored homerooms. Since 2026-08-12 the agenda shows the Y-set's whole slot-teacher surface, activities included.
The own-group read gate. /rows and /inconsistencies evaluate complete
management and teacher alternatives. Management callers are intersected with
their resolved department set; a teacher limb must name a cohort
assertTeacherCohortRead admits. Three teacher ways in:
- a teaching unit they hold a teaching episode for on the queried date (
SUBJECT_GROUP, orCOMBINED_CLASSwhere any member-on-date counts), - a homeroom they tutor (2026-08-03). Tutor identity is asked live, undated — deliberately the same question the Y-set's tutor clause asks (§7). The two must answer identically about the same class on the same day, or a tutor authors cells into a register they can never open, which is exactly what the pre-2026-08-03 gate did, or
- an activity they supervise on the queried weekday. The owning timetable version remains the valid-time boundary; inside that version,
ScheduledActivityTeacher.weekdaysqualifies the requested date ([]is the internal all-parent-days sentinel, a non-empty array must contain the weekday). Before this limb, a supervisor who taught none of the audience could write an activity's cells through the Y-set's slot-teacher clause but never open — or even declare — its register.
Everything else — a GRADE_GROUP (it has neither a tutor nor a teacher), no cohort at all, a unit they do not teach on that date, a class they do not tutor, an activity they do not supervise — refuses 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT. School-wide readers get every cohort; scoped managers get only cohorts inside their allowed departments. A mixed session can still reach a valid teacher cohort outside its management set through the independent teacher limb.
The ACTIVITY cohort (2026-08-12) is the 5th value of the shared cohort const, accepted everywhere the others are: /rows and /inconsistencies build a synthetic group over the activity's as-of-D audience expansion (loadActivityCohortGroup / loadActivityRosterIds — expandAudienceOn with the activity's department fence, never the live expander; single department ⇒ single mode; grade/curriculum carry no single value, the combined-cohort treatment), the write routes take it as the declared cohort (the batch roster fence rides the same expansion), the echo-less PATCH on an activity cell answers a teacher with the activity itself (school-wide callers keep the class — their surface IS the class register), and the follow-up filters accept it by materialized roster (studentId IN — audience expansion has no relational shape, and duplicating it as a Prisma where would fork the semantics expandAudienceOn owns). The admin board catalogue (/groups) deliberately grows no activity cards — deferred 2026-08-12.
Since 2026-08-03 the same gate fences the two WRITE routes (§10 gate 1, §11): the write envelope is a cohort-shaped read, so a cohort a caller could not open must not be assemblable by writing into it.
8. Read visibility — two policies, and why¶
There are five policies in attendance.policy.ts, and putting the wrong one on a route is a data leak. The rule:
| Policy | Routes | Admits |
|---|---|---|
AttendancePolicy |
POST /records, PATCH /records/:id, GET /records/:id/history |
admin, Front Office, Administrative Assistant, Director, HR Manager, teacher, and DEPARTMENT/CURRICULUM admission; services apply the qualified management fence |
AttendanceBoardPolicy |
GET /groups, /groups/pending, /follow-ups* (incl. the ack PATCH) |
admin, Front Office, Administrative Assistant, Director, HR Manager, plus DEPARTMENT/CURRICULUM managers whose services intersect the resolved department set |
AttendanceCohortReadPolicy |
GET /rows, /inconsistencies, /justifications/:id/files/:fileId |
the same management readers plus teacher — management is department-fenced and the teacher is independently restricted to own cohorts/taught students. The fail-closed branch list remains load-bearing against family sessions |
AttendanceTeacherPolicy |
GET /teacher-day |
teacher only — school-wide roles use the board |
AttendanceFamilyPolicy |
GET+POST /family/* (§11.2–§11.3) |
referent + student only — the service narrows to linked/self students (and writes add the justify action + per-link canWrite). Failing closed here is load-bearing: a teacher's register WRITE passes the read scope gate, so this policy IS the teacher/office fence on the family routes |
The split exists because a record-level policy only protects routes that query records through it. The aggregate surface does not: its unit of answer is a class card, so it assembles the group catalogue for the whole tenant and there is nothing for a where clause to attach to. Applying the broad policy there admitted a teacher to the register, absence counts and contradiction list of every class in the school — the role gate said "yes" and no narrowing followed. The teacher's admission to the two cohort-scoped routes is therefore paired with an in-service gate (AttendanceAccessService, §7.1) rather than a policy where; the policy alone is NOT the teacher's gate.
AttendanceBoardPolicy and AttendanceCohortReadPolicy declare DEPARTMENT and
CURRICULUM admission dimensions. That metadata is never row authority by
itself: aggregate services resolve the grant-qualified department set and AND
it into catalogues, records, and counts.
AttendancePolicy branches¶
AttendancePolicy is a standard record-access policy (see chapter 04) filtering AttendanceRecordWhereInput:
- admin / Front Office / Administrative Assistant / Director / HR Manager —
pass-throughfor READ; only roles whose qualified WRITE/action survives enter mutation paths. - teacher — narrowed to
departmentId IN (ctx.teacherDepartmentIds), matching the row's denormalizeddepartmentIdsnapshot. A teacher with no departments resolves toNEVER_MATCH_WHERE(fail-closed). DEPARTMENTparametric branch — same narrowing. CURRICULUM is admission-only in the synchronous policy and becomes owning departments throughAttendanceAccessService.- every other role — no branch, so the resolver fails closed. referent/student read is deferred to their own spec.
The gate is grant-shaped, not role-shaped. Routes carry only the scope (and, where relevant, the action) requirement — there is no @RequireRoles on this module. A role reaches the register iff it holds attendance.read and has a branch here. This is what lets role presets change without touching controllers, and it is why the register survives the role-switching views that are coming.
RBAC catalogue (see chapter 04 for the full model): entity EntityKey.ATTENDANCE = 'attendance', one field-level scope register, three actions — take (write a register), manage_communications (work the family follow-up list) and justify (submit family justifications, §11.3). Scope requirements are enforced at runtime: PermissionsService.computeEffectiveActions drops a granted action whose required scope level the role does not meet, so a mis-leveled requirement 403s every caller silently. take and manage_communications require register write; justify requires register READ. Seeded grants: Admin, Front Office, Administrative Assistant, Department Principal, and Curriculum Coordinator get WRITE plus all three actions. Admin, Front Office, and Administrative Assistant are school-wide writers; Department Principal and Curriculum Coordinator remain qualified-department writers. Director and HR Manager get tenant-wide READ and no attendance action. Teacher gets WRITE plus take; referent gets READ plus justify. The management justify grant does not override AttendanceFamilyPolicy, so referents remain the only callers admitted to family justification mutations.
9. Status field rules¶
assertValidStatusFields(f) (attendance-status-rules.ts) enforces two field/status invariants, both throwing 400 VALIDATION_FAILED:
| Field | Allowed on | Rule |
|---|---|---|
time (HH:mm) |
EARLY_EXIT, LATE_ENTRY (TRANSITION_STATUSES) |
the moment of a transition only — a whole-day state has no time |
note |
anything except PRESENT, ABSENT (NOTE_FORBIDDEN_STATUSES) |
a note implies a qualified status; plain present/absent carries none |
note is normalized before the rule runs (normalizeNote): trimmed, and blank → null. Without that, "" passes the note-forbidden check on a truthiness technicality and lands in the column as a value that reads like a note.
SUSPENDED is a qualified, non-transition status: it takes a note, never a time. Justification (isJustified / justificationReason) is gone — it moved to the communication loop, where an acknowledgement is a family-side fact on the day event rather than a flag on a cell.
On a batch write these are hard 400s. On PATCH the rules are reached after a coerce step (§11) that proactively clears now-illegal fields, so a status change rarely 400s the caller.
10. Write flow — POST /attendance/records¶
AttendanceService.writeRecords records a day register. The batch is single-group but multi-slot: the day screen saves whatever the admin touched, which routinely spans several lessons and an activity.
The request is { date, cohortType, cohortId, unitIds?, entries[] }, each entry { studentId, anchor, status, time?, note? } and each anchor { subjectGroupId ⊕ combinedClassId ⊕ activityId, startTick }. Anchors are BE-minted — every register read hands them out on its items; echo them back verbatim rather than assembling one.
In order:
- Cohort fence — the first authority decision, ahead of the write transaction.
loadAttendanceCohortOwnershipOnasks only whether the dated cohort exists and which departments own it; it does not assemble slots, records, counters or follow-ups. A scoped management writer must cover every returned department with the WRITE+take-qualified set; otherwise a mixed session may try §7.1's teacher limb once. The selected school-wide/management/teacher limb is carried into the response envelope, so it is neither re-decided nor followed by a speculative scoped assembly. A miss is403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORTbefore mutation.
Why it is first, and caller-shaped before data-shaped: the response is a cohort-shaped read — the group's recounted card, its contradictions, its follow-ups — so without this gate any teacher could assemble any class's register by writing one legitimate cell into it. And a refusal must never follow a write, which is why it precedes the transaction rather than joining the per-entry gates. "Not your group" is also a better first answer than "not a school day": the second invites the caller to retry a request that was never theirs to make.
Practical consequence for callers: a teacher writes under the teaching unit they teach (SUBJECT_GROUP / COMBINED_CLASS), the class they tutor (HOMEROOM), or the activity they supervise (ACTIVITY). GRADE_GROUP is an admin cohort.
- Write-window gate (2026-08-04; management grants realigned 2026-08-28) — asymmetric by ruling. The past (
date < schoolToday(tz)) is school-management-only: Admin/Front Office/Administrative Assistant and a Department Principal or Curriculum Coordinator inside their qualified departments may correct it; a related-teacher-only limb gets422 ATTENDANCE_PAST_DATE_LOCKED. Director is READ-only and never reaches this gate. The future (and today) is open to every writer that passes its complete authority limb. The date is school time, never UTC. The same rule guardsPATCH /records/:idusing the cell's own date and frozen department. - Schedule-source gate — a timetable version's interval must cover D. Not →
409 ATTENDANCE_NO_PUBLISHED_TIMETABLE(code preserved for FE compatibility; copy re-worded to "No schedule is in force on this date"). Status is never consulted — after an unpublish an archived version legitimately governs today, and the old status oracle refused there. The tenant-wide "no published timetable at all" case degenerates into this rule, so its FE-visible behaviour is unchanged.
Since spec E the manifest is not an alternative source here, which reduces the matrix to two rows:
| D | a version covers D | read | write |
|---|---|---|---|
| any date | yes | full arithmetic; schoolDay from the derivation + calendar |
plans against the resolved day |
| any date | no | cards render, nothing owed, schoolDay: false |
409 |
The rows this replaces — "past, no version, manifest present → back-fills against the manifest" and "past, version governs, day shapeless → 422 ATTENDANCE_DAY_SHAPE_UNAVAILABLE" — are both gone: expectation resolves on every date, and a day that resolves with no slot at the requested anchor answers the ordinary 404 about the anchor rather than a 422 about the day.
3. Group fence — every entry's student must be in the declared cohort's roster (loadCohortRosterIds), else 422 ATTENDANCE_ENTRIES_SPAN_GROUPS naming the offending student. This runs first on purpose: a mixed batch would otherwise fail later as "not on this lesson's roster", which names the wrong problem. Since 2026-08-02 the declared cohort may be a teaching unit (SUBJECT_GROUP / COMBINED_CLASS, roster = dated membership ∩ placed cohort — a combined cohort unions over its members-on-date) — necessary, not cosmetic: a subject group's roster spans homerooms, so a teacher writing from an SG screen under a HOMEROOM cohort would trip this very fence. An unknown teaching-unit id is the ordinary 404 NOT_FOUND naming subject_group / combined_class.
4. Anchor planning — entries are grouped by anchor and each distinct slot resolves once (planLessonAnchor / planActivityAnchor): the lesson or activity, its roster, its Y-set teacher clause and its wall bounds. An activity anchor whose tick isn't the slot's own 404s. Multiple ticks of the same subject group or combined class share one request-local curriculum-graph promise keyed by the teaching-unit id; each plan still keeps its own tick, room, teachers and duration.
One path for every date since spec E. Every anchor is matched against the resolved day, so the live-vs-frozen fork is gone and with it the provenance question — ExpectedSlot cannot tell you where it came from. The split that does matter survives unchanged: the resolved slot restores what the timetable said on that date (tick, duration, room, teachers), while loadLessonGraphByAnchor — a by-id lookup with no timetable filter — restores what the curriculum says (subject, grade, department, units). A revision rewrites the first and never touches the second.
The roster fence and the section attribution both come from that same resolution: the slot's studentIds are the memberships effective on D intersected with the placed cohort, so a student who joined the group last week cannot be recorded into a February lesson and one who has since left still can — and a combined lesson's per-student contributing section is resolved as of D too, never from the live SubjectGroup.combinedClassId head pointer. loadRegisterRoster is no longer consulted here.
An anchor absent from the resolved day → the ordinary 404 (it was not on that day). No fallback to whatever is published now. An activity needs no lookup at all: it carries no curriculum, so the resolved slot already holds every column its cell freezes.
5. Unit validation — unitIds is batch-level, but a unit belongs to one subject: every id must be a unit of some anchor in the batch (422 ATTENDANCE_UNIT_NOT_IN_SUBJECT), and each cell keeps only the units of its own subject (§10.1).
6. Per-entry gates, all before any write — roster fence (422 ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER), status-field rules (§9), Y-set authority (403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT), school day (422 ATTENDANCE_NOT_A_SCHOOL_DAY). Each entry's frozen snapshot is resolved on the way through.
7. One transaction — dayShape.freezeDay(tx, …) first, so no register can outlive knowledge of the schedule it was taken against. It is handed the schedule resolver's own result (carried through the expected-attendance result by identity), so what gets frozen stays the day's geometry rather than E's as-of-D rosters — changing the manifest's content is spec F's decision. Then then the in-transaction pre-read that decides each cell's audit verb, one upsert per cell on attendance_cell_unique, one read-back, one audit.recordMany (verbs attendance_record.created / .updated). The (student × tick) read-back rectangle can contain cells the batch never touched, so matches are keyed on the exact pair.
The per-cell upsert replaced a createMany + N-update split whose create-vs-update decision came from a read taken outside the transaction — a race by construction, and two teachers saving a co-taught lesson was the realistic trigger (the loser hit attendance_cell_unique and lost its whole batch to a generic 409). The audit verb still comes from the in-transaction pre-read, so under an exact race the label can say created for a row that landed as an update; the value history stays complete and correct, which is what the trail is for.
The update has two shapes (ruling 2026-07-29). A same-anchor rewrite moves the value only — renames and reassignments after the fact must never heal history. A different-anchor rewrite (the stored anchor, read in the same pre-read, differs from the incoming one — e.g. an activity overlapping the lesson the cell was first recorded at) is a different assertion event: one tick holds one fact, so the later assertion supersedes, and the row re-photographs the full snapshot (anchor triple + names, subject columns, teachers, bounds, coveredUnit*) so it testifies about the assertion it now carries. recordedBy* never moves — first provenance is the row's own fact — and the superseded context survives in the audit trail: the re-anchored cell's audit data carries { anchorKey, previousAnchorKey }. The stored anchor is recovered most-specific-first (storedAnchorKeyOf: activity, then combination, then section), because a combined-lesson cell stamps both the combination and the student's contributing section.
The response is the write envelope — { records, group, inconsistencies, followUps }: the cells in submission order, plus the group's recounted card, its contradictions on that date and up to 50 of its open follow-ups across every unresolved day (WRITE_FOLLOW_UP_LIMIT, a stated contract cap). Refresh all four widgets from the one response instead of re-fetching the board. For POST, the locked transaction returns its resolved expected day and captured academic year/school clock alongside the cells; the envelope reuses that request-local context instead of resolving active year, timezone and expected attendance again, while still rereading authored cells, events and follow-ups after commit. This is not a cross-request cache. PATCH has no such in-transaction result and performs one normal assembly. The group card is shaped by the declared cohort — a SUBJECT_GROUP batch answers with the subject group's card, via the same synthetic group the reads use (§5).
Each cell carries its student's row (records[].row, AttendanceWrittenCellDto, 2026-08-03). In DAILY the register is projected at read time (§1), so writing one cell re-shapes every other slot of that student's day — and nothing in the pre-2026-08-03 envelope could express it: records[] holds stored rows, while a projected slot is a status with no row behind it. Clients reloaded the whole group after every save. The row is the whole-day form of the same AttendanceStudentRowDto /rows returns (§5.1) — strip, day summary, classifications, per-item inconsistency markers — built by the shared buildStudentRows, narrowed to the students the write touched, because projection is derived per student from that student's own cells and no untouched row can change. (startTick slices only GET items[]; write rows remain whole-day.) null under exactly the condition that nulls group: the group no longer resolves. Three consequences worth knowing before reading a payload:
- It is the student's own day, not the declared cohort's slice — so it is correct when the cohort is a subject group whose roster spans classes, and on
PATCHwith or without the cohort echo. - A student written at two slots in one batch gets the identical row on both cells — one assembly snapshot, so the copies are byte-identical and merging is idempotent. Accepted redundancy, not a signal.
- The written cell appears at three depths as three different projections:
records[i](the full snapshot cell),row.items[k].record(the lean item view),row.register[j](the day-summary view). They are not copies of one object; never diff them for equality.
10.1 Observed unit-coverage tag (coveredUnit*)¶
When a lesson's subject is a container (taught in units — ch14 §1.4), the write accepts an optional session-level unitIds?: string[] recording which units were actually covered. The tag is fanned onto every record the write creates as the snapshot arrays coveredUnitIds / coveredUnitNames, keeping only the units belonging to that record's own section. Two deliberate behaviours: the tag is frozen (re-writing a cell updates value + lastModifiedBy only), and it is never set on activity cells (an activity has no subject). A leaf subject or an untagged write → empty arrays.
11. Cell edit — PATCH /attendance/records/:id¶
AttendanceService.updateCell edits one existing cell. All value fields are optional; the merge is coerce-then-validate:
- Load the cell through
AttendancePolicyvisibility; not visible →404 NOT_FOUND. - Merge each field (
dto.x !== undefined ? dto.x : existing.x). - Coerce to keep the row legal across a status change rather than 400-ing: a non-transition status clears
time, aPRESENT/ABSENTstatus clearsnote. ThenassertValidStatusFieldsruns as a backstop. - Write-window (§10 gate 1): a past cell is writable through a complete management limb (Admin/Front Office/Administrative Assistant tenant-wide, or a matching Department Principal/Curriculum Coordinator department). Related-teacher-only authority gets
422 ATTENDANCE_PAST_DATE_LOCKEDbefore its Y-set check. - Y-set re-check (§7) using the stored
lessonTeacherIdssnapshot + the homeroom assignment effective on the record's own date. Denied →403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT. - Cohort echo (optional, 2026-08-02):
cohortType+cohortId— both or neither, any of the four cohort kinds — asks for the envelope'sgroupcard recounted over THAT cohort instead of the default, so a screen anchored on a subject group refreshes from the one response. Validated against the cohort's roster on the record's own date and year: the cell's student not in it →422 ATTENDANCE_ENTRIES_SPAN_GROUPS. A cohort-only body still 400s — the echo is not an edit. - The default cohort is caller-dependent (2026-08-03). The echo always wins; without one:
| Caller | Default group |
|---|---|
| management (Admin/Front Office/Administrative Assistant school-wide, or qualified Department Principal/Curriculum Coordinator) | the cell's own class — homeroomId → HOMEROOM, else the grade group, still intersected with the management department set. |
| teacher | the cell's own teaching unit — combinedClassId ?? subjectGroupId; an activity cell has neither, so it falls back to the class (and then passes only for that class's tutor). |
Why the split: the cell's snapshot names the unit the authority assertion was made through, which is both the teacher's screen and the only cohort shape the fence below admits them for. Before this, an echo-less teacher PATCH answered with a class card they had no right to read — the write envelope was leaking exactly what §7.1's read gate refuses.
8. Cohort fence — the same complete management/teacher alternatives as §10, on the cohort the envelope will describe and the record's own date, run before the transaction opens so refusal never follows a write.
9. Update + audit inside one $transaction, picking the verb by intent: attendance_record.admin_override when a school-wide writer edits a cell they could not reach as a teacher, else attendance_record.updated.
The edited cell carries row like any written cell (§10), built on the record's own date — which is what makes a single-cell correction self-sufficient on a DAILY screen: changing 08:00 from ABSENT to LATE_ENTRY re-projects 09:00 from ABSENT to PRESENT, and the caller learns that from the same response. The row is per student, so it is unaffected by whether the caller sent the cohort echo.
11.1 The family communication loop — minting, visibility, ack¶
Slice A of the referent-attendance program (spec): the loop's data side. The program is complete: B (family reads, §11.2), C (justifications, §11.3) and D (the notification cadence — the sweeper block below, consumer spec, engine in chapter 23) are all live.
Minting. Both write paths call AttendanceLoopService.mintForStudentDays inside their transaction, after the cells land: same-database state derived from the same write, so there is no crash window where a register says absent but no event exists. Per touched (student, date) the whole day's authored cells are re-classified (classifyStudentDay — the one opinion about what a day means, §4) and mapped to the one event type the day warrants: absent → ABSENT, lateEntry → LATE_ENTRY, anything else → none (trips, suspension and early exits never mint; an early exit always involves a referent in person). The diff against the existing rows:
| Existing row | Day still warrants the type | Day no longer warrants it |
|---|---|---|
| (none) | create UNDER_REVIEW, armAt = now + grace |
— |
UNDER_REVIEW |
leave | CANCELLED if still in grace, else SUPERSEDED |
NOTIFIED / FOLLOW_UP |
leave | SUPERSEDED |
ACKNOWLEDGED / NOT_REQUIRED |
leave — resolved is history | leave |
CANCELLED / SUPERSEDED |
re-open: UNDER_REVIEW, fresh armAt, timing/ack fields cleared |
leave |
Minting performs no authorization and no validation — the register write already decided both — and adds no audit rows (the cell write is audited; the diff is derived state).
The arm window (default 15 minutes, env-overridable — the cadence table below) does three jobs: a teacher's fat-finger corrected inside it is CANCELLED silently (the family never knew), it delays family-facing visibility, and it keeps two-minute-old mistakes off the office worklist. The clock runs from the write, never the event's date — a backdated absence recorded today arms one grace from now, because the notification is a justify-request about a date, not a live alert.
Visibility is read-time. Open — owed an answer, shown on the worklists — is openFollowUpWhere(now) (attendance-insights.queries.ts): state ∈ {NOTIFIED, FOLLOW_UP} or armed UNDER_REVIEW (armAt <= now). It carries its own OR, so compose it under AND (the effectiveOn rule). The admin rows' dayEvents[] stay deliberately unfiltered — full office detail, in-grace and resolved states included; worklists are what apply the open rule. Because the rule always admitted NOTIFIED/FOLLOW_UP, the sweeper below flips states without touching a single reader.
The ack (PATCH /attendance/follow-ups/:id/ack, §13) resolves a live event — UNDER_REVIEW included, even in grace: the parent who phones at 8:20 closes the case before it surfaces and before anything sends. Since 2026-08-10 (spec) the ack records a full justification on the family's behalf, in the same transaction: a single-day AttendanceJustification row — kind via suggestedKindFor, arrival time inherited from the day's earliest late-entry cell (findLateEntryArrivalTime, honestly null if the teacher never filled it), reason carried over, channel = PHONE/IN_PERSON, office-attributed — stamped onto the event via justificationId, the third covering seam beside APP resolution and NOT_REQUIRED birth. It stamps the full ack block (ackAt/ackByUserId/ackByName/ackChannel/ackReason), records two audit rows (attendance_day_event.acknowledged + attendance_justification.created), and answers with the refreshed FollowUpItemDto. It writes no cells (the register for an evented day already exists) and runs no overlap matrix — a live event proves no same-kind covering row exists, since any such row would already have resolved it at submit or mint, and a resolved event 409s the ack; the justifications e2e pins the invariant. APP is not accepted — that channel is what referent submissions stamp for themselves.
The covering seam (slice C — §11.3). findCoveringJustification is real: at mint, a covering justification births the event NOT_REQUIRED with armAt = NULL and the justification stamped as provenance; a covered re-open of a CANCELLED/SUPERSEDED row lands the fresh episode the same way.
The sweeper (slice D). AttendanceNotifierService — the platform's first scheduled background job (in-process @nestjs/schedule, dynamic SchedulerRegistry interval since a static decorator can't read config). Every tick, per tenant, two passes:
- notify — armed
UNDER_REVIEWpastarmAt→NOTIFIED(notifiedAtstamped,escalateAt = now + reminder delay) +send('attendance.event'); - remind —
NOTIFIEDpastescalateAt→FOLLOW_UP(remindedAtstamped) +send('attendance.reminder'). No third strike — the open worklists are the escalation.
Sends go through the notification engine (chapter 23): inbox row per linked referent with an account + templated email on the school language. Recipients are resolved per event from StudentReferentLink → Referent.userId; zero reachable recipients leaves the state flip standing (the school announced; nobody to tell — the family page still shows it). Mechanics worth copying for the next scheduled job: per-tenant withTenantGuc (no request CLS — RLS stays honest, tenant-led indexes serve the scans), per-row CAS claims (updateMany re-checking the FULL prior state + deadline; count === 1 wins, so concurrent processes and a re-open racing the scan both lose cleanly), each claim its own committed transaction before its send (the engine's post-commit contract, held per event; death between claim and send loses that one send — at-most-once, the worklists are the safety net). Sweeper flips write no audit rows — the audit log is a who-did-what trail and the sweeper is not a who; notifiedAt/remindedAt are the machine evidence. The no-notify invariant is structural end-to-end: coverage and acks remove events from the claimable states inside their own transactions, so a covered event cannot send.
Cadence configuration (env, validated + bounded; resolver attendance-timing.config.ts):
| Var | Default | Meaning |
|---|---|---|
ATTENDANCE_ARM_GRACE_MINUTES |
15 | armAt = write + grace (1..1440) |
ATTENDANCE_REMINDER_DELAY_MINUTES |
60 | escalateAt = notifiedAt + delay (1..1440) |
ATTENDANCE_SWEEP_INTERVAL_SECONDS |
60 — 0 under NODE_ENV=test |
tick; 0 disables the timer while sweep() stays invocable (how e2e drives the cadence deterministically) (0..3600) |
ATTENDANCE_ARM_GRACE_MINUTES and ATTENDANCE_SWEEP_INTERVAL_SECONDS are now the shared family-alert pair resolved by src/common/utils/family-alert-timing.ts; disciplinary notes consume the same values. Their attendance-prefixed names are retained for deployment continuity. ATTENDANCE_REMINDER_DELAY_MINUTES remains attendance-only.
11.2 The family read surface — to-justify, overview, day view¶
Slice B of the program (spec): the first family-facing reads, and the program's first RBAC delta — referent and student gain READ on attendance.register (prod reseed required). Three GETs on AttendanceFamilyController (/attendance/family/*), read-only end to end, all @RequireScopes(ATTENDANCE, 'read') + AttendanceFamilyPolicy (§8 — the policy is the role fence; the record-level narrowing is familyStudentAccessWhere in attendance-family.queries.ts, the grades-visibility idiom: referent → linked students via StudentReferentLink, student → self).
Two visibility rules, one clock. The to-justify list uses slice A's openFollowUpWhere(now) — owed an answer, resolved events gone. Everything else family-facing uses the wider familyVisibleEventWhere(now): state ∈ {NOTIFIED, FOLLOW_UP, ACKNOWLEDGED, NOT_REQUIRED} or armed UNDER_REVIEW. CANCELLED, SUPERSEDED and in-grace UNDER_REVIEW never appear in any family response — the §11.1 grace window is only real because the family literally cannot observe it. Both builders carry their own OR; compose under AND.
Lists show ENROLLED children only (2026-09-03). to-justify and the family justification list ride familyParticipatingStudentWhere (link access ∧ status = ENROLLED), so a child who has left drops out of the badge and the lists; the two per-student routes keep the bare link access (familyStudentAccessWhere), so that child's history stays reachable by id.
The three reads.
- GET /family/to-justify — every open event across all the caller's linked students in the active year, newest day first, paginated: one call powers the badge and the list. studentId and from narrow it (FamilyToJustifyQueryDto, 2026-08-10 — the filters its admin siblings always had; an unlinked or unknown studentId simply composes with the policy predicate under AND and answers an empty page, never an error). Each item carries surfacedByName — the recordedByName snapshot of the earliest cell of that day whose status matches the event type (batch-resolved; null if the cells are gone) — and suggestedKind (2026-08-10): the covering rule's third reading (§11.3 states the other two), the justification kind whose submission would cover this event (ABSENT → ABSENCE, LATE_ENTRY → LATE_ENTRY), published so no client re-derives the event→kind mapping from enum names.
- GET /family/students/:studentId/overview — the stats block + the family-visible event history. Stats are record-derived (one query over the student's year cells, folded per date through classifyStudentDay — no calendar walking, days nobody took don't count): schoolDays = record-bearing dates minus suspended/trip dates (surfaced as excludedDays); absent/lateEntry day counts with one-decimal percentages (null on a zero denominator); and presentHours — an authored-cell hours approximation (minutes from the cells' own periodStartTime/periodEndTime snapshots, time-less cells skipped from both sums, no projection — a DAILY department's sparse authoring under-weights days, consistently on both sides of the ratio) against the department's minPresentHoursPercentage (the config the Italian ≥75%-of-hours rule lives in; null when unset).
- GET /family/students/:studentId/days/:date — the admin row for one student-day, built through the same buildStudentRowCore the office rows use (§5): same slots, cells, classifications by construction. Two deltas: dayEvents[] is family-visible and reduced (FamilyDayEventDto carries no ackReason, no ackByName, no lastContactAt — the office trail stays office-side), and the row's register[] lands as cells[] — an @AggregateResponse() route must not answer with a top-level key equal to one of its entity's scope keys (attendance.register), or FieldFilterInterceptor.assertAggregateShape 500s it; the admin row never trips this because its register sits nested inside students[]. :date is a path param, validated by hand with the same isCalendarDateOnly rule @IsDateOnly uses; any date is readable and a no-school date renders empty.
An unlinked, foreign or nonexistent studentId answers the same 404 (NOT_FOUND, entity: 'student' — the grades existence-hiding idiom). The student role reaches the two per-student routes for themselves; their to-justify degrades to their own rows. Office and teaching roles get 403 (§8).
11.3 Justifications — the family write path (slice C)¶
Slice C of the program (spec): referents submit justifications and announcements — AttendanceJustification, a dated statement, not a review workflow. No approval step: the school's posture toward a family's statement is still a later iteration's concern, and the office has no edit or delete route at all.
The family, however, owns what it has announced. Since the CRUD iteration (spec) a referent may revise or withdraw a communication while every day it covers is still ahead — the mutability block below. Past days stay untouchable: a family may revise what it has announced, never rewrite what has already happened. Beyond those two routes the only mutations remain the consistency matrix's own (QA batch 2026-08-07, spec §5-W3 — the matrix block below): a same-kind same-day resubmission overwrites in place, an ABSENCE supersedes the single-day rows inside its range, and contradictions are refused with 409 JUSTIFICATION_CONFLICT.
Two jobs, one route (materialization, 2026-08-10 — normative). The same POST means different things either side of today, and the axis is opening vs answering, not the age of the date:
- Opening a communication about today or a day ahead is unconditional, and it additionally pre-fills the register (§11.4).
- Answering a past day is accepted only where the school has actually recorded something to answer: a day in the submitted range carrying a family-visible event (
familyVisibleEventWhere— so an alreadyACKNOWLEDGEDorNOT_REQUIREDday still admits a submission; in-graceUNDER_REVIEW,CANCELLEDandSUPERSEDEDdo not, because the family was never shown them) of a type the kind covers (coveredEventTypesFor). It writes no cell. One flagged day in the range suffices — the rule is anti-fabrication, not completeness, so a Mon–Fri illness passes on a register that was only taken Mon–Wed. Otherwise422 JUSTIFICATION_DAY_NOT_FLAGGED, whose message names the day and the alternative that is still open. EARLY_EXITcan never pass the gate, because no event type exists for an early exit — it is inherently an announcement, today or future only.
There is no deadline on answering, and adding one is a regression. A flagged day stays answerable for as long as the year is open. The school already controls which days are on the list, so nothing is unbounded; and a certificate arrives when it arrives. A configurable date window was designed and then deleted as the wrong axis — if you find yourself reaching for justifyUntil, an env knob or a per-row countdown, the axis you want is the one above and it is already implemented (assertPastDaysAreAnswerable).
The mutability window (normative). A row is open to change iff channel = APP and isoDate(startDate) > schoolTodayFor(tenant) — strictly greater, so a row covering today is already final. The channel leg (2026-08-10): an office-recorded row is never the family's to rewrite or withdraw, whatever its date. Usually the date leg already covers it (events live on past/today days), but a future-day register mints a future event and the ack materializes a future-dated PHONE row there — letting the family withdraw it would leave the settled day invisible again, the exact state the ack-records-a-justification rule exists to eliminate. The whole row freezes together: a multi-day absence that began yesterday is not half-editable, which is what keeps one rule for both PATCH and DELETE and leaves no partial-range semantics anywhere. PATCH applies the fence twice — once to the stored row and once to the proposed range, the second stopping an edit from back-dating a statement into a day that has begun. Both fire before the transaction opens; both answer 422 JUSTIFICATION_NOT_EDITABLE naming the blocking date. The per-row answer is published to the FE as canEdit on JustificationDto: the temporal window AND the caller's per-link canWrite, resolved with one batched link query per page (referentWritableStudentIds). Since the harmonization (spec) the flag has exactly one definition — canEditJustification in attendance-justifications.shared.ts — used by the family list and by both mutation responses, so a create/PATCH answer and the next list read can never disagree (before the split they combined the two legs independently, a latent divergence for any future non-referent writer). It is false for student sessions (they never submit); the office row does not carry the key at all since 2026-08-10 (no edit route exists — the field was a hard-wired false under an inherited description claiming otherwise; its removal is the harmonization's one breaking change). It cannot be derived from /permissions — the grant is unconditional, the window is per row.
The family list (GET /family/justifications) is the archive half of the surface: every communication across the caller's linked students in the active year, paginated. studentId, from (rows whose coverage ends on/after the date — an in-progress multi-day absence still matches) and, since 2026-08-10, kind narrow it; an unlinked studentId composes with familyStudentAccessWhere and answers an empty page. Each row carries studentName (2026-08-10) — this is the multi-child projection, and the join the server already has is published rather than re-fetched by every client. Ordering is submission recency (createdAt desc), not covered date — deliberate: a startDate sort wants an index this iteration refused to buy a migration for, and the FE's future→past table sorts client-side anyway. Do not "fix" the ordering without the index.
Attachments are exempt. Upload always ignored the window — a medical certificate arrives after the child is back, i.e. onto a past row — so DELETE …/files/:fileId ignores it too. The statement freezes; its evidence never does. This is the one carve-out in the read-only-past rule and the FE must not gate attachment controls on canEdit.
The channel marker (2026-08-10). Every justification projection — the family list, the office browse, the nested row blocks — carries channel: APP | PHONE | IN_PERSON: APP = submitted by the family in the app; PHONE/IN_PERSON = the office resolved the follow-up directly and the ack recorded the statement (§11.1). A write-time provenance column (@default(APP), so every pre-existing family row reads right with zero backfill), not a read-time derivation from coveredEvents — overlapping rows make that ambiguous. Two consequences worth knowing cold: an office ABSENCE row blocks overlapping family submissions with 409 JUSTIFICATION_CONFLICT exactly as a family one would (participate-normally was the explicit product call), and a family single-day resubmission landing on an office row ("last wins, in place") flips the marker to APP along with the substance — intended, not drift; the settled event keeps its PHONE/IN_PERSON ack block either way.
The edit path is the create path. PATCH merges the patch over the stored row and validates the result with the same validateKindShape, so there is one shape contract. kind and studentId are absent from UpdateJustificationDto entirely: the global pipe's whitelist + forbidNonWhitelisted turns a body naming either into a plain 400, which is why neither needs a domain error code. One merge rule is not a plain spread — for the single-day kinds the stored endDate is dropped and re-derived from the effective startDate, or moving a late arrival to another day would strand it on the date it used to cover.
The model. kind ∈ {ABSENCE, LATE_ENTRY, EARLY_EXIT} with kind-conditional shape (normative — mirrored by the FE form and enforced in validateKindShape, mismatches are plain 400 VALIDATION_FAILED):
| Field | ABSENCE | LATE_ENTRY | EARLY_EXIT |
|---|---|---|---|
startDate/endDate |
range (endDate required, ≥ start) |
single day (endDate omitted or equal) |
single day |
time |
forbidden | required | required |
reason |
required | required | optional |
| attachments | ≤ 5 | ≤ 5 | none (422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED) |
The range must fall inside the department's school year. The submitter's name is snapshotted at submit (submittedByName). The EARLY_EXIT pickup-person selector (type + id + name snapshot, PICKUP_PERSON_NOT_ELIGIBLE) was removed by product decision 2026-08-10 — an early exit is now just date + time (+ optional reason); the isAuthorizedPickup flags on referent links and guardians survive as inert data, no longer consulted here.
The consistency matrix (QA batch 2026-08-07 — enforceConsistency, probed inside the create transaction over the student's intersecting rows in the year; probe-then-write TOCTOU accepted at family cadence):
| new ↓ vs existing → | ABSENCE (any shared day) | same single-day kind | other single-day kind |
|---|---|---|---|
| ABSENCE | 409 JUSTIFICATION_CONFLICT (extend by submitting the remainder) |
supersede — delete + provenance re-stamp | supersede |
| LATE_ENTRY / EARLY_EXIT | 409 JUSTIFICATION_CONFLICT |
overwrite in place (same row id — provenance and attachments survive; substance and submitter replaced; audit attendance_justification.replaced) |
coexist (late arrival + early exit is a legitimate day) |
The asymmetry is deliberate: blocking the absence direction would deadlock a family whose plans worsened (announced a late arrival, child turned out sick all day). A delete route now exists, but it only reaches strictly future rows — so the family whose child fell ill on a day they had already announced a late arrival for still has supersede as its only escape. The deadlock the asymmetry was invented for is narrower than it was, not gone. A supersede deletes the swallowed rows and their attachments, then restampEventProvenance re-points the dangling settled events the new kind exactly covers (ACKNOWLEDGED/APP and NOT_REQUIRED only — office PHONE/IN_PERSON acks name a human decision and keep their null stamp) at the new absence. The 409's params name the blocking row (kind, conflictingKind, startDate, endDate).
The matrix governs both write paths, the edit excluding itself from the probe (excludeId). It diverges in exactly one verdict: an edit refuses what a create would have absorbed into itself, and supersedes what a create would have superseded. On create, "overwrite in place" collapses a resubmission into the existing row and answers with it — sound, because the caller owned no row yet. An edit already owns its target, so collapsing would either return an id the caller never asked about or destroy a same-kind announcement as a side effect of an unrelated change; it answers 409 JUSTIFICATION_CONFLICT instead.
Withdrawal is not a row delete. A justification owns downstream event state, so DELETE hands it back before removing the row: AttendanceLoopService.reopenForJustification returns every event this row resolved (ACKNOWLEDGED via APP) or pre-empted (NOT_REQUIRED) to UNDER_REVIEW on a fresh armAt, clearing the whole timing/ack/provenance block through the same buildReopenData the mint diff uses. Office acks (PHONE/IN_PERSON) and CANCELLED/SUPERSEDED are never touched — the first names a human decision rather than this row, the second belongs to the day's own shape. PATCH calls the same thing with outsideRange set to the surviving range, so only days that left coverage re-open while days that joined it get the ordinary submit-time sweep.
Ordering is load-bearing, twice. AttendanceDayEvent.justificationId is onDelete: SetNull, so the re-open must run before the row delete — afterwards the events carry no stamp and cannot be found, and the withdrawal would silently leave absences marked answered with nothing behind them. And the fresh armAt is not incidental: it gives the office worklist the case back after the usual correction grace, and it makes withdraw-then-resubmit inside that grace completely silent.
Withdrawal also un-writes the register (materialization, 2026-08-10 — §11.4). Two steps now precede the re-open: hard-delete the cells this justification pre-filled and no human has touched, then re-mint the touched (student, date) pairs so a now-notTaken day cancels its own event through the ordinary diff — and only then re-open what the diff left standing. Three cases, all intended:
| What the register holds | Withdrawal result |
|---|---|
| only cells this row pre-filled, untouched | cells deleted → day notTaken → the event cancels through the mint diff. Not re-opened: the school never asserted an absence, so nothing is owed an answer. |
| a teacher-authored register (this row was an answer) | nothing to delete → the re-open applies verbatim, and the day returns to to justify. |
| pre-filled cells, one since edited by a teacher | that cell survives (adoption) → a real register remains → the event re-opens. |
The ordering interaction is self-correcting and must not be "fixed": reopenEventsStampedBy admits only NOT_REQUIRED and ACKNOWLEDGED+APP, so an event the dematerialize-mint already moved to CANCELLED is not resurrected.
The covering rule (normative, both directions in attendance-justifications.queries.ts): matching is exact — ABSENCE covers ABSENT only; LATE_ENTRY covers LATE_ENTRY only; EARLY_EXIT covers nothing because no event type exists for it. A future announcement that differs from the recorded outcome therefore leaves the new event open and creates a fresh to-justify item after the arm window. PRESENT and EARLY_EXIT still mint no event. Applied at exactly two event-driven seams, never at read time:
- Mint time (§11.1) — an event whose
(student, date, type)a justification already covers is bornNOT_REQUIRED,armAt = NULL,justificationIdstamped. Same for a covered re-open. - Submit time — inside the create transaction, every covered live event (
UNDER_REVIEWincluding in-grace — a justification pre-empts sends exactly like an in-grace office ack — plusNOTIFIED/FOLLOW_UP) resolves toACKNOWLEDGEDwithackChannel = APP, the submitter's name snapshot,ackReason = NULL(the reason lives on the justification row) andjustificationIdstamped. Resolved states are never touched.
The no-notify invariant (binding on slice D): exact coverage removes matching events from the open set at both seams, and slice D's sweeper claims only open events — so a covered event can never send, with zero slice-D coordination. Since the 2026-08-07 consistency matrix, overlapping rows can no longer be created — the mint-time newest-wins tiebreak survives only for legacy pre-matrix data.
Provenance, not derivation. AttendanceDayEvent.justificationId (nullable, SetNull) records which justification resolved or pre-empted the event. Re-opening clears it with the rest of the episode's facts, then the re-check may re-stamp. Read surfaces carry it (dayEvents[].justificationId on office rows) so the FE can correlate with the row's justifications[] — never re-derive coverage at read time; overlaps make that ambiguous.
Row surfacing, once. Every surface that renders a student-day nests the same block: justifications[] (RowJustificationDto — kind, range, time, reason, attachments, submitter, submittedAt), newest first, from the one shared loader AttendanceJustificationsReadService.getRowBlocks. Office/teacher rows get it through buildStudentRows (so group rows, the admin day register and the write envelope's records[].row all inherit); the family day view attaches the identical block. A justification needs no event to surface — a pre-announcement is visible on its days from the moment it is submitted.
The module shape (2026-08-10). Justifications are three services along their three jobs plus a pure shared module: writes + the consistency matrix (attendance-justifications.service.ts), read projections (attendance-justifications.read.service.ts — the family list, the office list and the row blocks all project through one baseProjection with two mappers: toFamilyDto, which resolves canEdit, and toAdminRow, which never sets the key — the split is what makes the office removal real, because @AggregateResponse() routes bypass field filtering and a DTO alone filters nothing), and attachments (attendance-justification-files.service.ts, which by design consults no mutability window). attendance-justifications.shared.ts holds the window (isMutable), the flag (canEditJustification), the constants and the hidden-404 thrower. The only cross-dependency is writes → reads, for response projection — never the reverse.
Gating. The family routes (§8 policy table) compose: register read scope + AttendanceFamilyPolicy (the referent-only write fence) + — on the writes — the attendance.justify action + the per-link canWrite gate (assertReferentCanWrite, the shared students-module helper). Management presets also carry the action for the roles catalogue, but the policy does not admit them; the student role reads and never submits. Deliberately not register WRITE: that is the teacher-write fence and would drag the Y-set semantics somewhere they don't apply. Family record-narrowing is familyStudentAccessWhere throughout; a foreign/unknown justification is a hidden 404 JUSTIFICATION_NOT_FOUND.
Attachments ride the shared collection-file path (FilesService.appendCollectionFile — upload-outside-tx, attach-inside-tx with compensation delete): FileUsage.JUSTIFICATION + FileOwnerType.JUSTIFICATION, the module's first non-person file owner. Cap 5 (409 JUSTIFICATION_ATTACHMENT_LIMIT), evidence only — a file never affects matching, which ran at submit. Downloads mint signed URLs by owner: the family route fences through familyStudentAccessWhere; the school route (GET /attendance/justifications/:id/files/:fileId) admits tenant-wide management readers, intersects scoped managers through the student's current department, and retains the independently qualified teacher taught-set limb. A miss is the same hidden 404 as a wrong id.
Audit. One row per submission — attendance_justification.created (plus supersededIds when an absence swallowed rows) or attendance_justification.replaced on an in-place overwrite — carrying the kind, range and resolvedEventIds: the sweep's effect is in the trail even though the event updates themselves are derived state.
The school-side list (GET /attendance/justifications, QA batch 2026-08-07): the management mirror of the family list — communications of the year, newest first and paginated; date filters to rows covering that day, while studentId/kind narrow further. Rows are AdminJustificationRowDto, without canEdit. The read + manage_communications qualified slice makes tenant-wide managers see all rows and Department Principals see rows for students currently placed in their departments; rows and count share the same predicate.
11.4 Materialization — an announcement pre-fills the register¶
Spec (Approved 2026-08-10). AttendanceMaterializationService, called from the justifications write transaction.
An announcement about today or a day ahead is not merely a note to the office: it is the family asserting a fact about a day nobody has registered yet. So it lands as real AttendanceRecord rows, and the teacher or the office opens that date to find the register already filled in, attributed to the parent who sent it.
Why the legal-record objection does not reach here. A compliance register must not be rewritable from outside — but that argument only ever covered the past. §10 gate 1 already rules the future open to every writer: a planned trip or a known absence is registrable in advance. Pre-filling a future day is therefore not a new liberty, only a new author for one the register already granted. The past half of the same route stays read-only: it answers, and answers change no cell (§11.3).
The mapping (normative — chooseCells). attendanceMode comes from the student's own department snapshot, never from the slot (the mode is a department property; since 2026-08-12 an activity's fenced roster shares its department, but the per-student rule is what the snapshot means):
| kind | PERIOD | DAILY |
|---|---|---|
ABSENCE |
every slot of the day, ABSENT |
the day's first slot only (reads project it forward) |
LATE_ENTRY |
the transition slot, LATE_ENTRY + time |
same — one cell |
EARLY_EXIT |
the transition slot, EARLY_EXIT + time |
same — one cell |
The transition slot is the one whose [start, start + duration) contains time; a time falling in a break takes the next slot to start, a time before the day begins clamps to the first, and a time after the day ends writes nothing.
Never overwrite an occupied tick. The insert is one createMany({ data, skipDuplicates: true }) per date against attendance_cell_unique. That flag is the rule, not an optimization of it, and simultaneously the race guard against a teacher saving the same tick in a concurrent transaction. Do not turn it into an upsert or a pre-read: an upsert would let a parent overwrite a witness, and a pre-read outside the row lock is a check-then-act. It is also precisely what makes including today safe — a register already taken this morning simply stands.
Never fabricate a status. The transition kinds write one cell and no PRESENT around it. The omission is the point: an announced 10:30 arrival asserts nothing about the rest of the day, and filling PRESENT either side would both invent a fact nobody stated and manufacture the §6 contradictions the board exists to surface.
Attribution. Cells carry the submitting family member's recordedByUserId/recordedByName — the first cells in the module written without any caller holding attendance.register:WRITE, deliberately, because the office looking at the row must see which parent asserted it. Two audit verbs: attendance_record.family_announced (one row per cell) and attendance_record.withdrawn (one row per withdrawal, since the cells are gone and the act is the event).
Skips are silent, always. No version governs the date (where a teacher's write would get 409 ATTENDANCE_NO_PUBLISHED_TIMETABLE — the family cannot know the school has no timetable for next Tuesday), no slots for this student, not a school day, every tick already taken, past the cap: each skips a date or a tick and never fails the submission. The communication is the family's to make; pre-filling is a convenience layered on top.
The cap is MATERIALIZE_MAX_DAYS = 30, counted in days that actually produced cells (so weekends and holidays inside a long absence do not consume it) and logger.debug-ed when it truncates. A local constant, not configuration: it bounds one transaction's work rather than expressing a school policy.
Cost posture. Inline in the submit transaction, per-date lockAyDayShared in ascending order — deterministic, because two submissions with overlapping ranges must take the days in the same sequence or they deadlock. Per date: freeze the day shape (a future-day freeze is new and harmless — the manifest is write-path evidence only), insert, audit, then mintForStudentDays. The no-notify invariant then holds with zero new coordination: the justification row already exists in this transaction, so the covering seam births the event NOT_REQUIRED with armAt = NULL.
The shared planners. Slot planning (planAnchors) and the school-day check (isSchoolDayCached) live in attendance-planning.ts as plain functions rather than on AttendanceService, because AttendanceService → AttendanceBoardService → AttendanceJustificationsService is an existing chain and the materializer is reached from its far end. The materializer must never inject AttendanceService. AttendanceService.assertSchoolDay survives as a four-line wrapper that turns false into ATTENDANCE_NOT_A_SCHOOL_DAY; the two callers disagree about the consequence, never about the answer.
Withdrawal un-writes what it wrote — the three-case table in §11.3, bounded to justificationId = X AND lastModifiedByUserId IS NULL. This is the module's only cell-deletion path, and a hard delete: the audit trail keeps the history, so there is no tombstone anywhere here. PATCH dematerializes totally and re-materializes, not selectively by outsideRange like the adjacent re-open — an edit can move time, which moves the transition slot, so a day that stayed in range may still need its cell at a different tick and a selective delete would strand the old one.
Read surfacing. justificationId on items[].record and on register[] (null on a staff-authored cell, and on a projected item like every other stored-only field). These are otherwise ordinary cells — editable, auditable, counted in toRegister and the group counters exactly like any other.
Not backfilled. Existing justifications were deliberately not materialized retroactively: they cover days that are past or already registered, which the rules above would skip anyway.
12. History as an audit projection¶
There is no bespoke attendance-history table. The cell row holds only current state; the full value timeline lives in audit_logs (entityType = 'attendance_record', entityId = cell id). GET /attendance/records/:id/history loads the (policy-visible) current cell and calls audit.historyFor(...), returning { current, history }. A cross-anchor re-assertion (§10) is readable here: the re-anchoring event's data carries { anchorKey, previousAnchorKey } alongside the value — the superseded context's only surviving record. See chapter 20.
13. Routes, DTOs & error-code map¶
Twenty-three endpoints across five controllers, all @ProtectedResource() under @Controller('attendance') — except the family controller, which mounts at @Controller('attendance/family'). No route carries @RequireRoles (role gating is the policies', §8).
Materialization (§11.4) added no route — a reader scanning for it will not find one, because it is a side effect of the existing family POST/PATCH/DELETE. What it did add to this surface is one refusal (JUSTIFICATION_DAY_NOT_FLAGGED, below) and one field: justificationId on both AttendanceItemRecordDto and AttendanceRegisterCellDto.
| Route | Guard | DTO in → out |
|---|---|---|
GET /attendance/groups |
read |
DayFilterQueryDto → AttendanceGroupBoardDto |
GET /attendance/groups/pending |
read |
DayFilterQueryDto → PendingRegisterResponseDto |
GET /attendance/rows |
read |
AttendanceRowsQueryDto (cohort required, startTick?) → AttendanceGroupRowsDto |
GET /attendance/inconsistencies |
read |
DayFilterQueryDto → InconsistenciesResponseDto |
GET /attendance/teacher-day |
read (teacher-only policy, §7.1) |
TeacherDayQueryDto (date?) → TeacherDayResponseDto |
GET /attendance/follow-ups/summary |
read + action manage_communications |
FollowUpSummaryQueryDto → FollowUpSummaryDto |
GET /attendance/follow-ups |
read + action manage_communications |
FollowUpFilterQueryDto → paginated FollowUpItemDto |
PATCH /attendance/follow-ups/:id/ack |
register:write + action manage_communications |
AckFollowUpDto → FollowUpItemDto (the refreshed worklist row, §11.1); records a justification on the family's behalf |
POST /attendance/records |
register:write + action take |
TakeAttendanceDto → AttendanceWriteResponseDto |
PATCH /attendance/records/:id |
register:write + action take |
UpdateAttendanceCellDto → AttendanceWriteResponseDto |
GET /attendance/records/:id/history |
read |
— → AttendanceCellHistoryDto |
GET /attendance/family/to-justify |
read (family-only policy, §11.2) |
FamilyToJustifyQueryDto (studentId?, from?) → paginated FamilyToJustifyItemDto |
GET /attendance/family/students/:studentId/overview |
read (family-only policy, §11.2) |
— → FamilyStudentOverviewDto |
GET /attendance/family/students/:studentId/days/:date |
read (family-only policy, §11.2) |
— → FamilyDayDto |
POST /attendance/family/justifications |
read + action justify (family-only policy, §11.3) |
CreateJustificationDto → JustificationDto |
GET /attendance/family/justifications |
read (family-only policy, §11.3) |
FamilyJustificationsQueryDto (studentId?, from?, kind?) → paginated JustificationDto |
PATCH /attendance/family/justifications/:id |
read + action justify (family-only policy, §11.3) |
UpdateJustificationDto → JustificationDto |
DELETE /attendance/family/justifications/:id |
read + action justify (family-only policy, §11.3) |
— → 204 |
POST /attendance/family/justifications/:id/files |
read + action justify (family-only policy, §11.3) |
multipart file → FileMetadataDto |
DELETE /attendance/family/justifications/:id/files/:fileId |
read + action justify (family-only policy, §11.3) |
— → 204 (no date window — §11.3) |
GET /attendance/family/justifications/:id/files/:fileId |
read (family-only policy, §11.3) |
— → SignedFileUrlDto |
GET /attendance/justifications |
read + action manage_communications (board policy, §11.3) |
AdminJustificationListQueryDto (date?, studentId?, kind?) → paginated AdminJustificationRowDto |
GET /attendance/justifications/:id/files/:fileId |
read (cohort-read policy + in-service taught-set fence, §11.3) |
— → SignedFileUrlDto |
Two cell DTOs, deliberately split: AttendanceWrittenCellDto (the write envelope's records[]) extends AttendanceCellDto with row. The base class stays row-less because AttendanceCellHistoryDto.current shares it, and a single cell's history has no group day to build.
cohortType is a five-value enum everywhere it appears (ATTENDANCE_COHORT_TYPES, shared const): HOMEROOM, GRADE_GROUP, SUBJECT_GROUP, COMBINED_CLASS, ACTIVITY (2026-08-12, §7.1) — the read filters, the write batch, the PATCH echo and the follow-up filters all widen together. The route deliberately named teacher-day rather than me: in this codebase me means "the calling person's own record" (/grades/me is student-self). The student/referent surface landed as /attendance/family/* (§11.2) — target-parameterized rather than self-anchored, because a referent reads linked students, not themselves — so /attendance/me remains free.
AttendanceGroupBoardDto, PendingRegisterResponseDto, AttendanceGroupRowsDto, InconsistenciesResponseDto and AttendanceWriteResponseDto used to carry a completeness marker; it was retired 2026-07-29 (see §5). All of these routes are @AggregateResponse(), which bypasses scope-grouped field filtering — no ScopeFieldMapping entry is needed.
date is required on every read except the two follow-up endpoints, which span every unresolved day unless a date narrows them — an unacknowledged absence does not stop mattering when the day ends. It is date-only (@IsDateOnly, YYYY-MM-DD): a full timestamp is rejected rather than truncated, because an instant carrying an offset names a different calendar day than its first ten characters do.
On the write DTOs: entries needs at least one item (an empty batch asserts nothing yet still freezes the day and answers with an envelope) and is unique per (student, startTick) — the DB cell's own identity, so a batch cannot assert one cell twice through two different anchors and silently conflate them; unitIds is @ArrayUnique, time is a real HH:mm on a 24-hour clock, and cohortType/cohortId on the read filters are both or neither — a lone half used to be dropped silently, so a mistyped filter came back as "no groups matched". The anchor XOR (@ExactlyOneAnchor, attached to the always-validated startTick so no @ValidateIf gate can skip it) rejects every multi-anchor combination, including combinedClassId+activityId. PATCH rejects an empty body (it would re-stamp provenance and emit an audit event for a change that never happened) and accepts explicit null on time/note to clear them.
Error codes (chapter 06 delegates per-domain codes here):
| Code | HTTP | Thrown when | params |
|---|---|---|---|
ATTENDANCE_NO_PUBLISHED_TIMETABLE |
409 | write on a date no timetable version covers. Code and status preserved for FE compatibility; the copy is now per-date ("No schedule is in force on this date"). | — |
ATTENDANCE_NOT_A_SCHOOL_DAY |
422 | date outside the department school-day set | { date } |
ATTENDANCE_PAST_DATE_LOCKED |
422 | related-teacher-only authority writes (POST) or edits (PATCH) a date before school-today; Admin/Front Office/Administrative Assistant and qualified Department Principals/Curriculum Coordinators in-department may correct the past |
{ date } |
ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT |
403 | Y-set denies the caller for that student | { studentId } |
ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER |
422 | entry's student not on the anchor's roster | { studentId } |
ATTENDANCE_ENTRIES_SPAN_GROUPS |
422 | an entry's student is not in the declared cohort (also the PATCH echo's refusal, §11) | { studentId } |
ATTENDANCE_UNIT_NOT_IN_SUBJECT |
422 | a tagged unitIds entry isn't a unit of any subject in the batch |
{ curriculumSubjectId } — the offending unit's id; keyed generically because a unit is itself a CurriculumSubject row |
ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT |
403 | a scoped manager names a foreign department/cohort, or a teacher limb names a cohort it neither teaches, tutors, nor supervises | { cohortType, cohortId } — 'ALL' for a missing half |
ATTENDANCE_EVENT_NOT_FOUND |
404 | ack targets a follow-up id that does not exist in the tenant (§11.1) | { eventId } |
ATTENDANCE_EVENT_ALREADY_RESOLVED |
409 | ack on a resolved event — ACKNOWLEDGED, NOT_REQUIRED, CANCELLED or SUPERSEDED (§11.1) |
{ state } |
JUSTIFICATION_NOT_FOUND |
404 | a justification the caller may not see — missing, foreign-tenant, unlinked-student (family routes) or non-taught-student (teacher on the school download) are all this one hidden 404 (§11.3) | { justificationId } |
JUSTIFICATION_CONFLICT |
409 | the consistency matrix refuses the write — a day already covered by an ABSENCE, two overlapping absences, or (edit path only) a same-kind row a create would have absorbed (§11.3) | { kind, conflictingKind, startDate, endDate } — the blocking row's kind and range |
JUSTIFICATION_NOT_EDITABLE |
422 | the mutability window has closed: the row covers a day that has already begun, or a PATCH would back-date it into one (§11.3) |
{ startDate } — the first day the frozen row covers, rendered into the message |
JUSTIFICATION_ATTACHMENT_LIMIT |
409 | a 6th file on one justification (§11.3) | { limit } |
JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED |
422 | a file on an EARLY_EXIT announcement (§11.3) | { kind } |
JUSTIFICATION_DAY_NOT_FLAGGED |
422 | the submission reaches into the past but no day in that past portion carries a family-visible event the kind could answer — including every past EARLY_EXIT, which no event type can answer (§11.3) |
{ startDate } — rendered into the message, together with the alternative that is open |
Temporal-program codes reaching these surfaces (declared by specs A and C1, consumed here):
| Code | HTTP | Thrown when | params |
|---|---|---|---|
TEMPORAL_ROW_IMMUTABLE |
409 | deleting anything that has governed a lived school day — a homeroom, course, combination, teacher, student, or a curriculum whose selections have covered one. Either a lived child interval or a lived timetable reference is enough. "Lived" = overlapping [the year's earliest department calendar start, today] — before the year starts nothing is lived, so pre-year configuration deletes freely (2026-07-30). |
{ entity, id } |
TEMPORAL_BACKDATE_FORBIDDEN |
422 | a past-dated validFrom on any membership command, or a department calendar / CLOSING-period change whose affected days reach before today |
{ requested, earliest } |
TEMPORAL_INTERVAL_OVERLAP |
409 | two concurrent changes to the same identity — the _excl violation and the lost-update rejection share one code |
{ entity, id } |
There is no amendment flow this version (contract §12): these refusals are final, and no error copy may promise one.
Reused codes: SCHEDULED_LESSON_NOT_FOUND (404) — the anchor is absent from the resolved day (also a memberless combination); SCHEDULED_ACTIVITY_NOT_FOUND (404) — same, for an activity anchor (including a tick that is not the slot's own); COMBINED_CLASS_DURATION_MISMATCH (409) — a combined lesson's members no longer agree on the period-duration cascade; VALIDATION_FAILED (400) — anchor XOR violations, a batch asserting one (student, startTick) cell twice, and status-field rule violations; NOT_FOUND (404) — record/homeroom/grade/department lookup misses.
(ATTENDANCE_DAY_SHAPE_UNAVAILABLE was retired 2026-07-29 — see §3.1.)
14. Recipes & gotchas¶
Management attendance fence (realigned 2026-08-28)¶
AttendanceAccessService.resolveManagementAttendanceScope binds the required
attendance.register access and optional action to the assignment that supplied
it. Admin, Director, Front Office, Administrative Assistant, and HR Manager are
school-wide READ. For WRITE+take, only admin, Front Office, and Administrative
Assistant are school-wide; Director and HR have no write.
A DEPARTMENT role receives its qualified department ids and a CURRICULUM role
receives the active-year departments owning its qualified curricula. Department
Principal and Curriculum Coordinator also inherit the holder's
Teacher-anchored related cohort, while Staff-only anchors do not. Boards,
pending cards, rows, inconsistencies,
follow-ups, school justifications, attachment reads, record history, creates,
updates and acknowledgements intersect that set before returning or mutating
data. Explicit foreign cohort filters answer
ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT; foreign record/event ids remain hidden
404s.
Department Principal is a management writer inside that set, including past
dates, and has take plus manage_communications. Curriculum Coordinator has
WRITE+take inside its owning departments. The inherited teacher limb is a
today/future Y-set/taught-cohort alternative and never opens past corrections.
Mixed sessions union completed limbs; a coordinator assignment missing the
requested action cannot widen a department writer's qualified action slice.
Add a status — extend enum AttendanceStatus (schema + migration, new enum value), slot it into the right set(s) in attendance-status-rules.ts, decide its carryForward and carryBackward mappings and whether it is NEUTRAL for the day-state machine, and give it a predicate in classifyStudentDay (or decide deliberately that it contributes no bucket — but then a day holding only that status classifies as nothing, which the "never empty" contract forbids).
Every cell needs a tick — there is no null-tick daily cell any more. A write anchors to the slot the FE clicked; a DAILY department simply doesn't need to click them all.
A cell can now be deleted — but only one way — through withdrawing the family communication that pre-filled it, only where justificationId is set, and only where lastModifiedByUserId IS NULL (§11.4). There is no other delete path in the module and no soft delete anywhere in it. This cuts hard against the "written once, never removed" instinct the neighbouring gotcha teaches, which is exactly why it has its own entry: the cells being deleted were never a human's assertion in the first place.
Snapshot columns are written once — copied at create and never re-synced. If a subject is renamed or a teacher reassigned afterwards, historical rows keep the old names by design. Do not add a "refresh snapshot" path; if you need current context, join live off the soft-FK ids (which may now be dangling).
A justification row is no longer always family-authored — since the office ack materializes one (§11.1), submittedByName may be a staff name on a family-visible row. channel is the discriminator; never infer authorship from the name.
Y-set vs policy — when debugging "why can/can't X write this cell", check canWriteStudentCell + resolveManagementAttendanceScope (write) and AttendancePolicy (read) separately. They diverge on purpose (§7, §8).
One clock, and it is the school's — every date comparison in this module (isElapsed, the resolver's past/live line, every effectiveOn predicate) is asked in School.timezone via loadSchoolTimezone + schoolToday. A new Date() compared against a stored date is a bug here, not a shortcut: the read and write paths disagreeing about which day is "today" makes them disagree about which interval governs it. A tenant with no school row falls back to UTC rather than to the server's zone.
Never key a slot by its tick alone — an option block puts several different lessons at the same tick for one grade, so startTick is not a slot identity. Use ${startTick}|${anchorKeyOf(anchor)} (this is what the pending register does); keying by tick folds the block's lessons into whichever was encountered first, carrying one lesson's label, room and teachers over another's roster.
Cells are written in (studentId, startTick) order, not submission order — two overlapping batches that took their row locks in submission order could acquire them inversely and deadlock. The response is rebuilt in submission order afterwards, so the ordering is invisible to callers; don't "simplify" it away.
/rows 404s on a group with zero students — except teaching-unit cohorts — homeroom/grade groups are built from students, so an empty class has no card and no rows (fine from board navigation, surprising on a hand-typed URL). A SUBJECT_GROUP/COMBINED_CLASS cohort is built from the section instead: an existing unit with an empty dated roster answers an empty roster, and only an unknown id 404s.
Back-filling a past register is supported — and is the commonest admin correction. It resolves the version effective on that date × the memberships effective on it (§3.1), so it keeps working after a timetable revision has moved or deleted the lesson. An anchor that was not on that day 404s; a date no version covers at all is the write gate's 409 ATTENDANCE_NO_PUBLISHED_TIMETABLE. There is no day-level "unrecoverable" state — nothing about a day can be lost when nothing about it is stored.
One tick, one fact — whatever the anchor. A student rostered on a lesson and an overlapping activity at the same tick has ONE cell there: a batch asserting both 400s (entries uniqueness), and a sequential re-assert through the other anchor supersedes — the cell re-stamps to the new assertion's context and the transition lands in its audit history (§10). On /rows, both same-tick items surface that one stored cell.
There is no period ordinal — periodLabel was dropped 2026-07-26 (column, four DTOs, RBAC scope field, and countEarlierLessonsInDay with it). It held the tick's rank among the grade's distinct lesson ticks, but the day strip renders per student: a student who skips a tick counts three lessons on screen while the grade-relative rank called the third one "Period 2". A slot is identified by its startTick and its wall clock; an ordinal, if a client wants one, is the array index + 1 — which matches what the user counts. Do not reintroduce a stored ordinal; a grade-relative one would require gradeIds String[] on AttendanceDaySlot, since a combined slot serves several grades.
A pre-enrolled student is never on the register; a departed one is, up to their exit — the dated limb status ≠ PRE_ENROLLED rides beside every effectiveOn(D) roster predicate (loadPlacedStudentsOn, loadGroupCatalog, loadSubjectCohortGroup, the three write-fence rosters, countSubjectGroupRostersOn), and exits are the placement close. If a PRE_ENROLLED student shows up on a board, look for a roster built outside those functions; if a LEFT student shows up on a today list, look for a display-tier read missing participatingStudentWhere(). Never put the live fence on a dated register read — that is how history goes dark.
Widget disagreement is a smell — every read is a projection of one assembleGroupDays call. If the board and the rows disagree, the bug is in the projection, not in a second query.
Combined classes¶
A co-taught (combined) meeting is one register over the union roster. The anchor dual-shapes — { subjectGroupId } ⊕ { combinedClassId } — and matches the resolved day's slot by (tick, anchorKey) like any other. buildLessonSnapshot returns a union bag (deduped union teachers, combined id/name, single-department assert) plus one member snapshot per section; a plain SG lesson is just the one-member case, so the write path is uniform.
Write-time contributor attribution. The expected-attendance resolution carries the union roster together with a per-student contributor map (contributorBySlot — which member section the student belonged to on the date; first member by name wins for a student enrolled in two). Each stored cell stamps the student's OWN contributing section into the subject/grade/department snapshot columns — per-subject reports never see the combination — while the lesson-level columns (room, union lessonTeacherIds, period bounds) plus the stored combinedClassId/combinedClassName come from the shared meeting.
Authorization is union-wide: the teaches-map crosses the union teacher set with the union roster, so a teacher of ANY member writes the whole shared meeting. The roster fence still 422s students in no member. Members must agree on the period-duration cascade — divergence throws 409 COMBINED_CLASS_DURATION_MISMATCH (naming the offending section) on the write path; the read path tolerates it rather than blanking the board.
Activities¶
Every activity slot (chapter 18) behaves like a subject group for attendance — no opt-out flag. An activity anchor is { activityId, startTick }; the activity must live on the version governing the cell's date (§3.1) with weekday(date) ∈ weekdays and the tick must be the slot's own (404 SCHEDULED_ACTIVITY_NOT_FOUND otherwise — a mismatch means the anchor was assembled by hand rather than echoed back). The roster is the audience re-expanded as of the date from the version's stored selectors (expandAudienceOn over the resolver's as-of-D indexes, with placement as the candidate universe for every selector kind, fenced to the activity's single department since 2026-08-12); a student outside it 422s. The timetables module's own surfaces keep their live expander (resolveActivityAudiences) — display rosters are a current question, registers an effective-on-D one.
Per-student mode and context. The cell's attendanceMode and department/grade columns resolve from the student's own record rather than from the slot — the v1 seam, kept because it is what the snapshot means. Since single-department (2026-08-12) the fenced roster makes every student's department the activity's own, so the per-student answer and the slot-level answer agree; the card and group mode (the teacher-day ACTIVITY card, loadActivityCohortGroup) reads the activity's department directly. The cell itself is anchored at the activity's tick for everyone.
Snapshot. Activity cells set activityId/activityName (soft columns, no FK) and leave subjectGroup*/subject*/combinedClass* NULL; roomId/roomName + lessonTeacherIds/Names come from the activity's room + only the supervisors covering that weekday (which is also the Y-set "lesson teacher" clause — a supervising teacher writes their own occurrence). updateCell is unchanged — it reads the stored lessonTeacherIds snapshot for the Y-set re-check.