Skip to content

ADR-030 — Notification channels and delivery rules

Status

Accepted (2026-09-26), consolidated through the 2026-10-01 amendments without changing a decision. The project kinds (addendum of 2026-10-04, merge decision D18, delivered by merge task M3-09b) are folded into rules 5, 7, 10, 12, 15, 19, 20 and 30 (2026-10-09); the contracts hold nineteen kinds.

Relations: extended by ADR-053 (no classroom join codes), ADR-055 (admin system alerts), ADR-034 §6 (2026-10-15: the in-app-only impersonation_ended kind, a row of rule 12's table) and ADR-035 (project kinds for the merge). The historical record keeps the rejected Teams transports, the migration sequence, the implementation steps and the original section numbers; where this record is silent on a detail (an exact log line, a migration step) the archive still describes the implementation, and this record wins wherever both speak.

Context

Users need to hear about work without keeping Quiz open. Slow or failing external providers must not delay or undo the action that caused a notice. One system owns the App inbox/toasts, e-mail and Teams, with preferences per account and kind.

Decision

One entry, after commit

1. Every module uses notifyMany; notify is its one-recipient case; notifyUsers (notifications/service.ts) sends one payload to an audience, best-effort, the way every module tells people of a committed fact, and classroomStaffIds (org/service.ts) is the one staff audience of a classroom. Call after the announced write commits, never from its transaction: pg-boss has its own connection, so a queued job would survive a business rollback. Notification failures are best-effort and must not turn a committed business action into an apparent failure. This is not a transactional outbox or an exactly-once delivery guarantee.

2. The App channel (bell internally) writes a row and recipient refresh hint only when enabled. Off means no row, badge increment or toast.

3. Each selected external channel gets a notifications.deliver job containing recipient, channel and payload, not a bell-row reference: App may be off or its row deleted. Providers never run inline. The outbox is handed its queue at boot; while closed (tests, jobs disabled, queue startup failure) only App works, with no inline fallback. Enqueue failures are logged. Jobs retry with exponential backoff and retry limit five; development's in-process queue logs failures rather than promising production retries.

Preferences, audiences and payloads

4. notification_preferences stores only changed toggles, keyed by user/kind/channel. Missing values come from DEFAULT_CHANNEL_ENABLED in @quiz/contracts, a Record<NotificationKind, …>: a kind without its defaults is a compile error. The recipient's preferences are intersected with a delivery's allowed channels and KIND_CHANNELS; preferences cannot widen either restriction. App is on by default. Linking Teams is the account's opt-in before its enabled kinds can use that channel.

5. The catalogue, defaults and audience declarations live together in packages/contracts/src/notifications.ts, not in a second settings-specific list. Seat kinds target claimed non-staff enrollment seats, course kinds target course staff seats (not a seatless admin), pool kinds target eligible teacher/admin pool members, and admin kinds target the admin role. The settings list follows those capabilities: students see seat kinds before enrollment; teachers/admins need a student seat for those kinds, and an admin needs a course seat for course kinds. Teachers can configure course kinds before receiving a seat. The project kinds (2026-10-04, M3-09b) enter the grid with their emitters: on a platform without Quiz's GitHub App (GITHUB_APP_ID unset) they are never sent and not listed (GITHUB_KINDS, notificationKindsFor({ github })) — a toggle that does nothing is a lie.

6. Every kind has its sentence in en and fr twice: in the API's templates.ts (e-mail, Teams), whose fr is typed Record<keyof typeof en, string>, and in the web dictionary (bell, toast). A missing French sentence is a compile error.

7. Payloads carry identifiers, titles and counts, never grades, points, answers, question content, student names or student e-mail. Teacher names in sharing and ownership notices are deliberate exceptions. System alerts carry check keys only. Project payloads (2026-10-04, M3-09b) carry the project's id and name and counts, never a score, a login or a student's name (N-SEC-20); the entry opens the project (/projects/:id, the student's view of it for a student), github_org_lost the classroom's Settings.

8. External messages are rendered when the job runs, in the recipient's current locale (English if unset); user text is HTML-escaped and subjects flattened. Links use WEB_URL, defaulting to PUBLIC_URL. Settings are the preference entry point; there is no separate unsubscribe-token endpoint. Old classroom unsubscribe links are planned to lead to Quiz notification settings.

App aggregation and live toasts

9. A folded kind maintains one UNREAD entry per user and target. One atomic upsert adds the incoming count, replaces the latest descriptive payload and refreshes createdAt; after the row is read the next event creates another. Partial unique indexes are per kind and target, with the kind predicate written as a SQL literal. NOTIFICATION_FOLD_TARGETS owns the mapping; schema, writer and migration tests agree on it. Do not use a nullable multi-target unique index or read-then-write. Only App folds: enabled external channels still send one message per event.

10. A project fold target (2026-10-04): notifications.project_id lifts the payload's projectId (a foreign key: a deleted project takes its bells), and NOTIFICATION_FOLD_TARGETS gains project_deadline_applied and project_provision_failed on it — one unread entry per recipient and project, the partial unique indexes generated as for the other targets (migration 0068). F-NOTIF-12's list is amended accordingly. The latest payload wins the fold, so a project_provision_failed entry names the LAST failure's reason.

11. The notification stream carries a hint, not payload data. Clients refetch their own inbox, then toast new unread (id, createdAt) pairs. The first read after connection, reconnection or leaving a quiet page establishes a baseline and toasts nothing. Attempts, projections, other full-screen pages and the live dashboard stay quiet. A folded entry toasts at most once in five minutes. Several open tabs may each toast; that duplication is accepted. Immediate feedback from the user's own action (useToast) is separate and is never gated by notification preferences.

Events and trigger boundaries

12. The kinds below; the external-default column means both e-mail and Teams unless stated otherwise. App is on for all. The predicates are part of the decision, not mere UI hints. The project kinds (2026-10-04, M3-09b) (F-NOTIF-13, accepted by merge decision D18, delivered by M3-09b) use the same notification entry, locale and per-kind preferences as every other kind; nothing of heig-classroom's mails was ported independently, and its grade.final mail on the final review is dropped (F-PROJ-15: the score reaches a student with the release).

Kind Trigger, recipients and aggregation External default
results_released First release only; students in frozen snapshot with an attempt; no notice on re-release On
activity_scheduled First scheduling of an exercise in a classroom; claimed student seats; folded per classroom Off
activity_available Winning move of an exercise to running; claimed student seats; in-class delivery restricted to App On, take-home only
deadline_approaching Due non-poll evaluation/student pairs under the reminder rule below On
results_updated Visible final grade changes after a grading write; folded per evaluation Off
student_joined The student's own claim of a seat, at their sign-in or their next own request (ADR-053, addendum of 2026-10-11: no staff write claims); course staff, actor excluded; folded per classroom Off
roster_conflict Roster conflict needing attention; course staff, actor excluded; folded per classroom On
grading_ready Complete grading grid with proposals; course staff including the teacher who closed; not folded On
pool_question_added Every publication, first or later version; pool owner and contributor/owner shares, not readers or author; folded per pool Off
pool_shared, pool_ownership Existing sharing/ownership events for the affected account On
system_alert Admin health failure, continued failure after a day, or recovery; not folded; ADR-055 E-mail on; Teams forbidden
impersonation_ended (2026-10-15, ADR-034 §6) A session opened in the student's name ended, whatever ended it; the student; the role and two times, never who; not folded E-mail and Teams forbidden
project_published (2026-10-04, M3-09b) publishProject committed, by hand or by the ticker (once: a draft publishes once); the classroom's claimed student seats; not folded Off
project_deadline_reminder (2026-10-04, M3-09b) 24 h before the student's EFFECTIVE deadline, claimed and sent in one tick (rule 19); claimed student seats On
project_repo_invited (2026-10-04, M3-09b) Accept provisioned the repository in THIS request and GitHub left the invitation pending; the student; never on an idempotent repeat nor a resend On
project_grade_final (2026-10-04, M3-09b) FIRST release only (ProjectReleaseResult.first); the students of the repositories the release covered, a staff seat's never; no score carried On
project_deadline_applied (2026-10-04, M3-09b) A project.deadline job pass that locked or committed on at least one repository; the course staff; folded per project with the count Off
project_provision_failed (2026-10-04, M3-09b) A row's FIRST provisioning failure, never an invitation GitHub refused; the course staff; folded per project, reason the latest (repo_name_taken, github_error) On
github_org_lost (2026-10-04, M3-09b) The installation an organization held forgotten — uninstalled, deleted with it, or a 404 the healing met —; the course staff of each non-archived linked classroom, one entry per classroom; not folded; a suspension tells nobody On

13. activity_scheduled claims scheduled_announced_at before sending. The marker is never cleared by rescheduling or returning to draft, and never copied to a new instance. activity_available follows each winning move to running, not lobby entry or the nominal opensAt; both events exclude exams and polls. announceMove is invoked after commit by the winning transition/start, not inside the lower-level state CAS. An in-class exercise cannot leave App even if the recipient opted in. Its current payload is { activityKind, activityId, activityTitle }; scheduling folds { classroomId, classroomName, count }, with no individual opening date.

14. pool_question_added is emitted by the shared publication service after commit, so editor, MCP and API import paths share the same rule. Notification failure must not fail publication. student_joined and roster_conflict carry classroom/count, not student identity; the roster is where the teacher reads the names.

15. A lost organization (2026-10-04, M3-09b): the notice follows the ROW's transition and GitHub's STATE, never a webhook's words. The installation an organization held is forgotten by forgetInstallation when GitHub no longer has it (an installation webhook of any action re-read against GET /app/installations/{id}, the listing, the healing's 404), or by markOrgDeleted when the organization goes with its installation; each tells the staff once. A SUSPENDED installation is not lost: GitHub still has it and lifts the suspension, so the row keeps its installation_id with github_organizations.suspended_at set (audited installation_suspended on each change) and the App acts on it as on none — installed() is the one rule, installed AND not suspended AND active, the listing, the connect sheet, the projects and the journal reading it alike — and a deletion after a suspension is still the installation going away, told once. An organization whose App was already gone tells nobody again when deleted; a row retired because another organization took its login (retireLoginHolders) tells nobody, a rename race being the likelier reading. GitHub's sender is no Quiz account, so F-NOTIF-11's "own action" cannot apply.

16. Only activity_available has adopted the neutral activity payload; the remaining boundaries are I58–I60 in docs/merge/07-incompatibilities.md.

Deadline reminders

17. A minute ticker claims an evaluation/student pair only when ALL hold:

  • evaluation is running, is not a poll, and has closesAt;
  • closesAt - 24 h <= now < closesAt;
  • startedAt <= closesAt - 24 h, excluding windows shorter than a day;
  • student has a claimed non-staff classroom seat and NO finished attempt (submitted or expired), even if a later retake is open;
  • no (evaluation_id, user_id) marker exists in deadline_reminders.

18. The common end applies in every timing mode; individual extensions are ignored. The claim is one INSERT … SELECT … ON CONFLICT DO NOTHING RETURNING, and only returned pairs are told. Markers never clear when the end moves or evaluation reopens. Late scans catch up only before the end. Failed fan-out is logged per evaluation, other evaluations continue, and claimed failures are not retried. The payload has no date needing a recipient time zone; it says less than 24 hours.

19. The project reminder (2026-10-04, M3-09b) mirrors rules 17–18 on the student's EFFECTIVE deadline (their repository's own, else the project's), 24 hours before it — the one DEADLINE_REMINDER_MS of @quiz/domain, the evaluations' too —, with two claims in the project ticker (projectTick, every 20 s), each a conditional UPDATE whose returned rows alone are told: projects.reminder_sent_at claims the students under the project's deadline — every claimed student seat of the classroom, repository or not, except the members (individual or group) of a repository with its own deadline, a deleted one or one the staff locked by hand —; project_repos.reminder_sent_at claims the members of a repository with its own deadline.

  • Window rule: as the evaluation scan reads started_at, both scans require the project's start_at to lie at least 24 hours before the deadline they read — a project published, or a repository handed its own deadline, within a day of it tells nobody; an own deadline given within a day on a project open for longer is reminded (the student's window is their time in the project).
  • Re-arm rule: a deadline that moves (a project's patch, a repository's own deadline, a reopen) nulls the matching claim only when the new effective deadline is more than 24 hours away (reminderClaimAfterMove, the one domain rule); otherwise the claim stays — a reminder sent is never sent again, one still owed on a deadline brought nearer is still owed. A repository whose own deadline is taken back falls under the project's claim again: when that claim fired already, its members were reminded of their own deadline and are not reminded of the project's.
  • The scan never reminds after the deadline, skips drafts, archived projects and archived classrooms, and a late pass catches up while the deadline lies ahead.

