Skip to content

Attendance — the family surface + notifications, the FE guide

STATUS: AUTHORITATIVE for FE integration of the attendance family loop. First published 2026-08-05. Owner: backend. Amend in place.

Updated 2026-08-10 — families can now revise and withdraw communications that have not started yet, and remove attachments from any of them. Four new routes plus two additive fields (from on the list query, canEdit on every justification row). Nothing existing breaks: no response shape changed and no field was removed, so current screens keep working untouched. New work is in §3 (routes 6, 7, 9), §5.2 (from + the two-list landing pattern), §5.3 (documents ignore the edit window) and §5.5 (the whole revise/withdraw contract — start there).

Updated 2026-08-10, third batch (pickup removal) — BREAKING on EARLY_EXIT. Product removed the "who picks the student up" selector. pickupPersonType and pickupPersonId are no longer accepted on POST/PATCH justifications (sending them is now a 400 — the API rejects unknown fields), and pickupPersonType/pickupPersonId/pickupPersonName are gone from every response (family list, mutation responses, the justifications[] day blocks). PICKUP_PERSON_NOT_ELIGIBLE no longer exists. An EARLY_EXIT is now just startDate + time (+ optional reason). Delete the pickup picker and any pickup-name rendering. The isAuthorizedPickup flags on referent links and guardians still exist on the people surfaces (admin-editable) — they just gate nothing here anymore. Stale pickup copy below has been rewritten; if you find a straggler, the banner wins.

Updated 2026-08-10, fourth batch (office acks are justifications) — all additive. When the school settles a follow-up directly (a phone call, the front desk), that ack now records a real justification on the family's behalf, so the day appears in the family's justifications list like any submitted one. Every justification row (list, mutation responses, day blocks) gains channel: APP = submitted in the app, PHONE / IN_PERSON = recorded by the school. Render office rows as settled with a "recorded by the school" marker — submittedByName is a staff name on them, so the marker is channel, never the name — and expect canEdit: false on them always. Details in §5.2 and §5.5.

Updated 2026-08-10, second batch (harmonization) — the two lists stop making the FE do backend work. All additive on this surface: to-justify now takes studentId + from and each item carries suggestedKind (§4.1); the justifications list now takes kind and every justification row carries studentName (§5.2). No field here moved or changed meaning — existing screens keep working. Two contract notes beyond this guide's routes: the published Swagger enum for family event state now lists only values a family can actually receive (regenerating a codegen'd client makes dead branches like CANCELLED stop compiling — the runtime never sent them), and the office justification list (GET /attendance/justifications, not a family route) no longer returns canEdit — the one breaking change in this batch. It was a hard-wired false (the office has no edit route); any office screen reading it should simply stop.

This guide is self-contained: every request/response shape, state table and error row the family attendance loop needs is inline — you should not need another file to build it. The deep module contracts it distils are the attendance module guide (§5.8–§5.9) and the notifications guide; if a shape here ever disagrees with one of them, the module guide wins — and tell the backend, that's a doc bug. The wider referent persona (login, profile, children switcher) is composed in the referent-surface guide.

Scope: the family side of the attendance register — a referent (or the student themselves) following attendance, answering the school's justify-requests, and the notification center + email that close the loop. The school-side surfaces (admin board, teacher day, follow-up worklist) are not here; they live in the attendance module guide.


1. Mental model

The loop, end to end. The school records an absence or a late arrival → ~15 minutes later it becomes an event asking the family for an answer → the referent gets an inbox notification + a templated email → they open the child's day, submit a justification → the event resolves instantly, on every surface at once (family app and office worklist alike). Announcing an absence in advance short-circuits the whole loop: nothing is ever asked for that day.

The 15-minute rule governs everything here. An absence/late arrival becomes a family-visible event about 15 minutes after the register cell behind it is recorded (the correction grace window — env-tunable per environment, so don't hardcode the number). A correction made inside that window means the event never appears at all. Do not promise real-time. The register content itself (the day view's slots and cells) is not delayed — only the event objects are.

Two personas, one surface.

