Skip to content

Attendance family loop, slice B — referent/student read surface (to-justify, stats overview, day view)

Program position: A loop data core (landed) → B this spec → C justifications → D notifications. Slice B is the first family-facing surface: the page where a to-justify event is seen is what makes the loop real before any notification sends.

1. Problem distillation

  • Slice A mints and arms AttendanceDayEvent rows, but only the office can see them. The US's referent page — "my child's to-justify events, their attendance history, and how their absences stack against the school's threshold" — has no backend.
  • Three reads cover the whole page: a to-justify list across all the referent's linked students (badge + list in one call), a per-student stats overview (per-type day counts + percentages against school days elapsed, excluding suspended/trip days, plus the department's minPresentHoursPercentage), and a per-date admin-like day view (the same row the office sees: slots, register cells, day events).
  • Referent and student hold zero attendance grants today, and AttendancePolicy's doc comment explicitly defers their branches "to their own spec" — this is that spec. First RBAC delta of the program (→ prod reseed).
  • Family visibility must respect the slice-A clock: in-grace UNDER_REVIEW (armAt in the future), CANCELLED and SUPERSEDED events never reach a family eye; resolved events (ACKNOWLEDGED / NOT_REQUIRED) are history the family took part in and stay visible.
  • All five brainstorm decisions resolved by Fabio in chat 2026-08-04 ("ok"): dual stats metric (day-based per type + one hours-based presence %), record-derived denominator, "who surfaced it" = earliest matching cell's recorder snapshot, student self-view included on the per-student routes, separate /attendance/family/* cluster.

Success criteria (observable behavior that proves this works): - A referent calls GET /attendance/family/to-justify and receives every open event (slice-A rule: NOTIFIED/FOLLOW_UP or armed UNDER_REVIEW) of every linked student in the active year — each item saying which child, which date, which type, and who recorded it. An event minted 5 minutes ago is absent; the same call 15 minutes later shows it. - GET /attendance/family/students/:studentId/overview returns the day-based stats (absent/lateEntry day counts + percentages over record-bearing school days, suspended/trip days excluded from the denominator), the hours-based presence percentage, the department's minPresentHoursPercentage, and the family-visible event history newest-first. - GET /attendance/family/students/:studentId/days/:date returns the admin row for that student-day — classifications, per-slot items, register cells — with dayEvents[] filtered to family-visible states only. - A referent asking about a student they are not linked to (or a student asking about anyone but themselves) gets a 404, indistinguishable from a nonexistent student. - A teacher calling any family route gets 403 — even though their WRITE grant on attendance.register passes the scope gate, the family policy has no teacher branch and fails closed. - An office ack removes the event from to-justify but keeps it in the overview history as ACKNOWLEDGED.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No justify action, no justification entity, no attachments, no proactive communication forms — slice C. This surface is read-only end to end (three GETs, zero writes). - No notifications, no sweeper — slice D. notifiedAt stays NULL on everything the family sees. - No A-cell justification info on admin/student cells — arrives with justifications (slice C). - No calendar-derived "school days elapsed" — the denominator is register-derived by decision (§8); days nobody took don't count yet. - No earlyExit stats bucket — the classifier yields it for free but the US keys the overview on the loop's event types; revisit if product asks (§9).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/grades/grades.visibility.ts The closest precedent: per-role WHERE builders for family reads — referent narrows through student.referents.some.referent.userId, student through student.userId; unlinked targets resolve to not-found (existence-hiding) Grades splits three builders across three entity shapes; here one event entity + one student-access assert suffice, so the builders live in the family queries file
src/attendance/attendance-teacher.controller.ts + AttendanceTeacherPolicy The role-anchored sub-surface idiom: own controller on /attendance, @RequireScopes(ATTENDANCE, 'read'), a policy whose branch list IS the role gate (fails closed for everyone else), service anchored on the caller Teacher-day is caller-anchored with no target parameter; family routes take a studentId the service must assert against the link
src/attendance/attendance-board.service.ts buildStudentRows / StudentDay The day-row assembly the family day view must agree with cell-for-cell: expected-attendance resolver → authored cells → projectDayStatusesclassifyStudentDay → row mapping buildStudentRows takes a whole GroupDay and decorates rows with the unfiltered admin dayEvents[]; the per-student row core is factored out so the family view reuses it with its own filtered events (§7)
src/attendance/attendance-insights.queries.ts openFollowUpWhere(now) The slice-A open rule, verbatim, for the to-justify list — one builder, admin worklist and family list cannot disagree about "owed an answer" The family list also needs the wider visible rule (resolved states included) for histories — a sibling builder, same file-style
src/attendance/attendance-day-model.ts classifyStudentDay Stats are a fold of this pure classifier over the student's record dates — no second opinion about what a day means (same rule as the slice-A mint) Classifier works from authored cells only; that is exactly the record-derived denominator decision, not a limitation
docs/superpowers/specs/2026-08-04-attendance-family-loop-design.md The state machine, the arm clock, the visibility semantics this surface renders Fits cleanly — slice B changes nothing in the machine

