date: 2026-08-04 slug: attendance-family-loop status: Approved # Draft | Approved | Superseded clickup_us: none — Fabio's referent-attendance-surface map, chat 2026-08-04 (slice A of 4; resequenced same day: notifications deferred to program end) epic: attendance related_specs: - docs/superpowers/specs/2026-07-25-attendance-admin-day-register-design.md (minted the AttendanceDayEvent model this spec finally writes) - docs/superpowers/specs/2026-07-23-notification-engine-design.md (stays Draft — its consumer role moves to the program-final notifications spec) - docs/superpowers/specs/2026-08-03-teacher-attendance-surface-iteration-2-design.md (current shape of the write path the mint hooks into) supersedes: none
Attendance family loop, slice A — event minting, to-justify visibility, office ack (notifications deferred)¶
Program resequencing (Fabio, chat 2026-08-04): "we can defer notification at the end and build the surface without it anyway — if a to-justify exists, it will surface on the student page referent side anyways." The program is now: A loop data core (this spec) → B referent read surface → C justifications → D notifications (sweeper + engine + email). The referent page is the first delivery channel; push/email cadence is additive on top of data that already behaves correctly.
1. Problem distillation¶
AttendanceDayEventhas existed since the admin-day-register spec as a read-only model: the follow-up worklist, summary widget and per-rowdayEvents[]all read it, and it is always empty because nothing writes it. This spec is the writer that model's own schema comment promises ("the writer is the communication-loop spec").- The rule (Fabio, chat 2026-08-04): every absence-like day event not justified referent-side is owed to the family — as a to-justify item on the referent surface (slice B), and later as push/email (slice D). A backdated absence recorded today is owed identically to a same-day one. No date special-casing anywhere in the machine.
- The 15-minute arm window survives the notification deferral — it is doing three jobs, only one of which was notification timing: it absorbs teacher fat-fingers (a correction inside the window cancels the event silently), it delays referent-GET visibility (the US's "after 15 min it becomes available"), and it keeps 2-minute-old mistakes out of the admin worklist.
armAtis stamped at mint; visibility is a read-time comparison (armAt <= now) — no timer exists in this slice. - With no sends, the states
NOTIFIED/FOLLOW_UPwould be lies (they mean "we told the family"). Events therefore live inUNDER_REVIEWuntil resolved, and the read-side definition of open widens to include armedUNDER_REVIEW: once the referent page exists, an armed event has left the building — the page is the message. - Resolution paths in this slice: an office-side acknowledgement (
PHONE/IN_PERSON, one new endpoint). Referent-side justification (APP) is slice C; both slices B and C plug into this machine without changing it.
Success criteria (observable behavior that proves this works):
- Writing an ABSENT cell for a student creates an UNDER_REVIEW event with armAt = now + 15m; correcting the day to PRESENT before armAt flips it to CANCELLED — the family-facing surfaces never knew.
- An uncorrected event appears in GET /attendance/follow-ups and follow-ups/summary once armAt <= now — the widened open rule — with zero change to those endpoints' shapes.
- A day whose classification changes from absent to lateEntry (student marked absent, then arrives late) supersedes the ABSENT event and mints a fresh LATE_ENTRY event with its own arm window.
- PATCH /attendance/follow-ups/:id/ack (channel: PHONE | IN_PERSON, optional reason) resolves a live event to ACKNOWLEDGED (ack fields + audit entry); terminal states 409; teachers 403.
- A backdated ABSENT write (admin records last Tuesday) mints and arms exactly like a same-day one.
- Suspended / trip / present / not-taken days never mint anything; EARLY_EXIT never mints (no such AttendanceEventType — an early exit always involves a referent in person, per US).
- No scheduler, no notification engine, no mailer touch anywhere in the diff.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No notifications of any kind — no sweeper, no @nestjs/schedule, no engine dependency, no email. Slice D owns the entire send cadence (NOTIFIED / FOLLOW_UP transitions, notifiedAt / escalateAt / remindedAt stamping, reminder at +1h).
- No referent-facing read surface (student picker, to-justify list, day rows, stats overview + Department.minPresentHoursPercentage, the 15-min GET-visibility rule) — slice B, keyed on the same armAt clock this spec stamps.
- No justification entity, attachments, proactive communications, or covering-justification matching — slice C. With no sweeper, matching is event-driven there (checked at mint time and at justification-submit time), which needs nothing from this slice but the states.
- No contact-log write endpoint, no per-pair notification preferences, no quiet hours.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/attendance/attendance-day-model.ts classifyStudentDay |
The pure day classifier already answering "is this day absent / lateEntry"; the mint diff derives its desired-event set from it — no second opinion about what a day means | suspended / trip / earlyExit / present / notTaken classifications map to no event, by design |
src/attendance/attendance-insights.queries.ts |
OPEN_FOLLOW_UP_STATES — the read side's definition of open, which this spec widens to armed UNDER_REVIEW (one where-builder change, all three consumers inherit it) |
Its comment ("nobody can follow up on a message that has not left the building") predates the referent surface being the message — updated, see §7 |
src/attendance/attendance.service.ts writeRecords / updateCell |
The two write paths (there is no delete route) whose $transaction the mint step joins |
Fits cleanly |
src/audit-log/ AuditService.record(tx, …) |
In-tx audit entry for the human ack (attendance is already the first audit consumer) | Mint transitions ride the register write, which is already audited — no double entry |
docs/superpowers/specs/2026-07-25-attendance-admin-day-register-design.md |
The model, indexes, unique key, worklist DTOs — all landed there read-only; this spec animates them | Fits cleanly |
docs/superpowers/specs/2026-07-23-notification-engine-design.md |
Nothing yet — listed to pin the relationship: slice D consumes it; nothing in this slice may depend on it | By decision (resequencing, chat 2026-08-04) |
On-axis / off-axis check (ch16 §4–§5): with the sweeper deferred, everything in this slice is on-axis — in-tx derived state on an existing model, an endpoint on existing entity/scope/action keys, a where-builder widening. The one genuinely off-axis invention of the program (the first scheduler) moves to slice D, where it arrives with its consumer instead of ahead of it.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Model already tenanted (RLS policy + registry entries landed with the admin-day-register migration); mint runs inside the request's tenant tx; ack inside its own | No new model, no new coverage entries |
| Academic-year scope | yes | academicYearId stamped at mint from the write path's already-resolved active year; readers already filter by it |
|
| RBAC entity key | existing ATTENDANCE |
No delta to entity-keys.ts |
|
| Scopes | existing attendance.register |
Ack endpoint: @RequireScopes(ATTENDANCE, 'write') |
|
| Actions | existing attendance.manage_communications |
Ack endpoint additionally @RequireAction(ATTENDANCE, 'manage_communications') — same gate as the follow-up GETs, held by admin, secretary, principal; teachers hold attendance.take only and are excluded |
The action was seeded for exactly this surface |
| Service base | custom | AttendanceLoopService (mint diff + ack) in src/attendance/ — no new module, no new DI seams |
The loop is attendance-domain logic |
queries.ts shape |
yes | New named functions (in attendance.queries.ts, or attendance-loop.queries.ts if it crowds): loadDayEventsFor(tx, tenantId, studentIds, date), upsertDayEvent(tx, …), resolveDayEvent(tx, …); in attendance-insights.queries.ts the open-follow-up where becomes a builder openFollowUpWhere(now) = state IN (NOTIFIED, FOLLOW_UP) OR (state = UNDER_REVIEW AND armAt <= now) |
Standard convention; the widening lives in one place, all readers inherit |
| Error codes | new | ATTENDANCE_EVENT_NOT_FOUND (404), ATTENDANCE_EVENT_ALREADY_RESOLVED (409, params: state) — ch06 registry + messages{en,it} full-semantics |
Only the ack endpoint errors; minting is never a caller-visible failure |
| DTO conventions | yes | dto/ack-follow-up.dto.ts (channel: 'PHONE' \| 'IN_PERSON', reason?: string); response reuses the existing FollowUpItemDto so FE gets the refreshed worklist row back |
APP is deliberately not accepted here — it is the slice-C justification channel |
| File-backed sub-resources | n/a | Attachments arrive with justifications (slice C) | |
| Custom fields | no | ||
| Profile completeness | no |
The state machine (normative)¶
One row per (student, date, eventType) — the existing unique key. In this slice only three timing/ack facts are ever written: armAt (mint), the ack block (ack endpoint), state. notifiedAt / escalateAt / remindedAt stay NULL until slice D.
Desired-event derivation (pure, from classifyStudentDay over the day's authored cells): classification absent → {ABSENT}; lateEntry → {LATE_ENTRY}; everything else → {}. The two are mutually exclusive by construction (absent requires every cell ABSENT; lateEntry requires a LATE_ENTRY cell).
Mint diff (in the write tx, per affected (student, date), after the record writes):
| Existing row state | Day still warrants the type | Day no longer warrants it |
|---|---|---|
| (none) | create UNDER_REVIEW, armAt = now + 15m |
— |
UNDER_REVIEW |
leave (armed or not — the clock keeps running) | → CANCELLED if still in grace (armAt > now) — family never saw it; → SUPERSEDED if already armed — it may have been seen |
NOTIFIED / FOLLOW_UP (slice-D states) |
leave | → SUPERSEDED |
ACKNOWLEDGED / NOT_REQUIRED |
leave (resolved is history) | leave (the communication happened; don't rewrite it) |
CANCELLED / SUPERSEDED |
re-open: back to UNDER_REVIEW, fresh armAt, timing/ack fields cleared |
leave |
Visibility rule (read-time, no timer): an event is open — owed an answer, shown in the admin worklist now and in the referent to-justify list in slice B — iff state ∈ {NOTIFIED, FOLLOW_UP} or (state = UNDER_REVIEW and armAt <= now). In-grace UNDER_REVIEW is invisible everywhere family-facing and absent from worklists.
Ack (PATCH /attendance/follow-ups/:id/ack): from UNDER_REVIEW / NOTIFIED / FOLLOW_UP → ACKNOWLEDGED + ackAt/ackByUserId/ackByName (display-name snapshot)/ackChannel/ackReason. Acking an in-grace event is allowed — the parent who phones at 8:20 resolves it before it ever surfaces (and, come slice D, before anything sends).
Slice seams left in place:
- Slice C: findCoveringJustification(tx, …) consulted at mint time (born covered → NOT_REQUIRED) and justification-submit resolving open events → ACKNOWLEDGED/APP — event-driven, no timer needed.
- Slice D: the sweeper claims armed UNDER_REVIEW rows → NOTIFIED + send #1, then escalateAt → FOLLOW_UP + send #2. The widened open rule already counts those states, so slice D changes no reader.
4. Data model plan¶
Schema deltas¶
- None.
AttendanceDayEvent(witharmAt, timing and ack fields),AttendanceContactLog, the unique key and all four indexes landed with the admin-day-register migration. The model's "READ-ONLY in this iteration" comment is updated to point here.
Migration shape¶
- n/a — no migration.
Indexes and uniqueness¶
- Existing
attendance_day_event_unique (tenantId, studentId, date, eventType)is what makes the mint diff an upsert-by-key and re-opening reuse the same row (event identity is stable across cancel/re-open — the contact-log trail stays attached).(tenantId, state, armAt)serves the widened open filter;(tenantId, state, date)the summary.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| PATCH | /attendance/follow-ups/:id/ack |
@RequireScopes(ATTENDANCE, 'write'), @RequireAction(ATTENDANCE, 'manage_communications'), @AppliesPolicy(AttendanceBoardPolicy) |
AckFollowUpDto { channel: PHONE \| IN_PERSON, reason? } |
FollowUpItemDto (refreshed worklist row) |
- Terminal states → 409
ATTENDANCE_EVENT_ALREADY_RESOLVED. Missing/foreign id → 404ATTENDANCE_EVENT_NOT_FOUND. AuditService.record(tx, …)inside the ack tx (human action on the family trail). Mint transitions are not separately audited — they ride the register write, which already is.- Existing GET surfaces (
follow-ups,follow-ups/summary, rows/envelopedayEvents[]) change shape not at all; they simply stop being empty. Their behavior delta is the widened open rule (armedUNDER_REVIEWnow counts) — FE-visible only as "the widgets work now". - No HTTP surface for minting — in-process only.
Swagger considerations¶
- New
ApiAckFollowUpdecorator inattendance.swagger.tswith both error examples; operation description is FE-facing copy: what ack means, which channels, thatAPParrives with referent justifications in a later release. - The follow-up GETs' descriptions gain one sentence on when an event appears (15 minutes after recording, corrections inside the window never appear).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none — attendance.manage_communications exists |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none — admin, secretary (SECRETARY_ACTION_KEYS), principal (PRINCIPAL_ACTION_KEYS) already hold manage_communications; teacher deliberately does not |
*_SCOPES runtime constant |
none |
No reseed needed anywhere.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Side-effects run post-commit (AfterCommitQueue idiom) |
The mint diff runs inside the write tx | It is same-database state derived from the same write — atomicity is the point: no crash window where a register says absent but no event exists | Slightly longer write tx (one indexed read + ≤2 upserts per touched student-day) |
OPEN_FOLLOW_UP_STATES comment: UNDER_REVIEW excluded, "nobody can follow up on a message that has not left the building" |
Armed UNDER_REVIEW counts as open |
The referent page (slice B) is now the message — an armed event has left the building; and states claiming "we sent" (NOTIFIED) would be lies until slice D actually sends |
Between slice A and B, the admin worklist chases events the family cannot yet see in-app — accepted, that is today's phone-first reality anyway |
AttendanceDayEvent schema comment says "READ-ONLY in this iteration" |
This spec is the promised writer | The comment names this very spec as its future | Comment updated on landing |
| A notify-style feature would reach for the engine + a scheduler | Neither appears in this slice | Resequencing decision: surface first, notifications last; visibility is a read-time armAt <= now comparison, which needs no timer |
notifiedAt/escalateAt/remindedAt stay NULL and two enum states stay unreachable until slice D — documented on the model comment |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "after 15 min … a first notification is sent (push + email); after 1h … a second notification" | Notification engine is an unimplemented Draft; a sweeper would be the codebase's first scheduler, landing ahead of any user-visible surface | Resequenced by Fabio: build the loop data + referent surface first ("if a to-justify exists, it will surface on the student page referent side anyways"); notifications land last as slice D | Resolved (chat 2026-08-04) |
| (my earlier proposal, not the US) backdated writes should skip notification | Fabio: "a follow up is a follow up — notification MUST be triggered. Always trigger for all absences not yet justified referent side" | Universal rule, no date special-casing — in this slice: backdated events mint, arm and surface identically | Resolved (chat 2026-08-04) |
| "the number set on the school config % max must be surfaced" | No school-level field; Department.minPresentHoursPercentage already exists (per-department, min-presence phrasing, schema comment even promises these notifications) |
Surface the student's department's value in the slice-B overview; no new config field | Resolved (chat 2026-08-04, verified in code) |
| "early exit can only ever happen with a referent/guardian in person so it's fine" | — | Confirmed structurally: AttendanceEventType has no EARLY_EXIT; the loop never mints one |
Resolved — US and schema agree |
9. Deferrals¶
- Slice B — referent read surface (student picker, to-justify list, admin-like day rows, stats overview +
minPresentHoursPercentage, referent GET visibility on thearmAtclock) — own spec, next. - Slice C — justifications (entity + attachments, reactive justify →
ACKNOWLEDGED/APP, proactive absence-range / early-exit / late-entry communications, event-driven matching at mint- and submit-time →NOT_REQUIRED, A-cell justification surfacing) — own spec; this slice ships thefindCoveringJustificationstub seam. - Slice D — notifications (notification engine v1 per its own Draft spec, the first scheduler/sweeper,
NOTIFIED/FOLLOW_UPtransitions + 15m/1h send cadence, per-language emails viaMailerPort, engine kindsattendance.event/attendance.reminder) — program-final by decision. Everything in this slice was shaped so D changes no reader and no mint logic. - Contact-log write endpoint (
AttendanceContactLogappend) — not in the US; worklist already renders the trail when something writes it. - Per-pair notification preferences —
StudentReferentLinkhas no notify flag; revisit with slice D. - Per-school cadence config (arm/escalation durations) — named constants; becomes school config only if product asks.
10. Open questions¶
Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.
(none open)
Resolved:
- [x] Notifications deferred to program end; surface built without them — Fabio, chat 2026-08-04 ("find yourself the best way to execute this"); this spec is the resulting slice A.
- [x] Always notify unjustified events, including backdated — Fabio, chat 2026-08-04; in this slice the equivalent rule is "always surface"; slice D inherits it for sends.
- [x] Engine prerequisite question — dissolved by the resequencing: no engine dependency until slice D.
- [x] Ack from UNDER_REVIEW — now the normal case (every live event is UNDER_REVIEW in this slice); in-grace ack allowed and pre-empts slice-D sends by construction.
- [x] NOT_REQUIRED reserved for slice C (born covered by a pre-announced justification); slice A never writes it, keeping ACKNOWLEDGED = "someone answered us".
- [x] Max-% config already exists — Department.minPresentHoursPercentage, surfaced in slice B.
11. Verification plan¶
- Unit specs:
attendance-loop.service.spec.ts— desired-set derivation (absent / lateEntry / suspended / trip / mixed / empty days); every cell of the mint-diff table, incl. in-grace→CANCELLEDvs armed→SUPERSEDEDon correction, re-open clearing stale timing/ack fields, resolved-states immutability; ack transitions (each allowed state, 409 on each terminal state, 404, audit recorded, name snapshot); injected clock throughout.attendance.service.spec.ts— both write paths invoke the mint step in-tx with the affected student-days.attendance-insights.*.spec.ts— widenedopenFollowUpWhere(now): armedUNDER_REVIEWin, in-grace out,NOTIFIED/FOLLOW_UPstill in.- E2E specs (
attendance-loop.e2e-spec.ts): absence write → event row existsUNDER_REVIEW(asserted via prisma) and worklist is empty (in grace); fixture-shiftarmAtinto the past via prisma → worklist + summary show it; correct-within-grace →CANCELLED, worklist stays empty; absent→late-entry → supersede + fresh mint; backdated write mints; ack: secretary/principal 200 with refreshed row + ack fields, terminal 409, teacher 403, foreign id 404. Fixture discipline perfeedback_e2e_isolation_patterns+project_e2e_temporal_fixture_discipline(arm-clock shifting is a fixture concern, not an env knob). - Manual verification: dev server, take an absence, watch the row in Studio, shift
armAt, reload the admin follow-up widget, ack it. - Docs on landing: ch19 gains the family-loop section (mint diff + visibility rule + slice seams);
docs/REFERENCE.mdattendance row; attendance FE guide:dayEvents[]now live + the ack endpoint + the 15-minute rule. Plan ends with the FE-guide task (house rule).
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-08-04
- Chat reference: "sign off" in chat 2026-08-04, after the same-day resequencing rework (notifications deferred to slice D)
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.