Skip to content

The justification table — the atomic FE guide for the "all justifications" modal

STATUS: AUTHORITATIVE for this screen. Published 2026-08-10; amended the same day to be self-contained. Owner: backend. Amend in place.

Everything this screen needs is inline — the request and response shape of every route it calls, the gating rules, the error table. You should not need another file to build it. The attendance family guide remains authoritative for the wider family surface (the child overview, the day view, the notification center) and is the base contract this guide restates; if the two ever disagree, the family guide wins — and tell the backend, that's a doc bug.

No backend work is needed for this screen. Everything below is built from endpoints that shipped on 2026-08-10.

Updated 2026-08-10 (pickup removal) — BREAKING on EARLY_EXIT. Product removed the "who picks the student up" selector: pickupPersonType / pickupPersonId are no longer accepted on POST/PATCH (sending them is a 400), the three pickupPerson* fields are gone from every response, and PICKUP_PERSON_NOT_ELIGIBLE no longer exists. An EARLY_EXIT is date + time (+ optional reason). Build no pickup picker; the copy below has been rewritten accordingly.

Updated 2026-08-10 (office acks are justifications) — additive, and it REVERSES one product call below. A day the school settles directly (a phone call, the front desk) now records a real justification row, so it lands in the justified bucket of this very table instead of vanishing from both. Every justification row gains channel (APP | PHONE | IN_PERSON) — render PHONE/IN_PERSON rows as "recorded by the school", with edit/withdraw hidden (canEdit is always false on them) and documents still allowed. The two buckets stay disjoint — the ack closes the pending event and creates the justified row in one commit — so the merge logic below is unchanged. §10's "phoned days show nothing" entry is rewritten accordingly.

Scope: the modal that shows a family every attendance matter for their children — what the school is still waiting on, and what the family has already told them — as one filterable table sorted newest-first, future days above past ones, with the submit / revise / withdraw / documents actions on each row.


1. The one idea

Two things can appear in this table, and they come from two endpoints:

Row status Means Comes from
Pending The school recorded an absence or late arrival and is waiting for an answer. GET /attendance/family/to-justify
Justified The family made a statement about a day — either answering the school, or announcing a day in advance. GET /attendance/family/justifications

The two lists never overlap. That is a backend guarantee, not a coincidence: the moment a justification covers an event, that event stops being open and leaves to-justify. So you can concatenate the two results with no deduplication, no cross-referencing, and no risk of showing the same day twice as both pending and justified.

This is also exactly the user's mental model — "what do they want from me" and "what have I told them" — so the two buckets are the status filter.

Everything else is client-side. Fetch both lists in full, normalize them into one row shape, then sort and filter in memory. Per-family volumes are tens of rows per school year, so this is cheap and gives you instant filtering with no refetch. The two-endpoint merge is the platform's response-composition doctrine, not a workaround — do not wait for a unified endpoint.


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; none is public.
  • Dates are calendar days in the school's timezone. Every date — query 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, and "today" below always means school-today.
  • Pagination is the platform standard: page / limit query params, { data: [...], meta: { total, page, limit, totalPages } } envelope, limit capped at 100.
  • The error body is the platform envelope — branch on code, render messages[lang] verbatim (backend-owned, already localized):
{
  "statusCode": 422,
  "code": "JUSTIFICATION_NOT_EDITABLE",
  "messages": { "en_US": "…", "it_IT": "…" },
  "field": "…",       // only on field-locatable validation errors
  "timestamp": "2026-09-10T09:12:33.120Z",
  "path": "/attendance/family/justifications/…"
}
  • Who can do what. Reads (both lists, the download URL) ride the attendance read grant every family session holds. Every mutation additionally needs the attendance.justify action from GET /permissions (referent-only today — render the whole write surface off that flag, never off the role name) plus a per-child canWrite link flag (§3), plus — for revise/withdraw only — the per-row canEdit (§8.1).

3. The data you need

Three calls, once when the modal opens.

