Skip to content

Attendance Register — the complete FE guide

Role-matrix v3 (2026-08-20): HR Manager reads the school-wide register. Curriculum coordinators read only departments owning their assigned curricula. Department Principals read and edit only their assigned departments, including past corrections and follow-up/justification work. Keep sending departmentId as a filter; the backend intersects it with the caller's authority and returns ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT for a foreign explicit cohort.

STATUS: CONSOLIDATED — verified against source 2026-07-29; product sign-off pending. This document describes the attendance module as it is built today, and it is the authoritative integration contract for the admin surface. Spec E (expected-attendance resolver) has landed, which closes the temporal program's read/write story: the behaviour below is the final shape rather than an intermediate one. On 2026-07-29 every endpoint, payload example, enum, gate order and error row in this file was re-verified line-by-line against the shipped controllers, DTOs and services; the one drift found (a stale COMBINED_CLASS_DURATION_MISMATCH row in §12) is fixed. What remains open is listed in §15 and in the sign-off checklist, and none of it is a pending change to anything documented here.

Three consequences deserve top billing, because each is the likeliest thing to look like a bug:

  1. A teacher, member or roster change made without validFrom applies today — current in the same read, nothing pending — for as long as no attendance has been recorded yet for what the command touches (always the case for pure additions, and for everything before the department's calendar starts). The next school day story is now the register-already-taken case: once a cell exists today for an affected student/group, the dateless change books onto the next school day and shows up in the pending arrays instead. See §11.6(b)/(c). Amended 2026-07-30 (data-sensitive default).
  2. A historical board now shows the class a student was in then, not the one they are in now. That is the headline user-visible change of the whole program, and it reverses what this guide previously said. See §11.4.
  3. BREAKING (2026-08-02): a teacher's write authority narrowed. The former "teaches the student in any lesson that same day" clause is deleted — a teacher now writes only cells of slots they teach (as of the cell's date) or of students in the homeroom they tutor. Writes that used to succeed now return 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT; remove any UI affordance built on the old allowance. See §10.
  4. BREAKING (2026-08-03): a teacher must now declare a cohort they own on the two write routes. POST /attendance/records and PATCH /attendance/records/:id refuse a teacher's cohortType + cohortId outside a teaching unit they teach on the date or a class they tutor — 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT, before anything is written. In particular a GRADE_GROUP write by a teacher now always 403s. Same release, non-breaking companion: an echo-less PATCH by a teacher answers with the cell's own teaching unit where it used to answer with the class. See §6.1, §6.2, §3.1.
  5. The family communication loop is LIVE (2026-08-04, additive). Absence-like register writes now mint day events: dayEvents[] and the follow-up worklist stop answering empty, byState carries UNDER_REVIEW, and an event surfaces ~15 minutes after the cell behind it (a correction inside that window means it never surfaces at all). One new endpoint: PATCH /attendance/follow-ups/:id/ack. See §5.3, §5.5, §13.5.
  6. The referent/student surface is LIVE (2026-08-04, additive). Three new read-only endpoints under /attendance/family/* — the cross-children to-justify list, a per-student stats overview, and a per-date day view — callable by the referent and student roles, which stop 403ing on the attendance module for the first time. Every other role 403s on them. See §3, §5.8.
  7. Justifications are LIVE (2026-08-04, additive). A referent submits absence justifications, late-arrival announcements and early-exit announcements (with attachments) from the app: four new routes under /attendance/family/justifications plus a school-side attachment download. A justification resolves matching open events automatically (they leave the to-justify list and the office worklist, ackChannel: "APP"), a pre-announced absence means the later event never surfaces anywhere, and every student-day surface now nests a justifications[] block on the row. NOT_REQUIRED and ackChannel: "APP" are now real values you will receive. See §5.9, §5.3, §5.8.
  8. Notifications are LIVE (2026-08-04, additive — no shape change). NOTIFIED and FOLLOW_UP — always documented in the state enum as reserved — now actually occur: a background sweeper flips an armed event to NOTIFIED when its grace expires and to FOLLOW_UP one reminder delay later (defaults 15m / 60m, env-tunable per environment). At each flip the student's linked referents with accounts receive an in-app notification (see the notifications FE guide, 2026-08-04-notifications-FE-guide.md — kinds attendance.event / attendance.reminder) and a templated email. Nothing about the attendance payloads changed: treat the two states exactly as the state tables always described them (both are "open" on every worklist). An event resolved by an ack or justification before its deadline never notifies.

Date: 2026-07-29; temporal-commands sections amended 2026-07-30 (data-sensitive dateless default + replace-by-default — the net delta is also published standalone as 2026-07-30-temporal-commands-delta-FE-guide.md); teacher surface added 2026-08-02 (GET /attendance/teacher-day §5.7, teaching-unit cohorts §1.5/§5.3/§6.1, PATCH cohort echo §6.2, Y-set narrowing §10); records[].row added 2026-08-03 — additive, non-breaking: every written cell now carries its student's refreshed day row, so a DAILY save no longer needs a reload to show the re-projected day (§6.4); write cohort fence + tutor reads + agenda badge, 2026-08-03 (§3.1, §5.3, §5.4, §5.7, §6.1, §6.2, §12); family loop slice A, 2026-08-04 — day-event minting on every absence-like write + the follow-up ack endpoint (§5.3, §5.5, §12, §13.5, §15); family surface slice B, 2026-08-04 — the referent/student read routes under /attendance/family/* (§3, §5.8, §15); justifications slice C, 2026-08-04 — the family justification write path + attachments + the justifications[] row block on every day surface + dayEvents[].justificationId (§3, §5.3, §5.8, §5.9, §12, §15); notifications slice D, 2026-08-04NOTIFIED/FOLLOW_UP go live via the background sweeper + the notification center (separate guide 2026-08-04-notifications-FE-guide.md); no attendance payload changed; teacher day completed + the ACTIVITY cohort, 2026-08-12 (additive)teacher-day cards gain kind (LESSON/ACTIVITY) and a duties[] array, cohortType gains a fifth value ACTIVITY on every surface (§1.5, §2), the teacher leash gains a supervisor limb, and an echo-less teacher PATCH on an activity cell answers the ACTIVITY card instead of the class (§6.2); scoped guide 2026-08-02-teacher-attendance-surface-FE-guide.md amended in place; teacher-day badge narrowed to per-slot, 2026-08-14 (breaking) — a card's inconsistencies counts only the contradictions triggered at its own tick, so slots of one group no longer share the number and the cohort's day total lives only in GET /attendance/inconsistencies (§5.4, §5.7). Owner: backend. Sign-off: pending the items in the checklist at the end of this file.

Attendance is a compliance document (Italian registro), not a convenience feature. Every row is a legal record of who was in the room, asserted by a named person at a named time, and it is never silently rewritten when the school reorganises. Most of the surprising behaviour in this guide follows from that one fact.

FE has not integrated any of this yet, so this guide starts from zero. Read §1–§4 before writing a line of code; the rest is reference.


Table of contents

  1. Mental model
  2. Vocabulary
  3. The endpoints, and who may call them
  4. Conventions that apply to every call
  5. The reads
  6. The writes
  7. Status field rules
  8. Counters and classifications
  9. Inconsistencies
  10. Write authority — the Y-set
  11. The temporal model — the part that will bite you
  12. Error catalogue
  13. Rendering recipes
  14. Gotchas
  15. Not built yet
  16. This file is the whole contract

1. Mental model

1.1 One cell = one (student, date, slot)

The unit of storage is an attendance cell: one student, one calendar day, one slot of that day. The slot is identified by startTick — the slot's start time expressed in 5-minute steps from midnight. 09:00 is tick 108, 12:00 is 144.

There is no "whole-day cell" with an empty slot. A day always resolves to a list of slots, and cells hang off those slots.

1.2 Two department modes: DAILY and PERIOD

attendanceMode is a property of the department (not the school, not the class), and different departments in the same school run different modes at the same time. It is frozen once the department's calendar starts.

Mode How the register is taken How reads behave
PERIOD Once per period. Each slot needs its own cell. A slot shows its own cell, or nothing. No inference.
DAILY Once per day (plus a cell whenever something changes — a late arrival, an early exit). Reads project the authored cells across the whole day.

You do not choose the mode. It arrives on every group card as mode, and it tells you which of the two UIs to render (see §13.2).

1.3 Projection is semantic, and it runs both ways

In a DAILY department, a cell authored at 10:00 says something about 11:00 too — but not literally the same thing.

  • Forward: LATE_ENTRY means "in school from here", so later slots show PRESENT. EARLY_EXIT means "gone from here", so later slots show ABSENT. Everything else carries as itself.
  • Backward (slots before the earliest authored cell): the mapping inverts. LATE_ENTRY backfills as ABSENT (they were not there yet); EARLY_EXIT backfills as PRESENT (they were there until they left).

A worked day, 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

1.4 Three display states — never conflate them

Every slot in a student's day is exactly one of:

source record Meaning Editable?
'RECORDED' an object with an id A stored cell. Has provenance, a time, a note. Yes — PATCH it by record.id.
'PROJECTED' an object with id: null Inferred from an earlier cell (DAILY only). Only status is meaningful. No — writing here creates a cell via POST.
null null Not taken. Writing here creates a cell via POST.

source: null is not an absence. Rendering "not taken" as A is the single most damaging mistake available in this module — it turns a missing register into a legal claim that a child was absent.

1.5 A "group" is a class, a grade's classless students, a teaching unit, or an activity

The register is organised by group, and a group is one of five things (cohortType widened 2026-08-02 and again 2026-08-12 — the same enum everywhere it appears):

  • cohortType: 'HOMEROOM' — a class (registration/form group). cohortId is the homeroom id.
  • cohortType: 'GRADE_GROUP' — the students of one grade who belong to no class. cohortId is the grade id.
  • cohortType: 'SUBJECT_GROUP' — one course/section. cohortId is the subject-group id. The roster is the group's membership on the date.
  • cohortType: 'COMBINED_CLASS' — one co-taught combination. cohortId is the combined-class id. The roster is the union over the members combined on the date.
  • cohortType: 'ACTIVITY' (2026-08-12) — one scheduled activity. cohortId is the scheduled-activity id. The roster is the audience resolved on the date, inside the activity's single department; the card's mode is that department's. grade/curriculum carry no single value.

The first two are built from the students, not from a catalogue: a grade where everyone has a class has no grade-group card, and a class with zero students has no card at all. The teaching-unit and activity cohorts are built from the section/activity: an existing one with an empty dated roster answers with an empty roster rather than a 404. Every read and write that names a group carries the cohortType + cohortId pair together — one without the other is a 400. The admin board catalogue (/groups) does not list activity cards — reach an activity register by its cohort pair.

1.6 The day is rendered per student, not as a grid

Option blocks mean two students in the same class legitimately attend different lessons at the same tick. So /rows returns one strip per student, and two students of one class can show different rows. Do not build a class-×-period matrix; it cannot represent the data.


2. Vocabulary

Term Meaning
cell One stored AttendanceRecord: (student, date, startTick) + status.
slot One meeting on the day's schedule: a lesson or an activity, at a tick, with a roster, a room and teachers.
tick (startTick) Slot start in 5-minute steps from midnight. 108 = 09:00.
anchor The address of a slot: { subjectGroupId ⊕ combinedClassId ⊕ activityId, startTick }. Backend-minted — echo it back, never assemble one.
group / cohort A class (HOMEROOM), a grade's classless students (GRADE_GROUP), one course (SUBJECT_GROUP), one co-taught combination (COMBINED_CLASS) or one activity (ACTIVITY, 2026-08-12).
authored cell A cell someone actually wrote. Opposed to a projected slot.
day shape / manifest The frozen record of what a past day was scheduled for. Why back-filling survives a timetable revision.
Y-set The rule deciding whether this caller may write this student's cell. Broader than read visibility.
school clock The school's own timezone (School.timezone, an IANA zone). Every date comparison in this module uses it — never the browser's.
combined class A co-taught meeting of several subject groups. One register over the union roster.
activity A named non-curriculum slot (assembly, club, trip block). Registers exactly like a lesson.

3. The endpoints, and who may call them

Eighteen endpoints, all under /attendance (plus the ack PATCH of §5.5). All require authentication.

# Verb Path Purpose
1 GET /attendance/groups The day's register board — one card per group + totals.
2 GET /attendance/groups/pending Which groups still owe a register today, and who owes it.
3 GET /attendance/rows One group's student rows for the day.
4 GET /attendance/inconsistencies Registers that contradict themselves on this date.
5 GET /attendance/follow-ups/summary Counts of unacknowledged absences/late arrivals.
6 GET /attendance/follow-ups The family follow-up worklist (paginated).
7 POST /attendance/records Write a day register (creates and updates, one call).
8 PATCH /attendance/records/:id Correct one cell.
9 GET /attendance/records/:id/history One cell's current state + its full value history.
10 GET /attendance/teacher-day The caller's teaching day — teacher-only (§5.7).
11 GET /attendance/family/to-justify The family's unresolved events, across all linked students (§5.8).
12 GET /attendance/family/students/:studentId/overview One student's attendance stats + event history (§5.8).
13 GET /attendance/family/students/:studentId/days/:date One student-day in full — the family's copy of the admin row (§5.8).
14 POST /attendance/family/justifications Submit a justification / announcement (§5.9).
15 GET /attendance/family/justifications The caller's submissions this year, paginated (§5.9).
16 POST /attendance/family/justifications/:id/files Attach one document to a justification (§5.9).
17 GET /attendance/family/justifications/:id/files/:fileId Signed download URL, family side (§5.9).
18 GET /attendance/justifications/:id/files/:fileId Signed download URL, school side — office + the student's own teachers (§5.9).

3.1 Permissions — and the split that will surprise you

The RBAC surface is: entity attendance, one scope register, three actions take, manage_communications and justify (new 2026-08-04 — referent-only, gates the two justification writes).

Role 1–2 (board) 3–4 (rows/inconsistencies) 5–6 (follow-ups) 7–8 (write) 9 (history) 10 (teacher-day) 11–13 (family reads) 14+16 (justify writes) 15+17 (justify reads) 18 (school file)
admin 403 403 403 403
secretary 403 403 403 403
principal (Director) 403 403 403 403
teacher 403 own cohorts only 403 own cohorts only, and bounded per student 403 403 403 taught students only — anyone else's is a 404
referent 403 403 403 403 403 403 linked students only linked + writable links only linked students only 403
student 403 403 403 403 403 403 self only (11 answers own rows) 403 (no justify action) self only 403

"Own cohorts" means exactly three things (2026-08-03; the activity limb 2026-08-12), and the same rule governs the reads and the writes:

Cohort a teacher declares Admitted when
SUBJECT_GROUP they hold a teaching episode for it on the date in the request
COMBINED_CLASS they teach any member section of it on that date
HOMEROOM they are its tutor (homeroomTeacherId) — live, not dated
ACTIVITY they are one of its supervisors on the requested weekday
GRADE_GROUP never. It has neither a tutor nor a teacher.
none at all never on any cohort-scoped route

Teachers can write the register but cannot see the tenant-wide admin board. This is intentional, not an oversight. Endpoints 1–2 and 5–6 answer over the whole tenant — their unit of answer is a class card, so there is nothing for a per-record filter to narrow — and letting a teacher in handed them the register, absence counts and contradiction list of every class in the school.

Since 2026-08-02, endpoints 3–4 are teacher-reachable with a leash: the cohort must be one of the caller's own, per the table above. Anything else is 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT. Admins keep every cohort tenant-wide. Endpoint 10 is the teacher's landing view and is teacher-role-only — an admin gets 403 (their surface is the board).

Endpoints 7–9 filter per record; the gates differ and the difference matters:

  • 7–8 (the writes) now pass two gates. The cohort fence runs first and is about the caller, not the data: the declared cohortType + cohortId must be one of the caller's own cohorts, else 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT and nothing is written — not even partially. Only then do the per-student gates run, bounded by the Y-set (§10).

The reason the write cares which cohort you name: the write response is a cohort-shaped read (the group's card, its contradictions, its follow-ups). Without the fence, writing one legitimate cell into any class would hand back that class's whole register summary. - 9 (history) is gated by record-level read visibility: a teacher sees the cells of their own departments; a cell outside them is a 404, not a 403. The Y-set does not apply to reading a cell's history.

What this means for you: do not render the tenant-wide board, the pending list or the follow-up worklist in a teacher session. The teacher session's landing view is GET /attendance/teacher-day (§5.7), and each of its cards opens /rows with its own cohort selector.

principal holds register write despite reading as a read-only role: attendance is the head's compliance surface. take and manage_communications require register write; justify rides register read on the referent role — which is why a student session, holding the same read, still 403s on the justification writes: the action is the differentiator, not the scope.


4. Conventions that apply to every call

4.0 Base path and authentication

Every path in this guide is relative to the platform's API prefix: the wire path of GET /attendance/groups is /api/v1/attendance/groups. Every call carries the usual Authorization: Bearer <access token> header from the login flow; there are no attendance-specific headers, and no endpoint here is public.

4.1 Response envelopes

  • Endpoints 1–5, 7–9 return their DTO flat — no { data } wrapper. They are aggregate responses and bypass scope-grouped field filtering entirely.
  • Endpoint 6 (/follow-ups) is the only paginated one: { data: [...], meta: { total, page, limit, totalPages } }, plus the usual page/limit query params.

4.2 date is a calendar day, and only that

Every date — query param or body field — must be exactly YYYY-MM-DD. A full ISO instant (2026-07-25T00:00:00.000Z, 2026-07-25T23:00:00-05:00) is rejected with 400 VALIDATION_FAILED; it is not truncated.

Truncation is only correct when the instant carries no offset, and the register is keyed by calendar day. Send the day, not a moment.

Endpoint date
1, 2, 3, 4 required
5, 6 (follow-ups) optional — omit to span every unresolved day
7 (POST) required in the body

The write window is asymmetric (2026-08-04). Future dates are accepted on reads (that is how you show tomorrow's timetable) and on writes, for every writer: the register can be filled ahead of time — a planned trip, a known absence — with the audit trail carrying the responsibility. Past dates are management-only on writes: tenant-wide writers and Department Principals inside their qualified departments may correct them; teacher-only authority gets 422 ATTENDANCE_PAST_DATE_LOCKED on POST and PATCH. Use permissions plus canWrite/route results rather than an admin-name check. Today is live for every qualified writer, and structural fences apply on every date.

4.3 The cohort pair is both-or-neither

On endpoints 1–6, cohortType and cohortId must be sent together. A lone half is a 400, not a silently ignored filter. (It used to be dropped on the floor, which made a typo look like "no groups matched".)

Passing the pair turns any of endpoints 1, 2, 4, 5, 6 into its single-group version. There is no separate per-group endpoint — that is how you build a class panel.

4.4 Filter semantics on the day endpoints (1–4)

Three rules, and guessing them wrong produces plausible-looking wrong numbers:

  1. Structural ids (departmentId, gradeId, homeroomId, cohortType + cohortId) and search select whole groups. A group's counters always describe its full roster, even when only one student matched the filter.
  2. curriculumId is the exception — it narrows the students inside a group. A grade group spanning several curricula shrinks rather than disappearing.
  3. search (max 100 chars) matches student names, identification codes and class names, and keeps the whole group the match belongs to.

On the follow-up endpoints search means something different on purpose: that worklist is a list of students, so free text narrows the students themselves (names + identification codes only).

4.5 The error body

{
  "statusCode": 422,
  "code": "ATTENDANCE_NOT_A_SCHOOL_DAY",
  "messages": {
    "en_US": "2026-07-25 is not a school day",
    "it_IT": "Il 25/07/2026 non è un giorno di scuola"
  },
  "field": "…",        // present only on field-locatable validation errors
  "data": { … },        // present only for collection errors (per-row detail)
  "timestamp": "2026-07-28T09:12:33.120Z",
  "path": "/attendance/records"
}

Branch on code. Render messages[lang]. Do not compose your own sentence from the code — the copy is owned by the backend catalogue and is already localized in both languages.

There is no params on the wire. This is the one thing to internalise about error handling here, because it is easy to assume otherwise. Backend throw sites carry structured params, but the response filter consumes them: params that the message text references are interpolated into messages and then dropped; params that the text does not reference are classified internal and never leave the server. Either way body.params does not exist. The only machine-readable parts of an error are code, field (on field-locatable validation errors) and data (on collection errors, which carry per-row message-bearing leaves).

Two consequences you will actually hit:

  • You cannot extract the offending id. ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT, ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER, ATTENDANCE_ENTRIES_SPAN_GROUPS and ATTENDANCE_UNIT_NOT_IN_SUBJECT each know which student or unit was at fault, and none of them tell you. Since the whole batch is rejected atomically (§6.1), the correct behaviour is to render the message and leave the form untouched — not to try to highlight a row.
  • You cannot extract a suggested date. The temporal errors described in §11.6 name their earliest-acceptable date inside the sentence. If a product flow needs that date as a button ("use 14 September instead"), it has to be promoted to data backend-side first — ask for it rather than parsing the copy.

4.6 Reads never 409

A date that no schedule source covers, a tenant mid-setup, a holiday — all of these render cards with nothing owed. Only the write path refuses.


5. The reads

5.1 GET /attendance/groups — the board

Query: DayFilterQueryDto (date required; the filters in §4.4).

The groups themselves are resolved as of date, not as of today — a student who changed class in January is under their old class for December and their new one from January. Do not reconcile this against a roster endpoint, and do not reuse one date's roster to render another; see §11.4.

{
  "date": "2026-07-25",
  "groups": [
    {
      "cohortType": "HOMEROOM",
      "cohortId": "…",
      "name": "1A",
      "departmentId": "…", "departmentName": "Middle School",
      "gradeId": "…",      "gradeName": "Grade 1",
      "curriculumId": "…", "curriculumName": "National",   // null on a GRADE_GROUP
      "mode": "PERIOD",
      "schoolDay": true,
      "numStudents": 24,
      "toRegister": 3,
      "counters": { "notTaken": 3, "suspended": 0, "trip": 0,
                    "absent": 2, "lateEntry": 1, "earlyExit": 1, "present": 18 },
      "inconsistencies": 1
    }
  ],
  "totals": { "numGroups": 12, "numStudents": 268, "toRegister": 14,
              "groupsPendingRegister": 5, "inconsistencies": 2,
              /* …the same seven counters… */ }
}