On-axis / off-axis check (ch16 §4–§5): on-axis throughout — role-anchored read controller (teacher-day precedent), visibility builders (grades precedent), one role-grant delta on an existing scope, pure-function stats over existing rows. No new cross-cutting concern, no writes, no migration.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All queries tenant-filtered; link traversal (referent.userId + referent.tenantId) mirrors StudentsPolicy/grades Existing tenanted models only
Academic-year scope yes resolveActiveYear per request; events and stats filtered to the active year Same as every attendance read
RBAC entity key existing ATTENDANCE No delta
Scopes existing attendance.register All three routes @RequireScopes(ATTENDANCE, 'read'); referent + student gain READ on the scope (§6) The scope already gates every attendance read
Actions none manage_communications stays office-side; family routes are plain reads
Service base custom AttendanceFamilyService in src/attendance/ — same module, no new DI seams Attendance-domain logic, mirrors the teacher sub-surface
queries.ts shape yes attendance-family.queries.ts: familyStudentAccessWhere(ctx) (referent-link / self WHERE), familyVisibleEventWhere(now), findFamilyOpenEvents(db, tenantId, academicYearId, ctx, now), findFamilyEventHistory(db, tenantId, academicYearId, studentId, now), loadStudentYearCells(db, tenantId, academicYearId, studentId) (date/tick/status/period times), loadSurfacingRecorders(db, tenantId, pairs) Named functions, no repository classes
Error codes existing 404 → STUDENT_NOT_FOUND (unlinked or nonexistent — indistinguishable by design); date validation → VALIDATION_FAILED Read-only surface mints no new failures
DTO conventions yes dto/family-to-justify-response.dto.ts (FamilyToJustifyItemDto, paginated), dto/family-overview-response.dto.ts (FamilyStudentOverviewDto + stats blocks + FamilyDayEventDto), dto/family-day-response.dto.ts (FamilyDayDto reusing AttendanceDayItemDto + AttendanceRegisterCellDto) Family event DTO is a reduction of the admin one: no ackReason, no lastContactAt, no ackByName (office trail stays office-side)
File-backed sub-resources n/a Attachments arrive with justifications (slice C)
Custom fields no
Profile completeness no

The family visibility rule (normative)

One builder, used by every family-facing read: an event is family-visible iff state ∈ {NOTIFIED, FOLLOW_UP, ACKNOWLEDGED, NOT_REQUIRED} or (state = UNDER_REVIEW and armAt <= now). CANCELLED, SUPERSEDED and in-grace UNDER_REVIEW never reach a family response — the fat-finger grace of slice A is only real if the family literally cannot observe the window.

The to-justify list uses the narrower slice-A open rule (openFollowUpWhere(now)) — resolved events are not owed anything. The overview history and day view use the family-visible rule — resolved events are history.

Stats (normative)