GET /attendance/family/to-justify?page=1&limit=100
GET /attendance/family/justifications?page=1&limit=100
GET /auth/profile                     → profiles.referent.students.items[]

Every row in both lists carries studentName, so GET /students is not part of this screen's requirements — see the child map below for the one case where you might still want it.

Both attendance lists are paginated and capped at limit=100. Page until meta.page >= meta.totalPages. A family of three children with a heavy year is two or three calls per list; most families are one each. Do not stop at the first page — see §9 for why that would silently truncate the table.

Do not pass the filters (studentId / from / kind) on the initial loads. They exist for narrower screens — the "what's coming up" landing widget — and for families whose year outgrows client-side volumes (§7); this table wants the whole year and does its own filtering.

The child map

There is no name join: every row already carries studentName. The map exists for canWrite, which the rows cannot tell you:

type Child = { studentId: string; canWrite: boolean };
// canWrite ← GET /auth/profile → profiles.referent.students.items[]
const children = new Map<string, Child>();

canWrite decides whether a row's actions are offered at all (§8). For the child filter's option list, derive (studentId, studentName) pairs from the assembled rows themselves — that covers every child who can appear in the table. Keep GET /students only if you want the filter to also list a linked child with zero rows this year.


4. The two payloads

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

Every event still awaiting the family's answer, across all the caller's linked children in the active year, newest day first. 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.

{
  "data": [
    {
      "eventId": "…",
      "date": "2026-09-07",            // the school day it is about
      "eventType": "ABSENT",           // ABSENT | LATE_ENTRY — what the register recorded
      "suggestedKind": "ABSENCE",      // the justification kind that covers it — §5
      "state": "UNDER_REVIEW",         // UNDER_REVIEW | NOTIFIED | FOLLOW_UP
      "studentId": "…",
      "studentName": "Ada Rossi",
      "surfacedByName": "Anna Bruni"   // who recorded it; null if unresolvable
    }
  ],
  "meta": { "total": 1, "page": 1, "limit": 100, "totalPages": 1 }
}

state is the school's escalation ladder (notified → reminded), not something a family acts on — every pending row means the same thing to them: answer this. You will never receive a resolved or cancelled state here.

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

Every communication the family submitted this school year, newest submission first (see the ordering trap in §6). studentId and kind narrow; from filters by coverage — it keeps rows whose endDate is on or after the day, so a multi-day absence already under way still matches.

{
  "data": [
    {
      "id": "…",
      "studentId": "…",
      "studentName": "Ada Rossi",
      "kind": "ABSENCE",                 // ABSENCE | LATE_ENTRY | EARLY_EXIT
      "startDate": "2026-09-14",
      "endDate": "2026-09-18",           // === startDate for the single-day kinds
      "time": null,                      // HH:mm — arrival (LATE_ENTRY) / leave (EARLY_EXIT)
      "reason": "surgery",
      "attachments": [                   // { fileId, fileName }[]
        { "fileId": "…", "fileName": "cert.pdf" }
      ],
      "submittedByName": "Maria Rossi",  // snapshotted at submit — a STAFF name when channel ≠ APP
      "submittedAt": "2026-09-09T18:00:00.000Z",
      "channel": "APP",                  // APP = submitted here | PHONE | IN_PERSON = recorded by the school
      "canEdit": true                    // §8.1 — drives revise/withdraw, nothing else
    }
  ],
  "meta": { "total": 1, "page": 1, "limit": 100, "totalPages": 1 }
}

Every mutation (POST 201, PATCH 200) answers this same full JustificationDto, canEdit recomputed.

channel says who recorded the statement (2026-08-10). APP is a family submission. PHONE / IN_PERSON means the school settled the day directly and the ack recorded the justification on the family's behalf — an ordinary row of this list, with a staff submittedByName and canEdit: false. Badge those rows off channel; see §10.


5. One row shape

Normalize both payloads into a single type before they reach the table. Every sort and filter below operates on this shape, not on the raw DTOs.

type MatterStatus = 'PENDING' | 'JUSTIFIED';
type MatterKind = 'ABSENCE' | 'LATE_ENTRY' | 'EARLY_EXIT';