Field notes:

  • inconsistencies (added 2026-07-30) is the group's register-contradiction count for this date — the number of items GET /attendance/inconsistencies returns for it, so the per-card badge needs no second call. It counts contradictions, not students: it is a sibling of counters, not an eighth bucket, and none of the §8 arithmetic includes it. totals.inconsistencies is the sum over the school-day groups (next note). You still call /inconsistencies when the user opens the warning widget — that is where the item detail lives.

  • curriculumId / curriculumName are null on a GRADE_GROUP — its students may hold different curricula.

  • schoolDay: false means the date is a holiday, a closure, or outside the department calendar. Nothing is owed. Render the card greyed, not empty.
  • totals cover only the groups with schoolDay: true (changed 2026-08-03). Calendars are per department, so on a partial holiday the closed departments' cards still render — greyed, per the previous note — but contribute nothing to numGroups, numStudents, the counters, toRegister, groupsPendingRegister or inconsistencies. On a date nobody has school the totals are all zeros with toRegister: null. Previously the totals summed every rendered card, so a partial holiday reported the whole school under notTaken; do not use totals.numGroups as "cards on screen" — count groups[] for that.
  • toRegister is number | null. null means the question does not apply — a future date, or a date no schedule source covers. It is not 0. Render null as "—", never as "all done".
  • toRegister climbs through the day: it counts only slots that have already started in school time. At 08:30 a day of eight periods does not owe eight registers.
  • counters overlap — see §8. This is the other high-damage misreading in this module.

5.2 GET /attendance/groups/pending — who still owes a register

Same query DTO. Only groups with at least one owed cell appear, so the list shrinks as teachers save and grows as the day proceeds.

{
  "date": "2026-07-25",
  "toRegister": 14,
  "groups": [
    {
      "cohortType": "HOMEROOM", "cohortId": "…", "name": "1A",
      "departmentName": "Middle School", "gradeName": "Grade 1",
      "mode": "PERIOD",
      "toRegister": 3,
      "pendingTeachers": [ { "teacherId": "…", "teacherName": "R. Bianchi", "slots": 2 } ],
      "pendingSlots": [
        { "label": "Mathematics", "wallStart": "09:00", "wallEnd": "10:00",
          "startTick": 108, "roomName": "Room 12",
          "teacherNames": ["R. Bianchi"], "numStudents": 24 }
      ]
    }
  ]
}
  • PERIOD department: every elapsed slot missing a cell is owed and named.
  • DAILY department: the day needs one take per student, attributed to the first elapsed slot of the day.
  • PendingSlotDto.numStudents is the count of that group's students still missing a cell at that slot, not the roster size.

Here toRegister is a plain number (a group only appears because it owes something).

5.3 GET /attendance/rows — one group's day

cohortType + cohortId are required here — omitting them is 400 VALIDATION_FAILED. Optional startTick (0–287) selects one occurrence: with a SUBJECT_GROUP / COMBINED_CLASS, each student's items[] must match both the cohort anchor and tick, so a teaching unit that meets twice that day returns at most one item per student. Omit it for the full timetable strip. A group that resolves to nothing (including an empty class) is 404 NOT_FOUND — with one exception: a teaching-unit cohort (SUBJECT_GROUP / COMBINED_CLASS, since 2026-08-02) that exists but has an empty roster on the date answers students: [] instead; only an unknown id 404s. A combined cohort's roster is the union over its members-on-date.

Who may open which cohort: tenant-wide management readers open any cohort; Department Principals are limited to assigned departments and Curriculum Coordinators to departments owning their assigned curricula. A teacher may open a SUBJECT_GROUP/COMBINED_CLASS cohort of a group they teach on the queried date, and — since 2026-08-03 — the HOMEROOM of a class they tutor: a tutor already writes their class's cells through the Y-set, so refusing them the class register left them authoring a page they could never open. Anything else, GRADE_GROUP included, is 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT (§12). The response shape is identical for every caller.

{
  "date": "2026-07-25",
  "group": { /* the same AttendanceGroupCardDto as the board */ },
  "students": [
    {
      "studentId": "…", "firstName": "Anna", "lastName": "Rossi",
      "identificationCode": "S-0042",
      "classifications": ["lateEntry", "earlyExit"],
      "items": [ /* the timetable strip — see below */ ],
      "register": [ /* the cells actually written — see below */ ],
      "dayEvents": [ /* family communication state */ ],
      "justifications": [ /* family submissions covering this day — §5.9 */ ]
    }
  ]
}

items[] — the strip

One entry per slot this student attends, or at most the selected occurrence when the query includes startTick:

{
  "kind": "LESSON",                       // or "ACTIVITY"
  "anchor": { "subjectGroupId": "…", "startTick": 108 },
  "label": "Mathematics",                 // subject name, or activity name
  "wallStart": "09:00", "wallEnd": "10:00", "startTick": 108,
  "roomName": "Room 12",
  "teacherNames": ["R. Bianchi"],
  "source": "RECORDED",                   // or "PROJECTED" or null
  "record": {
    "id": "…", "status": "LATE_ENTRY", "time": "09:15", "note": null,
    "coveredUnitIds": ["…"], "coveredUnitNames": ["Il Neoclassicismo"],
    "justificationId": null,              // set when a family communication
                                          //   pre-filled this entry (see below)
    "recordedByName": "R. Bianchi", "recordedAt": "…",
    "lastModifiedByName": null, "lastModifiedAt": null
  },
  "inconsistency": null
}
  • anchor is what you echo back on a write. Copy the object; do not rebuild it. The three id fields are mutually exclusive and the absent ones are simply not present.
  • coveredUnitIds / coveredUnitNames (added 2026-07-30) — the units observed in the session this cell was taken at (§6.3), from the cell's own snapshot. This is the "Svolto" line: render the names, no extra fetch needed. Empty for a leaf subject, an untagged take, and always empty on a PROJECTED item — units are an authored fact, never carried forward.
  • justificationId (added 2026-08-10) — set when this entry was pre-filled from a family communication rather than recorded by a colleague; it matches an id in the row's justifications[] (§5.9). null on every staff-authored entry, and on a PROJECTED item. See "The register is no longer always hand-taken" below.
  • On a PROJECTED item, record.id is null and only status is meaningful.
  • inconsistency, when present, carries a ready-to-render localized message and the tick of the earlier cell it contradicts. Do not map the code to your own copy.
  • startTick narrows only this array. register[], classifications, dayEvents[], justifications[], and the group card remain whole-day facts.
  • items[] labels come from today's live slots — this is the strip, so it describes the schedule.