Session Reads (§4) Submits (§5) Notifications (§6)
referent linked students only ✅ where the link has canWrite ✅ (the loop's audience)
student self only 403 — no self-justification inbox exists, but attendance never writes into it
every other role (admin included) 403 on all /attendance/family/* routes 403 inbox exists, empty for now

Record narrowing is backend-owned. A referent sees only their linked students; a student only themselves. There is no client-side "which children may I see" filtering to do — and no way to probe: a foreign or made-up studentId answers a hidden 404, never a 403 that would confirm existence.

canWrite is per link, not per referent. A read-only link (school-configured) sees everything in §4 but cannot submit justifications. Fetch the link flags once from GET /auth/profileprofiles.referent.students.items[] (each item: studentId, canWrite, relationshipType, pickup/emergency flags) and hide the justify affordances per child. Children's names: since 2026-08-10 both list endpoints carry studentName on every row, so a name join is only needed where you list children who have no rows — there, GET /students (record-narrowed to the linked students) supplies them, joined on studentId. Full recipe: referent-surface guide §2–§3.

Permissions drive the UI, not the role name. The justify form exists iff the attendance.justify action is granted in GET /permissions (and in the map a view-switch returns). Never hardcode "referents can submit".

2. Conventions on every call

  • Base path: every path below is relative to /api/v1 — the wire path of GET /attendance/family/to-justify is /api/v1/attendance/family/to-justify. All routes require authentication (the platform's standard login/session contract — see the session-view guide); none is public.
  • Dates are calendar days in the school's timezone. Every date — path param or body field — is exactly YYYY-MM-DD. A full ISO instant is rejected 400 VALIDATION_FAILED, not truncated. Never derive the day through the browser clock: the school's own timezone is the register's clock.
  • Pagination is the platform standard: page / limit query params, { data: [...], meta: { total, page, limit, totalPages } } envelope.
  • The error body is the platform envelope:
{
  "statusCode": 422,
  "code": "JUSTIFICATION_NOT_EDITABLE",
  "messages": {
    "en_US": "This communication can no longer be changed — it covers 2026-09-10, which has already begun",
    "it_IT": "Questa comunicazione non può più essere modificata — riguarda il 2026-09-10, giorno già iniziato"
  },
  "field": "…",       // only on field-locatable validation errors
  "timestamp": "2026-09-10T09:12:33.120Z",
  "path": "/attendance/family/justifications/…"
}

Branch on code, render messages[lang] — the copy is backend-owned and already localized. There is no params on the wire: params referenced by the sentence are interpolated into messages and dropped, the rest never leave the server.

3. The endpoints

Ten attendance routes plus the notification center. "R" = referent, "S" = student.

# Verb Path Purpose Who
1 GET /attendance/family/to-justify Unresolved events across all linked children — the landing widget; filters studentId, from (§4.1) R + S
2 GET /attendance/family/students/:studentId/overview One child's year: stats + event history (§4.2) R + S
3 GET /attendance/family/students/:studentId/days/:date One child-day in full (§4.3) R + S
4 POST /attendance/family/justifications Submit a justification / announcement (§5.1) R (canWrite)
5 GET /attendance/family/justifications The caller's submissions this year; filters studentId, from, kind (§5.2) R + S
6 PATCH /attendance/family/justifications/:id Revise one that has not started yet (§5.5) R (canWrite)
7 DELETE /attendance/family/justifications/:id Withdraw one that has not started yet (§5.5) R (canWrite)
8 POST /attendance/family/justifications/:id/files Attach one evidence document (§5.3) R (canWrite)
9 DELETE /attendance/family/justifications/:id/files/:fileId Remove one document — any date (§5.3) R (canWrite)
10 GET /attendance/family/justifications/:id/files/:fileId Signed download URL (§5.3) R + S
GET /notifications (+ unread-count, read, read-all) The notification center (§6) any logged-in user

RBAC, for completeness: the three reads and the two justification-GETs ride attendance.register read; every write additionally needs the attendance.justify action (referent-only today) — which is why a student session, holding the same read, still 403s on 4, 6, 7, 8 and 9. There is no separate "may withdraw" grant: revising and withdrawing are the same authority as submitting.

4. Following attendance — the reads

4.1 GET /attendance/family/to-justify?page=&limit=&studentId=&from= — the landing widget

Every event still awaiting the family's answer, across all the caller's linked students, newest day first, paginated (default limit=20). One call powers both the badge (meta.total) and the list. Resolved events leave the list; history stays on the overview. A student session gets their own rows — but the student-facing UI is the overview + day view; this list is the referent's landing widget.

Two filters (2026-08-10): studentId narrows to one child; from keeps events dated on/after a day. An unlinked or unknown studentId answers an empty page, never an error — the same existence-hiding as everywhere on this surface.

{
  "data": [
    {
      "eventId": "…",
      "date": "2026-09-07",            // the school day it is about
      "eventType": "ABSENT",           // ABSENT | LATE_ENTRY
      "suggestedKind": "ABSENCE",      // the justification kind that covers it
      "state": "UNDER_REVIEW",         // see the state table in §4.3
      "studentId": "…",
      "studentName": "Ada Lovelace",
      "surfacedByName": "Anna Rossi"   // who recorded it; null if unresolvable
    }
  ],
  "meta": { "total": 1, "page": 1, "limit": 20, "totalPages": 1 }
}

suggestedKind (2026-08-10) publishes the coverage mapping — ABSENT → ABSENCE, LATE_ENTRY → LATE_ENTRY — so you never translate between the two enums yourself: pre-fill the submit form (§5.1) with it verbatim.

4.2 GET /attendance/family/students/:studentId/overview — one child's year

studentId, firstName, lastName, the stats block, and events[] — the family-visible event history, newest first, same shape as §4.3's dayEvents[].

"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, or null
}

Null semantics to render for, not around:

  • every percentage is null until there is anything to count (start of year) — hide the number, don't show 0%;
  • minPresentHoursPercentage is null when the school configured no threshold — hide the comparison entirely.

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) — that is the number the school's threshold speaks about; render presentHours.percentage against minPresentHoursPercentage.

4.3 GET /attendance/family/students/:studentId/days/:date — one day in full

The child's day exactly as the school sees it. Any date is readable; a no-school date answers an empty day, never an error.

{
  "date": "2026-09-07",
  "studentId": "…", "firstName": "Ada", "lastName": "Lovelace",
  "classifications": ["absent"],   // notTaken | suspended | trip | absent | lateEntry | earlyExit | present
                                   // usually one, can combine (late + left early);
                                   // never empty — an untaken register is ["notTaken"]
  "items": [ /* the day's slots, period order — below */ ],
  "cells": [ /* the register as written, oldest first — below */ ],
  "dayEvents": [ /* the family event shape — below */ ],
  "justifications": [ /* every submission covering this day, newest first — §5.2 */ ]
}

