Skip to content

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:

  1. 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.
  2. 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):

  1. 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.
  2. ALTER TABLE attendance_records ALTER COLUMN start_tick SET NOT NULL.
  3. DROP INDEX attendance_daily_unique; DROP INDEX attendance_period_unique; then CREATE UNIQUE INDEX ... (tenant_id, student_id, date, start_tick).
  4. ALTER TABLE attendance_records DROP COLUMN is_justified, DROP COLUMN justification_reason;
  5. 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.
  6. 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:

  1. Resolve the PUBLISHED timetable for Y. If none exists, every group returns toRegister: null with empty items — reads never 409 (unlike writes, which keep ATTENDANCE_NO_PUBLISHED_TIMETABLE).
  2. weekday = weekday(D) in school-local time (School.timezone).
  3. Lessons: ScheduledLesson on 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).
  4. Activities: findActivityGraphRows + resolveActivityAudiences for that weekday — attendance-bearing exactly as in v1.
  5. School day: per department, isSchoolDay(D, calendar). A group whose department is closed on D returns schoolDay: false, toRegister: 0 and no items.
  6. 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, and toRegister is null rather than 0 (not applicable, not satisfied).
  7. 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

  • AttendanceDayItemDto and the group board nest three levels — explicit @ApiProperty({ type: [...] }) at each level; AttendanceInconsistencyCode, AttendanceEventState, AttendanceEventType, AckChannel surface via @ApiProperty({ enum }).
  • Error examples for ATTENDANCE_ENTRIES_SPAN_GROUPS; drop justification from the existing attendance examples.
  • Controller JSDoc is public FE copy: document PROJECTED items as "carried forward from the last recorded state" and never mention the projection internals, the comms-loop TODOs, or the Y-set.
  • AttendanceCellDto loses isJustified/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 / maxAbsenceHours monitoring — 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 NULL row 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 → all null; 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 (especially OUT_LEFT → LATE_ENTRY re-entry and first-cell EARLY_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; SUSPENDED accepted; time transitions-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 and groupsPendingRegister matching the cards; each filter dimension in isolation (including curriculumId narrowing a grade group and q matching 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_tick NOT NULL holds, single unique rejects a duplicate (tenant, student, date, tick), SUSPENDED writable 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.