register[] — the day, as written

{
  "recordId": "…", "status": "LATE_ENTRY", "time": "09:15", "note": null,
  "startTick": 108, "wallStart": "09:00", "label": "Mathematics",
  "coveredUnitIds": [], "coveredUnitNames": [],
  "justificationId": null,
  "recordedByName": "R. Bianchi", "recordedAt": "…",
  "lastModifiedByName": null, "lastModifiedAt": "…"
}

Oldest first. This is the day summary, and it is a different question from the strip: a day accumulates events, so a student who arrived late and left early carries two entries and the UI renders them as one rich cell (LE 09:15 · EE 13:00).

You cannot reconstruct register[] 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, and would silently vanish from the summary while still being the register's truth. register[] is projected from the records directly and each entry is named from its own frozen snapshot, not from today's timetable.

The register is no longer always hand-taken (2026-08-10)

When a family announces an absence, a late arrival or an early exit for today or a day ahead, the backend writes that day's register entries there and then. Open the date and you may find it already filled in — by a parent, not a colleague. Three things follow, and the third is the one most likely to be got wrong:

  1. record.justificationId is the badge. Non-null ⇒ pre-filled from the communication with that id, which is in the same row's justifications[] (kind, range, reason, submitter). Show a small marker and, if you like, the reason on hover. recordedByName will be the parent's name — that is correct, not a data bug.
  2. They are otherwise ordinary entries. Editable, auditable, counted in register[], in the group counters and in toRegister exactly like any other. Do not move them into a separate visual class beyond the badge, and do not make them read-only: a teacher correcting one is a supported and important case (their edit also makes the entry survive a later withdrawal).
  3. What pre-fills, and what deliberately does not. An absence fills the day — every lesson in a PERIOD department, the day's first slot in a DAILY one. A late arrival or early exit fills exactly one slot: the one its time falls in. The slots around it stay untaken on purpose — an announced 10:30 arrival says nothing about the rest of the day. Do not auto-fill PRESENT around it client-side: that invents a fact nobody stated and will make the day contradict itself in §5.4.

A tick that is already taken is never overwritten. If a teacher recorded PRESENT at 08:00 and a parent files an absence at noon, the 08:00 entry stands untouched with justificationId: null, and only the slots nobody had taken get pre-filled. The communication still resolves the day's follow-up — it just does not rewrite the register. Expect, and render calmly, days that mix both provenances.

Withdrawal removes them again. If the family withdraws the communication, the entries it pre-filled disappear — except any a teacher has since edited, which are kept. Nothing else in the module deletes an attendance entry, so a disappearing row here is not a bug.

dayEvents[] — family communication

Day-level (not per slot), one per (student, date, eventType) with eventType ∈ {ABSENT, LATE_ENTRY} and a state machine UNDER_REVIEW → NOTIFIED → FOLLOW_UP → ACKNOWLEDGED | NOT_REQUIRED | CANCELLED | SUPERSEDED.

The full shape of one event:

{
  "id": "…",
  "eventType": "ABSENT",            // or "LATE_ENTRY"
  "state": "NOTIFIED",
  "notifiedAt": null,                // when the family was first told
  "remindedAt": null,                // when the reminder went out
  "escalateAt": null,                // when this becomes overdue
  "ackAt": null,                     // the acknowledgement triple —
  "ackByName": null,                 //   who acknowledged,
  "ackChannel": null,                //   over APP | PHONE | IN_PERSON
  "ackReason": null,                 // free-text reason given with the ack
  "lastContactAt": null,             // when the school last logged contact
  "justificationId": null            // the family submission that resolved or
                                     //   pre-empted this event — matches an id
                                     //   in the row's justifications[] (§5.9)
}

Note ackReason exists here only — the follow-up worklist item (§5.5) carries the same timeline without it.

Live since 2026-08-04. Absence-like register writes mint these rows: a day whose every cell is ABSENT carries an ABSENT event, a day with a late-entry cell carries a LATE_ENTRY one, and a correction retires the event (CANCELLED if corrected within the ~15-minute grace window — the family-facing surfaces never knew — SUPERSEDED after it). dayEvents[] is the office's full-detail view: unlike the worklist it is not filtered by state or by the grace window, so freshly minted UNDER_REVIEW events and resolved ones both appear here — filter by state yourself if you only want open ones.

Since notifications (2026-08-04, headline 9) an unresolved event no longer sits in UNDER_REVIEW forever: the sweeper flips it to NOTIFIED when its grace expires (notifiedAt + escalateAt stamped) and to FOLLOW_UP one reminder delay later (remindedAt stamped) — render both as open, exactly as the tables always said. Since justifications (2026-08-04, §5.9) two more values are real: state: "NOT_REQUIRED" (the family announced the absence before the register was taken — nothing was ever owed; justificationId points at the announcement) and ackChannel: "APP" (a family submission resolved a live event; ackReason stays null — the reason text lives on the justification itself, in the row's justifications[]). The office ack endpoint still only accepts PHONE / IN_PERSON (§5.5).

5.4 GET /attendance/inconsistencies

Same query DTO; pass the cohort pair to scope it to one class or teaching unit. Same teacher rule as /rows: a teacher must pass one of their own cohorts (§3.1) — a cohort-less call, fine for an admin, is a 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT for a teacher. Since 2026-08-03 a tutor may also pass their own HOMEROOM here, which is the drill-down behind a teacher-day card's inconsistencies badge (§5.7).

{
  "date": "2026-07-25",
  "count": 2,
  "items": [
    {
      "studentId": "…", "studentName": "Anna Rossi",
      "cohortType": "HOMEROOM", "cohortId": "…", "cohortName": "1A",
      "code": "PRESENT_AFTER_ABSENCE_WITHOUT_LATE_ENTRY",
      "message": { "en_US": "…", "it_IT": "…" },
      "triggeringCell": { "recordId": "…", "status": "PRESENT", "label": "Mathematics",
                          "wallStart": "11:00", "startTick": 132, "recordedByName": "…" },
      "previousCell":   { "recordId": "…", "status": "ABSENT",  "label": "History",
                          "wallStart": "09:00", "startTick": 108, "recordedByName": "…" },
      "recordedByNames": ["R. Bianchi", "M. Verdi"],
      "lessonTeacherNames": ["R. Bianchi", "M. Verdi", "L. Neri"]
    }
  ]
}

An item is directly navigable: cohortType + cohortId are exactly the /rows selector, and studentId + triggeringCell.startTick locate the cell inside it.

Cells here 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 deliberately does the opposite for items[] — it renders today's schedule.)

5.5 GET /attendance/follow-ups and /follow-ups/summary

Both need the extra action manage_communications — a teacher gets 403.

Both differ from every other read in one way: date is optional, and omitting it spans every unresolved day. An unacknowledged absence does not stop mattering when the day ends.

The filter set is otherwise the same as the day endpoints' — departmentId, curriculumId, gradeId, homeroomId, the both-or-neither cohort pair, and search (here over student names and identification codes only, §4.4). The list adds the standard page / limit; the summary takes no paging.

When an event appears (2026-08-04): an event enters this worklist about 15 minutes after the register cell behind it lands — the correction grace window. A cell corrected inside that window means the event never appears anywhere. The clock runs from the write, not the event's date, so a backdated absence recorded this morning surfaces 15 minutes later like a same-day one.

/follow-ups/summary:

{
  "total": 6,
  "byState": { "UNDER_REVIEW": 6 },
  "byType":  { "ABSENT": 5, "LATE_ENTRY": 1 }
}

Both maps are sparse — a state or type with nothing open is absent, not zero. Iterate the map; do not index a fixed list of keys and expect numbers. Since notifications (2026-08-04) NOTIFIED and FOLLOW_UP appear in byState alongside UNDER_REVIEW — all three are open.

/follow-ups is paginated ({ data, meta }), oldest day first. Each item carries eventId, date, the student, their class/grade/department, the eventType and state, and the timeline: notifiedAt, remindedAt, escalateAt (when it becomes overdue), lastContactAt, and the acknowledgement triple ackAt / ackByName / ackChannel (APP | PHONE | IN_PERSON).

homeroomId / homeroomName are null when the student has no class.

Deliberately as-of-today, and E did not change it: which cohort an exited or moved student is reported under on this worklist is resolved as of today. An unanswered absence stays with whoever is responsible for the student now — this is the one place where a "current" answer is the right one, because the worklist is about who must act, not about what happened. Every other surface in this module answers as of the date you asked about.

PATCH /attendance/follow-ups/:id/ack — resolve one (2026-08-04)

Same gate as the two GETs (manage_communications — a teacher gets 403). The office resolves a follow-up the school handled directly:

// PATCH /api/v1/attendance/follow-ups/<eventId>/ack
{ "channel": "PHONE", "reason": "Mother called — flu" }   // reason optional, ≤1000 chars

channel is PHONE | IN_PERSON only — sending APP is a 400 (that channel is what referent-submitted justifications stamp for themselves). The response is the refreshed worklist item (same shape as a /follow-ups list entry), now with state: "ACKNOWLEDGED" and the ackAt / ackByName / ackChannel triple filled — swap it into your list (or drop it, since it is no longer open) without a re-fetch.

The ack records a full justification (2026-08-10). The request and the response are unchanged, but the same action now also writes a real justification on the family's behalf: a single-day row on the event's date, ABSENT → ABSENCE / LATE_ENTRY → LATE_ENTRY, the ack's reason carried over, the arrival time inherited from the day's late-entry entry, attributed to the acking staff member and marked channel: "PHONE" | "IN_PERSON". The consequences for the office UI:

  • it appears immediately in GET /attendance/justifications (the office browse) and in the justifications[] block of that day's register row, next to family submissions, which carry channel: "APP";
  • no attachment is required — an office row may legitimately have none, and the family can still attach a certificate to it afterwards;
  • the family sees it too, as a settled row they cannot edit or withdraw. If the office needs to correct a mistyped ack, there is no un-ack route — raise it with the backend rather than looking for one.

Rules worth building around:

  • Acking works even before the event surfaces. A parent who phones at 8:20, inside the grace window, can be acked immediately — the event then never appears on any worklist. There is no need to wait for it to show up; if you hold its id (e.g. from dayEvents[]), you can ack it.
  • A resolved event refuses a second ack: 409 ATTENDANCE_EVENT_ALREADY_RESOLVED, with the current state in params.state. Treat it as "someone else got there first" and refresh. This is also why an ack can never collide with an existing justification: a day already covered by one is not open any more.
  • A missing (or other-tenant) id is 404 ATTENDANCE_EVENT_NOT_FOUND.
  • The ack lands on the audit trail with the actor's name — there is no anonymous resolution. It now writes two entries: the event acknowledgement and the justification it recorded.

5.6 GET /attendance/records/:id/history

There is no bespoke history table. The cell row holds only current state; the value timeline is an audit-log projection.

{
  "current": { /* AttendanceCellDto — the full snapshot, see §6.4 */ },
  "history": [
    {
      "id": "…",
      "actorUserId": "…",              // null for a system actor
      "actorName": "R. Bianchi",
      "actorRole": "teacher",
      "entityType": "attendance_record",
      "entityId": "…",                  // the cell's id
      "action": "attendance_record.updated",
      "data": { "status": "PRESENT", "time": null, "note": null },
      "requestContext": { /* opaque — ignore */ },
      "createdAt": "2026-07-28T09:12:33.120Z"
    }
  ]
}

history is the raw audit trail, newest context in current. For attendance verbs, data carries the value fields as written by that event (status / time / note); on a cross-anchor re-assertion (§6.6) it additionally carries anchorKey / previousAnchorKey. Render the timeline from actorName + createdAt + data.

Audit verbs you will see: attendance_record.created, attendance_record.updated, and attendance_record.admin_override (a school-wide writer edited a cell they could not have reached as a teacher).

5.7 GET /attendance/teacher-day — the teacher's day

New 2026-08-02. Teacher-role only — an admin gets 403 (their surface is the board), and a teacher-role user with no teacher profile in the active year gets an empty day (today: [], otherGroups: []), never an error.

Query: date? (YYYY-MM-DD), defaulting to today in school time. A past date answers as the day was then (same dated rules as every other read) but is read-only for the teacher — past corrections go through an administrator; a future date is an agenda preview, writable like today (2026-08-04, §4.2).

{
  "date": "2026-09-14",
  "today": [                              // the caller's lessons, in period order
    {
      "anchor": { "subjectGroupId": "…", "startTick": 96 },   // echo verbatim on writes
      "startTime": "08:00", "endTime": "09:00", "durationMinutes": 60,
      "label": "Mathematics",             // subject name, or the combined meeting's name
      "name": "Math 2A",                  // the unit's OWN name (2026-08-04) — the card title
      "subjectName": "Mathematics",       // plain subject; null on a combined meeting
      "roomId": "…", "roomName": "Room 12",
      "teacherIds": ["…"], "teacherNames": ["R. Bianchi"],   // co-teachers included
      "cohort": { "cohortType": "SUBJECT_GROUP", "cohortId": "…" },
      "attendanceMode": "PERIOD",         // the slot's own department's mode
      "needsAttendance": true,            // the register DUTY — see below
      "rosterSize": 22,
      "recorded": 15,                     // completeness — see below
      "inconsistencies": 1                // contradictions THIS slot triggered — see below (per-slot 2026-08-14)
    }
  ],
  "otherGroups": [                        // every OTHER group the caller teaches, name-ordered
    {
      "subjectGroupId": "…", "name": "Math 2B",
      "subjectName": "Mathematics", "subjectCode": "MAT-01",
      "gradeName": "Year 10", "departmentName": "Secondary",
      "rosterCount": 19,
      "cohort": { "cohortType": "SUBJECT_GROUP", "cohortId": "…" }
    }
  ]
}

What each piece means:

  • today[] — the caller's LESSON slots on the date, tick-ordered. A combined (co-taught) meeting appears once as one shared slot with cohort.cohortType: 'COMBINED_CLASS' and the union roster — never as its member sections. Activity slots are not listed (deferred); they still register through /rows + POST as before.
  • name / subjectName (new 2026-08-04) — the card's title. name is the teaching unit's own name (the subject group's — the same string otherGroups[].name and the /rows register header carry — or the combined meeting's). subjectName is the plain subject, null on a combined meeting whose members may span subjects. label predates both and stays as-is.
  • needsAttendancethe register duty lives on this slot today. In a PERIOD department every lesson carries it. In a DAILY department only the slot that opens the day for at least one of its students does — computed over the students' whole schedule, so a slot after someone else's earlier lesson or activity is not flagged. The flag is static: it does not clear when the register is completed. Duty and completeness are different signals — render them separately.
  • recorded — how many of rosterSize are already covered: by a cell at this slot's tick (PERIOD), or by any cell that day (DAILY — so a morning take completes the afternoon slots' counters too). rosterSize - recorded is what is still missing.
  • inconsistencies (2026-08-03; per-slot since 2026-08-14) — how many contradictions this slot triggered on this date: of the items GET /attendance/inconsistencies returns for the card's own cohort (§5.4), those whose triggeringCell.startTick equals the card's anchor.startTick. Three things to internalise before rendering it:
  • each contradiction badges exactly one card — the one whose cell broke the chain (absent at 10:30, present at 13:30 with nothing between → the badge sits on the 13:30 card only). Two slots of one group now differ;
  • a contradiction triggered at a slot outside this agenda — a colleague's lesson, or a tick the schedule no longer has — shows on no card here. The drill-down (§5.4) remains the whole-day authority: a card's badge is a lower bound of its cohort's count, never assert equality;
  • at slot scope, contradictions and students coincide — a student can trigger at most one contradiction per tick.

