Attendance loop notifications — sweeper, cadence, and the first scheduled job¶
Slice D of the attendance family loop: the piece slice A deliberately faked. Events have sat in armed
UNDER_REVIEW(readers already treat that as open); this slice adds the sweeper that flips them throughNOTIFIED→FOLLOW_UPand actually tells the family — inbox + email via the notification engine. It is the platform's first scheduled background job, which is why it gets its own design gate.
1. Problem distillation¶
- Slices A–C built the loop's data core, the referent read surface, and justifications — but nobody is actively told anything: an absence surfaces only when a referent happens to open the app. Slice D adds active outreach: at
armAt(the correction grace expiring) the family is notified; one hour later, if still unresolved, they are reminded once. No third strike — afterFOLLOW_UPthe event just stays open on every worklist. - The delivery mechanism is the notification engine (approved in this slice): the sweeper calls
send()with kindattendance.event(at arm) /attendance.reminder(at +1h) → inbox row per recipient + templated email. Recipients = the student's linked referents with user accounts (Fabio 2026-08-04); students are never recipients of their own events, and account-less referents are unreachable by decision (their email is not used). - State flips are reader-free by construction:
openFollowUpWherehas admittedNOTIFIED/FOLLOW_UPsince slice A,familyVisibleEventWherelikewise, and theAttendanceDayEventcolumns (notifiedAt,escalateAt,remindedAt) and indexes ((tenantId, state, armAt),(tenantId, state, escalateAt)) were reserved in the slice-A migration. Zero migration, zero RBAC delta, zero new endpoints in this slice. - The no-notify invariant is already structural (slice C): covered events are born
NOT_REQUIREDor swept toACKNOWLEDGEDinside the justification tx — neither state is ever in the sweeper's claim set, so a covered event cannot send. Backdated events notify identically to fresh ones (Fabio: "a follow up is a follow up") — all copy leads with the event's date. - Mechanically: an in-process
@nestjs/scheduleinterval sweeper (accepted over external cron, chat 2026-08-04), per-row CAS claims (updateManywith full prior-state predicate,count === 1= claimed) so concurrent processes can't double-send, and env-configurable timings (grace, reminder delay, tick interval — Fabio 2026-08-04: "the sweeper + 15min + 1h times must be env configurable"). The sweeper runs in dev and prod; under test it is off by default and e2e suites invoke the sweep method directly.
Success criteria (observable behavior that proves this works):
- Boot dev, mark a student absent, wait out the (configurable) grace: the event flips to NOTIFIED with notifiedAt stamped and escalateAt = notifiedAt + reminder delay; each linked referent with an account gets an inbox row (GET /notifications) and one templated email (observable as a mailer log line under MAIL_TRANSPORT=log).
- One reminder delay later with no ack/justification: the event flips to FOLLOW_UP with remindedAt stamped, and a second notification (attendance.reminder) goes out. No further sends ever, for any state.
- An event acked (office) or covered (justification) before either deadline never sends the corresponding notification — and a justification submitted between the two sends suppresses the reminder (the covering sweep moves it to ACKNOWLEDGED, out of the claim set).
- A slice-A re-open (record correction while resolved) restarts the cycle: timing fields were cleared, so the event re-arms and re-notifies.
- Two processes sweeping concurrently produce exactly one send per transition (CAS claim).
- ATTENDANCE_ARM_GRACE_MINUTES / ATTENDANCE_REMINDER_DELAY_MINUTES / ATTENDANCE_SWEEP_INTERVAL_SECONDS override the 15m / 60m / 60s defaults; interval 0 disables the timer entirely (test default) while the sweep method remains invocable.
- The full e2e suite runs with no timer interference (sweeper off under NODE_ENV=test; suites drive sweep() deterministically).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No digest/batching — N events → N notifications, even to the same referent in one sweep (school-sized volumes; collapse is a future engine concern).
- No third notification, no escalation to the office, no admin "resend" action — the open worklists are the escalation.
- No student recipients, no email to account-less referents (decided 2026-08-04; the referent row's bare email field stays unused here).
- No per-link notification opt-out (StudentReferentLink flags untouched) — routing/preferences are an engine deferral.
- No audit-log rows for sweeper flips — the stamped timestamps (notifiedAt/remindedAt) are the evidence; audit records who-did-what and the sweeper is not a who (divergence §7).
- No new HTTP surface, no FE-breaking change — NOTIFIED/FOLLOW_UP were always in the published state enum; they simply start occurring.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/attendance/attendance-loop.service.ts |
The loop's single-writer discipline: all state transitions live in loop-owned code; ARM_WINDOW_MINUTES is the constant this slice makes configurable; re-open semantics (cleared timing fields) already produce the re-notify cycle for free |
The loop service runs inside request txs; the sweeper is a background writer with no request context |
docs/superpowers/specs/2026-07-23-notification-engine-design.md |
send() contract (post-commit, never-throw, User-id addressing), attendance.event/attendance.reminder kinds + AttendanceEventNotificationPayload, email specs (template prefixes attendance-event/attendance-reminder) |
Engine is timer-free by contract — all scheduling here |
src/invitations/invitations.service.ts sendBatch |
Per-recipient loop shape; tenant-wide context resolved once per batch; try/finally so a mid-batch throw can't strand earlier work | Invitations dispatch from a request; the sweeper self-triggers. AfterCommitQueue not needed: each CAS claim commits before its send is issued |
src/attendance/attendance-justifications.service.ts (slice C covering sweep) |
The open-set discipline this slice relies on: coverage removes events from the claim set inside the justification tx — the no-notify invariant needs zero code here | Slice C's sweep is event-driven (in-tx); this one is time-driven |
src/common/utils/with-tenant-guc + src/prisma/admin-prisma.usage.drift.spec.ts |
Background work runs per-tenant under withTenantGuc(tenantId) — RLS stays honest, tenant-led indexes get used, and the AdminPrisma drift guard stays quiet |
n/a — fits; only the tenant enumeration itself is cross-tenant (the tenants root table carries no RLS policy) |
src/config/env.validation.ts + typed config |
Optional-int env vars with defaults + validation | First env vars whose default depends on NODE_ENV (interval defaults to 0 under test) — mirror the existing prod-like conditionals |
test/attendance-loop.e2e-spec.ts + feedback_e2e_isolation_patterns / temporal fixture discipline |
Fixture shapes for loop events; the DB-nudge idiom (backdating armAt/escalateAt directly) to cross time boundaries deterministically |
n/a — fits |
On-axis / off-axis check (ch16 §4–§5): mostly on-axis (a consumer of two existing seams: the loop's state machine and the engine's send()). Off-axis and called out: the platform's first scheduled background writer — a new cross-cutting execution context (no request, no CLS tenant, self-triggering). The invented pattern is deliberately minimal: per-tenant withTenantGuc iteration + per-row CAS claims + config-gated interval registration; it is the template any future scheduled job (AY archival, retention pruning) should copy.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | The sweeper enumerates tenants (prisma.tenant.findMany — root table, no RLS policy) and does all per-tenant work under withTenantGuc(tenantId): candidate scans, CAS claims, recipient resolution. No AdminPrismaService (drift guard stays untouched) |
Background context has no CLS tenant; GUC-per-tenant keeps RLS honest and uses the tenant-led indexes |
| Academic-year scope | no filter needed | Claims are (state, deadline)-driven; open events only exist where the register wrote them. academicYearId rides along on claimed rows, unused |
Adding an AY predicate would silently strand events across year boundaries |
| RBAC entity key | existing ATTENDANCE |
No delta — no new HTTP surface | |
| Scopes | none touched | ||
| Actions | none | ||
| Service base | custom, stateless | New AttendanceNotifierService (attendance module): interval registration + sweep() + the two passes. AttendanceLoopService untouched except reading the grace from config |
The sweeper is not a CRUD surface |
queries.ts shape |
yes | New attendance-notifier.queries.ts: findDueForNotify, findDueForReminder (candidate scans incl. student name + linked referent userIds), claimForNotify, claimForReminder (CAS updateMany) |
Standard convention |
| Error codes | none | The sweeper throws nothing outward; per-event failures log and continue | |
| DTO conventions | n/a | No HTTP boundary | |
| File-backed sub-resources | n/a | ||
| Custom fields | no | ||
| Profile completeness | no |
Sweep mechanics (normative)¶
Every tick (re-entrancy-guarded: a tick that finds the previous sweep still running skips), per tenant, two passes:
- Notify pass — candidates:
state = UNDER_REVIEW AND armAt <= now(index(tenantId, state, armAt)). Per candidate, CAS claim:updateMany({ where: { id, tenantId, state: UNDER_REVIEW, armAt: { lte: now } }, data: { state: NOTIFIED, notifiedAt: now, escalateAt: now + reminderDelay } })— thearmAtpredicate re-checked in the claim guards against a re-open racing between scan and claim.count === 1→ claimed → resolve recipients (student'sStudentReferentLink→Referent.userIdnon-null) →send({ kind: 'attendance.event', … }); zero recipients → claim stands (the school announced; nobody to tell), send skipped. - Reminder pass — candidates:
state = NOTIFIED AND escalateAt <= now(index(tenantId, state, escalateAt)). CAS to{ state: FOLLOW_UP, remindedAt: now }, thensend({ kind: 'attendance.reminder', … }). Acked/covered events leftNOTIFIEDnever — they moved toACKNOWLEDGED, structurally out of the claim set.
Each claim is its own committed write before its send — the engine's post-commit convention holds per event. Death between claim and send loses that one send (at-most-once, consistent with engine v1); the event still surfaces on every open worklist and family page, which is the loop's designed safety net ("the referent page is the message"). A send-leg failure never blocks the sweep (engine never rejects) and never rolls back the claim.
Payload per event: { eventId, studentId, studentName, date (ISO day), eventType } — the engine's AttendanceEventNotificationPayload; email variables pre-localize the event-type label via VALUE_LABELS on the school language.
Configuration (typed config + env.validation.ts)¶
| Env var | Default | Meaning |
|---|---|---|
ATTENDANCE_ARM_GRACE_MINUTES |
15 |
Mint-time correction grace: armAt = now + grace. Replaces the ARM_WINDOW_MINUTES constant (loop service reads config; constant becomes the default value) |
ATTENDANCE_REMINDER_DELAY_MINUTES |
60 |
escalateAt = notifiedAt + delay |
ATTENDANCE_SWEEP_INTERVAL_SECONDS |
60 (0 when NODE_ENV=test) |
Sweep tick; 0 = timer disabled (the sweep() method stays invocable) |
Scheduler wiring: new dependency @nestjs/schedule; ScheduleModule.forRoot() in app.module.ts; AttendanceNotifierService.onModuleInit registers a setInterval via SchedulerRegistry only when the configured interval is > 0 (a static @Interval decorator can't read config — dynamic registration is the documented pattern for exactly this).
4. Data model plan¶
Schema deltas¶
- None. All columns, states, and indexes were reserved by the slice-A migration. (The engine's
notificationstable belongs to the engine spec's migration.) - Cosmetic only: the
AttendanceDayEventdoc comment ("reserved for the notifications iteration") updated to describe the live semantics.
Migration shape¶
- n/a — no migration in this slice.
Indexes and uniqueness¶
- Existing
(tenantId, state, armAt)and(tenantId, state, escalateAt)serve the two candidate scans exactly; nothing added.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| — | none | n/a — the sweeper has no HTTP surface; the engine's read API is the engine spec's |
Swagger considerations¶
- None here. FE-facing effect is behavioral only:
NOTIFIED/FOLLOW_UP(already in the published state enum, documented as reserved) start occurring; the attendance FE guide gets a "now live" note, and the notification center guide (engine) documents the two attendance kinds' payloads.
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) | none |
*_SCOPES runtime constant |
none |
The family read grants landed in slice B; the inbox read API is auth-only (engine spec §6). Nothing to seed.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| All writes happen in request contexts (guards, CLS tenant, audited actor) | First self-triggering background writer: no request, no CLS, per-tenant withTenantGuc iteration |
The cadence is time-driven; Fabio accepted in-process scheduling over external cron (2026-08-04) | In-process timer dies with the process — a crashed node just delays sends until the next boot's tick (events wait, nothing is lost); multi-node double-fire is handled by CAS claims, not by a lock service |
Attendance state transitions are audited (AuditService.record on ack, justify) |
Sweeper flips write no audit rows | The audit log is a who-did-what trail; the sweeper is not a who. notifiedAt/remindedAt are the machine evidence |
An admin reading the audit log won't see NOTIFIED/FOLLOW_UP transitions there; they see them on the event itself |
Domain time uses the school-day clock (schoolTodayFor) |
The cadence compares instants (armAt/escalateAt vs new Date()) |
The grace and reminder delays are wall-clock durations, not calendar concepts — slice A already stamped instants | A 15-minute grace spans midnight indifferently; correct, but worth stating |
queries.ts returns data; services decide |
CAS claim functions both decide and write (count === 1 is the decision) |
The atomicity IS the correctness — splitting read from write reintroduces the race |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| None — program-internal slice; the open decisions (email audience = accounts only, referents only, engine-channel email, configurable timings) were resolved in chat 2026-08-04 and are logged in §1 and the engine spec §8/§10 | Resolved |
9. Deferrals¶
- Digest/batching per recipient — N same-sweep events send N notifications — revisit with engine routing/collapse (engine spec §9).
- Email to account-less referents — their
Referent.emailexists but is unused; would need its own template + no-inbox semantics — revisit if product asks. - Escalation beyond FOLLOW_UP (third strike, office alert) — the open worklists are the escalation — revisit on product signal.
- Scheduled-job framework (job registry, distributed locks, cron table) — one job doesn't justify it; the per-tenant + CAS pattern here is the copyable template — revisit at the second scheduled job (AY archival and inbox retention are candidates).
- Delivery-failure surfacing for notification emails — engine outbox deferral.
- Teachers'
students.healthREAD on own students — slice C's separate deferred item, unrelated to notifications (spec §9 there) — students-module iteration.
10. Open questions¶
Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.
Resolved (all in chat 2026-08-04):
- [x] Email = engine channel (not consumer-side MailerPort calls) — engine spec §8.
- [x] Email audience = linked referents with accounts; identical audience on every channel.
- [x] Recipients = referents only; students never notified about their own events.
- [x] Timings env-configurable (grace / reminder delay / tick); sweeper live in dev + prod, off under test with sweep() driven directly by e2e.
11. Verification plan¶
- Unit specs:
attendance-notifier.service.spec.ts— notify pass: due candidate → claim called with the full CAS predicate (state +armAt lte now), send fired with kind/payload/recipient userIds from the links; claimcount === 0→ no send; zero-recipient claim → state flip persists, no send; reminder pass symmetric (escalateAtpredicate,attendance.reminder); per-event send failure → remaining events still processed; re-entrancy guard skips an overlapping tick; interval0→ no registration withSchedulerRegistry; interval > 0 → registered with the configured period.attendance-loop.service.spec.ts(amend) —armAtminted from configured grace, default 15.- Config/env validation specs — the three vars: defaults, overrides, test-default-0 for the interval, rejection of negatives.
- E2E specs (
test/attendance-loop.e2e-spec.tsamendments or a sibling suite): with the timer off, driveapp.get(AttendanceNotifierService).sweep(): - Absent student,
armAtbackdated (temporal-fixture DB nudge) → sweep → follow-ups list showsNOTIFIED; the linked referent'sGET /notificationsshows oneattendance.eventrow with the payload contract; a second linked referent without a user gets nothing and breaks nothing. - Backdate
escalateAt→ sweep →FOLLOW_UP+ anattendance.reminderrow; a third sweep sends nothing further. - Office-acked before the nudge → sweep → no reminder, no rows.
- Justification covering the event pre-arm (born
NOT_REQUIRED) → sweep → never notified (the no-notify invariant, observed end-to-end). - Concurrent-claim simulation is unit-level (two sweeps racing is not black-box observable); e2e asserts idempotence instead: two consecutive sweeps, one send.
- Manual verification: dev server with
ATTENDANCE_ARM_GRACE_MINUTES=1 ATTENDANCE_REMINDER_DELAY_MINUTES=2 ATTENDANCE_SWEEP_INTERVAL_SECONDS=10, mark a student absent, watch the log transport + mailer log lines + Prisma Studio state flips end-to-end.
Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns, project_e2e_temporal_fixture_discipline (the DB-nudge rules). Docs follow-up on landing: ch19 §11.1 rewritten (states live, cadence, config table), REFERENCE attendance row amended, AttendanceDayEvent schema comment refreshed, attendance FE guide "NOTIFIED/FOLLOW_UP now occur" note + notification-center FE guide cross-link (plans end with the FE-guide task, amended in place).
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-08-04
- Chat reference: "approved" in chat 2026-08-04, together with the engine spec (slice D kickoff; audience/recipients/config decisions resolved same chat)
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.