type Matter = {
  key: string;            // `event:${eventId}` | `justification:${id}`
  status: MatterStatus;
  studentId: string;
  studentName: string;

  kind: MatterKind;       // unified — see the mapping below
  startDate: string;      // YYYY-MM-DD
  endDate: string;        // === startDate for everything single-day
  sortDate: string;       // === startDate; the day the row is ABOUT

  // Justified rows only
  justificationId?: string;
  time?: string | null;           // HH:mm — arrival / leave
  reason?: string | null;
  attachments?: { fileId: string; fileName: string }[];
  submittedByName?: string;       // a STAFF name when channel ≠ APP
  submittedAt?: string;
  channel?: 'APP' | 'PHONE' | 'IN_PERSON'; // who recorded it — badge, never infer from the name
  canEdit?: boolean;              // §8.1 — drives the row actions

  // Pending rows only
  eventId?: string;
  surfacedByName?: string | null; // who recorded it
};

Mapping

Matter field From a pending item From a justification
status 'PENDING' 'JUSTIFIED'
key `event:${eventId}` `justification:${id}`
studentName studentName verbatim studentName verbatim
kind suggestedKind verbatim kind verbatim
startDate / endDate / sortDate all three = date startDate / endDate / startDate
channel (omit — a pending row is nobody's statement yet) channel verbatim
canEdit (omit — see §8.1) canEdit verbatim

You never derive the enum mapping. A pending item still carries eventType (ABSENT — what the register says happened), but it also carries suggestedKind (ABSENCE — what a justification covering it is called). Take suggestedKind as the unified kind and the two payloads share one vocabulary with zero translation on your side. EARLY_EXIT exists only on justified rows — no event type corresponds to it, because an early exit always involves a person collecting the child.

A multi-day absence is one row. startDateendDate describes the range, and the row sorts on startDate. Do not expand it into a row per covered day: five rows sharing one id would make the edit and withdraw controls act on a row the user did not click, and a single withdraw would silently remove four other rows from the table.


6. Sorting

Sort by sortDate descending — future days first, then today, then the past. That is what "sorted by recency" means here: a family cares most about what is coming and what just happened.

Add a deterministic tie-break so the table does not reorder between renders when several rows share a date:

matters.sort((a, b) =>
  b.sortDate.localeCompare(a.sortDate) ||        // future → past
  Number(b.status === 'PENDING') - Number(a.status === 'PENDING') ||
  a.studentName.localeCompare(b.studentName) ||
  a.key.localeCompare(b.key),
);

Pending sorts above justified within a day on purpose: on a day that has both, the one needing action is the one to show first.

sortDate is a YYYY-MM-DD string, so plain string comparison is correct ordering — no Date parsing, and no timezone to get wrong. Resist converting these to Date objects: these are calendar days, and a Date will drag a local offset into them that can shift a row by a day.

The ordering trap. The justifications endpoint is sorted by submission time, not by the days it covers — a row submitted months ago can still cover next week. That is exactly why this section sorts client-side on sortDate after fetching everything: do not assume upcoming rows sit on the endpoint's page one.


7. Filters

All client-side, all instant, none needing a refetch.

Filter Applies to How
Status both status === 'PENDING' \| 'JUSTIFIED' — the two buckets.
Child both studentId; options derived from the rows (§3). Multi-select is a plain includes.
What happened both the unified kind. Note EARLY_EXIT only ever matches justified rows.
Date range both overlap, not containment: row.endDate >= filterFrom && row.startDate <= filterTo. A multi-day absence must match a filter that touches any day it covers.
Has documents justified (attachments?.length ?? 0) > 0. Renders as "with certificate".
Free text justified reason and submittedByName. Pending rows have no free text of their own — decide whether they drop out of a text search or always pass; dropping out is the less surprising default.

Child and date can also be pushed server-side: both endpoints take studentId and from, and the justifications list takes kind. For a typical family the client-side approach above is strictly better — instant, no refetch — but if a heavy year makes the full fetch feel expensive, narrow at the source and keep the rest of this section unchanged.

Two filters that look useful and are not:

  • "Editable only"canEdit is a temporal fact that changes at midnight, so a filter on it goes stale while the modal is open. Show it as a per-row affordance (§8) instead.
  • Event state (NOTIFIED / FOLLOW_UP / UNDER_REVIEW) — this is the school's escalation ladder, not information a family can act on. Every pending row means the same thing to them: answer this.

8. Managing rows — the CRUD

8.1 The three gates, and which control reads which

Gate Comes from Governs
attendance.justify action GET /permissions whether the write surface exists at all — student sessions hold the reads but never this, so their table renders read-only
canWrite the child map (§3) all actions on that child's rows: justify, revise, withdraw, documents
canEdit the row itself revise and withdraw only — never documents, never the pending-row justify action

canEdit is true only while the first day the row covers is still in the future and the caller's link has canWrite and the row is the family's own (channel === 'APP'). Once a covered day begins, the whole statement is final — including a multi-day absence that started yesterday and runs through next week; there is no partial editing of the future tail. An office-recorded row (channel PHONE/IN_PERSON) is never editable at all, whatever its dates — it is the school's statement, not the family's.

canEdit cannot be derived from GET /permissions. The attendance.justify grant is unconditional — a referent always holds it — while the window is a per-row temporal fact. A table that greys out actions based on the permissions payload will offer edit on frozen rows and collect 422s. Read canEdit off the row, and nothing else.

8.2 Justify a pending row / announce a day — POST /attendance/family/justifications

The action on a pending row is justify this day: open the form pre-filled with the row's studentId, date and suggestedKind, and submit. Matching is by kind and date — there is no event id in the request; coverage happens server-side the moment the row lands. The same form, opened blank, is how a family announces a future day.

// 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; any mismatch (and any range outside the school year) is 400 VALIDATION_FAILED:

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

201 answers the full JustificationDto (§4). What a submission covers:

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

Past days. Today and every day ahead: the form is always open, nothing to check client-side. A past day is accepted only where the school actually recorded something to answer — in practice, exactly the days sitting on the pending side of this table — anything else is 422 JUSTIFICATION_DAY_NOT_FLAGGED; render its messages verbatim, they name the day and what is still available. Three consequences for this screen:

  • There is no deadline. A pending row stays answerable for as long as the year is open. Do not build a countdown, grey out old rows, or hide the justify control by age.
  • EARLY_EXIT is today-or-future only — an early exit is inherently an announcement. Offer the kind only for today onward.
  • A range starting in the past is judged on its past part alone — one flagged day anywhere in it is enough; future days neither rescue nor hurt.

Resubmission is not an error. Submitting the same kind on the same day replaces the existing communication in place — same id, new content, attachments kept. A new ABSENCE absorbs any late-arrival/early-exit rows inside its range (they are hard-deleted). A contradiction — a day already covered by an absence, or two overlapping absences — is 409 JUSTIFICATION_CONFLICT; its messages name the blocking communication.

Coverage and consistency are separate: a different school-recorded follow-up state creates a new to-justify item, but the conflict rules above still prevent the family from submitting a communication that contradicts an existing absence-covered day.

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.

8.3 Revise — PATCH /attendance/family/justifications/:id

Gate: canEdit === true. Send only what changesstartDate, endDate, time, reason; anything omitted keeps its current value. 200 answers the full refreshed JustificationDto.

  • kind and studentId are not accepted — a body containing either is a plain 400. Changing the kind, or moving a communication 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 merged result must satisfy everything a fresh submission would: the kind-shape table, inside the school year.
  • The window applies twice — to the stored row and to the proposed range, so an edit cannot back-date a statement into a day that has begun. Either violation is 422 JUSTIFICATION_NOT_EDITABLE.

One behaviour differs from submitting. Where re-submitting the same kind on the same day silently replaces, moving a communication onto a day that already has the same kind is a 409 JUSTIFICATION_CONFLICT — 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, which still absorbs it.

8.4 Withdraw — DELETE /attendance/family/justifications/:id

Gate: canEdit === true. 204, and it deserves a real confirmation dialog, because it is not a soft hide:

  • the register entries the announcement pre-filled are removed too — the consequence worth naming in the dialog. Any entry a teacher has since edited is kept: their correction is their own statement about the day;
  • a day whose register the school wrote goes back to needing an answer and will reappear on the pending side after the correction grace (~15 min), so the school will chase it again. A day whose entries came from the announcement itself simply goes quiet;
  • its documents are deleted with it;
  • a day the school already settled itself (a phone call, the front desk) is never touched — that resolution was the school's decision, and since 2026-08-10 it is its own row here (channelAPP), which withdraw cannot reach.

Withdrawing and immediately re-submitting inside the grace is silent — fixing a mistake straight away triggers no notification.

8.5 Documents — POST / DELETE / GET …/:id/files

Gate: canWrite only — never canEdit. Evidence runs on its own clock: a medical certificate normally arrives after the child is back, so the row it belongs to has almost always started. Attachment controls stay live on a row whose edit and withdraw controls are gone; this is deliberate, and the one place where "the past is read-only" does not hold.

  • 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. Answers the file's metadata (id, fileName, mimeType, byteSize, …) — that id is what the row's attachments[] calls fileId. ABSENCE and LATE_ENTRY only — an EARLY_EXIT announcement carries no evidence (422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED).
  • DELETE …/:id/files/:fileId204. A fileId belonging to a different justification is a plain 404. Deleting frees a slot under the cap.
  • GET …/:id/files/:fileId — mints a signed URL { url, fileName, mimeType, byteSize, expiresAt }; fetch it directly, it expires — don't cache it. This one is a read: student sessions can download.

8.6 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 contract — §8.3)
read-only link (canWrite false) 403
student session on any write 403
unlinked studentId in a POST 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 422 JUSTIFICATION_NOT_EDITABLE
the range collides with another communication (an office-recorded one included) 409 JUSTIFICATION_CONFLICT
a past day the school never flagged (or any past EARLY_EXIT) 422 JUSTIFICATION_DAY_NOT_FLAGGED
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)