0 on a clean day. Render it as a warning affordance on the card, opening /inconsistencies with the card's cohort + the agenda's date. - otherGroups[] — every other subject group the caller teaches on the date (no lesson today), with dated roster counts. This is how a register stays reachable on days its group does not meet.

Navigation: a card's cohort is exactly the /rows selector (§5.3); its anchor is what you echo on POST /attendance/records (§6.1) — when writing from a teacher-day card, declare the card's own cohort as the batch's cohortType/cohortId. This is now required, not merely advisable: since 2026-08-03 a teacher's write into any other cohort is 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT (§6.1).

5.8 The family surface — /attendance/family/* (referent + student)

New 2026-08-04, additive. The read side of the family session — the first attendance routes the referent and student roles can call. A referent reads their linked students; a student reads themselves. Every other role — admin included — gets 403 here (their surfaces are the board and the teacher day). The write side — submitting justifications — landed the same day and is documented in §5.9.

The 15-minute rule governs everything here. An absence or late arrival appears on this surface about 15 minutes after the register cell behind it is recorded. A correction made inside that window means the event never appears at all — do not build any affordance that promises real-time mirroring of the register's absences. The register content itself (the day view's slots and cells) is not delayed; only the event objects are.

GET /attendance/family/to-justify?page=&limit=

Every event still awaiting the family's answer, across all the caller's linked students, newest day first, paginated ({ data, meta }, default limit=20). One call powers both the badge (use meta.total) and the list. Resolved events leave this list; history stays on the overview.

{
  "data": [
    {
      "eventId": "…",
      "date": "2026-09-07",
      "eventType": "ABSENT",        // ABSENT | LATE_ENTRY
      "state": "UNDER_REVIEW",      // see the state note below
      "studentId": "…",
      "studentName": "Ada Lovelace",
      "surfacedByName": "Anna Rossi" // who recorded it; null if unresolvable
    }
  ],
  "meta": { "total": 1, "page": 1, "limit": 20, "totalPages": 1 }
}

For a student session the same endpoint answers with the caller's own rows only — but the student-facing UI is the overview + day view; the to-justify list is the referent's landing widget.

GET /attendance/family/students/:studentId/overview

One student's attendance so far this year: the stats block plus the family-visible event history (newest first, same event shape as the day view below).

{
  "studentId": "…",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "stats": {
    "schoolDays": 41,                    // days with a register entry, minus the excluded ones
    "excludedDays": { "suspended": 1, "trip": 2 },
    "absent":    { "days": 3, "percentage": 7.3 },   // share of schoolDays, one decimal
    "lateEntry": { "days": 1, "percentage": 2.4 },
    "presentHours": {
      "absentMinutes": 720,
      "totalMinutes": 11840,
      "percentage": 93.9               // present share of recorded time
    },
    "minPresentHoursPercentage": 75    // the school's threshold; render presentHours.percentage against it
  },
  "events": [ /* FamilyDayEventDto — see the day view below */ ]
}

Null semantics to render for, not around: every percentage is null until there is anything to count (start of year), and minPresentHoursPercentage is null when the school has not configured a threshold — hide the comparison in both cases. The counts are register-derived: a day nobody took does not count as a school day yet, so early-year numbers grow as registers land. The hours pair measures recorded time (the cells' own clock spans), which is the number the school's threshold speaks about.

GET /attendance/family/students/:studentId/days/:date

The student's day exactly as the school sees it — the same slots, register cells and classification buckets the admin row carries (items[], cells[], classifications[]cells[] is the admin row's register[] under a different top-level name; the shapes are in §5.3 and identical by construction). date is a calendar day (YYYY-MM-DD, §4.2); any date is readable, and a no-school date answers an empty day, never an error.

The day view also carries justifications[] — every family submission whose date range covers this day, in exactly the row-block shape the school sees (§5.9); the two surfaces describe the day identically by construction.

Beyond the cells[] key name, the one divergence from the admin row: dayEvents[] here is the family shape

{
  "eventId": "…",
  "date": "2026-09-07",
  "eventType": "ABSENT",
  "state": "ACKNOWLEDGED",
  "notifiedAt": "2026-09-07T08:20:00.000Z",  // null while still in UNDER_REVIEW
  "ackAt": "2026-09-07T10:12:00.000Z",  // null while unresolved
  "ackChannel": "PHONE",       // PHONE | IN_PERSON | APP (APP = resolved by a justification)
  "surfacedByName": "Anna Rossi"
}

— filtered and reduced relative to the office's: an in-grace, cancelled or superseded event is never present, and there is no ackReason, ackByName or lastContactAt (office-internal). States you can receive: UNDER_REVIEW (armed — still awaiting an answer), NOTIFIED/FOLLOW_UP (live since 2026-08-04 — the family has been actively notified/reminded; render like UNDER_REVIEW, still awaiting an answer), ACKNOWLEDGED (resolved — ackChannel: "APP" means one of the family's own justifications resolved it, §5.9), NOT_REQUIRED (live since 2026-08-04 — the day was announced before the register was taken; render as "already justified", nothing owed).

Errors on this surface

Case Answer
studentId the caller may not read — unlinked, another student's, or nonexistent 404 NOT_FOUND, indistinguishable on purpose
malformed :date (not YYYY-MM-DD, or an impossible day) 400 VALIDATION_FAILED
any office/teacher session 403 on all three routes

5.9 Justifications — /attendance/family/justifications

New 2026-08-04, additive. The family's write path: a referent submits a justification or announcement for a linked student. Submitting is final — there is no edit, no delete, no school-side approval step. What it does is immediate and automatic:

  • every matching open event resolves on the spot (ACKNOWLEDGED, ackChannel: "APP") — it leaves the to-justify list and the office worklist in the same instant;
  • a day announced in advance never produces anything to chase: when the register later records the absence, the event is born NOT_REQUIRED and no surface ever shows it as owed;
  • the submission appears in the justifications[] block of every student-day surface — the admin/teacher row (§5.3), the write envelope's records[].row, and the family day view (§5.8) — from the moment it lands.

Matching is by kind and date, not by picking an event: an ABSENCE covers only ABSENT events on its days, a LATE_ENTRY covers only LATE_ENTRY, an EARLY_EXIT covers nothing (it is informational — no event type exists for early exits). To "justify" an item from the to-justify list, pre-fill the form from the item's studentId + date + eventType and submit — there is no event id in the request. If an advance announcement and the later register produce different ABSENT/LATE_ENTRY types, the recorded type creates a new to-justify item after the arm window.

Not every justification is family-submitted (2026-08-10). Every row on this surface carries channel: APP for a family submission, PHONE / IN_PERSON for one the office ack recorded on the family's behalf (§5.5). On those rows submittedByName is a staff name — badge them off channel, never off the name.

POST /attendance/family/justifications

// ABSENCE — a date range + reason, no time:
{ "studentId": "…", "kind": "ABSENCE",
  "startDate": "2026-09-10", "endDate": "2026-09-12", "reason": "influenza" }

// LATE_ENTRY — one day + time + reason (endDate omitted or equal):
{ "studentId": "…", "kind": "LATE_ENTRY",
  "startDate": "2026-09-10", "time": "09:15", "reason": "medical appointment" }

// EARLY_EXIT — one day + time; reason optional (the pickup-person selector
// was removed by product decision 2026-08-10):
{ "studentId": "…", "kind": "EARLY_EXIT",
  "startDate": "2026-09-10", "time": "14:00" }

The kind-conditional shape is validated strictly — mirror it in the form:

Field ABSENCE LATE_ENTRY EARLY_EXIT
endDate required, ≥ startDate omit (or equal to startDate) omit (or equal)
time (HH:mm) forbidden required required
reason required required optional

Any mismatch is a 400 VALIDATION_FAILED, as is a range outside the school year.

201 answers the full JustificationDto:

{
  "id": "…", "studentId": "…", "kind": "ABSENCE",
  "startDate": "2026-09-10", "endDate": "2026-09-12",
  "time": null, "reason": "influenza",
  "attachments": [],                       // { fileId, fileName }[]
  "submittedByName": "Maria Rossi",        // snapshotted at submit
  "submittedAt": "2026-09-09T18:00:00.000Z",
  "channel": "APP"                         // APP | PHONE | IN_PERSON — see below
}

Who may call it: a referent whose link to the student allows writing (canWrite) — a read-only link is 403, an unlinked student is a hidden 404. A student session cannot submit at all (403): self-justification is not in this release.

GET /attendance/family/justifications?page=&limit=&studentId=

The caller's submissions this school year, newest first, paginated ({ data, meta }); studentId narrows to one child. Students see their own. Each row is the same JustificationDto, attachments included.

Attachments — 16 to upload, 17/18 to download

POST /attendance/family/justifications/:id/filesmultipart/form-data, field name file, one file per call, up to 5 per justification, on ABSENCE and LATE_ENTRY only (an EARLY_EXIT announcement takes none — 422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED; the 6th file is 409 JUSTIFICATION_ATTACHMENT_LIMIT). Same writer gate as the POST. Files are evidence only — uploading one changes no event; matching already happened at submit. A medical certificate arriving days later is the expected flow.

Downloads mint a signed URL ({ url, fileName, mimeType, byteSize, expiresAt } — fetch the URL directly, it expires):

  • 17, family side (/attendance/family/justifications/:id/files/:fileId) — referent (linked students) and student (self).
  • 18, school side (/attendance/justifications/:id/files/:fileId — note: not under /family) — office roles reach every justification's files; a teacher only those of students they currently teach (anyone else's is a hidden 404, not a 403). Family sessions get 403 here — they have route 17.

The justifications[] row block (what §5.3's rows and §5.8's day view nest) is the JustificationDto minus studentId — id, kind, range, time, reason, attachments, submitter, submittedAt. Render it beside the register cells; correlate with events via dayEvents[].justificationId when you want to show which submission answered an event.

Errors on this surface

Case Answer
justification the caller may not see — wrong family, or nonexistent 404 JUSTIFICATION_NOT_FOUND, indistinguishable on purpose
unlinked studentId in the POST body 404 NOT_FOUND (same hiding as §5.8)
read-only referent link (canWrite false) 403
student session on 14/16 403 (no justify action)
kind-shape mismatch, out-of-year range 400 VALIDATION_FAILED
file on an EARLY_EXIT 422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED
6th file 409 JUSTIFICATION_ATTACHMENT_LIMIT
teacher downloading a non-taught student's file (18) 404 JUSTIFICATION_NOT_FOUND

6. The writes

6.1 POST /attendance/records — write a day register

One call creates and updates. An entry whose (student, slot) already has a cell updates it; the rest are created. There is no separate create/update decision for you to make.

{
  "date": "2026-07-25",
  "cohortType": "HOMEROOM",
  "cohortId": "…",
  "unitIds": ["…"],                       // optional, see §6.3
  "entries": [
    {
      "studentId": "…",
      "anchor": { "subjectGroupId": "…", "startTick": 108 },
      "status": "LATE_ENTRY",
      "time": "09:15",                     // only on LATE_ENTRY / EARLY_EXIT
      "note": null                         // forbidden on PRESENT / ABSENT
    }
  ]
}

The batch is single-group but multi-slot. Every entry's student must belong to the declared cohortType + cohortId, but the entries may span as many slots of that group's day as the screen shows — each entry carries its own anchor. This matches how the day screen actually saves: whatever the admin touched.

The declared cohort may be any of the four kinds (§1.5, widened 2026-08-02). Writing from a subject-group screen, declare the SUBJECT_GROUP (or COMBINED_CLASS) cohort — its roster legitimately spans classes, so declaring a HOMEROOM would reject the batch at gate 4. An unknown teaching-unit id is a 404 NOT_FOUND.

BREAKING 2026-08-03 — a teacher may only declare a cohort of their own. The cohort is checked against the caller before the batch's data is looked at (gate 1 below), by the same rule that governs the reads (§3.1): a teaching unit they teach on date, or a class they tutor. GRADE_GROUP by a teacher is now always 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT, and so is any group that is not theirs. Admins, secretaries and principals are unaffected — every cohort stays open to them.

Two practical consequences:

  • Writing at an activity, a supervising teacher has no teaching-unit cohort to declare (an activity has no subject group). Declare the class you tutor if the students are yours; otherwise the write belongs to the office.
  • Nothing is written on refusal. The fence precedes the transaction, so a 403 here never leaves a partial batch behind — you may retry the whole payload against a different cohort as-is.