items[] — the day's grid

One entry per slot of the child's own timetable that day:

{
  "kind": "LESSON",                 // or "ACTIVITY"
  "label": "Mathematics",           // subject name, or activity name
  "wallStart": "09:00", "wallEnd": "10:00",
  "roomName": "Room 12",
  "teacherNames": ["R. Bianchi"],
  "source": "RECORDED",             // "RECORDED" | "PROJECTED" | null
  "record": { "status": "LATE_ENTRY", "time": "09:15", "note": null, … }
}

source is the slot's display state, and the three values are not interchangeable:

source Meaning
"RECORDED" A stored register cell — record carries status/time/note
"PROJECTED" Inferred from an earlier cell (DAILY-mode departments take the register once per day and project it across the slots) — only record.status is meaningful
null Not taken. This is not an absence — render it as "no register", never as A

cells[] — the register as written

{ recordId, status, time, note, wallStart, label, recordedByName, recordedAt, … }, oldest first. A cell survives timetable edits (each entry is named from its own frozen snapshot), so a cell can legitimately exist at a time items[] no longer schedules. Render cells[] as the source of truth for "what the school wrote" and items[] for "the day's grid" — you cannot reconstruct one from the other.

dayEvents[] — the family event shape

{
  "eventId": "…",
  "date": "2026-09-07",
  "eventType": "ABSENT",                     // ABSENT | LATE_ENTRY
  "state": "ACKNOWLEDGED",
  "notifiedAt": "2026-09-07T08:20:00.000Z",  // null until a notification went out
  "ackAt": "2026-09-07T10:12:00.000Z",       // null while unresolved
  "ackChannel": "APP",                       // PHONE | IN_PERSON | APP — null while unresolved
  "surfacedByName": "Anna Rossi"             // who recorded it; null if unresolvable
}

States you can receive, and how to render them:

state Meaning Render
UNDER_REVIEW Armed, awaiting the family's answer "please justify"
NOTIFIED The grace expired unanswered — inbox + email went out same as UNDER_REVIEW — still open
FOLLOW_UP Reminded once (at most once per event), still unresolved same as UNDER_REVIEW — still open
ACKNOWLEDGED Resolved — ackChannel: "APP" = one of the family's own justifications did it; PHONE / IN_PERSON = the office logged a call/visit "justified / handled"
NOT_REQUIRED The day was announced in advance — nothing was ever owed "already justified"