Note what is absent: there is no "already justified" error. Covering an already-covered day is either an in-place replace or a 409 per §8.2 — the matrix, not a separate code.


9. Refetching

Refetch both lists after any successful submit, revise or withdraw. They are two halves of one picture and every mutation can move a row between them:

The user did What changes
Submitted a justification a pending row disappears, a justified row appears
Widened an absence over another announcement the swallowed row disappears (backend absorb)
Re-submitted the same kind on the same day an existing row is replaced in place — same id, new content
Withdrew a justification the row disappears; its school-written days return as pending after ~15 min

Because of rows two and three, do not treat justificationId as a stable handle across mutations, and do not patch the table optimistically from a mutation response alone — a single write can remove rows you did not touch. Refetch and rebuild.

The ~15 min lag is the correction grace: a teacher's mistyped absence corrected inside that window never reaches the family at all. It means a just-recorded absence will not appear immediately, and a just-withdrawn day will not return immediately. Neither is a bug, and neither needs polling — the modal reloading on open is enough. (The grace is env-tunable per environment; don't hardcode the number in copy.)

Document mutations (§8.5) don't move rows between buckets — after an upload or delete you may just patch the row's attachments locally, or refetch; either is correct.


10. Things that will look like bugs and are not

A day showing both a pending row and a justified row. Legitimate: an EARLY_EXIT announcement covers no event, so a child who was announced as leaving at 14:00 and also recorded absent that morning has one of each. The early exit does not answer the absence, and the school still wants an answer.