20. For the import (M8-01) (2026-10-04, M3-09b): projects.reminder_sent_at and project_repos.reminder_sent_at are null = owed. The import must set them to the import time for every imported project or repository whose effective deadline lies within 24 hours of the import, or the first tick sends a burst; a deadline further ahead is legitimately reminded later and may stay null; heig-classroom's own marker, when one exists, is copied as it is.

Grading-ready and changed results

21. grading_ready reads the committed grid: every attempt/item cell must have a standing validated or proposed grading, and proposals must remain. It excludes polls, running and paused evaluations and rechecks the current row. A conditional claim on grading_ready_at wins once before fan-out, regardless of whether a pass or runner job completed the last cells. Regrade returns the claim; reopening to draft clears it. The count includes placeholder proposals requiring attention. The asynchronous audience includes the teacher who closed the evaluation. A crash after claim can lose a notice: at most once, not exactly once. Historical backfill deliberately suppressed notices for evaluations already closed/grading/released at migration, including incomplete grids then in flight.

22. results_updated hooks the single writeGradings writer, once per write/batch, not per cell or inside flagReleasedEvaluationsOf. It reads shown grades before the write and announces after its final transaction commits. Compare the FINAL rounded grade, not changed points: frozen snapshot while unmodified, live validated gradings of the kept attempt after modification, through the same gradeShown rule as the results page. No extra grade snapshot is stored for notification. Only owners whose result is currently visible and changed are told; never a staff test, invisible feedback policy, missing attempt or release withdrawn meanwhile.