In-grace, cancelled and superseded events are never present on this surface, and there is no ackReason / ackByName / lastContactAt (office internals) and no justificationId — to show which submission covers a day, render the day's justifications[] block alongside (they match by kind and date, §5).

4.4 Errors on the reads

Case Answer
studentId unlinked, someone else's, or nonexistent 404 NOT_FOUND — indistinguishable on purpose
malformed :date (not YYYY-MM-DD, or an impossible day) 400 VALIDATION_FAILED
office/teacher session on any /family route 403

5. Answering the school — justifications

The family's one write path — and it does two different jobs depending on which side of today the dates fall. Same endpoint, same form; different rules and a different effect.

The form is about… Always available? Effect on the register
today or a day aheadannouncing yes, unconditionally the school's register for those days is pre-filled from the announcement
a past dayanswering only where the school has recorded an absence to answer none — an answer never changes a cell

Announcing is the family telling the school something it does not know yet. Answering is the family responding to something the school has already recorded. Build the form so the user never has to know the distinction: the only place it surfaces is one refusal (422 JUSTIFICATION_DAY_NOT_FLAGGED, §5.4a) and one detail on the confirmation copy (§5.4a again).

The effects are immediate and automatic:

  • every matching open event flips to ACKNOWLEDGED / ackChannel: "APP" on the spot — it leaves the to-justify list and the office worklist in the same instant;
  • a day announced in advance never produces anything to chase: the register for that day is pre-filled from the announcement there and then, and the day's follow-up is born already resolved (NOT_REQUIRED);
  • the submission appears in the justifications[] block of every student-day surface (family day view and school register row alike) from the moment it lands.

Matching is by kind and date — there is no event id in the request. To "justify" a to-justify item, pre-fill the form from the item's studentId + date + suggestedKind (§4.1) and submit. Coverage rules:

Submitted kind Covers
ABSENCE ABSENT events only
LATE_ENTRY LATE_ENTRY events only
EARLY_EXIT nothing — informational (no event type exists for early exits)

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.

5.1 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 + arrival time + reason:
{ "studentId": "…", "kind": "LATE_ENTRY",
  "startDate": "2026-09-10", "time": "09:15", "reason": "medical appointment" }

// EARLY_EXIT — one day + leave time; reason optional (pickup selector
// removed 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 — and any range outside the school year — is 400 VALIDATION_FAILED. 201 answers the full JustificationDto:

{
  "id": "…", "studentId": "…",
  "studentName": "Ada Lovelace",           // 2026-08-10 — on every justification row
  "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",
  "canEdit": true                          // §5.5 — recomputed on every response
}

Who may submit: a referent whose link to the student has canWrite. A read-only link is 403; an unlinked student is a hidden 404; a student session cannot submit at all (403).

No pickup picker. The "who picks the student up" selector was removed by product decision 2026-08-10 — an EARLY_EXIT no longer names a person, and the API neither accepts nor returns the pickupPerson* fields.

5.2 GET /attendance/family/justifications?page=&limit=&studentId=&from=&kind=

The caller's submissions this school year, newest first, paginated; studentId narrows to one child and kind (2026-08-10) to one kind (ABSENCE / LATE_ENTRY / EARLY_EXIT). Students see their own. Rows are the same JustificationDto, attachments included, plus canEdit (§5.5) and — since 2026-08-10 — studentName, so a multi-child table needs no name join.

Every row carries channel (2026-08-10): who recorded the statement. APP = submitted here, in the app. PHONE / IN_PERSON = the school settled the day directly with the family and the ack recorded the justification on their behalf — so office-settled days now appear on this list too, as ordinary rows. What to build for them:

  • render a "recorded by the school" marker off channelsubmittedByName is a staff name on these rows, so the marker is the discriminator, never the name;
  • canEdit is always false on them, whatever their dates: the office's statement is not the family's to rewrite or withdraw (PATCH/DELETE answer 422 JUSTIFICATION_NOT_EDITABLE);
  • documents still attach (§5.3 — the canWrite gate, unchanged): a family can drop the certificate onto the school's record of the phone call;
  • they conflict like any other row: a new family ABSENCE overlapping an office-settled day is a 409 JUSTIFICATION_CONFLICT, exactly as against the family's own earlier statement. A same-day same-kind LATE_ENTRY / EARLY_EXIT resubmission replaces the office row in place — same id, new content, and the marker flips to APP: the statement is now the family's.