A day the family phoned the office about appearing as justified — with a staff name on it. (Reversed 2026-08-10 — this table used to show such days in neither bucket.) When the school resolves a day directly — a phone call, a conversation at the desk — the ack records a justification on the family's behalf, so the day lands in the justified bucket like any submission. channel (PHONE / IN_PERSON) is the tell — render a "recorded by the school" marker off it, never off submittedByName (which is the staff member's name, not a bug). Edit and withdraw stay hidden (canEdit: false, always); documents still attach. A family ABSENCE overlapping such a day is a 409 JUSTIFICATION_CONFLICT, same as against their own earlier statement.

A justification for a day with no absence on it. Announcements are forward-looking; the register catches up later, or the child turns out to be fine and it never does. A justified row does not imply the school recorded anything.

A pending row for a future date. Rare but real: the school can record a known future absence in advance. It is pending because nobody has explained it.

Student sessions see the table read-only. A student holds the same reads but never the justify action, so every row's actions are hidden and every canEdit is false. Build one table and let the gates empty it of controls rather than branching the screen.


11. Worked example

Three children, school-today is 2026-09-10.

GET /attendance/family/to-justify?limit=100
  { data: [
    { eventId:'e1', date:'2026-09-09', eventType:'ABSENT',
      suggestedKind:'ABSENCE', studentId:'s1', studentName:'Ada Rossi',
      surfacedByName:'M. Bruni' },
    { eventId:'e2', date:'2026-09-04', eventType:'LATE_ENTRY',
      suggestedKind:'LATE_ENTRY', studentId:'s2', studentName:'Bo Rossi',
      surfacedByName:'M. Bruni' },
  ], meta:{ total:2, page:1, limit:100, totalPages:1 } }

