Skip to content

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 through NOTIFIEDFOLLOW_UP and 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 — after FOLLOW_UP the event just stays open on every worklist.
  • The delivery mechanism is the notification engine (approved in this slice): the sweeper calls send() with kind attendance.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: openFollowUpWhere has admitted NOTIFIED/FOLLOW_UP since slice A, familyVisibleEventWhere likewise, and the AttendanceDayEvent columns (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_REQUIRED or swept to ACKNOWLEDGED inside 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/schedule interval sweeper (accepted over external cron, chat 2026-08-04), per-row CAS claims (updateMany with 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:

  1. 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 } }) — the armAt predicate re-checked in the claim guards against a re-open racing between scan and claim. count === 1 → claimed → resolve recipients (student's StudentReferentLinkReferent.userId non-null) → send({ kind: 'attendance.event', … }); zero recipients → claim stands (the school announced; nobody to tell), send skipped.
  2. Reminder pass — candidates: state = NOTIFIED AND escalateAt <= now (index (tenantId, state, escalateAt)). CAS to { state: FOLLOW_UP, remindedAt: now }, then send({ kind: 'attendance.reminder', … }). Acked/covered events left NOTIFIED never — they moved to ACKNOWLEDGED, 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 notifications table belongs to the engine spec's migration.)
  • Cosmetic only: the AttendanceDayEvent doc 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.email exists 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.health READ 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; claim count === 0 → no send; zero-recipient claim → state flip persists, no send; reminder pass symmetric (escalateAt predicate, attendance.reminder); per-event send failure → remaining events still processed; re-entrancy guard skips an overlapping tick; interval 0 → no registration with SchedulerRegistry; interval > 0 → registered with the configured period.
  • attendance-loop.service.spec.ts (amend) — armAt minted 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.ts amendments or a sibling suite): with the timer off, drive app.get(AttendanceNotifierService).sweep():
  • Absent student, armAt backdated (temporal-fixture DB nudge) → sweep → follow-ups list shows NOTIFIED; the linked referent's GET /notifications shows one attendance.event row with the payload contract; a second linked referent without a user gets nothing and breaks nothing.
  • Backdate escalateAt → sweep → FOLLOW_UP + an attendance.reminder row; 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.