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
departmentIdas a filter; the backend intersects it with the caller's authority and returnsATTENDANCE_NOT_AUTHORIZED_FOR_COHORTfor 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_MISMATCHrow 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:
- A teacher, member or roster change made without
validFromapplies 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).- 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.
- 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.- BREAKING (2026-08-03): a teacher must now declare a cohort they own on the two write routes.
POST /attendance/recordsandPATCH /attendance/records/:idrefuse a teacher'scohortType+cohortIdoutside 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 aGRADE_GROUPwrite by a teacher now always 403s. Same release, non-breaking companion: an echo-lessPATCHby 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.- 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,byStatecarriesUNDER_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.- 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 thereferentandstudentroles, which stop 403ing on the attendance module for the first time. Every other role 403s on them. See §3, §5.8.- 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/justificationsplus 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 ajustifications[]block on the row.NOT_REQUIREDandackChannel: "APP"are now real values you will receive. See §5.9, §5.3, §5.8.- Notifications are LIVE (2026-08-04, additive — no shape change).
NOTIFIEDandFOLLOW_UP— always documented in the state enum as reserved — now actually occur: a background sweeper flips an armed event toNOTIFIEDwhen its grace expires and toFOLLOW_UPone 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— kindsattendance.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[].rowadded 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 + thejustifications[]row block on every day surface +dayEvents[].justificationId(§3, §5.3, §5.8, §5.9, §12, §15); notifications slice D, 2026-08-04 —NOTIFIED/FOLLOW_UPgo live via the background sweeper + the notification center (separate guide2026-08-04-notifications-FE-guide.md); no attendance payload changed; teacher day completed + the ACTIVITY cohort, 2026-08-12 (additive) —teacher-daycards gainkind(LESSON/ACTIVITY) and aduties[]array,cohortTypegains a fifth valueACTIVITYon every surface (§1.5, §2), the teacher leash gains a supervisor limb, and an echo-less teacherPATCHon an activity cell answers theACTIVITYcard instead of the class (§6.2); scoped guide2026-08-02-teacher-attendance-surface-FE-guide.mdamended in place; teacher-day badge narrowed to per-slot, 2026-08-14 (breaking) — a card'sinconsistenciescounts 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 inGET /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¶
- Mental model
- Vocabulary
- The endpoints, and who may call them
- Conventions that apply to every call
- The reads
- The writes
- Status field rules
- Counters and classifications
- Inconsistencies
- Write authority — the Y-set
- The temporal model — the part that will bite you
- Error catalogue
- Rendering recipes
- Gotchas
- Not built yet
- 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_ENTRYmeans "in school from here", so later slots showPRESENT.EARLY_EXITmeans "gone from here", so later slots showABSENT. Everything else carries as itself. - Backward (slots before the earliest authored cell): the mapping inverts.
LATE_ENTRYbackfills asABSENT(they were not there yet);EARLY_EXITbackfills asPRESENT(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).cohortIdis the homeroom id.cohortType: 'GRADE_GROUP'— the students of one grade who belong to no class.cohortIdis the grade id.cohortType: 'SUBJECT_GROUP'— one course/section.cohortIdis the subject-group id. The roster is the group's membership on the date.cohortType: 'COMBINED_CLASS'— one co-taught combination.cohortIdis the combined-class id. The roster is the union over the members combined on the date.cohortType: 'ACTIVITY'(2026-08-12) — one scheduled activity.cohortIdis 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/curriculumcarry 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+cohortIdmust be one of the caller's own cohorts, else403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORTand 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 usualpage/limitquery 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:
- Structural ids (
departmentId,gradeId,homeroomId,cohortType+cohortId) andsearchselect whole groups. A group's counters always describe its full roster, even when only one student matched the filter. curriculumIdis the exception — it narrows the students inside a group. A grade group spanning several curricula shrinks rather than disappearing.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_GROUPSandATTENDANCE_UNIT_NOT_IN_SUBJECTeach 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
databackend-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 itemsGET /attendance/inconsistenciesreturns for it, so the per-card badge needs no second call. It counts contradictions, not students: it is a sibling ofcounters, not an eighth bucket, and none of the §8 arithmetic includes it.totals.inconsistenciesis the sum over the school-day groups (next note). You still call/inconsistencieswhen the user opens the warning widget — that is where the item detail lives. -
curriculumId/curriculumNamearenullon aGRADE_GROUP— its students may hold different curricula. schoolDay: falsemeans the date is a holiday, a closure, or outside the department calendar. Nothing is owed. Render the card greyed, not empty.totalscover only the groups withschoolDay: 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 tonumGroups,numStudents, the counters,toRegister,groupsPendingRegisterorinconsistencies. On a date nobody has school the totals are all zeros withtoRegister: null. Previously the totals summed every rendered card, so a partial holiday reported the whole school undernotTaken; do not usetotals.numGroupsas "cards on screen" — countgroups[]for that.toRegisterisnumber | null.nullmeans the question does not apply — a future date, or a date no schedule source covers. It is not0. Rendernullas "—", never as "all done".toRegisterclimbs 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.countersoverlap — 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 }
]
}
]
}
PERIODdepartment: every elapsed slot missing a cell is owed and named.DAILYdepartment: the day needs one take per student, attributed to the first elapsed slot of the day.PendingSlotDto.numStudentsis 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
}
anchoris 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 aPROJECTEDitem — 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'sjustifications[](§5.9).nullon every staff-authored entry, and on aPROJECTEDitem. See "The register is no longer always hand-taken" below.- On a
PROJECTEDitem,record.idisnulland onlystatusis 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.startTicknarrows 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 filteringitems[]forsource === '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:
record.justificationIdis the badge. Non-null ⇒ pre-filled from the communication with that id, which is in the same row'sjustifications[](kind, range, reason, submitter). Show a small marker and, if you like, the reason on hover.recordedByNamewill be the parent's name — that is correct, not a data bug.- They are otherwise ordinary entries. Editable, auditable, counted in
register[], in the group counters and intoRegisterexactly 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). - 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
PRESENTaround 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:
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 thejustifications[]block of that day's register row, next to family submissions, which carrychannel: "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 inparams.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 withcohort.cohortType: 'COMBINED_CLASS'and the union roster — never as its member sections. Activity slots are not listed (deferred); they still register through/rows+POSTas before.name/subjectName(new 2026-08-04) — the card's title.nameis the teaching unit's own name (the subject group's — the same stringotherGroups[].nameand the/rowsregister header carry — or the combined meeting's).subjectNameis the plain subject,nullon a combined meeting whose members may span subjects.labelpredates both and stays as-is.needsAttendance— the register duty lives on this slot today. In aPERIODdepartment every lesson carries it. In aDAILYdepartment 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 ofrosterSizeare 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 - recordedis 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 itemsGET /attendance/inconsistenciesreturns for the card's owncohort(§5.4), those whosetriggeringCell.startTickequals the card'sanchor.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_REQUIREDand 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'srecords[].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/files — multipart/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 hidden404, not a403). Family sessions get403here — 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
403here never leaves a partial batch behind — you may retry the whole payload against a different cohort as-is.
Rules:
entriesneeds ≥ 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 a400— before, the two silently collapsed onto one cell. See §6.6 for the sequential case. - An anchor carries exactly one teaching unit —
subjectGroupId⊕combinedClassId⊕activityId. Every multi-anchor combination is a400(also tightened 2026-07-29:combinedClassId+activityIdused 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.
AttendanceStatusvalues: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¶
- The three value fields are optional, but at least one must be present —
an empty
PATCHwould 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.) timeandnoteaccept explicitnullto clear them. Omitting a field leaves it as it was — not the same thing.statusmay not benull.- 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 is404. - The response carries the cell's re-projected day (
records[0].row, §6.4), built on the record's own date. Correcting 08:00 fromABSENTtoLATE_ENTRYre-projects 09:00 fromABSENTtoPRESENT, 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'sgroupcard (§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, else422 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
lastModifiedByonly — 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:
- 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,sourceandrecord— some gaining a projection, some losing one. Only a wholesale swap ofitems[]is correct. - 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.
- Do not render
records[i]into the strip. That object is the write receipt (the full frozen snapshot); the strip rendersrow.items[]and the day summary rendersrow.register[]. - 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
PATCHwhether 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. rowisnullexactly whengroupis 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:activityIdset, subject columnsnull, 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 itsdata, 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. |
timemust be a real 24-hourHH:mm.24:00and11:60are rejected.SUSPENDEDis qualified but not a transition: it takes a note, never a time.- Blank text is not a note.
""and" "normalize tonull. Sendnullto 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:
numStudentsis the denominator. Always. Never the sum.notTakenis the one disjoint bucket, sonumStudents - notTaken= students with a register.- Every student contributes at least one bucket;
classificationsis 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:
absentmeans never appeared, not "has an absent cell". InDAILYmode the day often opens with anABSENTbaseline 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:
- they are a teacher of the targeted slot (activity supervisors assigned on that weekday count), or
- 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-daylists (§5.7), plus the tutor's homeroom. Anything outside those will 403; do not offer it. - A
403 ATTENDANCE_NOT_AUTHORIZED_FOR_STUDENTon 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. toRegisterclimbing 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_UNAVAILABLEwas 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
/rowsfor today cannot reuse that roster to render a different day. - Do not reconcile the board against a roster endpoint.
GET /homerooms/:idanswers "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.
TimetableResponseDtocarrieseffectiveFrom(YYYY-MM-DD | null): the first day this timetable governs.nullwhile 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/statuswithstatus: PUBLISHED, andPOST /timetables/:id/publish-revision) accept an optionaleffectiveFrom(YYYY-MM-DD, strict — a datetime is rejected), and answer with botheffectiveFromandappliedFromcarrying the same value.appliedFromis 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 noappliedFrom, and sendingeffectiveFromon an unpublish is a400. - 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_IMMUTABLEonDELETE /timetables/:idonce 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 fromeffectiveFrom— after an unpublish the returned draft haseffectiveFrom: nullwhile 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 /timetablesstill 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
LEFTorGRADUATEDdisappears 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:
appliedFromon 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 omittedvalidFromis simply today for every student (a bulk assign only creates brand-new selections), andappliedFromis present when one date covers everyone.pendingChanges[]onGET /homerooms/:id,GET /subject-groups/:idand 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[].
pendingSelectiononGET/PATCH /students/:id/curriculum-selection—nullwhen nothing is scheduled, otherwise the complete future selection (curriculum, track,trackCarried, choices) pluseffectiveFrom. 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 course —
PATCH /subject-groups/:idwithcomposition.teacherIds, andPOST /subject-groupsat create. - Which combination a course belongs to —
POST /combined-classesandPATCH /combined-classes/:idwithsubjectGroupIds.
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[]onGET /subject-groups/:idand the subject-group writes — always present, possibly empty, same shape aspendingChanges[]but keyed onteacherId(with the teacher'sfirstName/lastName).pendingChanges[]onGET /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'smembers[].teachers[]andmembers[]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 checkappliedFromrather 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
teacherIdswithout 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 avalidFromon such a request comes back400 VALIDATION_FAILEDwith a field error onvalidFromrather 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 date —
PATCH /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 withregister[], 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. Showitems[]as secondary detail if you show it at all.PERIOD— lead withitems[], the per-slot strip. Showregister[]/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¶
source: nullis not an absence. Render it as "not taken".- The seven counters overlap.
numStudentsis the denominator; onlynotTakenis disjoint. No stacked bars off their sum. toRegister: null ≠ 0. Null means the question does not apply.- 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. - 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. register[]is notitems[].filter(source === 'RECORDED'). A cell whose lesson has since moved appears in no item.- Snapshot columns never refresh. Old rows keep old names on purpose.
/rows404s on a group with zero students. Groups are built from students. Fine from board navigation, surprising on a hand-typed URL.- There is no period ordinal. A slot is identified by
startTickand 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. - The cohort pair is both-or-neither. A lone half is a
400. - 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.
- A timestamp in
dateis a400, not a truncation. - Don't pre-validate write authority. You cannot compute the Y-set client-side.
byState/byTypeare 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:
-
completenessremoved 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_IMMUTABLEtable) — includingDELETE /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_FORBIDDENon calendar-bound andCLOSING-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_MISMATCHrow 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 fulldayEvents[]shape includingackReason(§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): aPRE_ENROLLEDstudent is never in a roster, card, total orrosterSizeon any date. ALEFT/GRADUATEDstudent 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 with422 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):ENROLLEDstudents only. Rows of exited students vanish from these lists; nothing is deleted. - Family (
/attendance/family/to-justify, the family justification list):ENROLLEDchildren only./family/students/:id/day/:date,/overviewand justification-by-id still answer for an exited child, so route that child to history-only views by thestatusyou already read from/students.
Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.