Rules:

  • entries needs ≥ 1 item. An empty batch asserts nothing but would still freeze the day's shape and answer with an envelope — a write that reads like a no-op and is not one. Rejected.
  • Entries must be unique per (student, startTick) — tightened 2026-07-29, previously per (student, anchor). The register holds one fact per (student, period), whatever teaching unit it is asserted through, so a batch carrying a lesson entry and an overlapping activity entry for the same student at the same tick is a 400 — before, the two silently collapsed onto one cell. See §6.6 for the sequential case.
  • An anchor carries exactly one teaching unitsubjectGroupIdcombinedClassIdactivityId. Every multi-anchor combination is a 400 (also tightened 2026-07-29: combinedClassId+activityId used to slip through). Clients echoing minted anchors are unaffected.
  • Anchors are backend-minted. Every register read hands them out on its items. Echo the object back verbatim. A hand-assembled anchor whose tick is not the slot's own is a 404, by design — it means somebody built it rather than copied it.
  • Nothing is written unless every entry passes every gate. The write is atomic: one rejected entry rejects the batch.
  • AttendanceStatus values: PRESENT, ABSENT, EARLY_EXIT, LATE_ENTRY, FIELD_TRIP, DAY_TRIP, SUSPENDED.

The order the gates fire in, because it determines which error you see first:

# Gate Failure
1 The declared cohort is inside the management department fence or an independently authorized teacher cohort 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT
2 A past date needs management write (tenant-wide or matching Department Principal); teacher-only authority is read-only 422 ATTENDANCE_PAST_DATE_LOCKED
3 A schedule source governs the date 409 ATTENDANCE_NO_PUBLISHED_TIMETABLE
4 Every entry's student is in the declared cohort 422 ATTENDANCE_ENTRIES_SPAN_GROUPS
5 Each distinct anchor resolves to a real slot of that day 404 SCHEDULED_LESSON_NOT_FOUND / SCHEDULED_ACTIVITY_NOT_FOUND
6 Every unitIds entry belongs to a subject in the batch 422 ATTENDANCE_UNIT_NOT_IN_SUBJECT
7 Student is on that slot's roster 422 ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER
8 Status/time/note legality (§7) 400 VALIDATION_FAILED
9 Caller is authorized for that student (§10) 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT
10 The date is a school day for that department 422 ATTENDANCE_NOT_A_SCHOOL_DAY

There is no future-date gate since 2026-08-04 — the window only closes backwards (gate 2), and ATTENDANCE_FUTURE_DATE no longer exists.

Gate 4 runs before gate 7 on purpose: a mixed batch would otherwise fail as "not on this lesson's roster", which names the wrong problem.

Gate 1 runs before everything, including the date checks. That is deliberate: it is the only gate about the caller rather than the data, and "this group is not yours" is a better first answer than "that date is not a school day", which invites a retry of a request that was never the caller's to make. If you see this 403 on a payload that used to work, the fix is the cohort, not the entries.

On success every written cell comes back with its student's refreshed day row (§6.4) — in DAILY that is how the rest of the day re-projects without a reload.

6.2 PATCH /attendance/records/:id — correct one cell

{ "status": "PRESENT", "time": null, "note": null }
  • The three value fields are optional, but at least one must be present — an empty PATCH would re-stamp provenance and emit an audit event for a change that never happened. 400. (A body carrying only the cohort echo below is "empty" in this sense and also 400s.)
  • time and note accept explicit null to clear them. Omitting a field leaves it as it was — not the same thing. status may not be null.
  • A status change coerces rather than refuses. Changing to a status that cannot carry a time or a note clears those fields instead of rejecting the call. You do not need to null them out yourself.
  • The authority check re-runs (§10); denial is 403. A cell you cannot see is 404.
  • The response carries the cell's re-projected day (records[0].row, §6.4), built on the record's own date. Correcting 08:00 from ABSENT to LATE_ENTRY re-projects 09:00 from ABSENT to PRESENT, and you learn that from this one response — which is the whole point on a click-and-save screen.
  • Cohort echo (optional, 2026-08-02): add cohortType + cohortId (both or neither, any of the five kinds) to have the envelope's group card (§6.4) recounted over that group — so a screen anchored on a subject group refreshes from the one response. The cell's student must belong to the named cohort on the cell's own date, else 422 ATTENDANCE_ENTRIES_SPAN_GROUPS.
  • BREAKING 2026-08-03 — the echo-less default is now caller-dependent, and the echo is fenced.
Caller Default group when no echo is sent
admin / secretary / principal the cell's own class (HOMEROOM, or the grade group) — unchanged
teacher the cell's own unit: COMBINED_CLASS if the cell was taken at one, SUBJECT_GROUP for a plain lesson, or (2026-08-12) ACTIVITY for an activity cell — previously an activity cell fell back to the class

The teaching unit is the group the teacher's screen is anchored on, and the only cohort shape they are allowed to read back — the old default handed them a class card they had no right to open.

The echo itself is checked the same way as a batch write's cohort: a teacher echoing a group that is not theirs gets 403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT before the cell is updated. Note the ordering — the cohort 403 fires before the roster 422, so a bad echo that is also not the caller's group reports the 403.

What to change: if your teacher screen relied on the echo-less default returning a class card, send the echo explicitly. If it is a subject-group screen, you were already sending it and nothing changes.

6.3 unitIds — the observed unit-coverage tag

When a lesson's subject is taught in units (a container subject), the write accepts an optional session-level unitIds, recording which units were actually covered.

  • It is batch-level, but a unit belongs to one subject: every id must be a unit of some subject in the batch, and each cell keeps only the units of its own subject.
  • It is frozen at creation. Re-writing a cell updates the value and lastModifiedBy only — the tag does not move. There is no way to re-tag.
  • Never set on activity cells (an activity has no subject). A leaf subject or an untagged write leaves the arrays empty.

Cells expose it as coveredUnitIds / coveredUnitNames — on the write envelope's cells, on /rows (RECORDED items and register[] entries, since 2026-07-30), and on the history endpoint's current.

6.4 The write envelope — refresh from it, do not re-fetch

Both POST and PATCH answer with the same envelope:

{
  "records": [ /* one per submitted entry, in submission order — each with its `row` */ ],
  "group": { /* the written group's card, recounted — null only if it no longer resolves */ },
  "inconsistencies": [ /* the group's contradictions on this date, AFTER the write */ ],
  "followUps": [ /* the group's open follow-ups across every unresolved day — capped at 50 */ ]
}

Refresh all four widgets from this one response. Re-fetching /groups after a save is wasted work and can show a stale count.

records[].row — the re-projected day (new 2026-08-03)

Every written cell carries row: that cell's student, for the whole date, after the write.

{
  "records": [
    {
      /* …the cell: id, status, time, note, full snapshot, provenance… */
      "row": {
        "studentId": "…", "firstName": "Anna", "lastName": "Rossi",
        "identificationCode": "S-0042",
        "classifications": ["lateEntry"],
        "items": [ /* the day strip — every slot's displayed status + source */ ],
        "register": [ /* the day summary cells, oldest first */ ],
        "dayEvents": []
      }
    }
  ]
}

It is the whole-day form of the same row type GET /attendance/rows returns per student (§5.3) — same field meanings, while a rows request carrying startTick slices only items[]. Feed it to the row renderer you already have.

How to apply it. Key on row.studentId and replace the whole row:

function applyWriteEnvelope(state, res) {
  // The three group widgets are already recounted for you.
  state.group           = res.group;
  state.inconsistencies = res.inconsistencies;
  state.followUps       = res.followUps;

  for (const cell of res.records) {
    if (cell.row === null) return refetchRows();   // group no longer resolves
    const i = state.students.findIndex(s => s.studentId === cell.row.studentId);
    if (i !== -1) state.students[i] = cell.row;    // whole-row REPLACE
    // i === -1 → that student is not on this screen; ignore the row.
  }
}

Four rules that make the difference between this working and not:

  1. Replace, never deep-merge. A field-by-field merge re-introduces the bug the field exists to fix: after one save, slots you never touched legitimately change status, source and record — some gaining a projection, some losing one. Only a wholesale swap of items[] is correct.
  2. Only the students the write touched are present. Every other row in your state is still accurate — do not clear, blank or re-fetch them.
  3. Do not render records[i] into the strip. That object is the write receipt (the full frozen snapshot); the strip renders row.items[] and the day summary renders row.register[].
  4. Keep the whole row in state even if your screen shows one slot. The projection is a property of the day; filter at render time by items[].startTick, not by throwing the rest of the row away.

What it looks like in DAILY — the case that used to need a reload. One ABSENT taken at 08:00, then PATCH that cell to LATE_ENTRY 08:15:

Slot before the PATCH in records[0].row
08:00 ABSENT · RECORDED · record.id: "rec1" LATE_ENTRY · RECORDED · record.id: "rec1"
09:00 ABSENT · PROJECTED · record.id: null PRESENT · PROJECTED · record.id: null
10:00 ABSENT · PROJECTED · record.id: null PRESENT · PROJECTED · record.id: null

classifications flips to ["lateEntry"] and register[] carries the single LATE_ENTRY cell. You changed one slot; three re-render, from one response.

(Remember the three display states of §1.4: source: null is not taken, which is not ABSENT.)

Why it exists: in a DAILY department the register is projected at read time (§1.3), so writing one cell changes what every other slot of that student's day displays. Before this field the response could not express that — a projected slot is a status with no row behind it — and clients had to reload the group after each save. A click-and-save screen now reads records[0].row and is done.

Four things to know:

  • It is the student's own day, not the cohort's slice. The strip covers every slot that student attends, including slots of other groups and other teachers. So it is correct when your screen is a subject group whose roster spans classes, and on PATCH whether or not you sent the cohort echo.
  • A student written at two slots in one batch gets the identical row on both cells. They are byte-identical (one snapshot), so applying either is the same; do not treat the repetition as a signal.
  • The written cell appears three times, as three different views: records[i] (the full snapshot cell), row.items[k].record (the lean item view — no snapshot columns), row.register[j] (the day-summary view). They are different projections of one cell, not copies. Never diff them.
  • row is null exactly when group is null (the group no longer resolves — a deleted class, a moved student). Fall back to a re-fetch.

Note this does not refresh a teacher's agenda counters: recorded on GET /attendance/teacher-day (§5.7) lives on a different screen and still wants a re-fetch when the user navigates back to it.

followUps is capped at 50 open items (oldest day first), with no accompanying meta. A group with more than 50 open follow-ups shows the oldest 50 here; the full paginated list is GET /attendance/follow-ups with the cohort filter.

AttendanceCellDto is the full snapshot row: status/time/note, date, startTick, periodStartTime/periodEndTime, attendanceMode, the student's name + identification code, department/grade/homeroom ids and names, subject-group / combined-class / activity ids and names, curriculumSubjectId, subject name + code, coveredUnitIds/Names, room id + name, lessonTeacherIds/Names, and provenance (recordedByUserId/Name/At, lastModifiedByUserId/Name/At).

Every one of those context columns is a snapshot, written once and never re-synced. If a subject is renamed or a teacher reassigned next month, this row keeps the old names — by design. It is the legal record of what was true on the day, not a live join. Do not build a "refresh snapshot" feature; if you need current context, read it from the live entity. The one exception is a cross-anchor re-assertion (§6.6), which replaces the whole snapshot because the cell now records a different assertion.

6.5 Combined classes and activities

Combined (co-taught) class — one register over the union roster. The anchor uses combinedClassId instead of subjectGroupId. Each stored cell stamps the student's own contributing section into the subject/grade/ department columns (so per-subject reports never see the combination), while room, union teachers and period bounds come from the shared meeting, plus combinedClassId/combinedClassName. Authorization is union-wide: a teacher of any member writes the whole meeting.

Activity — behaves exactly like a subject group; there is no opt-out. The anchor is { activityId, startTick }. The roster is the activity's resolved audience; a student outside it is a 422. Activity cells set activityId/activityName and leave the subject columns null; room and teachers come from the activity's room and supervisors assigned on that weekday (who are therefore also authorized to write it). Because an activity roster can span departments, attendanceMode and the department/grade columns resolve from the student's own record, not from the slot.

6.6 One tick, one fact — cross-anchor re-assertion

A student rostered on a lesson and an overlapping activity at the same tick has one cell there — cell identity is (student, date, startTick) and the anchor is not part of it. Three consequences:

  • In one batch, asserting that cell through both anchors is a 400 (§6.1).
  • Sequentially, re-asserting the cell through the other anchor supersedes it (ruling 2026-07-29): the write updates the one cell — never a duplicate — and replaces its whole recorded context with the new assertion's (anchor ids + names, subject columns, teachers, bounds, coveredUnit*). After a lesson cell is re-asserted from the overlapping activity, the row reads as an activity cell: activityId set, subject columns null, that weekday's activity supervisors as its teachers. recordedBy* keeps naming the first recorder; lastModifiedBy* names the re-asserter. A same-anchor re-write still moves the value only.
  • The superseded context is not lost: the history endpoint's re-anchoring event carries { "anchorKey": "act:…", "previousAnchorKey": "sg:…" } in its data, so a timeline can say "recorded at Math, re-asserted at Assembly".

On /rows, both same-tick items surface the one stored cell (same record id on both). Its names/labels are the latest assertion's — do not be surprised when a cell first taken at the lesson starts rendering with the activity's label after a re-assert.


7. Status field rules

Two invariants, both 400 VALIDATION_FAILED on a batch write (and silently coerced on PATCH, §6.2). Mirror them client-side so the user never hits them:

Field Allowed on Rule
time (HH:mm) EARLY_EXIT, LATE_ENTRY only It is the moment of a transition. A whole-day state has no time.
note anything except PRESENT and ABSENT A note implies a qualified status. Plain present/absent carries none.
  • time must be a real 24-hour HH:mm. 24:00 and 11:60 are rejected.
  • SUSPENDED is qualified but not a transition: it takes a note, never a time.
  • Blank text is not a note. "" and " " normalize to null. Send null to clear.
  • There is no justification field on a cell. Justification moved to the family communication loop, where an acknowledgement is a family-side fact on the day event.

8. Counters and classifications

8.1 The seven buckets overlap

counters (on group cards and board totals) and classifications (per student row) use the same seven buckets:

notTaken, suspended, trip, absent, lateEntry, earlyExit, present

A student is counted in every bucket their day matches. Someone who arrived late and left early is counted under both lateEntry and earlyExit.

The seven therefore do NOT sum to numStudents. Anything rendering them as a stacked bar, a donut, or an "X of Y" derived from their sum is wrong.

What does hold:

  • numStudents is the denominator. Always. Never the sum.
  • notTaken is the one disjoint bucket, so numStudents - notTaken = students with a register.
  • Every student contributes at least one bucket; classifications is never empty. An untaken register is exactly ["notTaken"].

