Attendance — admin day register, aggregates & group board¶
1. Problem distillation¶
The attendance module serves three audiences with differently shaped data: admins want aggregates and exception worklists, teachers want their own must-take list, families want one child. v1 shipped a single cohort-cards + flat-grid read that serves none of them well, and its cell model can only express one status per student per day in DAILY departments. This spec redesigns the admin surface only; the teacher and referent/student surfaces follow as sibling specs against the same tables.
The admin's day. Every admin read is scoped to a single date and filtered by department / curriculum / grade / homeroom plus a free-text q that matches students and homerooms. The register is presented as groups: one per homeroom, plus one grade group per grade holding that grade's homeroom-less students (IB-style schools where students are grade-placed but not class-placed) — so a grade with N homerooms yields N+1 groups.
The board is deliberately two-tier: group cards carry only counts (a 500-student school renders every group at once), and the actual rows for one group are fetched separately when the admin opens it. Three aggregate widgets sit above the board — daily register status, inconsistencies, follow-ups — each computable over the filtered set (possibly the whole school) or over a single group, from the same endpoints with the same filter contract.
The cell model changes. Today AttendanceMode.DAILY stores one cell per (student, day) — which cannot represent "arrived late and left early": the second write overwrites the first, and the day reads as whatever happened last. Product decision (Fabio, 2026-07-25): there is no read distinction between modes — every student's day is a list of per-period items in both modes. The distinction is on the write side:
- PERIOD — every lesson's teacher authors their own cell. A period with no cell is not taken.
- DAILY — the register is authored once (typically at the first lesson) and carries forward through the day; a later teacher acts only when something changes (a late arrival, an early exit), and that change re-projects forward. A period with no authored cell inherits the last authored state.
So a DAILY day is stored as state changes, not as N rows, and reads project. Worked example — DAILY department, 6 periods, three authored cells:
| P1 | P2 | P3 | P4 | P5 | P6 | |
|---|---|---|---|---|---|---|
| authored | ABSENT | — | LATE_ENTRY | — | EARLY_EXIT | — |
| displayed | ABSENT | ABSENT | LATE_ENTRY | PRESENT | EARLY_EXIT | ABSENT |
The projection is semantic, not literal: LATE_ENTRY means "in school from here", EARLY_EXIT means "gone from here" (§4 "Projection"). Projected items are labelled PROJECTED so the FE renders them lighter than authored ones.
This collapses the DAILY/PERIOD storage split entirely: every cell now carries its period tick, the two partial unique indexes become one plain unique, and ch19's "single rule that trips people" (DAILY ⇒ startTick NULL) disappears. attendanceMode survives as the discriminator for write semantics and projection, not for row shape.
What this spec does not build: the family communication loop (event arming, the 15-minute review window, the 1-hour escalation, notification sends, referent/school acks, contact logging) is its own spec. This spec defines the two tables the admin worklist reads (attendance_day_events, attendance_contact_logs) and the read endpoints over them; every place the write path would arm or transition an event is marked with a TODO(comms-loop) comment pointing at that spec. Until it lands, the follow-up endpoints answer correctly over an empty set.
Success criteria (observable behavior that proves this works):
- GET /attendance/groups?date= returns one card per homeroom plus one grade-group card per grade with homeroom-less students, each carrying numStudents, toRegister, and a non-overlapping status breakdown that sums exactly to numStudents; plus totals over the filtered set including groupsPendingRegister.
- Filtering by departmentId / gradeId / homeroomId selects groups; curriculumId narrows the students inside a group (grade groups span curricula) and recomputes its counts; q filters whole groups in or out by student name/identification code or homeroom name.
- toRegister counts only cells owed for lessons that have already started in school-local time (School.timezone) — a 15:00 lesson is not owed at 09:00; on a past date every lesson counts; on a future date toRegister is null.
- GET /attendance/groups/pending lists the not-fully-registered groups with their department/grade/curriculum labels and the teachers who owe the take — for a PERIOD group the teachers of each elapsed untaken lesson, for a DAILY group the first lesson's teachers only.
- GET /attendance/rows?cohortType=&cohortId=&date= returns each student's own ordered day items (their lessons and activities, per their timetable — students of one homeroom legitimately differ through option blocks), each with subject/activity label, room, wall start/end, teacher names, and the record or null; in a DAILY group the un-authored items come back PROJECTED per the table above.
- A DAILY student authored ABSENT at P1, LATE_ENTRY at P3 and EARLY_EXIT at P5 reads exactly as the worked example, and classifies once — into earlyExit, not into absent.
- GET /attendance/inconsistencies returns a count and the items in one call; each item names the student, the triggering lesson, the previous lesson, a localized code, and all teachers involved (both cells' recorders and lesson teachers).
- A PERIOD student recorded PRESENT at P2 and ABSENT at P4 with no EARLY_EXIT between raises exactly one ABSENT_WITHOUT_EARLY_EXIT; the same shape authored in a DAILY department raises it too (the machine runs on authored cells in both modes).
- GET /attendance/follow-ups/summary returns totals split both by event state and by event type; GET /attendance/follow-ups returns the paginated detail (event date, student, cohort, type, state, notification time, reminder time, last contact time) over all unresolved days, not just the queried date, honoring the same filters.
- Every aggregate endpoint accepts cohortType+cohortId and then answers for that single group only.
- POST /attendance/records upserts a batch of heterogeneous entries (different anchors) that all resolve to one group, atomically, and returns the group's refreshed card, its inconsistencies and its follow-ups alongside the written records; PATCH /attendance/records/:id returns the same envelope.
- Every cell create/update writes an audit row (ch20) with the acting user; reads are never audited.
- GET /attendance/cohorts and GET /attendance/grid are gone (404).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No teacher surface (must-take list, "act only on your own period" write constraint) and no referent/student surface — sibling specs.
- No communication-loop behavior: no arming, no review window, no escalation, no notification send, no ack, no contact-log write. Tables + read contract only.
- No referent pre-reports / future-date declared absences — product hasn't decided how families declare them (deferred, §9); future dates are readable but carry no report surface.
- No multi-date reads (ranges, weekly/monthly views), no past-academic-year reads.
- No absence totals or maxAbsenceHours monitoring; no disciplinary notes module.
- No changes to the timetable, curriculum or roster modules.
- No new notification transport work — the engine spec owns that.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/attendance/ + attendance.queries.ts + ch19 (the shipped v1 — the code and the chapter are now the only record of it, its design docs having been deleted) |
The whole write spine survives: anchor resolution (loadLessonForTake, loadActivityForTake), deep-snapshot build (buildLessonSnapshot, per-student contributor attribution), roster fence, school-day calendar (isSchoolDay, loadOperativeWeekdaysForGrade), period-slot math (resolvePeriodSlot, countEarlierLessonsInDay), audit-in-tx, Y-set (attendance-authority.ts) |
v1's read surface (listCohorts, grid) is deleted; the DAILY startTick IS NULL row shape is deleted; the take DTO goes heterogeneous and gains a group fence |
src/homerooms/grouped-homerooms.controller.ts + dto/grouped-homerooms-response.dto.ts |
The counts-board precedent: a catalog-driven node list carrying only aggregates, rosters fetched separately per node; curriculumSelection.curriculumId as the student→curriculum source |
That board nests dept→curriculum→grade; this one is flat (each card carries its full label bag) because the aggregate widgets must address a single group by (cohortType, cohortId) |
src/filters/ + src/students/dto/list-students-query.dto.ts |
The filter/q idiom: case-insensitive contains over name + identification code, multi-value FK filters, PaginatedListQueryDto for the paginated worklist |
The board itself is unpaginated (group count is bounded by school structure); only follow-ups paginate |
src/timetables/timetables.diagnostics.ts + src/common/i18n/diagnostic-messages.catalog.ts |
The localized-code pattern for the inconsistency enum: a pure detector producing { code, params }, a BE-owned messages{en,it} catalog, and a drift spec keeping catalog and enum in lockstep |
Diagnostics are timetable-config violations gating publish; inconsistencies are read-time register warnings that never block a write |
src/audit-log/ + ch20 |
AuditService.record inside the caller's transaction; historyFor projection behind GET /attendance/records/:id/history |
n/a — attendance is already its first consumer |
src/grades/grades.visibility.ts |
The teacher-perimeter / referent / student where-builders, as the shape the sibling specs will reuse for their policy branches | Not used by the admin surface itself (admin is pass-through) |
src/command-center/overview.service.ts + shared/query-helpers.ts |
Admin-dashboard aggregate assembly: several counters computed in one pass over a bounded working set, DTO'd as a summary object | Command-center counts are structural/setup-state; these are per-date register facts derived from the published timetable |
On-axis / off-axis check (ch16 §4–§5). Almost entirely on-axis: new read endpoints on an existing entity, an existing scope, one new action (§4.2/§4.7 paved), a localized code catalog (paved), a counts board (precedent above). Two items deserve the explicit call-out §5 asks for:
- The projection (§4) is a genuine new read-time semantic — stored rows no longer map 1:1 onto displayed cells in DAILY departments. It is deliberately confined to one pure function (
projectDayItems) with no DB access, so it is unit-testable in isolation and cannot leak into the write path. - The cell-model migration (dropping the daily row shape) touches a compliance table. It is destructive and justified only by there being no production attendance data (§4 "Migration shape") — flagged, not assumed.
No timers, no cross-module side effects, no post-commit fan-out are introduced here (all of that is the comms-loop spec's problem).
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Existing attendance_records.tenantId; the two new tables carry tenantId FK Restrict + RLS policy + rls-coverage.ts + tenanted-models.ts entries (ch12 new-model checklist) |
Tenant-local register data |
| Academic-year scope | yes | Active year only (resolveActiveYear), as v1; both new tables carry academicYearId |
Past-year reads stay deferred |
| RBAC entity key | existing | EntityKey.ATTENDANCE |
No new entity |
| Scopes | existing, field list edited | attendance.register — remove isJustified / justificationReason |
Justification is deleted from the domain (§4) |
| Actions | one new | attendance.manage_communications (follow-up worklist + the future contact/ack writes). attendance.take unchanged. read/update implicit as always |
A positive grant expresses "school-side operator" without a negative-role predicate |
| Service base | custom | AttendanceService (write path, reshaped), new AttendanceBoardService (groups/rows/pending), AttendanceInsightsService (inconsistencies + follow-up reads). None are BaseTenantedCrudService |
Batch facts and aggregates, not single-row CRUD |
queries.ts shape |
named fns | attendance.queries.ts keeps the write loaders and gains loadDayLessonsForStudents, loadDayActivityRosters, loadCellsForStudentsOnDate, loadGroupCatalog; new attendance-insights.queries.ts (follow-up list + counts) |
Convention: mandatory queries.ts, named functions only |
| Error codes | one new | ATTENDANCE_ENTRIES_SPAN_GROUPS (422, { cohortIds }) + error-examples.ts + i18n messages{en,it}. Everything else reuses v1 codes |
The batch write is single-group by contract (§5) |
| DTO conventions | standard | dto/day-filter-query.dto.ts (shared filter base), dto/group-board-response.dto.ts, dto/group-rows-response.dto.ts, dto/pending-register-response.dto.ts, dto/inconsistencies-response.dto.ts, dto/follow-ups-response.dto.ts, reshaped dto/take-attendance.dto.ts |
Shared filter base keeps the five read endpoints' contracts identical |
| File-backed sub-resources | n/a | No files (justification evidence no longer exists) | |
| Custom fields | no | Register rows aren't custom-fieldable | |
| Profile completeness | no | n/a |
4. Data model plan¶
Schema deltas¶
AttendanceRecord — the cell model collapses to one shape.
- startTick becomes Int NOT NULL (every cell is anchored at a period tick; the "daily cell" row shape is gone).
- Drop the two partial unique indexes (attendance_daily_unique, attendance_period_unique); add one plain unique @@unique([tenantId, studentId, date, startTick]). Postgres NULL-distinctness is no longer load-bearing, so the hand-authored partial indexes (ch12) are no longer needed.
- Drop isJustified, justificationReason.
- attendanceMode stays: it drives write semantics + read projection.
- periodLabel / periodStartTime / periodEndTime stay nullable (an activity cell labels by activity name; no reason to tighten).
enum AttendanceStatus += SUSPENDED — an ordinary status: no side effect, note allowed, no time, settable by anyone who may take.
New enum AttendanceEventType { ABSENT, LATE_ENTRY }, enum AttendanceEventState { UNDER_REVIEW, NOTIFIED, FOLLOW_UP, ACKNOWLEDGED, NOT_REQUIRED, CANCELLED, SUPERSEDED }, enum AckChannel { APP, PHONE, IN_PERSON } — the vocabulary the admin worklist reads. Transitions belong to the comms-loop spec.
New table attendance_day_events — one row per (student, date, eventType):
id, tenantId (FK Restrict), academicYearId (FK Restrict), studentId (FK Restrict), date @db.Date, eventType, state, armAt, notifiedAt?, escalateAt?, remindedAt?, ackAt?, ackByUserId?, ackByName?, ackChannel?, ackReason?, createdAt, updatedAt.
Operational state, not a compliance snapshot — but ackByName is snapshotted so a deactivated user still reads correctly.
New table attendance_contact_logs — school↔family contact annotations on an event:
id, tenantId (FK Restrict), eventId (FK Cascade), at, outcome, note?, byUserId?, byName, createdAt. Never changes event state; the admin worklist reads only max(at) per event.
RLS: two new tenant-bearing models → policies in the migration + src/prisma/rls-coverage.ts + src/prisma/tenanted-models.ts entries (drift specs fail otherwise).
Migration shape¶
Ordered, and destructive — audit against the ch12 hazard checklist before committing, and run the uncommitted-migration fold check first (the tree has pending work):
DELETE FROM attendance_records WHERE start_tick IS NULL— the old daily rows have no period anchor to backfill from. Justified only because there is no production attendance data — confirmed by Fabio 2026-07-25 (§10.2). Re-verify against prod immediately before applying: if register data ever exists, this step must become a backfill (map each daily row onto the student's first lesson tick of that date), not a delete.ALTER TABLE attendance_records ALTER COLUMN start_tick SET NOT NULL.DROP INDEX attendance_daily_unique; DROP INDEX attendance_period_unique;thenCREATE UNIQUE INDEX ... (tenant_id, student_id, date, start_tick).ALTER TABLE attendance_records DROP COLUMN is_justified, DROP COLUMN justification_reason;ALTER TYPE attendance_status ADD VALUE 'SUSPENDED'— ch12 hazard: Postgres cannot consume a value added in the same transaction; this migration only adds the type value, never writes it.- Additive: 3 enums, 2 tables, their indexes, their RLS policies.
Indexes and uniqueness¶
attendance_records: single unique above; existing(tenantId, date)/(tenantId, studentId, date)read indexes retained — the board reads by(tenantId, date, studentId IN …).attendance_day_events:@@unique([tenantId, studentId, date, eventType]);@@index([tenantId, state, date])(worklist),@@index([tenantId, studentId, date])(row read),@@index([tenantId, state, armAt])+@@index([tenantId, state, escalateAt])(pre-created for the comms-loop sweep — cheap now, avoids a second migration).attendance_contact_logs:@@index([tenantId, eventId, at]).
Expected cells (normative)¶
The board's toRegister, the pending list and the row items all derive from one computation. For date D, tenant T, active year Y:
- Resolve the PUBLISHED timetable for
Y. If none exists, every group returnstoRegister: nullwith empty items — reads never 409 (unlike writes, which keepATTENDANCE_NO_PUBLISHED_TIMETABLE). weekday = weekday(D)in school-local time (School.timezone).- Lessons:
ScheduledLessonon that timetable+weekday, expanded to students through the subject group's assignments (combined lessons through each member's assignments) — this is what makes two students of one homeroom carry different items (option blocks). - Activities:
findActivityGraphRows+resolveActivityAudiencesfor that weekday — attendance-bearing exactly as in v1. - School day: per department,
isSchoolDay(D, calendar). A group whose department is closed onDreturnsschoolDay: false,toRegister: 0and no items. - Elapsed filter — an item is owed only if it has already started:
wallStart <= now_school_local(so "happening" counts, per product). Past dates: all items elapsed. Future dates: none, andtoRegisterisnullrather than0(not applicable, not satisfied). - Expected count per mode: PERIOD ⇒ one owed cell per (student × elapsed item); DAILY ⇒ one owed cell per student for the whole day (the register is authored once), elapsed as soon as the student's first item of the day has started.
toRegister = owed cells with no authored cell. Pending teachers = for PERIOD, the lesson teachers (or activity supervisors) of every elapsed item lacking a cell; for DAILY, the teachers of the student's first item only.
Projection (normative)¶
Pure function over one student's authored cells for D, sorted by startTick:
- PERIOD mode — no projection. An item's record is the authored cell at exactly that tick, else
null(= not taken). - DAILY mode — the earliest authored cell covers from the start of the day; every later authored cell applies from its own tick forward. An item with no authored cell inherits the covering state, mapped semantically:
| Covering authored status | Status shown on later un-authored items |
|---|---|
PRESENT |
PRESENT |
ABSENT |
ABSENT |
LATE_ENTRY |
PRESENT (in school from here) |
EARLY_EXIT |
ABSENT (gone from here) |
FIELD_TRIP / DAY_TRIP |
same value (a trip spans the day) |
SUSPENDED |
SUSPENDED |
Every item carries source: RECORDED | PROJECTED; projected items carry no record id, no time, no note, no provenance — they are a rendering of the covering cell, not a row. A student with zero authored cells has all items null (not taken), never projected.
Day classification (normative)¶
The group card's status breakdown is per student and non-overlapping — every student of the group falls in exactly one bucket, and the buckets sum to numStudents. Evaluated over the student's projected day timeline, first match wins:
| # | Bucket | Rule |
|---|---|---|
| 1 | notTaken |
no authored cell for the day |
| 2 | suspended |
any SUSPENDED |
| 3 | trip |
any FIELD_TRIP / DAY_TRIP |
| 4 | absent |
every state is ABSENT (never in school) |
| 5 | lateEntry / earlyExit |
by the last transition of the day — a student who arrived late and left early classifies as earlyExit (decided 2026-07-25: fold, no combined bucket) |
| 6 | present |
anything else |
toRegister (a cell count) stays independent of these buckets (a student count); notTaken students are always a subset of the students contributing to toRegister.
Inconsistencies (normative)¶
A pure detector over one student's authored cells for D, sorted by startTick — run in both modes (a DAILY day authored as PRESENT then ABSENT is just as wrong as a PERIOD one; a well-formed DAILY day is inconsistency-free by construction, so nothing is lost by not special-casing). Sequence-neutral statuses (FIELD_TRIP, DAY_TRIP, SUSPENDED) are skipped; not-taken periods are skipped (they break no chain).
Day state ∈ { IN_SCHOOL, OUT_ABSENT, OUT_LEFT }, seeded by the first non-neutral cell. Flag any cell illegal for the current state:
| Prior state | Cell status | Verdict | Code |
|---|---|---|---|
OUT_ABSENT |
PRESENT |
✗ returned without a late entry | PRESENT_AFTER_ABSENCE_WITHOUT_LATE_ENTRY |
OUT_ABSENT |
EARLY_EXIT |
✗ left while absent | EARLY_EXIT_WHILE_ABSENT |
OUT_LEFT |
PRESENT |
✗ present after leaving | PRESENT_AFTER_EARLY_EXIT |
OUT_LEFT |
EARLY_EXIT |
✗ left twice without returning | EARLY_EXIT_AFTER_EARLY_EXIT |
IN_SCHOOL |
ABSENT |
✗ absent without an early exit | ABSENT_WITHOUT_EARLY_EXIT |
IN_SCHOOL |
LATE_ENTRY |
✗ already in school | LATE_ENTRY_WHILE_PRESENT |
OUT_ABSENT |
ABSENT / LATE_ENTRY |
✓ | — |
OUT_LEFT |
ABSENT / LATE_ENTRY |
✓ (re-entry) | — |
IN_SCHOOL |
PRESENT / EARLY_EXIT |
✓ | — |
Decided edges: OUT_LEFT → LATE_ENTRY is a legal re-entry (a student who left at 10:00 and came back at 12:00 must be representable); a day whose first cell is EARLY_EXIT is legal (implicitly present until they left).
Codes live in enum AttendanceInconsistencyCode with a BE-owned messages{en,it} catalog (src/common/i18n/attendance-inconsistency-messages.catalog.ts) mirroring the diagnostic-cards pattern, with a drift spec pinning enum ↔ catalog.
Communication events (read-only here)¶
Rows are produced by the comms-loop spec. This spec: the tables above, the read projections, and TODO(comms-loop) markers at the two write-path sites where arming/re-evaluation will hook in (after cells are written in the batch upsert; after a cell edit in PATCH). The admin's "open follow-ups" are events in state NOTIFIED or FOLLOW_UP.
5. API surface¶
Removed: GET /attendance/cohorts, GET /attendance/grid, POST /attendance/take (replaced by POST /attendance/records — same batch-upsert semantics, RESTful pairing with PATCH /attendance/records/:id; no FE consumer exists).
All read endpoints share one filter base DayFilterQueryDto: date (required, YYYY-MM-DD), departmentId?, curriculumId?, gradeId?, homeroomId?, cohortType? + cohortId? (single-group mode), q? (≤100 chars). Filter semantics: structural ids and q select groups; curriculumId narrows the students inside a group and recomputes its counts (a grade group legitimately spans curricula, so it is never dropped by a curriculum filter — its cards just shrink). q matches student firstName/lastName/identificationCode or homeroom name, case-insensitive contains, and keeps the whole matching group with its full roster.
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /attendance/groups |
@RequireScopes(ATTENDANCE,'read') + AttendancePolicy |
DayFilterQueryDto |
AttendanceGroupBoardDto { groups: AttendanceGroupCardDto[], totals: AttendanceTotalsDto } |
| GET | /attendance/groups/pending |
same | DayFilterQueryDto |
PendingRegisterResponseDto { groups: PendingGroupDto[] } |
| GET | /attendance/rows |
same (requires cohortType+cohortId) |
DayFilterQueryDto |
AttendanceGroupRowsDto { group, students[] } |
| GET | /attendance/inconsistencies |
same | DayFilterQueryDto |
InconsistenciesResponseDto { count, items[] } |
| GET | /attendance/follow-ups/summary |
@RequireAction(ATTENDANCE,'manage_communications') |
FollowUpFilterQueryDto (filter base, date optional) |
FollowUpSummaryDto { total, byState, byType } |
| GET | /attendance/follow-ups |
same | FollowUpFilterQueryDto + pagination |
paginated { data: FollowUpItemDto[], meta } |
| POST | /attendance/records |
@RequireScope(ATTENDANCE,'register','write') + @RequireAction(ATTENDANCE,'take') |
TakeAttendanceDto |
AttendanceWriteResponseDto |
| PATCH | /attendance/records/:id |
same | UpdateAttendanceCellDto |
AttendanceWriteResponseDto |
| GET | /attendance/records/:id/history |
read + policy |
— | AttendanceCellHistoryDto (v1, unchanged) |
AttendanceGroupCardDto — { cohortType: 'HOMEROOM'|'GRADE_GROUP', cohortId, name, department{id,name}, grade{id,name}, curriculum{id,name}|null, mode, schoolDay, numStudents, toRegister, counters: { present, absent, lateEntry, earlyExit, trip, suspended, notTaken } }. curriculum is null for grade groups (their students may hold different selections). cohortId is the homeroom id, or the grade id for a grade group.
AttendanceTotalsDto — the same counters summed over the filtered groups, plus numStudents, toRegister, numGroups and groupsPendingRegister (groups with toRegister > 0).
PendingGroupDto — the card's label bag plus toRegister and pendingTeachers: [{ teacherId, teacherName }] (deduped) and pendingItems: [{ label, periodLabel, wallStart, teacherNames[] }].
AttendanceGroupRowsDto.students[] — { studentId, firstName, lastName, identificationCode, classification, items: AttendanceDayItemDto[], dayEvents: AttendanceDayEventDto[] }.
// AttendanceDayItemDto
{
"kind": "LESSON | ACTIVITY",
"anchor": { "subjectGroupId": "…", "combinedClassId": null, "activityId": null, "startTick": 102 },
"label": "Mathematics", // subject name, or activity name
"periodLabel": "Period 3",
"wallStart": "10:45", "wallEnd": "11:45",
"room": { "id": "…", "name": "A12" } ,
"teacherNames": ["Anna Rossi"],
"source": "RECORDED | PROJECTED | null", // null = not taken
"record": { // null when not taken; no id when PROJECTED
"id": "…", "status": "LATE_ENTRY", "time": "09:20", "note": "…",
"recordedByName": "…", "lastModifiedByName": "…", "lastModifiedAt": "…"
},
"inconsistency": { "code": "…", "message": { "en": "…", "it": "…" } } // null when consistent
}
The anchor bag is BE-minted and accepted verbatim by the write path — the FE never constructs anchors. Ack information is not on the item: it lives on dayEvents[] at student-day level ({ id, eventType, state, ackAt, ackByName, ackChannel, ackReason, notifiedAt, remindedAt, lastContactAt }), because a day's absence is acknowledged once, not per period.
InconsistenciesResponseDto.items[] — { studentId, studentName, cohort{cohortType,cohortId,name}, code, message{en,it}, triggeringItem{label, periodLabel, wallStart, status}, previousItem{label, periodLabel, wallStart, status}, recordedByNames[], lessonTeacherNames[] } — both cells contribute to both teacher arrays (product: carry all teachers involved).
FollowUpItemDto — { eventId, date, studentId, studentName, cohort{…}, eventType, state, notifiedAt, remindedAt, lastContactAt }. The follow-up endpoints default to all unresolved days (date optional, and when omitted the whole open worklist is returned) — deliberately asymmetric with the rest of the surface, because an unacknowledged absence from Tuesday is still the admin's problem on Thursday.
TakeAttendanceDto — { date, cohortType, cohortId, unitIds?, entries: [{ studentId, anchor, status, time?, note? }] }. Every entry's anchor must resolve into the declared group; a batch spanning groups → 422 ATTENDANCE_ENTRIES_SPAN_GROUPS. The whole batch is one transaction (all-or-nothing), keeping v1's gates per entry: roster fence, status-field rules, school-day, future-date, write authority.
AttendanceWriteResponseDto — { records: AttendanceCellDto[], group: AttendanceGroupCardDto, inconsistencies: InconsistencyItemDto[], followUps: FollowUpItemDto[] }. PATCH returns the same envelope (its records holding the single updated cell) so the FE refreshes the widgets from one response.
Write authority is grant-shaped, not profile-shaped: the route gate (register write + take) is the authority for every role except teacher — a caller whose write grant comes only from the teacher role stays narrowed by the in-service Y-set (lesson teacher ∨ homeroom tutor ∨ teaches-the-student-today), and DEPARTMENT-parametric roles stay narrowed to their parameter departments. So a secretary or principal with no Teacher profile takes attendance school-wide, and an admin-who-is-also-a-teacher gets the admin surface because they hold the admin grants. One helper (hasNonTeacherRegisterWrite(ctx)) resolves this per call; PATCH re-checks identically.
Swagger considerations¶
AttendanceDayItemDtoand the group board nest three levels — explicit@ApiProperty({ type: [...] })at each level;AttendanceInconsistencyCode,AttendanceEventState,AttendanceEventType,AckChannelsurface via@ApiProperty({ enum }).- Error examples for
ATTENDANCE_ENTRIES_SPAN_GROUPS; drop justification from the existing attendance examples. - Controller JSDoc is public FE copy: document
PROJECTEDitems as "carried forward from the last recorded state" and never mention the projection internals, the comms-loop TODOs, or the Y-set. AttendanceCellDtolosesisJustified/justificationReason— FE-breaking on paper; no FE consumer exists (coordinate per standing convention).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none (reuse attendance.register) |
PermissionAction (rbac-catalogue.ts) |
new: attendance.manage_communications |
ScopeFieldMapping (rbac-catalogue.ts) |
attendance.register: remove isJustified, justificationReason |
ACTION_SCOPE_REQUIREMENTS |
manage_communications → requires attendance.register read |
| Role grants (roles.ts) | admin (frozen): + manage_communications. teacher (frozen): unchanged (attendance.register R/W + take). secretary (rule-generated): add attendance.register to SECRETARY_WRITE_SCOPE_KEYS; add attendance.take + attendance.manage_communications to SECRETARY_ACTION_KEYS. principal (rule-generated): auto-READs attendance.register; no actions by rule (a school may edit the preset — role-based write then applies automatically). hr: attendance.register stays in HR_EXCLUDED_SCOPE_KEYS (personnel role off family surfaces). staff / referent / student (frozen): unchanged here — the referent/student surface is its own spec. |
*_SCOPES runtime constant |
update ATTENDANCE_SCOPES.register field list (RBAC drift check) |
'manage_communications' joins ACTION_NAMES in src/permissions/interfaces/decorators.interfaces.ts. Frozen-preset deltas propagate to existing tenants via reconcileFrozenPresetRolesIntoTenants.
AttendancePolicy branches: admin pass-through (v1); secretary + principal pass-through (school-wide operators); teacher department-narrowed (v1); DEPARTMENT parametric narrowed (v1); everyone else fails closed. v1's hard @RequireRoles('admin','teacher') route gates are dropped — with the sibling specs adding legitimate reader roles, a hard-coded role list is a maintenance trap; the scope grant plus a fail-closed policy govern.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| One stored row = one displayed cell (v1 compliance model) | DAILY departments store state changes; reads project the gaps | One status per day cannot express "arrived late and left early" — the register would show only the last event | Displayed data is partly derived; mitigated by source: PROJECTED, a pure projector, and the rule that projected items carry no id/provenance |
| Two partial unique indexes encoding DAILY vs PERIOD (hand-authored SQL, ch12) | One plain unique on (tenant, student, date, startTick) |
With every cell tick-anchored, NULL-distinctness is no longer load-bearing | A destructive migration on a compliance table, viable only because no production attendance data exists (confirmed, §10.2) |
| Read surfaces return the data the caller asked for | GET /attendance/groups returns cards and totals in one response |
The totals are a free by-product of the same pass; a separate /summary endpoint would double the work for the school-wide case |
Per-group widgets call /groups with cohortType+cohortId rather than a dedicated endpoint — one endpoint, two framings |
| Aggregates are computed from stored rows | toRegister and the pending list are computed against the published timetable expanded per student, filtered by school-local wall clock |
"Not taken" is the absence of a row — it can only be counted against an expectation, and the expectation is the timetable | Board reads are heavier than v1's flat cell fetch (bounded: one date, one timetable weekday); no denormalized expectation table (deferred, §9) |
| Modules own their whole domain | This spec defines two tables it never writes (attendance_day_events, attendance_contact_logs) |
Product split the communication loop into its own spec; the admin worklist still needs a read contract to build against | The follow-up endpoints answer over an empty set until the loop spec lands — documented, and the arming sites carry TODO(comms-loop) markers |
v1 justification fields + justified audit verb |
Deleted | Family acknowledgement (on the day event) is the only "family took note" concept in the redesign | v1 audit rows containing justification payloads stay readable; no data migration |
8. Pushback log¶
| Source says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| v1: DAILY mode = one cell per student per day | Cannot represent a late arrival followed by an early exit | Per-period items in both modes; DAILY authored as state changes + read projection | Resolved (Fabio, chat 2026-07-25) |
| My proposal: status counters count cells | Product wants a group card whose numbers sum to the roster | Per-student classification, non-overlapping, first-match ladder | Resolved (Fabio, chat 2026-07-25) |
My proposal: overlapping "any occurrence" buckets (a late student counts in both absent and lateEntry) |
"buckets should not overlap … so that the count is exactly num students" | Precedence ladder; a student with an early exit never counts as absent | Resolved (Fabio, chat 2026-07-25) |
My proposal: toRegister counts the whole day's lessons regardless of wall clock |
Would mark every group incomplete all morning | Only lessons already started (school-local) are owed | Resolved (Fabio, chat 2026-07-25) |
My proposal: q filters the student set inside groups |
Admin expects search to narrow the board, not to reshape group counts | q filters whole groups; counts always cover the full roster |
Resolved (Fabio, chat 2026-07-25) |
Superseded v2 draft (deleted 2026-07-25): one GET /attendance/day returning the whole school's rows |
A 500-student school bloats the page; no two groups are consulted at once | Two-tier board: cards with counts, rows per group on demand | Resolved (Fabio, chat 2026-07-25) |
| Superseded v2 draft (deleted 2026-07-25): three ad-hoc incongruence rules | Incomplete — misses EARLY_EXIT_WHILE_ABSENT, EARLY_EXIT_AFTER_EARLY_EXIT, LATE_ENTRY_WHILE_PRESENT |
Day-state machine (IN_SCHOOL/OUT_ABSENT/OUT_LEFT) with a total transition table |
Resolved (Fabio, chat 2026-07-25) |
9. Deferrals¶
- Teacher surface — must-take list, "act only on your own period" write constraint in DAILY mode, own-lessons narrowing — next sibling spec.
- Referent/student surface — own children / self reads, the review-window visibility clause — sibling spec.
- Communication loop — event arming, 15-minute review window, 1-hour escalation, notification sends through the notification engine, referent/school ack, contact-log writes — own spec; this spec ships its tables and read contract.
- Referent pre-reports / declared future absences — product has not decided how families declare them; future dates are readable today but carry no report surface (§1 non-goals).
- Multi-date reads (week/month views, per-student history across dates) and past-academic-year reads — single date, active year only.
- Absence totals /
maxAbsenceHoursmonitoring — future absence-monitoring spec. - A denormalized "expected cells" table — the board recomputes the timetable expansion per request; if the board becomes slow on large tenants, materializing expectations per (date, group) is the next step — revisit on measurement, not speculation.
- Bulk register actions (mark a whole group present in one gesture) — the batch write already supports it wire-wise; the UX affordance is a FE concern for now.
10. Open questions¶
Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.
- 10.1 Late and early on the same day — RESOLVED (Fabio, 2026-07-25): fold into
earlyExit(classify by the day's last transition). Exactly the five product-named buckets survive; no sixth combined bucket. - 10.2 No production attendance data — RESOLVED (Fabio, 2026-07-25): confirmed, no prod attendance data. The migration may delete every
start_tick IS NULLrow and drop the justification columns outright; no backfill strategy needed.
None open — ready for sign-off.
11. Verification plan¶
- Unit specs (pure, no DB):
attendance-projection.spec.ts— the worked example end-to-end; PERIOD mode never projects; zero authored cells → allnull; trips/suspension carry; projected items carry no id/provenance.attendance-classification.spec.ts— every ladder rung, in precedence order; late+early →earlyExit; buckets sum to roster size over a mixed group (property-style check).attendance-inconsistency.spec.ts— all six codes, plus every ✓ row of the transition table (especiallyOUT_LEFT → LATE_ENTRYre-entry and first-cellEARLY_EXIT); neutral statuses skipped; not-taken gaps skipped; unsorted input; DAILY authored inconsistency detected.attendance-expectations.spec.ts— elapsed filter at school-local boundaries (lesson starting now counts, starting in 5 minutes does not); past date all-elapsed; future date →null; DAILY expects one cell per student and attributes pending to the first item's teachers; closed school day → zero expected; no published timetable →null.attendance-authority.spec.ts(updated) — non-teacher write-granting role passes school-wide; teacher-only caller Y-set-narrowed;DEPARTMENT-parametric narrowed; no grant denied.attendance-status-rules.spec.ts(updated) — justification rules deleted;SUSPENDEDaccepted;timetransitions-only.attendance-inconsistency-messages.drift.spec.ts— enum ↔ i18n catalog lockstep (both languages present for every code).attendance.policy.spec.ts— secretary/principal pass-through; teacher narrowing; fail-closed default.- E2E specs (
test/attendance-admin.e2e-spec.ts): board over a seeded school (homerooms + a homeroom-less grade cohort) — N+1 groups, counters summing to rosters, totals andgroupsPendingRegistermatching the cards; each filter dimension in isolation (includingcurriculumIdnarrowing a grade group andqmatching a student vs a homeroom); rows for one group showing per-student divergent items through an option block; a DAILY group reproducing the worked example; inconsistency count + item payload (both teacher arrays populated); follow-up summary/detail over a seeded event set spanning two dates with a contact log (lastContactAt); batch write returning the refreshed envelope; a batch spanning two groups → 422; removed routes 404; drift guards (rbac-catalogue, error-examples, rls-coverage, tenanted-models) green. - Migration verification: apply against a seeded dev DB with daily rows present → rows removed,
start_tickNOT NULL holds, single unique rejects a duplicate(tenant, student, date, tick),SUSPENDEDwritable in a subsequent transaction. - Manual verification: dev server + seed; open the board mid-morning and confirm afternoon lessons are not counted as owed; take a DAILY register at P1, add a late entry at P3, confirm P4+ project to
PRESENT; correct a cell and watch the audit history endpoint.
Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md. Fixed clocks throughout (school-local boundaries are the interesting cases); no sleeps.
Docs follow-up on landing: rewrite docs/19-attendance.md (cell model collapse, projection, board, aggregates), docs/REFERENCE.md §4/§6 rows, ch06 error-code table, ch12 note on the removed partial indexes.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-25
- Chat reference: two-round Q&A on the admin surface (chat 2026-07-25) — grouping/filters, counting semantics, the DAILY per-period reshape (#13) and its five consequences, the inconsistency state machine; §10 closed with "1) fold into earlyExit ok 2) confirmed, no prod attendance data"; then "ok to proceed with plan", with all prior attendance specs deleted in the same breath.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.