Skip to content

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

  • AttendanceDayEvent has existed since the admin-day-register spec as a read-only model: the follow-up worklist, summary widget and per-row dayEvents[] 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. armAt is stamped at mint; visibility is a read-time comparison (armAt <= now) — no timer exists in this slice.
  • With no sends, the states NOTIFIED / FOLLOW_UP would be lies (they mean "we told the family"). Events therefore live in UNDER_REVIEW until resolved, and the read-side definition of open widens to include armed UNDER_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_UPACKNOWLEDGED + 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 escalateAtFOLLOW_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 (with armAt, 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 → 404 ATTENDANCE_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/envelope dayEvents[]) change shape not at all; they simply stop being empty. Their behavior delta is the widened open rule (armed UNDER_REVIEW now counts) — FE-visible only as "the widgets work now".
  • No HTTP surface for minting — in-process only.

Swagger considerations

  • New ApiAckFollowUp decorator in attendance.swagger.ts with both error examples; operation description is FE-facing copy: what ack means, which channels, that APP arrives 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 the armAt clock) — 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 the findCoveringJustification stub seam.
  • Slice D — notifications (notification engine v1 per its own Draft spec, the first scheduler/sweeper, NOTIFIED/FOLLOW_UP transitions + 15m/1h send cadence, per-language emails via MailerPort, engine kinds attendance.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 (AttendanceContactLog append) — not in the US; worklist already renders the trail when something writes it.
  • Per-pair notification preferencesStudentReferentLink has 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 existsDepartment.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→CANCELLED vs armed→SUPERSEDED on 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 — widened openFollowUpWhere(now): armed UNDER_REVIEW in, in-grace out, NOTIFIED/FOLLOW_UP still in.
  • E2E specs (attendance-loop.e2e-spec.ts): absence write → event row exists UNDER_REVIEW (asserted via prisma) and worklist is empty (in grace); fixture-shift armAt into 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 per feedback_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.md attendance 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.