8.2 What each bucket means

Bucket Meaning
notTaken Nothing authored for this student all day.
suspended A SUSPENDED cell anywhere in the day. Exclusive — replaces the others.
trip A FIELD_TRIP or DAY_TRIP cell anywhere. Exclusive — replaces the others.
absent Every authored cell is ABSENT — the student never appeared.
lateEntry A LATE_ENTRY cell anywhere, whether or not they also left early.
earlyExit An EARLY_EXIT cell anywhere, whether or not they arrived late.
present A PRESENT cell and no transition.

Two rules that look like bugs and are not:

  • absent means never appeared, not "has an absent cell". In DAILY mode the day often opens with an ABSENT baseline that a later arrival supersedes. Tallying raw statuses would count that student as an absence and a late arrival on the same day.
  • Trips and suspension are 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.

Classifications are computed from authored cells only, never from the projection — the projection is a rendering device; the day is what people actually asserted.


9. Inconsistencies

A register can contradict itself. The backend walks each student's authored cells 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 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. 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.

Render message.en_US / message.it_IT as given. The copy is localized backend-side; do not build your own mapping from the code. Treat the enum as open — switch on what you know, fall through gracefully.

An inconsistency is a warning, not a rejection: the write succeeds and the contradiction shows up in the envelope's inconsistencies[]. It is the admin's job to decide which of the two cells is wrong.


10. Write authority — the Y-set

BREAKING 2026-08-02 — the Y-set narrowed from three clauses to two. The former clause 3, "teaches that student in any lesson that same day", is deleted: a teacher teaching any lesson of the day must not modify previous or following slots that are not theirs. Writes that used to succeed through it now return 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT. If you built any affordance on the old allowance ("record what you witnessed into a colleague's slot"), remove it — the witnessed event is now the slot teacher's, the tutor's, or a school-wide writer's to record.

A teacher may write one student's cell if either:

  1. they are a teacher of the targeted slot (activity supervisors assigned on that weekday count), or
  2. they are the student's homeroom tutor.

A management writer bypasses the teacher Y-set tenant-wide (admin, Front Office, Director, platform admin) or only for cells in qualified departments (Department Principal).

FE implications

  • A teacher-day card is writable by construction — clause 1 covers exactly the slots GET /attendance/teacher-day lists (§5.7), plus the tutor's homeroom. Anything outside those will 403; do not offer it.
  • A 403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT on a batch rejects the whole batch. For a teacher UI, prefer smaller batches (one slot at a time) so one unauthorized student does not lose the rest of their work.
  • The check re-runs on PATCH, against the cell's stored teacher snapshot (the taker keeps editing their own cell) plus the homeroom membership on the cell's own date — so editing an old cell can legitimately be denied even when creating it was allowed, and vice versa.
  • The homeroom tutor's authority is the current tutor's, applied historically: a tutor change re-scopes past-day write authority to the new tutor. This is a deliberate v1 ruling, not a bug.
  • Clause 1 is answered as of the cell's own date (E): who taught the slot then. It flips in both directions, which is the part to get right in copy and in QA:
  • a teacher whose assignment closed before the date keeps back-fill authority over it — they did teach that day;
  • a teacher added after the date does not gain authority over it — they did not.

So an exited teacher can still correct their own February register, and a newly assigned one cannot reach back into it. Do not build a "current teacher of the course" check in the FE to predict this; the server's answer depends on the date and is the only correct one.


11. The temporal model — the part that will bite you

This is the section to read twice. Attendance is the one module where "now" is not a simple idea.

11.1 The clock is the school's, not the browser's

Every date comparison in this module — is this date in the future, has this slot started, is this day past — is asked in the school's timezone (School.timezone, an IANA zone; a school with none falls back to UTC).

Consequences you must handle:

  • "Today" is a server-side fact. Do not compute new Date() in the browser and assume the backend agrees. West of the school's zone your browser can be on the previous day; east of it, on the next. Take the day from the response (date) or from the user's explicit selection.
  • toRegister climbing through the day is measured on the school clock, so a user in another timezone sees the school's progression, not their own.
  • A write for what your browser calls today can legitimately be rejected as a future date, and vice versa. Surface the error rather than pre-validating.

11.2 Past days render against the schedule that was in force then

The naive design renders a past day against whatever timetable is published now. That is wrong, and it used to happen: publish a revision that moves Monday's maths lesson and last February's register moved with it — the board re-drew onto ticks the day never had, and back-filling a missed cell at the old tick failed.

Now the timetable carries valid time, and attendance resolves the version that was in force on the date you asked about. Past boards no longer move when the timetable is republished.

Since E, both halves of a past day are dated: the schedule comes from the version in force then, and the rosters, teachers and audiences come from the memberships in force then. A past day is composed from data rather than read from a stored copy, so it is stable by construction — nothing needs to have been frozen for it to be right, and nothing you do today can move it.

Practical consequences:

  • Back-filling a past register is fully supported and is the commonest admin correction. It keeps working after a revision has moved or deleted the lesson; the cell names the lesson, room, teachers and roster as they were.
  • The roster gate on a back-fill is that day's roster. A student who joined the group last week cannot be recorded into a February lesson; one who has since left still can.
  • An anchor that was not on that day is the ordinary 404. There is no "unrecoverable day" case any more: ATTENDANCE_DAY_SHAPE_UNAVAILABLE was deleted on 2026-07-29 (see §12).
  • A date no timetable version covers resolves to an empty day: reads render it with nothing owed, writes 409. Reads persist nothing and never 409.

11.3 completeness — REMOVED

Breaking change, 2026-07-29: the completeness field is gone from every response. It used to appear on /groups, /groups/pending, /rows, /inconsistencies and the write envelope, carrying SCHEDULE_ONLY, RECONSTRUCTED or a reserved FULL.

What to do: delete any read of completeness and any UI branching on it — the "approximate history" badge on old dates has nothing left to key on. No other field changed, and nothing replaces it.

Why it went, so nobody re-adds it: the marker covered one transitional window — the schedule had become historical while rosters were still resolved as of now, and a board silent about that would have been dishonest. Spec E closes the window by making rosters dated too, so the only remaining value would be FULL, i.e. a constant. Its sibling RECONSTRUCTED was already unreachable: it keyed on a per-tenant "start of recorded history" stamp, and this module has never been released, so no date precedes one.

What FULL was going to promise is now simply how every response behaves — schedule and rosters resolve as of the date you asked about. There is nothing to badge, because there is no lesser case.

11.4 Roster resolution — how "who is in this group" is decided

Attendance never stores a roster. It resolves one, and there are three different resolutions in play:

Question Resolved from
Who is in this group (class / grade group) on the date I asked about? Class membership + enrolment placement effective on that date.
Who was on this slot on that date? Course membership effective on that date, intersected with the students enrolled-and-placed on it.
Who attends which slots in a student's strip? The timetable version in force on that date, crossed with the same dated memberships (and, for activities, audience selectors re-expanded against that date).

All three rows are now the date you asked about. This is the reversal to brief QA on, and the previous version of this guide said the opposite for the first row:

A historical board shows each student in the class they were in then. A student who moved from 2A to 2B in January appears under 2A for every day before the move and under 2B from it — on the board, in the rows, in the counters, and in the owed-cell arithmetic.

So "the register for last term" is now genuinely last term's register, not last term's schedule drawn onto today's classes. Two things follow for the FE:

  • Do not cache a group's roster across dates. The roster is a function of the date, so a component that fetched /rows for today cannot reuse that roster to render a different day.
  • Do not reconcile the board against a roster endpoint. GET /homerooms/:id answers "who is in this class today"; the board for an old date will legitimately disagree, and the board is right for its date.

One deliberate exception, already noted in §5.5: the follow-up worklist stays as-of-today, because it is about who must act now.

Class and course memberships, and the student's own enrolment placement, are themselves valid-time data — a change can be scheduled for a future date, and the current answer is the one effective today, not the one "last written". That machinery lives on the students / homerooms / subject-groups endpoints, not on attendance, but attendance consumes its output, so §11.6 writes it out in full.

The attendance-visible consequence: a roster or placement change scheduled for tomorrow does not affect today's register. Today's board keeps answering the pre-change membership until the boundary date arrives, and then flips with nothing running. The mirror also holds, and is the new part: a change that took effect in January does not reach back before January.

Teacher assignments and combined-class membership are valid-time too (C3), and since E they are read as of the date on every attendance surface — see §10 clause 3 for the authorization consequences in both directions, and §11.6(c) for the scheduling behaviour on their own endpoints.

One field is deliberately live, not dated: the class tutor. Homeroom.homeroomTeacherId has no interval in v1, so the current tutor holds pastoral write authority over the class's whole year and a tutor change re-scopes past-day authority to the new tutor. The rule is: identity live, membership dated. Teacher display names follow the same split — the spelling on a resolved slot is current, the set of teachers is as of the date.

11.5 Why a day can have cards but owe nothing

Several legitimate states produce a board that renders but owes zero:

State schoolDay toRegister Write
Ordinary school day true a number works — past dates require management authority; Department Principals remain limited to assigned departments
Holiday / closure / outside the calendar false null or 0 422 ATTENDANCE_NOT_A_SCHOOL_DAY
Future school day true null (nothing owed yet) works for every writer (2026-08-04) — the register can be filled in advance
No timetable version covers the date (tenant mid-setup, or before the first publication) false null 409 ATTENDANCE_NO_PUBLISHED_TIMETABLE

Handle the read side of all four as empty states, not as errors. Only the write column errors. The fifth row this table used to carry — "past date, shape lost entirely" → 422 ATTENDANCE_DAY_SHAPE_UNAVAILABLE — is gone: since E a past day is composed rather than recalled, so there is no shape to lose.

11.6 The dated facts attendance reads from other modules

Attendance owns no schedule, no roster and no enrolment. It reads three other modules' dated data, and their behaviour leaks into what the register shows. This subsection writes those contracts out in full so you never have to leave this file. The endpoints below are not attendance endpoints — they are listed because their output is what the register renders.

(a) The timetable is a dated publication