All from one query — the student's AttendanceRecord rows for the active year (date, startTick, status, periodStartTime, periodEndTime) — folded per date through classifyStudentDay:

  • Denominator schoolDays = distinct record-bearing dates, minus dates classified suspended or trip (whole-day dispositions, excluded per US). Excluded counts surfaced as excludedDays: { suspended, trip }.
  • Per-type (the loop's two event types): absent.days = dates classified absent; lateEntry.days = dates classified lateEntry; each with percentage = days / schoolDays × 100 (one decimal), null when schoolDays is 0.
  • Hours (the metric minPresentHoursPercentage is phrased in): absentMinutes = Σ minutes of cells with status ABSENT on non-excluded dates; totalMinutes = Σ minutes of all cells on non-excluded dates; presentHoursPercentage = (1 − absent/total) × 100, null when totalMinutes is 0. Cell minutes come from the periodStartTime/periodEndTime snapshots; cells missing either (nullable columns) are skipped from both sums. Authored-cell approximation, documented in §7.
  • minPresentHoursPercentage = the student's department's value, null when unset. Comparison/rendering is FE's.

"Who surfaced it" (normative)

surfacedByName on to-justify items and history entries = the recordedByName snapshot of the earliest cell of that day whose status matches the event type (ABSENT → earliest ABSENT cell — on an all-absent day that is simply the earliest cell; LATE_ENTRY → earliest LATE_ENTRY cell). Batch-loaded per (studentId, date) set; null when no matching cell survives (defensive — records are snapshots and outlive most mutations, but the field must not 500 a list).


4. Data model plan

Schema deltas

  • None. Read-only surface over AttendanceDayEvent, AttendanceRecord, Student/StudentReferentLink, Department.

Migration shape

  • n/a — no migration.

Indexes and uniqueness

  • Existing cover everything: events by (tenantId, state, armAt) (open rule) and the student-date unique; records by their (tenantId, academicYearId, studentId?) read paths — the year-stats query filters tenant + year + studentId, same shape the register reads use. No new indexes.

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /attendance/family/to-justify @RequireScopes(ATTENDANCE, 'read'), @AppliesPolicy(AttendanceFamilyPolicy) PaginationQueryDto paginated FamilyToJustifyItemDto[]
GET /attendance/family/students/:studentId/overview same FamilyStudentOverviewDto
GET /attendance/family/students/:studentId/days/:date same FamilyDayDto
  • AttendanceFamilyPolicy (new, in attendance.policy.ts): branches referent + student, pass-through — the branch list is the role gate (teacher-day idiom); record-level narrowing runs in the service through the visibility builders. Everyone else — including teachers, whose register WRITE passes the scope gate — fails closed. AttendancePolicy's "deferred to their own spec" comment updated to point here.
  • To-justify is referent-only in practice: for a student caller the link-traversal WHERE matches their own row, so the route degrades gracefully — but the FE surface for students is the overview/day view; documented in swagger copy. Sorted date desc, paginated (house convention for scope-gated lists).
  • Overview / day view serve both roles: referent for linked students, student for self. Unlinked/foreign/nonexistent studentId → 404 STUDENT_NOT_FOUND (existence-hiding, grades idiom).
  • :date validated as ISO date; any date allowed (past = the point; future/no-school = empty day, reads never 409).
  • No picker endpoint — linked students already come from GET /referents/me + the narrowed students list.

Swagger considerations

  • New ApiAttendanceFamilyController + three route decorators in attendance.swagger.ts; copy is parent-facing: the 15-minute rule ("an absence appears here about 15 minutes after it is recorded; corrections made immediately never appear"), what to-justify means, that justify actions arrive in a later release.
  • STUDENT_NOT_FOUND error example on the two per-student routes.
  • Stats fields documented with their null semantics (no school days yet / no measured hours / department threshold unset).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) REFERENT_READ_SCOPE_KEYS += attendance.register; STUDENT_READ_SCOPE_KEYS += attendance.register (both with doc comments mirroring the grades-read precedent: record-level narrowing runs through the family visibility builders)
*_SCOPES runtime constant none — attendance.register already registered

Frozen-preset reconciliation applies the grants on the next seed run; prod needs a reseed (same operational note as the 2026-08-04 teacher-visibility change). The expected-preset-grants.ts drift mirror derives from the same constants — no separate edit.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
buildStudentRows(tenantId, date, groupDay, studentIds?) decorates rows with unfiltered admin dayEvents[] The per-student row core (classifications + items + register mapping) is factored out and shared; the family day view attaches its own family-visible, reduced events The family view must agree with the admin row cell-for-cell without inheriting the office event trail Small refactor of a load-bearing builder — covered by existing board specs staying green
Grades puts family WHERE builders in a dedicated *.visibility.ts Builders live in attendance-family.queries.ts One entity + one access-assert; a third attendance policy/visibility file would be foldering noise If slice C grows the family write side, promote to attendance-family.visibility.ts then
minPresentHoursPercentage is an hours metric; exact hours need per-date slot projection v1 hours are an authored-cell approximation: sums over recorded cells only, no projection, cells without period-time snapshots skipped Exact projection needs each date's slots — the temporal resolver per elapsed date is O(school days) resolver runs per request, and the frozen manifest is write-path evidence whose read use spec F may retire A DAILY-mode absence day contributes its authored cells' minutes, not the timetable day's — percentages are internally consistent but approximate; documented in swagger copy; exact version deferred until the number becomes compliance-official (§9)
Admin surfaces answer over the whole tenant behind role gates Family routes take a target studentId and 404 on unlinked targets Grades precedent — existence-hiding beats a 403 oracle for "is this student real" None

