Skip to content

Attendance family loop, slice C — justifications (reactive + proactive), covering-match, row surfacing

Amendment 2026-08-11: event coverage is now exact: ABSENCE covers only ABSENT, and LATE_ENTRY covers only LATE_ENTRY. A different school-recorded follow-up state creates its own to-justify item after the arm window. The existing consistency matrix remains unchanged: a family submission still cannot pierce an existing ABSENCE communication.

Program position: A loop data core → B referent read surface → C justifications (this spec) → D notifications. Slice A left exactly one seam for this slice: findCoveringJustification in attendance-loop.service.ts, called at mint time; AckChannel.APP and state NOT_REQUIRED are reserved and never written until now.

1. Problem distillation

  • Slice B shows families what they owe an answer for; nothing lets them answer. This slice is the family's write path: justify an absence-like event (reactive, from the to-justify list) and pre-announce one (proactive forms), with attachments (medical certificates).
  • Three proactive forms (Fabio's map): absence = date range + reason + attachments; early exit = date + hour + pickup person picked from the student's referents + guardians; late entry = date + hour + reason. The reactive justify is not a fourth thing — it is the same submission, pre-filled by FE to cover the event's date and type.
  • The covering rule closes the loop at both seams: a justification submitted before the register is written makes the event be born NOT_REQUIRED (mint-time check — the stub becomes real); a justification submitted after resolves matching open events to ACKNOWLEDGED via the APP channel (submit-time sweep). Either way the event leaves the open set.
  • The no-notify invariant (Fabio, chat 2026-08-04: "important to not notify when a justification/event is there"): slice D's sweeper will only ever claim open events. Coverage removes events from the open set at both seams, therefore a covered event can never trigger a notification — the invariant holds by construction, with zero slice-D coordination.
  • Surfacing is one place (Fabio, chat 2026-08-04): all justification information nests under the student's day row — the same nested block on the admin day register, the teacher group rows, and the family day view. No separate communications worklist; an early-exit announcement is visible to the office and the teacher exactly where they look at that student's day.
  • No review/approval workflow: a submitted justification is final and immediately effective. The office reads it on the row; disputing one is a human process outside the system.

Success criteria (observable behavior that proves this works): - Referent submits an ABSENCE justification for next Mon–Wed; when the teacher records ABSENT cells on Monday, the event is born NOT_REQUIRED (never armed, never in any worklist, never owed, will never notify). - Referent opens the to-justify list, picks an armed ABSENT event, submits a justification covering that date → event flips to ACKNOWLEDGED (APP, referent name snapshot), disappears from to-justify and from the admin worklist, stays in family history. - A student whose recorded outcome differs from the advance justification receives a new to-justify item for the recorded type after the arm window; matching remains exact in both directions. - Admin getRows, the admin day register, the write envelope records[].row, and the family day view all show the same justifications[] block for a covered date: kind, dates, time, reason, pickup person, attachment metadata, submitter, submitted-at. - Early exit submitted for today at 14:00 with grandma (an isAuthorizedPickup guardian) shows on that student's row for today on every surface; it never mints, resolves, or covers any event. - A second referent with canWrite = false on the link gets 403; a student login gets 403 on all writes (no justify action) while still reading their own day view where the justification block appears. - Attachments upload per-file onto an existing justification (cap 5), download via signed URL for the owning family, the office, and teachers for their own students (taught-set fenced); a teacher requesting a non-taught student's attachment gets an existence-hiding 404. - Every event a justification resolves or pre-empts records which justification did it (justificationId on the event) — write-time provenance, visible on the office dayEvents[].

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No notifications of any kind — slice D. This slice only guarantees the no-notify invariant structurally. - No review/approval state machine on the justification; no edit, no cancel, no delete (immutability is what keeps covering-matching sane — resolving events is one-way). - No student self-justify (no age-of-majority concept exists anywhere in the system). - No office-side justification write (the office already has the ack endpoint with PHONE/IN_PERSON). - No interplay with register cells: an EARLY_EXIT announcement does not write or expect an earlyExit cell; the teacher records reality independently. - No per-school caps/config for range length or attachment count — named constants.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/attendance-loop.service.ts The mint diff calls findCoveringJustification(tx, {tenantId, studentId, date, eventType}) and births covered events NOT_REQUIRED — this spec replaces that one stub function, not the diff around it Stub returns null today; contract unchanged
docs/superpowers/specs/2026-08-04-attendance-family-read-surface-design.md The /attendance/family/* cluster, AttendanceFamilyPolicy (referent+student pass-through; the policy is the office/teacher fence), familyStudentAccessWhere builders, existence-hiding 404 (NOT_FOUND {entity: 'student'}) Slice B is read-only; the write fence needs the new justify action on top
src/files/files.service.ts + src/students/students.controller.ts documents routes Collection-kind File (polymorphic ownerType + ownerId), uploadBlob outside tx / attachFile inside tx with compensation-delete, one-file-per-request multipart (FileInterceptor + AssertFilePresentPipe), signed-URL GETs Owner types are all person tables today — JUSTIFICATION is the first non-person owner (additive enum values, no new storage machinery)
src/attendance/attendance-board.service.ts buildStudentRows THE single choke point for every office/teacher row surface (group rows, admin day register, write envelope records[].row) — the justifications[] load+nest lands here once; it already batch-loads dayEvents the same way The family day view assembles its own response from buildStudentRowCore — it adds the same block with its own query (one extra field, symmetric by construction)
src/attendance/attendance-insights.controller.ts ack route (slice A) Scope + action gating shape; the singular-@RequireScope lesson (plural @RequireScopes(…, 'write') makes FieldWriteGuard read DTO keys as scope names) Ack is office-side write-scope + manage_communications; family writes gate on read-scope + the new justify action instead (§3)
src/audit-log/ AuditService.record(tx, …) One in-tx audit entry per justification create (family action on the family trail), carrying the swept event ids in the payload Attachment uploads ride the create's trail conceptually; per-file audit entries are noise — not recorded

On-axis / off-axis check (ch16 §4–§5): new entity + new action key + additive enum values are all paved paths (ch11 recipes + slice-A/B precedents). The submit-time sweep inside the create tx is the same divergence slice A already accepted for the mint diff (same-database derived state, atomicity is the point). The one mild novelty is the first non-person FileOwnerType — called out here as required; it extends an existing polymorphic pattern rather than inventing one.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes New model carries tenantId; full new-tenant-bearing-model checklist (ch12): RLS policy in the migration + src/prisma/rls-coverage.ts + src/prisma/tenanted-models.ts — all five drift guards New table
Academic-year scope yes academicYearId stamped from the active year at submit; the date range must fall inside the active year's calendar bounds Justifications are year-bound facts like events
RBAC entity key existing ATTENDANCE No delta to entity-keys.ts
Scopes existing attendance.register All family routes: @RequireScopes(ATTENDANCE, 'read') — referent+student already hold READ (slice B) Scope = surface membership; the privilege lives in the action
Actions new attendance.justify Writes additionally @RequireAction(ATTENDANCE, 'justify'); granted to the frozen referent preset only (self-applies to prod on deploy via frozen-preset reconciliation). Students hold read but not the action → 403 on writes, 200 on reads Mirrors the ack route's "scope + action" shape; avoids granting referents attendance.register WRITE (the teacher-write scope — would muddy the slice-B fence)
Service base custom AttendanceJustificationsService in src/attendance/ (create + sweep + list + attachments + row-nesting loader); findCoveringJustification in attendance-loop.service.ts becomes a real query via the new queries file Attendance-domain logic; no new module
queries.ts shape yes New attendance-justifications.queries.ts: createJustification(tx, …), findCoveringJustificationRow(tx, {tenantId, studentId, date, eventType}), sweepCoveredOpenEvents(tx, …) (or a load+update pair), findFamilyJustifications(db, …) (paginated), loadJustificationsForRows(db, tenantId, date, studentIds), findJustificationById(db, …) Standard convention
Error codes new JUSTIFICATION_NOT_FOUND (404), PICKUP_PERSON_NOT_ELIGIBLE (422, params: personType), JUSTIFICATION_ATTACHMENT_LIMIT (409, params: limit), JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED (422, params: kind) — ch06 registry + messages{en,it} full-semantics. Kind-conditional field requirements reuse VALIDATION_FAILED Student existence-hiding reuses generic NOT_FOUND {entity: 'student'} (slice-B idiom); canWrite = false → 403 with the house forbidden code
DTO conventions yes dto/create-justification.dto.ts (kind-discriminated validation), dto/justification-response.dto.ts (JustificationDto + RowJustificationDto + attachment metadata), list route reuses PaginationQueryDto / PaginatedResponseDto
File-backed sub-resources yes New FileUsage.JUSTIFICATION + FileOwnerType.JUSTIFICATION (collection-kind, append semantics); upload = one-file-per-request multipart; blob-then-attach flow with compensation-delete First non-person owner type — additive enum migration
Custom fields no
Profile completeness no

The covering rule (normative)

A justification covers an event iff all of: - same tenantId + studentId; - startDate <= event.date <= endDate; - kind compatibility is exact: ABSENCE covers ABSENT only; LATE_ENTRY covers LATE_ENTRY only (its single date); EARLY_EXIT covers nothing (no event type exists for it — it is purely informational).

Applied at exactly two seams, both event-driven, no timer: - Mint time (existing call site in the mint diff): a to-be-created event with a covering justification is born NOT_REQUIRED, armAt = NULL, justificationId = the covering row — it never arms, never appears in any worklist or to-justify list, and (slice D) never notifies. Re-opens (CANCELLED/SUPERSEDED → live again) clear justificationId and re-check coverage the same way (still covered → NOT_REQUIRED + re-stamp). - Submit time (inside the create tx): every event of that student with date in range, kind-compatible, and in a live state — UNDER_REVIEW (armed or in grace — pre-empts slice-D sends, same allowance as the office ack) or NOTIFIED/FOLLOW_UP (slice-D states, future-proof) — flips to ACKNOWLEDGED with ackAt = now, ackByUserId = caller, ackByName = referent display-name snapshot, ackChannel = APP, ackReason = NULL (the reason lives on the justification; no duplication), justificationId = the new row. Resolved states (ACKNOWLEDGED, NOT_REQUIRED, CANCELLED, SUPERSEDED) are never touched.

Provenance (Fabio, chat 2026-08-04): the event carries a nullable justificationId FK stamped at both seams above. Write-time truth beats read-time derivation — overlapping justifications make derivation ambiguous, and the FK is what makes the deferred cancel/edit un-resolution tractable if it ever arrives. The office dayEvents[] surfaces it so FE can pair an event with the justification shown on the same row.

No-notify invariant (binding on slice D): the notification sweeper may only ever claim events in the open set (openFollowUpWhere). Both seams above remove covered events from that set, so a justified/covered event can never send.

Kind-conditional field contract (normative)

Field ABSENCE LATE_ENTRY EARLY_EXIT
startDate required required required
endDate required, >= startDate must equal startDate (or omitted → defaulted) must equal startDate (or omitted → defaulted)
time (HH:mm) forbidden required ("arrives at") required ("leaves at")
reason required required optional
pickupPersonType + pickupPersonId forbidden forbidden required — a StudentReferentLinked referent or a Guardian of that student with isAuthorizedPickup = true, else 422 PICKUP_PERSON_NOT_ELIGIBLE
attachments allowed (≤5) allowed (≤5) rejected (JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED)

Date range must fall within the active academic year's calendar bounds; future dates are the pre-announcement case and fully supported. pickupPersonName is snapshotted at submit (house display-name-snapshot style — survives link/guardian deletion and rollover).


4. Data model plan

Schema deltas

New enums:

enum AttendanceJustificationKind {
  ABSENCE
  LATE_ENTRY
  EARLY_EXIT
}

enum PickupPersonType {
  REFERENT
  GUARDIAN
}

Extended enums (additive values only): FileUsage += JUSTIFICATION; FileOwnerType += JUSTIFICATION.

New model:

/// A family-submitted attendance justification/announcement. Immutable after
/// submit — matching resolves events one-way (see the covering rule in the
/// slice-C spec). EARLY_EXIT is informational only: it never covers events.
model AttendanceJustification {
  id             String                      @id @default(uuid()) @db.Uuid
  tenantId       String                      @map("tenant_id") @db.Uuid
  tenant         Tenant                      @relation(fields: [tenantId], references: [id], onDelete: Restrict)
  academicYearId String                      @map("academic_year_id") @db.Uuid
  academicYear   AcademicYear                @relation(fields: [academicYearId], references: [id], onDelete: Restrict)
  studentId      String                      @map("student_id") @db.Uuid
  student        Student                     @relation(fields: [studentId], references: [id], onDelete: Restrict)

  kind      AttendanceJustificationKind
  startDate DateTime                    @map("start_date") @db.Date
  endDate   DateTime                    @map("end_date") @db.Date
  /// HH:mm wall time — LATE_ENTRY arrival / EARLY_EXIT leave time. Null for ABSENCE.
  time      String?                     @db.VarChar(5)
  reason    String?

  pickupPersonType PickupPersonType? @map("pickup_person_type")
  /// Id of the row in the table named by pickupPersonType (polymorphic, no DB
  /// FK — same pattern as File.ownerId / Invitation.recipientId).
  pickupPersonId   String?           @map("pickup_person_id") @db.Uuid
  pickupPersonName String?           @map("pickup_person_name") @db.VarChar(200)

  submittedByUserId String? @map("submitted_by_user_id") @db.Uuid
  submittedByName   String  @map("submitted_by_name") @db.VarChar(200)

  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@index([tenantId, studentId, startDate])
  @@index([tenantId, academicYearId, createdAt])
  @@map("attendance_justifications")
}
  • Attachments are File rows with usage = JUSTIFICATION, ownerType = JUSTIFICATION, ownerId = justification.id — no relation field (polymorphic, consistent with the other collection usages).
  • submittedByUserId is a bare uuid + name snapshot (same shape as ackByUserId/ackByName on the event — no relation, survives user deletion).
  • AttendanceDayEvent delta: one nullable provenance FK —
  justificationId String?                  @map("justification_id") @db.Uuid
  justification   AttendanceJustification? @relation(fields: [justificationId], references: [id], onDelete: SetNull)

(AttendanceJustification gains the inverse coveredEvents AttendanceDayEvent[].) SetNull is defensive — no delete route exists v1. Stamped at both seams, cleared on re-open (§3).

Migration shape

  • One migration: two new enums, two ALTER TYPE … ADD VALUE (additive — safe, but the new values must not be used in the same migration's SQL, which they aren't), the new table, its indexes, its RLS policy, and the nullable justification_id column + FK on attendance_day_events (nullable add — no rewrite, no backfill). Audit against the ch12 hazard checklist before commit; check for an uncommitted migration to fold first (none expected — slices A/B were migration-free).
  • Registry entries: src/prisma/rls-coverage.ts + src/prisma/tenanted-models.ts (required tenantId) — the five drift guards fail otherwise.

Indexes and uniqueness

  • No natural unique key — overlapping submissions are allowed (two referents may both announce; matching takes any cover).
  • (tenantId, studentId, startDate) serves both hot lookups: mint-time cover check and per-date row nesting (startDate <= d AND endDate >= d AND studentId IN (…)).
  • (tenantId, academicYearId, createdAt) serves the family list (newest first).

5. API surface

All family routes on the existing /attendance/family cluster (attendance-family.controller.ts), @ProtectedResource() + @RequireScopes(ATTENDANCE, 'read') + @AppliesPolicy(AttendanceFamilyPolicy) + @AggregateResponse(); writes additionally @RequireAction(ATTENDANCE, 'justify').

Verb Path Extra gates Request Response
POST /attendance/family/justifications justify action; service-level: linked to student + canWrite = true, else 403 / hidden 404 CreateJustificationDto (JSON — kind-discriminated, §3 table) JustificationDto
GET /attendance/family/justifications — (reads open to referent + student; builders self-narrow) ?studentId=&page=&limit= (PaginationQueryDto) PaginatedResponseDto<JustificationDto>
POST /attendance/family/justifications/:id/files justify action; owner-family + canWrite; kind allows attachments; count < 5 multipart, single file (FileInterceptor + AssertFilePresentPipe) FileMetadataDto
GET /attendance/family/justifications/:id/files/:fileId SignedFileUrlDto

School-side (insights controller — office and teachers):

Verb Path Gates Response
GET /attendance/justifications/:id/files/:fileId @RequireScopes(ATTENDANCE, 'read') + the board/register policy (school-wide + teacher branches — families fail closed here and use their own route); in-service fence: isSchoolWideRegister(ctx, 'READ') passes outright, else teacher asserts the justification's student against studentTaughtByCallerOn(ctx, schoolToday) (src/students/students.policy.ts) — miss → existence-hiding 404 SignedFileUrlDto
  • Teachers download for their own students (product ruling, chat 2026-08-04: teachers hold the health scope on their own students, so the health-adjacency objection is void). The fence is the same 4-leg taught set that governs their student reads — a teacher can fetch attachments exactly for the students whose rows they see. No manage_communications requirement: downloads follow row visibility, not the comms-management privilege.
  • Create flow: matching sweep runs in the create tx; attachments are appended per-file afterwards (they are evidence, they never affect matching). A justification is therefore visible on rows for a few seconds before its files land — accepted.
  • Audit: one AuditService.record(tx, …) entry per create, payload carrying kind, range and the swept event ids. Attachment uploads and reads are not separately audited (consistent with person documents).
  • canWrite = false referents: 403 on writes for that student; their reads are unaffected (slice-B rule).

Row surfacing (the one place)

AttendanceStudentRowDto gains:

justifications: RowJustificationDto[]  // every justification whose range covers this date, newest first

RowJustificationDto: { id, kind, startDate, endDate, time, reason, pickupPersonName, attachments: [{ fileId, fileName }], submittedByName, submittedAt }.

  • Office/teacher surfaces inherit it from the single buildStudentRows choke point (group rows, admin day register, write envelope records[].row) — one batch query per rows call, same shape as the existing dayEvents load.
  • The office AttendanceDayEventDto gains justificationId: string | null (additive) so FE can pair a resolved/NOT_REQUIRED event with the justification in the same row's block. The family FamilyDayEventDto stays reduced and unchanged — the family sees the justification itself.
  • The family day view (FamilyDayDto) gains the identical justifications field, loaded in attendance-family.service.ts (its response is hand-assembled; the field name collides with no attendance scope key — the aggregate-shape tripwire from slice B does not fire).
  • Family overview/history and the to-justify list are unchanged — resolved events already leave the to-justify list, and the day view is where detail lives.

Swagger considerations

  • New decorators in attendance.swagger.ts for the five routes; all family-route copy is parent-phrased (what to submit for which situation, the pre-announcement effect, the 5-file cap).
  • The create route's copy states the resolution effect in FE terms: "submitting immediately marks matching open events as justified; days announced in advance never appear in to justify at all".
  • Row DTO copy for justifications[]: "every family communication covering this date — render alongside the day's cells".

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) new attendance.justify — "Submit attendance justifications and announcements for linked students"
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) referent frozen preset gains the justify action. No other role — students read-only, office has the ack route, teachers are fenced out by the family policy anyway
*_SCOPES runtime constant none

Referent is a frozen profile-coupled preset → the grant self-applies to prod on deploy (Tier-1 reconcileFrozenPresetRolesIntoTenants). No manual reseed.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Side-effects run post-commit (AfterCommitQueue idiom) The submit-time sweep runs inside the create tx Same-database state derived from the same write — identical to slice A's mint-diff divergence, for the same atomicity reason Slightly longer create tx (one indexed read + ≤N event updates)
Write routes gate with write-scope Family writes gate with read-scope + justify action Referents must not hold attendance.register WRITE (that is the teacher-write scope; slice B made the family policy the fence). The action is the privilege; the scope is surface membership — same composition the ack route uses, inverted A write behind a read scope-gate reads oddly in the RBAC table; documented here and in ch19
FileOwnerType values are person tables First non-person owner (JUSTIFICATION) The polymorphic collection pattern was built for exactly this extension; a parallel attachment table would duplicate storage machinery Person-specific helpers (getPersonDelegate) don't apply — the justification owner check is its own code path in the attendance service
Mutable CRUD entities Justification is immutable after submit (no PATCH/DELETE) Matching resolves events one-way; cancelling a justification that already acknowledged events would need un-resolution semantics nobody asked for A mistaken submission stands; the office disputes it humanly. Revisit only if product asks (§9)
Every list/table read gets filters/table variants Family list is a plain paginated GET Family-sized data (a handful of rows per child per year)

8. Pushback log

Proposed / assumed Conflicts with Resolved as Status
Separate admin communications worklist (GET /attendance/communications) for proactive announcements Fabio: surface all justification information once, on the student's day row, on all surfaces (admin, teacher, referent) Row nesting only, from the single buildStudentRows choke point + family day view; no new worklist Resolved (chat 2026-08-04)
Office review/approval of justifications Fabio: "no review/approval of justifications yet" Submit is final; matching is immediate; no state machine on the justification Resolved (chat 2026-08-04)
Strict type matching (ABSENCE covers ABSENT only) Earlier ruling allowed an ABSENCE announcement to settle a later LATE_ENTRY outcome Exact matching in both directions; the later recorded type is chased separately Superseded by product correction 2026-08-11
Notifications for covered events Fabio: "important to not notify when a justification/event is there" Structural invariant: coverage removes events from the open set at both seams; slice D's sweeper only claims open events Resolved (chat 2026-08-04)
Teachers download attachments like the office First resolved as no ("no teacher is fine") on the health-adjacency argument — teachers were excluded from students.health Reversed same day by product: teachers SHOULD hold the health scope on their own students, so they download attachments too — taught-set fenced (studentTaughtByCallerOn), never school-wide Resolved (Fabio, chat 2026-08-04, product ruling)
No event↔justification FK (first draft: coverage derivable at read time) Fabio challenged; overlapping justifications make derivation ambiguous, and the deferred cancel/edit needs write-time provenance to un-resolve Nullable justificationId on the event, stamped at both seams, cleared on re-open, surfaced on office dayEvents[] Resolved (chat 2026-08-04)

9. Deferrals

  • Slice D — notifications (engine v1, first scheduler/sweeper, NOTIFIED/FOLLOW_UP + 15m/1h cadence, MailerPort emails) — program-final. Inherits the no-notify invariant from §3 with zero changes here.
  • Justification edit/cancel/delete — immutable v1; would need un-resolution semantics and attachment cleanup (the only orphan-file path). Only if product asks.
  • Student self-justify (adult students) — no age-of-majority concept exists; revisit with product.
  • Office-submitted justifications on behalf of families — the ack endpoint (PHONE/IN_PERSON) already covers the office's real workflow.
  • Attachment on EARLY_EXIT — form definition has none; trivially unlockable later (drop one validation).
  • Notify the office on new family submissions (early exits especially are time-sensitive) — a slice-D-shaped concern; the row + day board is today's surface.
  • students.health READ for teachers on their own students — the product ruling behind the teacher-download reversal; it is a students-module RBAC delta (roles.ts + FE guide + record-level narrowing already taught-set-shaped), not part of this slice — own small change.
  • Per-pair notification preferences — unchanged from slice A's deferral list.

10. Open questions

Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.

(none open)

Resolved: - [x] Surfacing = once, on the student's day row, all surfaces — Fabio, chat 2026-08-04; kills the separate communications worklist. - [x] No review/approval workflow — Fabio, chat 2026-08-04. - [x] Covered events never notify — Fabio, chat 2026-08-04; encoded as the structural no-notify invariant (§3). - [x] One entity, three kinds; reactive = pre-filled proactive; referent-only writes via new justify action; exact ABSENCE/LATE_ENTRY event matching (amended 2026-08-11); File collection pattern for attachments; pickup from authorized referents+guardians; family cluster routes — the original brainstorm plus the later coverage correction. - [x] Teacher attachment downloads = yes, taught-set fenced — product ruling relayed by Fabio, chat 2026-08-04, reversing the earlier "no": teachers hold the health scope on their own students, so downloads follow row visibility. (The students.health teacher grant itself is a separate students-module change, not part of this slice — see §9.) - [x] Event carries a nullable justificationId FK — Fabio's challenge, chat 2026-08-04; write-time provenance + cancel-enabler (§3, §8).


11. Verification plan

  • Unit specs:
  • attendance-justifications.service.spec.ts — kind-conditional validation table (§3, every cell incl. endDate defaulting and VALIDATION_FAILED mismatches); pickup eligibility (linked referent ✓, authorized guardian ✓, unauthorized guardian ✗ 422, foreign person ✗ 422); canWrite = false 403; existence-hiding 404; submit-time sweep (each live state → ACKNOWLEDGED/APP + snapshots + justificationId stamped, each resolved state untouched, kind-compat incl. ABSENCE→LATE_ENTRY, range boundaries inclusive); attachment rules (cap 5 → 409, EARLY_EXIT → 422, owner-family check); school download fence (school-wide passes, teacher taught-set assert via studentTaughtByCallerOn → hidden 404 on miss); audit recorded with swept ids; injected clock.
  • attendance-loop.service.spec.tsfindCoveringJustification now real: covered mint births NOT_REQUIRED with armAt = NULL + justificationId stamped; kind-compat and boundary dates; re-open clears justificationId and re-checks coverage (still-covered re-stamps).
  • attendance-board.service.spec.tsbuildStudentRows nests justifications[] (batch query called once, per-student fan-out, date-cover filter, newest first, empty default).
  • attendance-family.service.spec.ts — day view gains the same block; list route builders (referent narrowing, student self-narrowing, pagination arithmetic).
  • E2E (attendance-justifications.e2e-spec.ts, family-suite fixture recipe — own users, both-auth-helper-generation-safe): pre-announce → matching register write → event NOT_REQUIRED via prisma, worklist + to-justify empty, row shows the block on admin getRows AND family day view; reactive justify on an armed event → ACKNOWLEDGED/APP everywhere, office dayEvents[] carries the justificationId, leaves to-justify, stays in history; a different recorded ABSENT/LATE_ENTRY outcome creates its own open event and to-justify row; early exit today → row block on teacher rows route; student login: reads 200 with block visible, POST 403; canWrite = false referent 403; attachment upload → family + office signed-URL 200, teacher 200 for a taught student and hidden 404 for a non-taught one, 6th file 409; office ack unaffected.
  • Manual verification: dev server — submit an absence range in advance, take the register, watch the event be born NOT_REQUIRED in Studio, see the row block on the admin day register and the referent day view.
  • Docs on landing: ch19 §11.3 (covering rule + no-notify invariant + row surfacing + the read-scope+action gate rationale); docs/REFERENCE.md attendance row; authoritative FE guide amended in place (§5.9 justification forms + row justifications[] + errors; changelog) — house rule, plan ends with this task; memory update.

12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-04
  • Chat reference: "no teacher is fine, move on" — chat 2026-08-04, closing the last two open flags (teacher downloads = no; event FK = yes, from his challenge in the same exchange). Post-sign-off amendment same day: product ruling reversed teacher downloads to yes, taught-set fenced (teachers hold health scope on own students) — §5/§8/§10 updated with Fabio's note; sign-off stands.

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.