Publishing a timetable is a dated act, not an immediate one.

  • TimetableResponseDto carries effectiveFrom (YYYY-MM-DD | null): the first day this timetable governs. null while it is a draft — a draft governs nothing.
  • By default a publish takes effect the next calendar day, in the school's timezone — not the moment the request returns. The one exception is the academic year's very first publication, which takes effect today. A UI that says "Published" and shows the new schedule as live is wrong for the rest of the day; confirm with the date instead ("Takes effect Monday 28 July").
  • Both publish routes (PUT /timetables/:id/status with status: PUBLISHED, and POST /timetables/:id/publish-revision) accept an optional effectiveFrom (YYYY-MM-DD, strict — a datetime is rejected), and answer with both effectiveFrom and appliedFrom carrying the same value. appliedFrom is the platform-wide name for "the date this write took effect", so a shared "when does this take effect" component can key on it across modules. An unpublish response carries no appliedFrom, and sending effectiveFrom on an unpublish is a 400.
  • The date rules: omitted → next calendar day (or today for the year's first publication); a future date → accepted as sent; today → accepted only while no attendance has been recorded today; a past date → rejected. If a version is already scheduled to start on a future date, the earliest date you can pick is that date, not tomorrow.
  • 422 TIMETABLE_EFFECTIVE_DATE_INVALID — the requested date is not available. Two reasons, both recoverable by taking the earliest allowed day: the date is before the minimum, or attendance already exists today so today is gone. The localized message names the day to use.
  • 409 TEMPORAL_ROW_IMMUTABLE on DELETE /timetables/:id once the lineage has governed at least one lived school day (a day at or after the year's calendar start — a version published and even superseded entirely pre-year never blocks, 2026-07-30). There is no force-delete. Do not infer deletability from effectiveFrom — after an unpublish the returned draft has effectiveFrom: null while its retained history still makes the lineage immutable. Treat the 409 as the authoritative answer, and offer Unpublish ("stop governing future days", preserves history) as the separate action.
  • Unpublish retains. The version that was live keeps governing through today; what you get back is a draft with effectiveFrom: null. Unpublishing does not erase today's schedule from the register's point of view, and re-publishing starts a new interval (tomorrow by default).
  • Republish retains too. A revision does not overwrite its predecessor: the outgoing version is kept as an archived row of the same lineage. The published timetable's id stays stable across a republish, and GET /timetables still returns one row per lineage — retained versions stay hidden from the list.

That retention is precisely why attendance can render a past day honestly (§11.2), and why status is never the attendance oracle: after an unpublish, an archived version legitimately governs today.

(b) A student's enrolment placement is a history of episodes

A student's department and grade used to be two columns, so a move took effect the instant it was saved. Enrolment is now a history of dated episodes.

On the student's assignment scope:

Field Type Where
department / grade objects every read — they mean "where is this student today"
exitDate string \| null every read and write response — the day they left or graduated
appliedFrom string \| null write responses only (POST /students, PATCH /students/:id); always null on reads
pendingPlacement { departmentId, gradeId, effectiveFrom } \| null detail only (GET /students/:id and write responses); always null on list rows

PATCH /students/:id accepts an optional assignment.validFrom (YYYY-MM-DD). Omitting it is the normal case, and since 2026-07-30 the default is data-sensitive: today — the move is current in the same read and pendingPlacement stays null — for as long as no attendance has been recorded for that student today (always the case pre-year); once a cell exists, the omitted date resolves to the next school day and the move books instead. An explicit today applies to the whole of today and is refused if attendance already exists for that student today; a future date schedules the change; a past date is refused. validFrom only means something alongside a change it can date (a move, or a status transition into/out of LEFT/GRADUATED) — sent alone it is a 400.

The Friday → Monday story, which is the part to brief QA on — it is now the register-already-taken story, not the default: it happens when the move is recorded dateless after attendance was taken for the student that day (or with an explicit future date). Record a move on Friday effective Monday, and for the rest of Friday, everywhere: the student's row shows the old grade and department; the students-table department and grade filters still find them under the old cohort and not the new one; a colleague scoped to the destination department cannot see them yet while one scoped to the source still can; and the attendance register lists them with their old class. On Monday, with no job and nobody pressing anything, every one of those answers flips and pendingPlacement becomes null. On a clean day none of this happens — the dateless move is simply current.

Two consequences: a list cached on Friday is stale on Monday in a way no write invalidates (refetch across a date boundary on long-lived views), and "I moved them and nothing happened" is the expected user report — pendingPlacement is what you show to explain it. Render it as a banner ("Moving to 2B on 7 September"), never as the student's grade.

Also relevant to the register: DELETE /students/:id now refuses any student whose enrolment has covered a lived school day (409 TEMPORAL_ROW_IMMUTABLE) — use status: LEFT or GRADUATED instead. Delete survives for a record that has not lived a day: future-dated setup mistakes, and — since 2026-07-30 — anything in a year whose calendar has not started, enrolments effective today included.

One known interim behaviour: a student marked LEFT or GRADUATED disappears from the attendance register immediately, because attendance follows the enrolment episode — but may still appear on class and course rosters until the membership release lands. Expected, time-boxed, not a bug.

(c) Class rosters, course rosters, curriculum selection — and who teaches — are dated

Adding a student to a class takes effect on a date — by default today, sliding to the next school day only once today's register has been taken for whoever the command affects (§7 data-sensitive default, 2026-07-30). Removing them does not delete the membership — it ends it, so the register for the days they attended still shows them.

The write side: an optional validFrom (YYYY-MM-DD) on POST /homerooms, POST|DELETE /homerooms/:id/students, POST /homerooms/:id/students/move, the equivalent subject-group commands (POST /subject-groups plus /students, /students/remove, /students/move), POST /subject-groups/from-homeroom (one date for the whole batch), PATCH /students/:id/curriculum-selection (nested as assignment.validFrom), and POST /students/curriculum-selections/bulk-assign.

Five rules, identical everywhere (the omitted row split on 2026-07-30):

You send What happens
omitted, no attendance yet today for anyone affected Today — immediately visible in students[] / teachers[], nothing pending. Pure additions are ALWAYS this row (they affect no existing register), and so is everything before the department's calendar starts. The normal case — do not send a date unless the user chose one.
omitted, attendance already recorded today for someone affected The next school day for the group's (department, grade) — the change books instead of refusing, and shows up in the pending arrays. appliedFrom tells you which row you got.
today Applies to the whole current day. Refused 422 TEMPORAL_BACKDATE_FORBIDDEN if attendance was already recorded today for anyone affected — the explicit path still refuses where the dateless one slides.
a future date The change is scheduled — it does not appear in the roster until that day.
a past date Refused 422 TEMPORAL_BACKDATE_FORBIDDEN.

The read side, two additive fields:

  • appliedFrom on every one of those writes — the date the change actually takes effect. Show it. On a move both sides share it. The bulk curriculum-selection endpoint keeps its list shape: applications[] (the date used per student) — since 2026-07-30 an omitted validFrom is simply today for every student (a bulk assign only creates brand-new selections), and appliedFrom is present when one date covers everyone.
  • pendingChanges[] on GET /homerooms/:id, GET /subject-groups/:id and the roster writes — always present, possibly empty:
"pendingChanges": [
  { "studentId": "uuid", "firstName": "Alice", "lastName": "Rossi", "kind": "ADDITION", "on": "2026-09-14" },
  { "studentId": "uuid", "firstName": "Bruno", "lastName": "Bianchi", "kind": "REMOVAL",  "on": "2026-09-14" }
]

A booked move appears as a REMOVAL on the source group and an ADDITION on the target, both on the same date. A cancelled plan produces no entry. students[] still means "who belongs today" — a student booked to join next Monday is in pendingChanges, not in students[].

  • pendingSelection on GET/PATCH /students/:id/curriculum-selectionnull when nothing is scheduled, otherwise the complete future selection (curriculum, track, trackCarried, choices) plus effectiveFrom. Because it is complete you can render and edit it directly, and it survives a refresh.

At most one scheduled change may be outstanding per student per group family — kept so by replacement, never refusal (2026-07-30). Re-issuing the same command with a new validFrom re-dates the booked change; issuing the opposite command cancels it (re-adding a student booked to leave cancels the departure); and a command that genuinely collides with the booked change replaces it, newest wins — the write succeeds and the replaced plan is reported. Nothing on these routes returns 409 TEMPORAL_PENDING_CHANGE_EXISTS any more — delete every handler branch for it — and the supersede request flag from the earlier draft of this section never shipped: do not send it (it is an unknown field now, 400). Effective history is never editable: only a change that has never governed a day can be re-dated, cancelled or replaced.

superseded[] — render it loudly. Every roster write response (homeroom / subject-group add, remove, move, both creates, the from-homeroom batch) and the PATCH /students/:id assignment block report every plan the command replaced — always present on write responses, empty when nothing was replaced. Because the command no longer blocks, this array is the user's only signal that something they (or a colleague) had booked was overwritten: the UX obligation is a toast/banner naming what was replaced ("replaced the change booked for Sep 14"). One worked example covering every entity shape:

"superseded": [
  {
    "studentId": "uuid",
    "entity": "homeroom_membership",
    "was": { "on": "2026-09-14", "incomingGroupId": "uuid", "outgoingGroupId": null }
  },
  {
    "studentId": "uuid",
    "entity": "student_placement",
    "was": { "on": "2026-09-14", "departmentId": "uuid", "gradeId": "uuid" }
  },
  {
    "studentId": "uuid",
    "entity": "curriculum_selection",
    "was": { "on": "2026-09-14" }
  }
]

was is the plan that no longer exists, and its fields are per-entity: memberships (homeroom_membership / subject_group_membership) carry incomingGroupId (the group it would have joined the student to) and outgoingGroupId (the one it would have removed them from — a move carries both); student_placement (on the assignment block — a move dated after a booked change, or an exit while one is booked) carries the booked departmentId/gradeId; curriculum_selection carries only the date.

Eligibility still wins — replacement never bypasses it: a student already booked into another group still needs a move, not an add, and on the subject-group axis a student still attending a same-subject group (even with a leave booked) gets STUDENT_ALREADY_IN_SUBJECT_GROUP on add/create; the move is the remediation there and it re-dates the booked leave itself. And the selection sync is admin-wins now: a compatible selection change at another date coexists (the class agrees with its whole forward timeline — nothing written), while an incompatible booked selection is replaced — cancelled whole, the class-implied fill opens at the roster command's own date, and superseded[] carries the curriculum_selection entry. No more routing the user to the selection surface to unblock a roster command.

One timing nuance worth a nudge in the UI: a selection change effective today whose prune closes the old class, paired with a membership change at a later date, leaves the student class-less for the days in between. The timeline does exactly what was asked — but when the user means "swap class and selection together", prefill both forms with the same date.

Curriculum selection versions as one aggregate. Curriculum, track and option-block choices move together — one PATCH, one date, one new state. There is no way to move the track on Monday and the choices on Tuesday.

One authorization exception worth knowing: on PATCH /students/:id/curriculum-selection a referent must omit validFrom (sending it is 403 ACTION_NOT_PERMITTED) and inherits whatever date the server or an already-scheduled change uses.

Teacher assignments and combined-class membership follow the same model. Two more facts stopped being "now" facts:

  • Who teaches a coursePATCH /subject-groups/:id with composition.teacherIds, and POST /subject-groups at create.
  • Which combination a course belongs toPOST /combined-classes and PATCH /combined-classes/:id with subjectGroupIds.

Both take the optional top-level validFrom with the same four rules as the table above, and both echo appliedFrom. The lists stay lists: you send the full set you want, not a diff — the server works out what to close and what to open, on one date for the whole command.

The read side:

  • pendingTeacherChanges[] on GET /subject-groups/:id and the subject-group writes — always present, possibly empty, same shape as pendingChanges[] but keyed on teacherId (with the teacher's firstName/lastName).
  • pendingChanges[] on GET /combined-classes/:id, the list, and the combined-class writes — { subjectGroupId, kind: "ADDITION" | "REMOVAL", on }.

A teacher added effective tomorrow is not in today's teachers[]. Same for a section joining a combination tomorrow: it is not in today's members[]. teachers[] and members[] answer "today"; the pending arrays answer "and then". Since the 2026-07-30 data-sensitive default a dateless change usually IS in today's lists (it applies today while the group's register is clean — always pre-year); it lands in the pending arrays only on the dirty-day slide, so check appliedFrom rather than assuming either shape.

Two notes specific to these list-shaped surfaces:

  • Replacement is the native shape here. The list you PATCH replaces the planned state: re-issuing with a different date re-dates the plan, and dropping a scheduled addition back out of the list cancels it. No conflict to resolve, no separate cancel affordance needed (this was always true here; since 2026-07-30 the student-membership routes behave the same way, via superseded[]).
  • A change that moves no date is refused if you send one. Reordering teacherIds without changing the set (the order is display order — index 0 is the primary teacher), renaming a combination, or PATCHing the identical member set moves no boundary, so a validFrom on such a request comes back 400 VALIDATION_FAILED with a field error on validFrom rather than being quietly ignored. Send the date only when the set actually changes. Reordering therefore still takes effect immediately.

Moving a course between combinations mid-year is two calls sharing one datePATCH /combined-classes/CC-1 without it, then PATCH /combined-classes/CC-2 with it, both carrying the same validFrom. Do them in that order: the first releases the course, the second claims it (the reverse order is 409 COMBINED_CLASS_MEMBER_ALREADY_COMBINED). Until the date arrives, the course is still in CC-1's members[] and the move shows in pendingChanges[] on both combinations.

The three temporal error codes, which you will meet on those endpoints and on the subject-group and combined-class write paths:

Code Status Meaning Handling
TEMPORAL_BACKDATE_FORBIDDEN 422 A past date, or an explicit today when attendance already exists (a dateless command slides to the next school day instead of raising this). The message names the earliest acceptable date. It is not available as a field (§4.5).
TEMPORAL_INTERVAL_OVERLAP 409 Someone changed the same roster, teacher list or combination membership concurrently and this request lost. Nothing was written. Refetch, then retry — never retry blind.
VALIDATION_FAILED 400 A validFrom was sent on a change that moves no date — a pure teacher reorder, a name/hours-only combination edit, an identical member set. Field error on validFrom. Resend without the date; it is never silently ignored.
TEMPORAL_ROW_IMMUTABLE 409 The target has governed a lived school day, so it cannot be deleted. Offer the end/exit action instead of delete.

TEMPORAL_ROW_IMMUTABLE now guards every delete that could erase history (E). "Effective" alone is not enough — the refusal fires once the thing has governed a lived school day, a day at or after the academic year's earliest calendar start (2026-07-30). Build the "offer end instead of delete" branch against all of them:

Route Refused once…
DELETE /homerooms/:id a roster episode has covered a lived day, or a governed version's break/activity audience named the class
DELETE /subject-groups/:id a roster or teaching episode has covered a lived day, or a governed version placed the course
DELETE /combined-classes/:id a membership episode has covered a lived day, or a governed version placed its shared lesson
DELETE /teachers/:id a teaching episode has covered a lived day, or a governed version named them as a supervisor or duty holder — this route is new to the guard, and the second arm catches teachers with no course assignment at all
DELETE /students/:id any of placement / class membership / course membership / curriculum selection has covered a lived day, or a governed version's audience named them
DELETE /curricula/:id a selection being swept has covered a lived day (the DRAFT + pre-year path only)
DELETE /timetables/:id the lineage has governed at least one lived school day

Three things that are not refused, deliberately — a genuine setup mistake stays erasable:

  • a structure whose every interval is still future-dated deletes freely;
  • an empty interval (a change cancelled before it ever took effect) blocks nothing;
  • anything in a year whose calendar has not started (2026-07-30) — episodes effective today included, because no day has been lived yet — and, by the same rule, a closed interval that never overlapped the calendar (pre-year churn residue) blocks nothing even when checked mid-year.

params carries { entity, id }, and entity is the localized word for the thing — use the message, not the raw literal. There is no force-delete and no amendment flow in this release: the refusal is final, so the FE's job is to offer the end/exit action beside it, never a retry.

A second refusal reaches the calendar, and it is easy to hit while editing a draft year: 422 TEMPORAL_BACKDATE_FORBIDDEN on a CLOSING-period create, move or delete, and on a calendarStartDate / calendarEndDate move, whenever the change would alter what a day before today meant. Attendance for a past day is worked out from the calendar as it stood, so a closing period cannot be added over last week, moved off it, or removed from it. TERM periods are exempt. The rule is about the days that change meaning, not about the value you typed — extending the end date from yesterday to tomorrow is accepted, because it only affects today and tomorrow. params.requested names the earliest day the change would have altered, and params.earliest is the first day you may affect.


12. Error catalogue

code HTTP When FE handling
ATTENDANCE_PAST_DATE_LOCKED 422 A caller without qualified school-side management authority writes (POST) or edits (PATCH) a date before the school's today. Department Principals may correct only inside assigned departments. Render past dates read-only in teacher-only sessions; still surface the message (clock skew is real).
ATTENDANCE_NO_PUBLISHED_TIMETABLE 409 Write on a date no timetable version covers. Copy is per-date: "No schedule is in force on this date". Empty-state, not a failure. A tenant mid-setup hits this on every date.
ATTENDANCE_NOT_A_SCHOOL_DAY 422 Date outside the department's school-day set (holiday, closure, non-operative weekday). Message names the date; render it.
ATTENDANCE_ENTRIES_SPAN_GROUPS 422 An entry's student is not in the declared cohort — also the PATCH cohort echo naming a group the cell's student is not in (§6.2). A client bug — you mixed groups in one batch, or echoed a stale selector. The offending id is not on the wire.
ATTENDANCE_STUDENT_NOT_IN_LESSON_ROSTER 422 An entry's student is not on that slot's roster. On a back-fill this is often correct (the frozen roster differs from today's). Show the message.
ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENT 403 The Y-set denies the caller for that student — broader since 2026-08-02: the teaches-same-day clause is gone (§10). Whole batch rejected. Prefer smaller batches in teacher UIs.
ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT 403 A teacher named a cohort that is not one of their own — or no cohort at all (§3.1). New 2026-08-02 on /rows + /inconsistencies; extended 2026-08-03 to both writes, where it is the first gate and fires before anything is written. Add a handler on the writes too. In a teacher session only offer the cohorts teacher-day handed out, and declare the card's own cohort on every write. On this 403, treat the group as not theirs (a roster or assignment may have changed under them) and re-fetch teacher-day; the payload itself is fine to retry against a cohort you own.
ATTENDANCE_UNIT_NOT_IN_SUBJECT 422 A tagged unitIds entry is not a unit of any subject in the batch. Client bug — only offer units of subjects present in the batch.
ATTENDANCE_EVENT_NOT_FOUND 404 The ack targets a follow-up id that does not exist (§5.5). New 2026-08-04. Stale list — re-fetch the worklist.
ATTENDANCE_EVENT_ALREADY_RESOLVED 409 The ack targets an already-resolved event; params.state names its state. New 2026-08-04. Someone else got there first (or the day was corrected). Drop the item and refresh.
JUSTIFICATION_NOT_FOUND 404 A justification the caller may not see — missing, another family's, or (route 18, teacher) a student they do not teach (§5.9). New 2026-08-04. Existence-hiding — treat like any 404, never as "ask for access".
JUSTIFICATION_ATTACHMENT_LIMIT 409 A 6th file on one justification; params.limit = 5. New 2026-08-04. Disable the upload affordance at 5.
JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED 422 A file on an EARLY_EXIT announcement. New 2026-08-04. Do not offer upload on that kind at all.
SCHEDULED_LESSON_NOT_FOUND 404 No lesson matches (anchor, weekday, tick) — including a memberless combination. Almost always a hand-assembled anchor. Echo the anchor you were given.
SCHEDULED_ACTIVITY_NOT_FOUND 404 No activity matches, or the anchor's tick is not the slot's own. Same.
VALIDATION_FAILED 400 Anchor XOR violations (every multi-anchor combination now fails, including combinedClassId+activityId, which previously slipped through), two entries for one (student, startTick) in a batch — whatever their anchors, status/time/note rules, missing cohort pair on /rows, empty PATCH body, a timestamp in date. Standard field-level handling. Clients echoing minted anchors are unaffected by the XOR tightening.
NOT_FOUND 404 Record, group, homeroom, grade or department lookup miss — including a group with zero students. /rows on an empty class lands here.

(ATTENDANCE_DAY_SHAPE_UNAVAILABLE was deleted on 2026-07-29 — it can no longer appear in any response. A past day is composed from dated data, so there is no shape to lose; a date with no lesson at the anchor answers the ordinary 404.)

(PICKUP_PERSON_NOT_ELIGIBLE was deleted on 2026-08-10 with the EARLY_EXIT pickup-person selector — the pickupPerson* fields are no longer accepted or returned anywhere on this surface.)

(COMBINED_CLASS_DURATION_MISMATCH was removed from this catalogue on the 2026-07-29 verification pass: it cannot surface from any register endpoint. A member-duration disagreement is refused upstream — when the lesson is placed on a timetable, or when the combination's membership is edited — and register reads deliberately tolerate one by anchoring the slot's clock on the first member section. You will only meet this code on the timetable and combined-class endpoints themselves.)

Reminder from §4.5: ids carried in error params are internal and do not reach the response. Branch on code, render messages.


13. Rendering recipes

13.1 The board screen

GET /attendance/groups?date=2026-07-25
  → cards; render toRegister === null as "—"
  → schoolDay === false → greyed card, no "take register" affordance
  → the per-card warning badge is card.inconsistencies — no extra call
GET /attendance/groups/pending?date=2026-07-25
  → the "who owes what" panel; refresh after every save
GET /attendance/inconsistencies?date=2026-07-25
  → the warning widget's ITEMS (who, which cells, localized copy);
    fetch it when the widget opens — the counts are already on the cards

Add cohortType + cohortId to any of the three to get its single-group version. That is the whole per-class panel — there is no other endpoint.

13.2 The day cell

Drive the layout off group.mode:

  • DAILY — lead with register[], the day summary. One cell per student, rich when the day holds more than one event: P · A · LE 09:15 · LE 09:15 · EE 13:00. Show items[] as secondary detail if you show it at all.
  • PERIOD — lead with items[], the per-slot strip. Show register[] / classifications[] as the highlighted day state beside it.

For each item, branch on source before looking at record:

source === 'RECORDED'  → the stored value; clicking edits (PATCH record.id)
source === 'PROJECTED' → the inferred value, visually distinct (lighter, italic);
                         clicking CREATES a cell (POST with this item's anchor)
source === null        → "not taken" — a neutral placeholder, NEVER an "A"

13.3 Saving the day screen

Collect every touched slot into one POST /attendance/records for the group:

{ "date": …, "cohortType": …, "cohortId": …,
  "entries": [ { "studentId": …, "anchor": <copied from the item>, "status": …, "time": …, "note": … } ] }

Then refresh the four widgets from the response: records, group, inconsistencies, followUps. Do not re-fetch the board.

For a teacher-facing screen, prefer one batch per slot — a single unauthorized student rejects the whole batch (§10).

13.4 Navigating from a warning to the cell

An inconsistency item gives you everything:

/rows?date=<item.date>&cohortType=<item.cohortType>&cohortId=<item.cohortId>&startTick=<item.triggeringCell.startTick>
  then scroll to item.studentId

13.5 The follow-up worklist

GET /attendance/follow-ups/summary        → the badge counts (sparse maps!)
GET /attendance/follow-ups?page=1&limit=20 → the list, oldest day first

Omit date for the cross-day worklist (the default and usual case). All three endpoints need manage_communications — hide the whole screen from a teacher. An event joins the list ~15 minutes after the register cell behind it; resolve one with:

PATCH /attendance/follow-ups/:id/ack   { channel: PHONE | IN_PERSON, reason? }
  → the refreshed item (state ACKNOWLEDGED) — swap it in, no re-fetch

14. Gotchas

  1. source: null is not an absence. Render it as "not taken".
  2. The seven counters overlap. numStudents is the denominator; only notTaken is disjoint. No stacked bars off their sum.
  3. toRegister: null ≠ 0. Null means the question does not apply.
  4. Never key a slot by its tick alone. An option block puts several different lessons at the same tick for one grade. Slot identity is ${startTick}|${anchor ids}. Keying by tick folds the block's lessons together and carries one lesson's label, room and teachers over another's roster.
  5. Echo anchors; never build them. A tick that is not the slot's own is a 404, deliberately — it means the anchor was assembled by hand.
  6. register[] is not items[].filter(source === 'RECORDED'). A cell whose lesson has since moved appears in no item.
  7. Snapshot columns never refresh. Old rows keep old names on purpose.
  8. /rows 404s on a group with zero students. Groups are built from students. Fine from board navigation, surprising on a hand-typed URL.
  9. There is no period ordinal. A slot is identified by startTick and its wall clock. If you want "Period 3", it is the array index + 1 of that student's strip — which is what the user counts. A grade-relative rank would call the third lesson on screen "Period 2" for a student who skips a tick.
  10. The cohort pair is both-or-neither. A lone half is a 400.
  11. Widget disagreement is a backend smell. Every read is a projection of one internal assembly. If the board and the rows disagree, report it — do not paper over it with a second fetch.
  12. A timestamp in date is a 400, not a truncation.
  13. Don't pre-validate write authority. You cannot compute the Y-set client-side.
  14. byState / byType are sparse. Absent key means zero.

15. Not built yet

Deliberately absent, so you do not go looking:

Missing Status
~~A teacher-facing register endpoint~~ Delivered 2026-08-02: GET /attendance/teacher-day (§5.7) plus teacher-reachable /rows + /inconsistencies on own teaching-unit cohorts (§3.1). Extended 2026-08-03: a per-slot inconsistencies badge (§5.7), tutor access to the tutored class's HOMEROOM cohort (§5.3), and the write-side cohort fence (§6.1). Still absent, deliberately: activity cards on the agenda (activities register through /rows + POST as before), a tutor's aggregate homeroom view, and a lastMetOn hint on otherGroups.
~~Referent / student read access~~ Delivered 2026-08-04 (family-loop slice B): /attendance/family/to-justify, /students/:id/overview, /students/:id/days/:date (§5.8). Read-only; every non-family role still 403s on them.
~~Communication-loop writes~~ Delivered 2026-08-04 (family-loop slice A): absence-like writes mint day events, the worklist and dayEvents[] are live, and the office acks over PATCH /follow-ups/:id/ack (§5.5).
~~Notifications~~ Delivered 2026-08-04 (family-loop slice D, the program-final slice): the NOTIFIED/FOLLOW_UP cadence is live — a background sweeper notifies the linked referents with accounts at grace-expiry and reminds once, via the notification center (in-app inbox, separate guide 2026-08-04-notifications-FE-guide.md) + templated email. No attendance payload changed (headline 9). Still absent, deliberately: web push / mobile push (future notification-engine transports), any third notification, and office escalation beyond the worklists.
~~Justification actions~~ Delivered 2026-08-04 (family-loop slice C): the referent-submitted justification flow with attachments and the APP ack channel (§5.9). Still absent, deliberately: editing/cancelling a submitted justification (immutable v1), student self-justification (adult students — later ruling), and any school-side review/approval of a justification (v1 records; it does not judge).
Cell deletion There is none. A cell is corrected, never removed; corrections append to the audit trail.
Bulk / cross-group writes One batch covers one group. By design.
Attendance reporting / exports Out of scope for this module.
Amending a past register's expectation There is none, and this is a deliberate v1 boundary rather than a gap to route around. A past day's facts stay editable — you can correct any cell under the same dated authority. What cannot be changed is what was expected: you cannot retroactively add a student to a February lesson's roster, move a lesson a governed version placed, or re-open a closed calendar day. Every such attempt is a final refusal (TEMPORAL_ROW_IMMUTABLE / TEMPORAL_BACKDATE_FORBIDDEN), so do not build a retry, an override toggle, or copy that promises an approval flow.

16. This file is the whole contract

This document is self-contained on purpose. It links to nothing outside itself: everything the FE needs to build the attendance module — including the dated behaviour of the timetable, the student placement and the rosters that attendance reads from (§11.6) — is written out above. If you find yourself needing another document to answer a question about attendance, that is a gap in this one; report it and it gets filled here.

Five earlier notes exist in this folder and are superseded. They were written as deltas against intermediate states that the FE never integrated, so following them will actively mislead you. Their content is folded into the sections named below; do not read them, and do not treat a disagreement between them and this file as an open question — this file wins.

Superseded note Where its content now lives
2026-07-25-attendance-admin-apis-BREAKING.md §3, §5, §6 — the endpoint surface as it now stands
2026-07-26-attendance-day-summary-and-counters-BREAKING.md §8 (overlapping counters), §5.3 (items[] vs register[])
2026-07-26-attendance-period-label-removed-BREAKING.md §14 gotcha 9 — there is no period ordinal
2026-07-26-attendance-audit-fixes-BREAKING.md §3.1 (teachers off the admin board), §4.2 (date is a calendar day), §4.3 (the cohort pair)
2026-07-27-attendance-dated-schedule-resolution-FE-guide.md §11.2 (dated resolution), §11.3 (completeness — since removed), §12 (ATTENDANCE_NO_PUBLISHED_TIMETABLE). Doubly superseded: it also describes the frozen-manifest read rule, which E replaced with full dated resolution.

Three non-attendance guides in this folder describe the same dated machinery from their own module's side — timetable publication, student placement, and membership valid time. They remain correct for their own endpoints, and §11.6 restates the parts that reach the register. One caveat if you do open them: their example error payloads show a params object on the response. That is wrong — see §4.5. Nothing in this file relies on it.


To be filled at sign-off

Tracked here so nothing is quietly forgotten when the program closes:

  • completeness removed from all five responses (2026-07-29) — §11.3 carries the note; the field is not coming back.
  • Teacher-assignment and combined-class valid time (C3) — the dated data, the validFrom / appliedFrom / pending-array surface, and the mid-year move are documented in §11.6(c); §11.4 and §10 now state precisely what C3 did not change.
  • Every ⏳ marker resolved (E, 2026-07-29). Expected attendance is dated on both axes (§11.2, §11.4), and the dated teaches-on-D query is wired into write authority in both directions (§10 clause 3). No marker remains in this file.
  • Parent-delete refusals documented (§11.6, the TEMPORAL_ROW_IMMUTABLE table) — including DELETE /teachers/:id, which the guard is new to, and the two escapes that stay open for genuine setup mistakes.
  • The retro-calendar refusal documented (§11.6) — TEMPORAL_BACKDATE_FORBIDDEN on calendar-bound and CLOSING-period edits that would change a past day.
  • Every payload re-verified against source (2026-07-29). Line-by-line pass over the three controllers, all ten DTO files, the write path's gate sequence, the status/projection/classification/inconsistency rule modules, the two policies and the error-code catalogue. Everything in this file matched the code, with one exception now fixed: the COMBINED_CLASS_DURATION_MISMATCH row claimed the code could surface on the attendance write — it cannot (§12 carries the correction). The same pass added the wire prefix + auth note (§4.0), the full dayEvents[] shape including ackReason (§5.3), the follow-up filter set (§5.5), and the precise 7–8 vs 9 gate split (§3.1).
  • Still open, and named rather than dropped:
    • the teacher-facing register surface — no endpoint, no spec (§15);
    • referent / student read access — deferred to its own spec (§15);
    • communication-loop writes — tables and reads wired, nothing populates them (§15).
  • Flip this banner to signed-off, with the reviewer and date. That is the product owner's call, not the backend's — the checklist above is what it depends on.

Student lifecycle (2026-09-03)

Only students with status ENROLLED take part in attendance. Payload shapes do not change.

  • Register reads (/attendance/groups, /groups/pending, /rows, /inconsistencies, /teacher/day): a PRE_ENROLLED student is never in a roster, card, total or rosterSize on any date. A LEFT/GRADUATED student stays on every day before their exit boundary and disappears from it on (unchanged behaviour, now stated).
  • Writes (POST /attendance/records, PATCH /attendance/records/:id): an entry naming a pre-enrolled student fails the group fence with 422 ATTENDANCE_ENTRIES_SPAN_GROUPS. The homeroom cohort fence now also requires an effective placement on the date.
  • Today-anchored lists (/attendance/follow-ups, /follow-ups/summary, /attendance/justifications): ENROLLED students only. Rows of exited students vanish from these lists; nothing is deleted.
  • Family (/attendance/family/to-justify, the family justification list): ENROLLED children only. /family/students/:id/day/:date, /overview and justification-by-id still answer for an exited child, so route that child to history-only views by the status you already read from /students.

Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.