from filters by coverage, not by start. from=2026-09-14 keeps every communication whose endDate is on or after that day — so a multi-day absence that began on the 12th and runs to the 16th is included. That is the useful reading for a family: an absence already under way is still upcoming.

The two lists a landing screen wants, neither needing a date from the user:

  • "What does the school still want an answer about"to-justify (§4.1), unchanged.
  • "What have we already told the school about, from today on"justifications?from=<today>.

They are disjoint by construction: anything covered by a submission has already been resolved (or never needed an answer).

One ordering trap: the list is sorted by submission time, not by the days it covers. A justification submitted months ago can still cover next week, so do not assume the upcoming ones sit on page one — page through the result (or pass a generous limit; per-family volumes are tens per year, not thousands) and then filter.

Refetch both lists after any submit, revise or withdraw. And do not cache rows by id across submissions: re-submitting the same kind on the same day overwrites the existing row in place (same id, new content), and a new ABSENCE hard-deletes any late-arrival/early-exit rows inside its range.

The same content also appears per-day in the day view's justifications[] (§4.3): every submission whose date range covers that day, in the identical block the school sees on its register row (the DTO minus studentId / studentName / canEdit — the day already names the student, and the school's copy carries no edit affordance).

5.3 Attachments

Evidence documents (medical certificate, booking confirmation) on ABSENCE and LATE_ENTRY only. Uploading changes no event — matching already happened at submit; a certificate arriving days later is the expected flow.

  • POST /attendance/family/justifications/:id/filesmultipart/form-data, field name file, one file per call, up to 5 per justification, max 10 MB, application/pdf / image/jpeg / image/png. Same writer gate as the POST.
  • DELETE /attendance/family/justifications/:id/files/:fileId204. A fileId belonging to a different justification is a plain 404.
  • GET /attendance/family/justifications/:id/files/:fileId — mints a signed URL { url, fileName, mimeType, byteSize, expiresAt }; fetch the URL directly, it expires — don't cache it.

Documents ignore the edit window. Both the upload and the delete work on any justification regardless of its dates, including ones that are already final. This is deliberate and it is the one exception to "the past is read-only": a certificate normally arrives after the child is back, so the communication it belongs to has almost always started already. Do not gate the attachment controls on canEdit — the statement is frozen, its evidence is not.