GET /attendance/family/justifications?limit=100
  { data: [
    { id:'j1', studentId:'s1', studentName:'Ada Rossi', kind:'ABSENCE',
      startDate:'2026-09-14', endDate:'2026-09-18',
      reason:'surgery', attachments:[], canEdit:true,  … },
    { id:'j2', studentId:'s2', studentName:'Bo Rossi', kind:'EARLY_EXIT',
      startDate:'2026-09-10', endDate:'2026-09-10', time:'14:00',
      canEdit:false, … },
    { id:'j3', studentId:'s1', studentName:'Ada Rossi', kind:'ABSENCE',
      startDate:'2026-09-01', endDate:'2026-09-01',
      reason:'flu', attachments:[{fileId:'f1',fileName:'cert.pdf'}],
      canEdit:false, … },
  ], meta:{ total:3, page:1, limit:100, totalPages:1 } }

Merged, normalized and sorted:

sortDate Child What Status Row actions
14–18 Sep Ada Rossi Absence Justified edit · withdraw · documents
10 Sep Bo Rossi Early exit (14:00) Justified no documents (EARLY_EXIT) — no actions left; the day has begun
09 Sep Ada Rossi Absence Pending justify this
04 Sep Bo Rossi Late arrival Pending justify this
01 Sep Ada Rossi Absence (1 document) Justified documents only

Note row two: canEdit is false because 10 Sep is today — the window is strictly future — and an early exit never carries documents, so the row has no live control at all. And note that no row is duplicated even though j3 almost certainly resolved an event on 1 Sep: that event is resolved, so it never reached to-justify.


12. Suggested build order

  1. The two fetch loops with page exhaustion, and the child map (canWrite). Log meta.total against the row count you assembled — that catches a truncating loop immediately, which is the one bug in this screen that produces a plausible-looking table.
  2. The Matter normalizer. Unit-test the range fields and that kind reads suggestedKind on pending rows; everything downstream depends on it.
  3. Sort and render, no filters. Confirm future-first ordering against a multi-day row.
  4. Filters, cheapest first: status, child, kind, then date-range overlap.
  5. The submit form (§8.2) with the kind-shape table mirrored, wired to the pending rows' justify this pre-fill.
  6. Revise + withdraw (§8.3–§8.4), gated on canEdit, with the withdraw confirmation naming its consequences.
  7. Documents (§8.5), gated on canWrite — deliberately not on canEdit.

Student lifecycle (2026-09-03)

GET /attendance/justifications lists rows of ENROLLED students only; a justification of a student who has since left is no longer in the table (the row and its attachments still exist, and justification-by-id still answers). No payload change. Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.