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
AttendanceDayEventrows, 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),CANCELLEDandSUPERSEDEDevents 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 → projectDayStatuses → classifyStudentDay → 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 classifiedsuspendedortrip(whole-day dispositions, excluded per US). Excluded counts surfaced asexcludedDays: { suspended, trip }. - Per-type (the loop's two event types):
absent.days= dates classifiedabsent;lateEntry.days= dates classifiedlateEntry; each withpercentage= days /schoolDays× 100 (one decimal),nullwhenschoolDaysis 0. - Hours (the metric
minPresentHoursPercentageis phrased in):absentMinutes= Σ minutes of cells with statusABSENTon non-excluded dates;totalMinutes= Σ minutes of all cells on non-excluded dates;presentHoursPercentage= (1 − absent/total) × 100,nullwhentotalMinutesis 0. Cell minutes come from theperiodStartTime/periodEndTimesnapshots; cells missing either (nullable columns) are skipped from both sums. Authored-cell approximation, documented in §7. minPresentHoursPercentage= the student's department's value,nullwhen 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, inattendance.policy.ts): branchesreferent+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
studentcaller 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. Sorteddate 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→ 404STUDENT_NOT_FOUND(existence-hiding, grades idiom). :datevalidated 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 inattendance.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_FOUNDerror example on the two per-student routes.- Stats fields documented with their
nullsemantics (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 /
NOTIFIEDtransitions — slice D; nothing here readsnotifiedAtas anything butnull. - 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).
earlyExitstats 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 mapssurfacedByNamefrom the earliest matching cell; overview stats table-driven (absent/lateEntry/mixed/suspended/trip/none fixtures → counts, percentages,nulldenominators, 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 perfeedback_e2e_isolation_patterns+ the family-loop suite's discipline:attendanceDayEventwipe before student cleanup, arm-clock shifts via prismaarmAtupdates): - 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 withsurfacedByName; office ack → leaves to-justify, overview history showsACKNOWLEDGED;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
armAtin 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.mdattendance 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, statsnullsemantics, 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.