(The school downloads the same files through its own route, outside /family — not this guide's surface.)

5.4 Errors on the write path

Case Answer
kind-shape mismatch, out-of-year range 400 VALIDATION_FAILED
kind or studentId in a PATCH body 400 (not in the DTO — §5.5)
read-only link (canWrite false) 403
student session on any write 403
unlinked studentId in the body 404 NOT_FOUND
justification not the caller's / nonexistent 404 JUSTIFICATION_NOT_FOUND
the communication has already started (PATCH/DELETE) 422 JUSTIFICATION_NOT_EDITABLE
a PATCH that would back-date into a started day 422 JUSTIFICATION_NOT_EDITABLE
an office-recorded row (channelAPP) on PATCH/DELETE, whatever its dates 422 JUSTIFICATION_NOT_EDITABLE
the new range collides with another communication 409 JUSTIFICATION_CONFLICT
a past day the school never recorded an absence for 422 JUSTIFICATION_DAY_NOT_FLAGGED (§5.4a)
a past EARLY_EXIT, whatever the day 422 JUSTIFICATION_DAY_NOT_FLAGGED (§5.4a)
file on an EARLY_EXIT 422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED
6th file 409 JUSTIFICATION_ATTACHMENT_LIMIT
oversized / wrong-type file standard file-upload errors (413 / 422)

5.4a Reaching into the past — what the form may and may not do

Today and every day ahead: the form is always open. Nothing to check, no date bound to enforce client-side.

A past day is only accepted where the school has actually recorded something to answer — in practice, exactly the days already sitting on to justify (§4.1) and the resolved days in the child's history. Anything else answers 422 JUSTIFICATION_DAY_NOT_FLAGGED. Render the server messages verbatim: they name the day and the thing that is still available ("You can still tell us about today or a day ahead"), so the refusal is not a dead end.

Two details worth building for:

  • There is no deadline. A day on to justify stays answerable for as long as the year is open. Do not build a countdown, grey out old rows, or hide the answer control by age — a certificate arrives when it arrives, and the school controls which days are on the list.
  • EARLY_EXIT is today-or-future only. An early exit is inherently an announcement; there is no such thing as answering one after the fact. Offer the kind only for today onward.

A range that starts in the past and ends in the future is judged on its past part alone — future days do not rescue it. One flagged day anywhere in that past part is enough, so a Mon–Fri illness is accepted even if the school only took the register Mon–Wed.

5.5 Revising and withdrawing

The rule, in one line: a family may change what it has announced, never what has already happened. A communication stays editable only while the first day it covers is still in the future. The moment that day begins the whole row freezes — including a multi-day absence that started yesterday and runs through next week. There is no partial editing of the future tail.

canEdit on every row is the answer — use it, don't recompute it. It is true only when the window is open and the caller holds a writable link to that child. It is false for every student session and on every row the school sees.

canEdit cannot be derived from /permissions. The attendance.justify grant is unconditional — a referent always has it. The window is a per-row temporal fact, not a permission, so /permissions will happily report that the user may justify while a specific row is frozen. Drive the edit and withdraw controls off canEdit, and nothing else.

PATCH /attendance/family/justifications/:id — send only what changes: startDate, endDate, time, reason. Anything omitted keeps its current value. Answers the full refreshed JustificationDto.

  • kind and studentId are not accepted and a body containing either is a 400. Changing the kind of communication, or moving it to a sibling, is withdraw-and-resubmit.
  • For LATE_ENTRY / EARLY_EXIT, sending startDate alone moves the whole (single-day) communication — you do not also need endDate.
  • For ABSENCE, if the new startDate is later than the current endDate, send both or you get a 400.
  • The result must satisfy the same rules a fresh submission would (kind shape, inside the school year).

One behaviour differs from submitting. Where re-submitting the same kind on the same day silently replaces the existing communication, moving one onto a day that already has the same kind is a 409 JUSTIFICATION_CONFLICT instead — an edit already has a target row, so nothing is destroyed as a side effect. The family withdraws the other one first. Everything else about the conflict rules is identical, including an absence widened over a late arrival or early exit, which still absorbs it.

DELETE /attendance/family/justifications/:id204, same window. Worth a confirmation dialog, because it is not a soft hide:

  • the register entries the announcement pre-filled are removed too — this is the biggest consequence and the one worth naming in the dialog, because it is much more than "the row disappears from my list". Any entry a teacher has since edited is kept: their correction is their own statement about the day and outlives the announcement;
  • a day whose register was written by the school goes back to needing an answer and will reappear in to justify after the usual correction grace (~15 min), so the school will chase it again. A day whose entries came from the announcement itself simply goes quiet — the school never asserted an absence there, so there is nothing left to answer;
  • its attachments go with it.

Withdrawing and immediately re-submitting inside that grace is silent — the family will not trigger a notification by fixing a mistake straight away.

Note the interaction with the window: a communication announced for today freezes the moment the day begins (canEdit: false), so from then on its pre-filled entries can only be corrected by the office. Say so in the confirmation copy if the user is announcing for today.

One thing withdrawal never touches: if the school had already resolved the day themselves — a phone call to the office, a conversation at the desk — that resolution stands. It was the school's decision, not the family's statement. Since 2026-08-10 that decision is itself visible as a row on the list (channel: PHONE | IN_PERSON, §5.2) — and it is exactly the row the family cannot withdraw.

6. The notification center

Every user has a private inbox; for referents it is where the attendance loop's nudges land. All endpoints are auth-only and self-scoped by construction — no permission scope to check, no cross-user view.

Verb + path Purpose Answer
GET /notifications The inbox, newest first paginated { data, meta } (standard page/limit)
GET /notifications?unread=true Only unread rows same shape
GET /notifications/unread-count The badge — poll every 30–60 s while the app is open (no socket/SSE exists or is planned) { count }
PATCH /notifications/:id/read Mark one read — idempotent, keeps the first readAt the updated row
POST /notifications/read-all Mark everything read { updated }

A row is:

{
  "id": "…",
  "kind": "attendance.event",
  "payload": { /* kind-specific — below */ },
  "createdAt": "2026-09-10T08:20:00.000Z",
  "readAt": null                 // null = unread
}

No tenantId, no recipient echo — the caller is the recipient. A PATCH …/read on a missing or not-own id answers 404 NOTIFICATION_NOT_FOUND — treat as gone, refresh the list.

The backend never sends display copy. Render localized text from kind + payload (same division of labor as the error contract), and render unknown kinds harmlessly (a generic "you have a notification" row, never a crash) — new kinds will appear without an FE release.

The two kinds today

Both share one payload:

{ "eventId": "…", "studentId": "…", "studentName": "Ada Lovelace",
  "date": "2026-09-07", "eventType": "ABSENT" }   // ABSENT | LATE_ENTRY
Kind When Suggested copy shape
attendance.event An event passed its grace unanswered — the school asks the family to justify "{studentName} was marked {absent/late} on {date} — please review"
attendance.reminder One reminder delay later, still unresolved (sent at most once per event) "Reminder: {studentName}'s {absence/late entry} on {date} is still awaiting a justification"

Two rules the copy and navigation must respect:

  • Copy MUST lead with the date. Events can be backdated (the school filling in last Tuesday), so "absent today" is wrong copy — this is a justify-request about a specific day, not a live alert.
  • Deep-link both kinds to the child's day view for studentId + date (§4.3), where the justify form pre-fills (§5.1).

Two badges, two meanings — keep both. The to-justify meta.total counts items needing action (drops when an event resolves, regardless of reading); unread-count counts unread inbox rows (drops only on mark-read). Marking a notification read changes nothing on the attendance side, and resolving an event does not mark its notification read.

Audience today: referents with accounts, one notification per linked referent per event. Students are never notified about their own events. An event resolved (justified or office-acked) before its grace expires never notifies at all. Each notification also sends a templated email in the school's language — backend-owned end to end, nothing for the FE to build.

7. Suggested screen map

  1. Landing — the to-justify list (§4.1) as the primary widget with meta.total as its badge; the notification bell polling unread-count (§6).
  2. Child → attendance overview (§4.2) — stats header (percentages vs threshold, only when non-null) + event history; tapping an event or a calendar day opens…
  3. Child → day view (§4.3) — the slots grid (items[]), the recorded cells (cells[]), the day's events with their states, the day's justifications[]; a "justify" button (iff the link has canWrite and attendance.justify is granted) opening…
  4. Justify form (§5.1) — kind selector driving the conditional fields; pre-filled from a to-justify item or a notification deep link; attachment upload afterwards (§5.3).
  5. My submissions (§5.2) — history, per child, attachments downloadable and removable. Two sensible tabs off one endpoint: upcoming (?from=<today>) and all. Show edit/withdraw controls only where the row's canEdit is true (§5.5) — never derived from /permissions — and reuse the §5.1 form for the edit, minus the kind and child selectors, which are fixed. Attachment controls are not gated on canEdit. → For the richer variant — one modal listing every matter, pending and justified together, filterable and sorted future-first — screens 1 and 5 collapse into the justification table guide, which builds it from these same two endpoints with no backend work.
  6. Notification center (§6) — list, mark-read on tap (fire-and-forget — idempotent and cheap), "mark all read" then re-poll the count; rows deep-link to screen 3.

8. What is NOT here

  • No real-time. Events surface ~15 minutes after recording; the badge is a poll. No sockets, and no Web Push / mobile push yet (both are designed as future channels of the same notifications — do not build against them).
  • No school-side approval step to track — a submission takes effect immediately and nobody reviews it. (Editing and withdrawing do exist now, but only before the first covered day begins — §5.5.)
  • No editing the past. Once a covered day has begun the communication is final, and there is no appeal route; a genuine mistake on a past day is a phone call to the office. The only thing that stays mutable is its attachments.
  • No self-justification by students — a student session reads §4 for themselves but cannot submit.
  • No school-side surfaces — the admin board, the teacher day, the office follow-up worklist and the school-side attachment download are the attendance module guide's territory.

Student lifecycle (2026-09-03)

GET /attendance/family/to-justify and the family justification list return ENROLLED children only, so a child who has left drops out of the badge and the lists. GET /attendance/family/students/:id/day/:date, /overview and justification-by-id still answer for that child, so route exited children to history-only views using the status you already read from /students. Notifications and reminders about an exited or pre-enrolled child stop; the events stay open server-side. No payload change. Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.