23. Regrade comparisons use pointsAcrossRegrade, the superseded grading without a successor, to avoid comparing against the temporarily missing item. Restoring the same points then sends nothing. Residual limitation: a superseded row does not retain whether it was validated. A pending proposal stood down after release, or a validated row stood down before release whose replacement was pending at release, can therefore supply the wrong comparison baseline and miss a notice. Preserve this limitation rather than promising that every grade change is announced. The historical Step 8 retains the detailed cases.

24. Changed-result payloads contain evaluation/attempt ids and count, never grade. The count supports folding, not the displayed sentence. Withdrawing release deletes both released/updated bells; e-mails already sent cannot be withdrawn. Read-before and send-after failures are logged and never fail the grading write or release.

External transports and identity

25. E-mail uses Scaleway Transactional Email with the shared mailer settings. Without both credentials it logs recipient/subject only and sends nothing. This is the development/test/staging dry-run. The destination is the account's current e-mail.

26. Teams uses Graph activity-feed notifications with user-granted RSC TeamsActivity.Send.User, through an app the user uploads. No tenant administrator consent, bot resource or chat delivery is part of this design. Tenant policy can still prohibit custom apps or user consent; those users retain App/e-mail. Installation and Entra configuration belong in the Teams runbook, not in application decision history.

27. The personal Teams tab cannot use Quiz SameSite cookies; it gets Teams SSO instead. Its sessionless endpoint verifies RS256 signature, exact tenant-derived Entra v2 issuer, our audience, delegated access_as_user, a Teams client id, tenant allowlist, expiry/not-before with five minutes of skew, object id, and idtyp not app. Entra's keys come from the common key set, cached a day and re-read at most every five minutes for an unknown key. Production refuses enabled Teams with an empty tenant allowlist. The endpoint, POST /app/api/notifications/teams/tab, reads no cookie (so no CSRF), answers Cache-Control: no-store and 404 while Teams is off; a tenant outside the allowlist gets 403 tenant_not_allowed, any other refusal a 401 and a teams tab refused log line naming the reason, never the token. It returns only that identity's link state or a 15-minute, random 32-byte, SHA-256-stored link token.