8. Pushback log

US says Conflicts with Proposed instead Status
Overview % is "vs school days happened" The config it faces (minPresentHoursPercentage) is phrased in hours Both: day-based counts/percentages per event type (US-literal) + one hours-based presentHoursPercentage (config-comparable) Resolved (Fabio "ok", chat 2026-08-04)
"school days happened" (implies calendar) Calendar-walking lies when registers lag, and needs holiday knowledge the register already encodes Register-derived denominator: days with records; untaken days don't count yet Resolved (same)
To-justify shows "who surfaced" the event Event rows carry no recorder Earliest matching cell's recordedByName snapshot Resolved (same)
Students also see their notified events Slice A deferred all family-facing reads Student self-view rides this slice on the per-student routes (student policy branch + grant) — one reseed instead of two; A-cell justification info stays slice C Resolved (same)
(shape question) fold family reads into /attendance/follow-ups? Admin worklist DTO carries office-only fields (ack trail, contact log) Separate /attendance/family/* cluster with reduced DTOs Resolved (same)

9. Deferrals

  • Justify action + justification entity + attachments + proactive forms — slice C; the to-justify list is its landing page.
  • Notifications / sweeper / NOTIFIED transitions — slice D; nothing here reads notifiedAt as anything but null.
  • Exact (projection-based) hours percentage — revisit if the number becomes compliance-official; would need a per-date slot source the read path currently doesn't have (§7).
  • earlyExit stats bucket — classifier yields it for free; add when product asks for it on the overview.
  • A-cell justification surfacing (admin + student cells) — slice C, with the justification data it renders.
  • Per-child referent notification preferences — slice D territory.

10. Open questions

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

(none open)

Resolved (all Fabio, chat 2026-08-04 — the five brainstorm decisions): dual stats metric; register-derived denominator; earliest-matching-cell recorder as surfacedByName; student self-view included on per-student routes; separate /attendance/family/* cluster.


11. Verification plan

  • Unit specs:
  • attendance-family.service.spec.ts — visibility WHERE per role (referent link traversal, student self, tenant fences asserted on the query doubles); to-justify uses the open rule and maps surfacedByName from the earliest matching cell; overview stats table-driven (absent/lateEntry/mixed/suspended/trip/none fixtures → counts, percentages, null denominators, hours sums incl. skipped time-less cells); day view filters events to family-visible states and strips office fields; 404 on unlinked target.
  • attendance-board.service.spec.ts — row-core factoring keeps every existing assertion green (the refactor's only proof burden).
  • E2E specs (attendance-family.e2e-spec.ts, own fixtures per feedback_e2e_isolation_patterns + the family-loop suite's discipline: attendanceDayEvent wipe before student cleanup, arm-clock shifts via prisma armAt updates):
  • Referent linked to two students, one stranger student, one student agent. Admin writes an absence → in-grace: to-justify empty, day view shows no event; shift armAt → item appears with surfacedByName; office ack → leaves to-justify, overview history shows ACKNOWLEDGED; CANCELLED (in-grace correction) never appears anywhere family-facing.
  • Overview math over a seeded week (absent day + late-entry day + suspended day + present days) → counts, denominator excludes the suspended day, hours percentage present.
  • Authz: stranger studentId → 404 for referent; other-student → 404 for student agent; teacher → 403 on all three routes (policy fails closed past the scope gate); admin → 403 likewise (their surface is the board).
  • Manual verification: dev server — take an absence as teacher, shift armAt in Studio, load the three routes as the seeded referent, ack as secretary, reload.
  • Docs on landing: ch19 gains §11.2 (family read surface: routes, visibility rule, stats semantics); docs/REFERENCE.md attendance row + file index; FE-guide task last (house rule) — amend the authoritative attendance FE guide in place: new family-surface section (three routes, 15-minute rule parent-phrasing, stats null semantics, reduced event DTO), §15 role-visibility rows for referent/student.

12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-04
  • Chat reference: "sign off" in chat 2026-08-04, after the five-decision brainstorm ("ok") resolved stats shape, denominator, surfacedBy, student inclusion and route cluster

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