28. Issuance is serialized per identity and replaces outstanding tokens; there is no issuance cooldown. The /teams/link page previews without consuming; a Quiz browser session and CSRF-protected confirmation consume it, the token in a body and one conditional UPDATE deciding, checking the tenant again. The confirmation names Microsoft account and organization; its address need not equal edu-ID's. One Teams identity links to one Quiz account and vice versa (UNIQUE (tenant_id, aad_object_id)). Confirming a token of an identity already linked to another account MOVES the link (audited teams.unlink, { via: "moved", to }), and a new link replaces the account's previous one.

29. A malicious same-tenant user could forward their genuine linking URL: confirmation and the named identity mitigate that residual consent-phishing risk, they do not cryptographically prove both accounts belong to one human. Payload minimization bounds the exposure. Link URLs/return paths and authorization headers are redacted.

30. Graph credentials are obtained for the recipient's tenant and cached to just before expiry. The manifest declares each Teams-supported activity type, excluding system_alert and impersonation_ended; catalogue tests enforce completeness. That list alone may run ahead of the catalogue, so one manifest bump can declare the types of kinds still to be emitted. Bundle newly added kinds into one manifest update, since users may need to reinstall (the seven project activity types, 2026-10-04, M3-09b, were declared in ONE bump, TEAMS_APP_VERSION 2.1.0 → 2.2.0). Teams renders its template in Teams' language and the server preview in Quiz locale, capped at 150 characters. Deep links are parsed into a known router route and rebuilt under the app's origin, never followed as an arbitrary supplied URL.

31. Jobs re-read account, locale, link and allowed tenant. Missing/anonymized users, invalid old payloads, removed links and no-longer-allowed tenants are dropped. Admin payloads additionally require the current admin role. Graph 400/403/404 are permanent failures and not retried; the link stays for a user to repair installation. Token/configuration failures and other transport errors are retried. Disabling Teams credentials hides its channel/routes without deleting links. Disabling mail credentials restores dry-run. Returning to the historical bot requires a migration and cannot reconstruct chats for links made by the current design.

Consequences

External provider failures cost jobs/logs, not failed business transactions. Claim markers favor avoiding duplicates over guaranteed delivery. Browser push, digests, per-course muting and configurable reminder delays remain out of scope.

Where the rules live: packages/contracts/src/notifications.ts (payloads, kinds, audiences, defaults); apps/api/src/modules/notifications/{service,outbox,jobs,deadline,templates}.ts (fan-out, aggregation, delivery, reminder claims); apps/api/src/db/notifications.ts (folded targets, partial unique indexes); apps/api/src/modules/{evaluation/announce,grading/ready,results/updated}.ts and grading/service.ts (committed event hooks, grade comparison); apps/api/src/modules/project/notify.ts (audiences, the reminder's claims, the sends), the trigger sites in project/{lifecycle,accept,grades,jobs,deadline}.ts and github/service.ts (orgLost), @quiz/domain/deadlineReminder.ts; apps/api/src/modules/notifications/{ssoAuth,teamsLink,teams,teamsApp}.ts (Teams identity, installation, transport) and their database/unit tests; apps/web/src/notifications/toasts.ts, App.tsx (live baseline, quiet pages).

Alternatives considered

Always writing disabled bell rows, sending inline, per-user webhook secrets and stored delegated Microsoft credentials were rejected. See the archive for the two prior Teams designs and why their tenant/operational requirements failed.

Correspondence of old references

Original numbered decisions, the lettered points of the 2026-09-28 addendum, the implementation steps and this record's former section headings are historical; apply the rules above. The full text of the first three is the historical record. A citation in code or another record ("ADR-030 §h", "ADR-030, addendum §c; #198 step 5") lives here now:

Old reference Rules now Full text
§1 notify the one entry; §f fan-out after commit; §h best-effort after commit 1–3 §f, §h
§2 rendering and locale; §5 preferences; §b defaults 4–8 §1–§6, §b
§a bell and toast; §e aggregation; §h.2 quiet pages and throttle; Step 4 9–11 §a, §e, §h, Step 4
§c the kinds; §6 results_released; §h.1 activity_scheduled; §h.3 take-home only; Step 6; the addenda of 2026-09-30 (project kinds, ADR-035; the admin kind, ADR-055) 12–16, Status §c, §h, Step 6, 2026-09-30
§d deadline_approaching; Step 7 17–18 §d, Step 7
Step 5 (grading_ready); §h.4–5 and Step 8 (results_updated) 21–24 Step 5, §h, Step 8
§3 e-mail; §4 Teams; §f manifest 25–31 §4, §f, Step 4
The project kinds (addendum of 2026-10-04, M3-09b): kinds, Settings, project fold target, reminder, lost organization, import, manifest bump kinds and payloads 7, 12; Settings 5; fold target 10; lost organization 15; reminder 19; import 20; manifest 30 —
§g out of scope; Rollback; Alternatives considered; former "Consequences and alternatives" Consequences, Alternatives considered archive
Former "Implementation references" Consequences —

Amended 2026-10-09: the list of compatibility anchors into the historical record was reduced to this table (the references that code and records cite) and bare compatibility anchors, so old links still land here; the full text stays in history/ADR-030. The same day the record was condensed into numbered rules and the project-kinds addendum folded into them (no decision changed).