Skip to content

Saved notifications and study attention delivery

Authorized outcome

Deliver a persistent personal inbox with history, unread count, mark-read, live delivery, offline/reconnect catch-up and a detail destination preserved through sign-in. Notification ownership, current account admission and current resource authority are server checked. Read state does not resolve a workflow or acknowledge an email.

Categories: completed imports, completed exports, project invitations, new review access, workload changes, explicit reconciliation questions/responses and study issue updates. Routine individual study claims do not notify. All emails link to the saved notification. Users opt into each category and choose immediate or daily email. Email defaults off.

Reconciliation questions target selected candidate reviewers, retain project/study/stage context and response history, and preserve blinding. Study reports target authorized project administrators and cover PDF problems, bibliographic errors, invalid formatting and other issues. Proposed corrections require administrator acceptance before modifying canonical data.

PDF proposals accept uploaded files and external links. Download once into pending storage; validate and scan before administrator preview. Approval attaches the exact validated file, never refetches its URL, and preserves the previous association/history. Failed or incomplete checks block approval. Rejection preserves the current file. All decisions notify the proposer.

First usable slice

Saved inbox for completed exports: persist one notification per successful job, expose recipient-scoped history/count/detail/read APIs, show an inbox and related result link, push invalidations through the existing hub and refetch on reconnect. Exports remain browser streamed; completion is not a promise of a stored downloadable artifact. Capture notification creation with the persisted terminal transition, including persistence-failure recovery: if capture fails with the flag on, the completion write rolls back and the job stays InProgress after the file streamed; an authorised caller streams it again with GET api/projects/{projectId}/data-export/{jobId}, and capture reuses the job's notification identity (insert-only), so a retry yields one item.

Acceptance: duplicate completion yields one item; another account cannot read it; revoked access does not leak content; refresh/offline/reconnect recovers it; concurrent tabs and API instances converge; marking read does not change an export or send mail.

Critical path: terminal persistence and durable capture → account APIs → inbox/detail UI → live invalidation and recovery → integration and browser validation.

Ordered slices

  1. Inbox and export completion (this PR).
  2. Import recipient capture and completion; known-account invitation capture. Preserve existing transactional invitation mail, including delivery to unknown addresses.
  3. New review access and workload-change capture based on committed meaningful transitions.
  4. Separate atomic account preferences and leased immediate-email delivery.
  5. Daily digest with persistent scheduling and a durable per-recipient window/item ledger.
  6. Selected-recipient reconciliation questions and response history.
  7. Study issue reports, administrator response/resolution and accepted correction proposals.
  8. PDF upload/link quarantine, download controls, scanning/validation and exact-file approval.

Inbox and email need independent default-off rollout decisions. Durable workers must finish accepted obligations even if admission/UI is disabled. No merge or deployment is authorized.

Delivery semantics

Use stable recipient/category/source identities for duplicate suppression and atomic source capture where supported. Existing unawaited domain callbacks and in-memory outboxes are not durable capture. Live delivery is a hint; saved state is authoritative. Every API replica must observe relevant invalidations rather than compete for one queue and push only locally.

Re-evaluate subscriptions and access before unsent mail. Default digest fallback: 09:00 UTC; users can select an IANA timezone and send time. Include unread eligible items at digest claim time; later reads do not alter an already claimed message. Switching timing affects unsent items, with one delivery ledger preventing immediate/digest duplication. Ambiguous transport acknowledgements are recorded explicitly rather than automatically replayed.

Validation

Test source commit/capture failures, duplicate completion, pagination, concurrent mark-read, cross-user reads, inactive subjects, revoked membership and unavailable authority. Validate multiple tabs/replicas, duplicate/out-of-order hints and reconnect. Test email deep links through sign-in, opt-out races, timing changes, worker lease recovery, ambiguous sends, timezone/DST windows, candidate reviewer eligibility/blinding, report resolution separate from read state, PDF URL redirect/address/size restrictions, incomplete/failed scans, exact preview/approval identity, concurrent replacement and preservation of old files. Never send test mail to users.

Status

The first slice is implemented in PR #3932, locally validated and awaiting CI/review. It adds the default-off notificationInbox flag, transactional export capture, recipient-bound APIs, inbox/detail routes at /inbox, navigation unread count and empty live hints from a watcher on each API instance. Clients refetch on hints/reconnect and every 30 seconds while signed in with the inbox on (the unread count alone unless the inbox view is open; see the L3 review findings below). Account or impersonation changes clear client state, and stale responses cannot restore an older count.

The saved notification ID equals its export job ID in this slice. Repeated completion and summary updates do not reset its creation/read timestamps. Source IDs are not displayed when current project authority is refused or unavailable. History and unread metadata remain owned by the recipient even when the related resource becomes unavailable.

Mongo replica-set tests cover capture rollback, replay, recipient isolation, pagination and concurrent reads; API tests cover inactive subjects, authority loss, feature rollout and two API observers; Angular tests cover recovery, stale responses and account changes. The real local-stack browser journey passes: a streamed export arrives live, another reviewer is refused, and saved read state survives reload. The test uses the existing GUID URL encoding.

Saved export notification after marking read

Subsequent slices remain part of the authorized outcome; this document is not a substitute for them. No merge or production/staging deployment has occurred.

Import and invitation slice (PR #3938)

Persist the manual import requester before handing out an upload signature or publishing a start message. Both competing upload-start paths then use the same trusted admission; S3 metadata is not the recipient authority. Capture completion inside the existing Project/Search transaction. Retain that admitted obligation if the UI/admission flag later turns off. Superseded by L2 (24 October 2026): completion captures only while the inbox is admitted at completion, so flag off is exactly main's behaviour. Legacy or automated imports without an established recipient do not infer one from project ownership. Existing living-search delivery currently has no dependable actor contract.

Known-account invitation creation and its inbox item commit together before dispatching the existing domain email. Unknown-address invitation mail remains intact. A pending invitation allows its intended recipient to see only the invitation's own details/action; general project access is still checked independently. A read never accepts, declines or resolves an invite.

Acceptance: source/capture rollback, concurrent duplicate delivery, immutable admission binding, ownership/access checks and usable import/invitation action links. The shortest critical path is admission/capture, authorized shaping, then client/browser validation.

Implemented: immutable manual-import admission, transactional completion capture, known-account invitation capture and authorized detail/action shaping. Inbox persistence failure aborts the source transaction without invoking destructive import-failure compensation; retry retains the parsed studies. Existing invitation email dispatch follows the confirmed source commit.

Validation (October 2026 run on the original #3938 slice; superseded by the L2 notes below): 10 Mongo replica-set tests, 66 project-management service tests, 32 focused API tests and 14 inbox Angular tests pass. The real local-stack RIS import and invitation browser journey passes, including recipient refusal, saved import details and an invitation action that remains pending after marking its notification read. The journey also passes with normal browser trace/video artifact settings (4 tests including authentication setup).

CI validation for stacked slices

The repository's product PR workflow runs only for PRs targeting main. Stacked slices are therefore temporarily targeted at main while their current heads receive full product checks; restore their predecessor base after checks and review. Claude cloud review currently requires its comment trigger and a main-targeted PR; the label alone does not start that workflow. No merge or deployment is part of this validation step.

Review access and workload slice (PR #3941)

Save notifications for effective stage review grants caused by committed project membership or stage permission changes, and for changed configured reviewer workload. Compare recipient access before and after the source mutation, rather than notifying on every group edit. Routine individual study claims remain silent. Each notification identifies its saved stage context; current recipient and stage authority determine which details/actions remain available.

Acceptance: meaningful transitions only, atomic source/capture rollback, retry deduplication, recipient isolation and usable stage destinations. The shortest critical path is effective transition comparison, source-transaction capture, authorized detail shaping and verification.

Implemented: compare effective reviewer access around membership, invitation acceptance and permission mutations, and configured allocation around fenced review-setting/regime changes. Capture joins the source transaction; no-op changes and routine study claims remain silent. Saved stage details recheck current recipient authority. Inactive stages retain authorized history but offer no review action until active again.

Validation (October 2026 run on the original slice; current counts are in the L3 review-findings section below): 24 Mongo replica-set tests cover source/capture commit and rollback, publisher failure and replay. Four transition tests and 66 project-management regression tests pass. The 83 focused API tests and 63 stage/settings regression tests pass. Seventeen inbox Angular tests pass, with changed-file lint and generated-file validation clean. The real local-stack browser journey passes (four tests including authentication setup): live review grant, saved workload change, inactive-stage action suppression, recipient refusal, identical-regime silence and silence for an actual individual study claim.

Saved workload change for an inactive stage

Foundation validation follow-up: 24 notification API tests pass, including paging boundaries, recipient-scoped counts and reads, absent/malformed/mismatched subjects, foreign export source binding and missing legacy project authority. Mainline feature-flag additions are preserved; conflicting generated files were regenerated through the official flag/environment generators.

Workload review follow-up: the pending-invitation join path now has a controller regression test asserting HTTP 200, accepted invitation, persisted active membership and accessGranted. That test exposed and corrected the existing access-result helper omitting this successful transition. This response correction is intentionally unflagged so callers see the membership actually granted. Isolated mutation reads also remain active with inbox capture disabled: they prevent shared-cache publication of unsaved source changes; only notification capture is flagged. Superseded by L3 (25 October 2026): the private read scope opens only on the capture path, as in L2, so flag off reads and saves exactly as main does. UpdateProject and UpdateStage keep the scope main already gives them.

Post-review validation: all 63 ProjectController tests pass, including the pending-invitation regression with inbox capture both off and on. The flagged path asserts exactly one review grant, a source save without dispatch, transaction commit and subsequent event dispatch.

The flag-off project mutation path retains its existing synchronous persistence and event timing contract. Its single synchronous call has a scoped S6966 justification; enabled notification capture continues through the asynchronous transaction. Current-head CI retries address an unrelated overlapping impersonation test and an annotation test timeout. Cloud review uses the repository GitHub workflow, triggered with /claude-review on a ready PR temporarily targeting main, after all checks on its current head finish and pass. Codex cloud review is usage-blocked; Claude fallback is authorized. The stacked base is restored after review. The latest main annotation-fold changes are integrated without changing the import or invitation notification contract.

Brought up to main (24 October 2026, notifications restart L1)

The restart plan's step L1 merged main into this PR and moved it onto main's rules. The permanent record is ADR-026.

  • Flag. notificationInbox is feature notification-inbox in src/charts/syrf-common/feature-definitions.yaml (Release, Developing, What's New quiet, permission SignedIn; D50 pending, built to recommendation A), with a reviewed consumer contract and a deployment-managed pin, because Project Management also captures from #3938 on. It is read only through IFeatureDecisions, behind one gate, IInboxAdmission; the FeatureFlags property and the runtime-provider mutation are gone.
  • Indexes. The three inbox indexes are pre-deploy migrations 0023–0025 (PmMigrations), with Downs. Nothing builds them at start-up. While any is missing the inbox stays closed: no capture, 503 on reads, no change stream.
  • Worker. InboxInvalidationWorker opens no change stream while the inbox is not admitted, and closes it within one bounded wait (5 s) after the flag goes off.
  • Routes. The API controller is unroutable while the flag is off (FeatureGated); the web /inbox and /inbox/:id routes fall through to not-found. Superseded for reads by N2 (below): the API has no route gate, and with the flag off it serves only callers who have saved notices.
  • Not changed here. The /inbox/:id detail page conflicts with owner decision 3 (one expanding card); it stays behind the flag until N3 replaces it. Dismiss, the new unread rule and the kind registry are N1 and N2.

Import and invitation capture on the amended foundation (24 October 2026, notifications restart L2)

#3938 merged the amended L1 and moved its own capture onto the same rules. The permanent record is ADR-026 decisions 7 and 8.

  • One gate. Upload signing (API), import completion (Project Management) and invitations (API) ask IInboxAdmission once per operation, before the source transaction. Off, or on with migrations 0023–0025 missing, does exactly what main does: no admission row, no capture, no extra transaction, no isolated-read scope.
  • Changed from the original slice. Completion no longer captures whenever an admission exists; it captures only while the inbox is admitted at completion. An import admitted while on and completed while off saves no row.
  • Both hosts. The notificationInbox contract names the API and Project Management, so both decide from one authority revision; the pin keeps both at the deployed value. The runtime report's statistics rule now keys on the contract owner, not the host.
  • Collection. pmImportNotificationAdmission (search ID → project and recipient, set once) is documented in mongodb-reference.md.
  • E2E. notification-import-invitation.spec.ts relies on the stack's deployed flag (a runtime override is refused).
  • Mismatched admission is no recipient (#4562). An admission whose project differs from the completing import's, or that has no recipient, is permanent, so completion writes no notification and logs a warning with IDs only; it no longer throws a retry that could never succeed.
  • One read per key per request (#4547 item 1). Shaping a page loads each distinct Project and export job once and makes each authority decision once per project and activity. The decisions are unchanged; nothing is cached across requests. Keyset paging and lighter polling followed in #3941 (below).

Review access and workload capture on the amended foundation (25 October 2026, notifications restart L3)

#3941 merged the amended L2 (#3938) and moved its own capture onto the same rules. The permanent record is ADR-026 decision 9.

  • One gate. The ProjectController membership, join, invitation-response, stage and stage-permission actions, the guarded review-settings save and the allocation-regime publisher ask IInboxAdmission once per operation, before they read the project and before any transaction. The review-settings save asks once for all of its retries. Nothing reads the FeatureFlags POCO.
  • Off is main. Off, or on with migrations 0023–0025 missing, gives the same synchronous or asynchronous save as main, no transaction, no capture, and no private read scope where main had none.
  • Ownership rule kept. #3964's refusals (owner-reserved grants, a transfer by a non-owner, unreadable permission entries) run before the capture path. A refused request saves nothing and captures nothing, whatever the flag.
  • Tests. On → off → on with a change made in each state for the stage PATCH, the review-settings save and the allocation publisher; D1-09's refused-grant test over HTTP with the inbox open, with the same grant on its own as the positive control; and flag-off save-identity checks for each writer.
  • Deterministic grant identity (#4530, M17, done before R1c). reviewAccessGranted's SourceId is SHA-256 over the kind, the project and stage, and the project version the before snapshot read; the row ID adds the recipient. The same grant processed twice stores one row; a new grant after removal stores another (replica-set test with both, and a mutation check each way). Existing random-ID rows are left alone. workloadChanged keeps a random SourceId.
  • Not changed here. The pending-invitation join response fix (PendingInvitationAcceptedInstead answers 200 with accessGranted) stays unflagged, as reviewed in October.

L3 review findings and #4547 (27 October 2026)

After L2 merged (864fc8bbc) #3941 took main and closed the last review round and the rest of #4547. ADR-026 decisions 5, 9 and 10 record the result.

  • Clock. The review-settings save stamps notifications from the injected TimeProvider, as ProjectController does; its workload test pins and advances a fake clock.
  • No capture where nothing can change. Inviting people and changing project-level permissions cannot change who may review a stage, so InviteInvestigatorsToProjectAsync and UpdateProjectPermissions no longer snapshot or open a capture transaction. Both are byte-for-byte main again.
  • Flag-on tests for every capturing action. Stage permissions, join approval, both membership-group actions and re-enabling a member each prove, with the inbox open, one reviewAccessGranted saved in the project's own transaction and dispatched after the commit. A missing snapshot or a plain save fails them.
  • Keyset paging. GET api/account/notifications?cursor=&limit= replaces offset with the previous page's opaque nextCursor ({CreatedAtUtc ticks}.{Id:N}); the page reads strictly after it in (CreatedAtUtc, _id) descending order, and a malformed cursor is 400. Migration 0023 already indexes exactly that order, and an explain-plan test proves the read uses it with no in-memory sort, so there is no new migration. Repository tests cover an item arriving mid-browse (with offset paging's duplicate as the positive control) and equal timestamps across page boundaries. The web client keeps a stack of cursors for Previous and Next.
  • Lighter polling. The 30-second timer and the live-hint subscriptions run only while someone is signed in with the inbox on. With no inbox view open each tick fetches the unread count alone; an open list refreshes with one request, because the page carries unreadCount. The detail view still asks unread-count, since a saved item has no count.
  • Validation at this head (Bramble). Project Management Core tests 4517 passed (1 skipped); Mongo data tests filtered to notification, inbox, capture and migration 78 passed; API endpoint tests filtered to notifications, endpoint authorization, serialization and the project and stage-settings controllers 967 passed; 24 inbox Angular specs pass.
  • Contract. offset is gone from the list endpoint and NotificationPageDto gains nextCursor (swagger.json, the generated client and the serialization golden are regenerated). The flag is off everywhere, and the only client is the web app updated here.

Kind registry, Source and NotificationView (27 October 2026, notifications restart N1)

#4581 builds the C15 v2 core on the merged foundation. The permanent record is ADR-026 decision 11. No new flag: everything stays behind notificationInbox, and no screen changes (the expanding card is N3).

  • Kinds. INotificationKind (FoundationNotificationKinds) declares the five kinds; the API registers a resolver for each (AddFoundationNotificationKinds), joined by NotificationKindRegistry.
  • Source. Every capture from N1 on stores an additive Source sub-document; rows from L1-L3 have none and are read from their typed fields (mongodb-reference.md).
  • One capture path. InboxNotificationWriter serves the export, import, invitation, review-access and workload writers; each stores exactly what it stored before, plus Source.
  • View. List and detail return view beside the unchanged fields. The copy follows the inbox prototype's present() where the saved data supports it:
Kind Headline Key fact Body Primary action
exportCompleted Your export finished {project} · {n} studies processed facts (studies processed, export errors); note that exports stream and no copy is kept Open export page
importCompleted {n} studies imported from {search} {project} stat: {n} studies added to {project} Open searches
projectInvitation You are invited to join {project} Waiting for your answer state; the invitation message; note that reading does not answer it Review invitation (workflow state pending)
reviewAccessGranted You can now review in {stage} {project} callout when the stage is inactive Open stage (active stages only)
workloadChanged Your workload in {stage} increased, decreased or changed Share {a}% → {b}% · {r} reviews per study compare (share, reviews per study); callout when inactive Open stage (active stages only)
any, unavailable the kind's label none none none
  • Reads. Authority and workflow checks load the Project fresh, as before. A project name shown only for display (export and import key facts) comes from the shared repository cache once per project per request, or from a fresh load the request already made, never a second private load of the whole aggregate. No project read or authority decision is ever made for Guid.Empty: a global kind stores no ProjectId, and its resolver keys off Source.
  • Differences from the prototype copy. The prototype's invitation headline names the inviter ("Ana invited you…"); N1 says "You are invited to join…" because the row and project carry only the inviter's ID, and naming them needs an Investigator read and a disclosure decision (follow-up). The prototype's export headline names the format and its key fact counts rows; the job records studies processed, so N1 shows that. The prototype's review-access key fact says "granted by a project administrator"; a grant can also come from joining an open project, so N1 shows the project only. Workload shares are the configured percentage (basis points / 100), or "Default allocation" when the stage uses no shares.
  • Tests. Registry completeness with negative controls (C15-T03); per-kind disclosure fixtures (C15-T04, inbox channel: the available item shows its protected fields, the unavailable one shows the label only and none of them in the response bytes); the old fields byte-identical against verbatim pre-N1 goldens; 100 items in one project make one authority evaluation (fake gate; two projects make two); each writer's stored elements, a pre-N1 row read and replayed unchanged, and an L3-shaped model reading a new row.

Per-recipient state and the unread rule (27 October 2026, notifications restart N2)

#4584 adds dismiss, notice resolution and the new unread rule (owner decisions 1 and 2 of 24 October 2026; C15 v2 "Per-recipient state"; ACD §3.7). The permanent record is ADR-026 decision 12. No new flag, no screen changes (the expanding card, tabs, Dismiss, Clear all and Undo are N3).

  • State. InboxNotification gains DismissedAtUtc, ResolvedAtUtc and Resolution {Reason, SourceTransition, ResolvedByRef}: additive, nullable, no element until set, never backfilled (mongodb-reference.md).
  • Endpoints (account scope, the caller's own rows only, idempotent):
Route What it does
PUT api/account/notifications/{id}/dismissed Dismiss: 204 (a repeat keeps the first time), 404 if the caller has no such notice
DELETE api/account/notifications/{id}/dismissed Restore: removes the element; 204 (also when not dismissed), 404 if not the caller's
POST api/account/notifications/dismissed {ids} Clear all: dismisses the 1-100 named notices that are the caller's own and not dismissed; others are ignored; returns {dismissed}
GET api/account/notifications?status= all (default: not dismissed), unread (not dismissed, not read), resolved (not dismissed, resolved), dismissed; anything else 400
  • Unread count (list page and unread-count): unread, unresolved, not dismissed and available. The repository returns the caller's candidates with one query on migration 0026's index; the controller shapes each candidate with its kind's resolver, exactly as a read would, under the request's memo (one Project load and one authority decision per distinct project and activity, one per stage for review kinds) and one batched fresh read per source type (export jobs, searches), prefetched per batch of 100 candidates. Unknown kinds, items in a project the reader left or that is deleted, and anything the resolver calls unavailable are not counted. Candidates are resolved newest first and resolution stops at the 100th available item: more than 99 counts as 99 with unreadCountCapped true (N4's badge shows "99+"), while exactly 99 is 99 and not capped. Project loads stay uncached full loads (revocation must show at once); a projection was not used because the resolvers apply the aggregate's own rules, which a partial aggregate could misjudge. The candidate read returns whole rows (the resolvers read every stored element; a projection would drop only RecipientId) and is unbounded in rows (one person's unread, unresolved, not dismissed rows, index-bounded); the cap bounds the resolution work, not that read.
  • Deleted projects. NotificationReadContext treats a project marked deleted as absent, whatever the authority mode, so every kind reads "no longer available" there; a restored project is available again at the next read.
  • Invitation answers resolve their notice. Accepting or declining (including a join request that accepts a pending invitation instead) resolves the invitee's projectInvitation row with reason completed and transition accepted or declined, in the same transaction as the project save, while the inbox is open (the same once-per-operation decision as capture). Resolution matches an existing unresolved row only, so a replay keeps the first resolution and an invitation whose notice was never captured writes nothing. Flag off, the answer saves exactly as main does; its notice (if any) then reads as unavailable (the invitation is no longer pending), so it is not counted either way. A revoked invitation is not an answer and resolves nothing here.
  • Reads for people with saved items, whatever the capture flag (M7, D3-21). The controller lost its FeatureGated route gate and asks IInboxAdmission.CheckReadsAsync. Indexes (0023-0026) present: flag on serves every active caller (an empty inbox is 200); flag off serves only a caller with at least one saved row (one indexed existence read, limit 1, once per request); a well-formed request from anyone else gets 404 with no further read. So with the flag off, only people with saved notices can read them; for everyone else a well-formed request gets 404, exactly as before the inbox existed. A malformed request (bad cursor, limit or status; an empty or oversized Clear all) gets 400 first, because input checks run before admission. Missing indexes still fail closed (503 with the flag on; 404 with it off, where no row can exist). Capture, the live-update worker and the web routes still follow the flag, so with it off nothing new is saved and the web shows no inbox. An anonymous request now gets [Authorize]'s 401 instead of the old route gate's 404.
  • Wire. NotificationDto gains dismissedAtUtc, resolvedAtUtc and resolution {reason, sourceTransition} after view, and NotificationPageDto and NotificationCountDto gain unreadCountCapped (old fields byte-identical; the goldens only gain these). ResolvedByRef is stored but never returned until the disclosure policy shapes it.
  • Tests. InboxRecipientStateTests (replica set: dismiss/restore idempotence and scoping, clear-all scoping, each status, the 0026 plan, invitation answers resolving in the transaction, dismiss leaving the invitation pending, on/off/on, read admission per flag and schema, an N1-shaped model reading a new row); NotificationRecipientStateControllerTests (count and list through the endpoints, unavailability, efficiency, flag off with and without saved rows, flag on with none); ProjectControllerTests (answer resolves only while open); StageNotificationSnapshotTests; the migration tests for 0026.

Opt-in email preferences and immediate delivery (27 October 2026, notifications restart L4)

#3942 brought up to main and amended per the restart plan's step L4. The permanent record is ADR-026 decision 13. Email is off everywhere and cannot send even when its flag is on, because the delivery pause defaults to paused.

  • Flag decision: flagged. notificationEmail (feature notification-email, Release, Developing, quiet, SignedIn, default off everywhere), declaring its dependency on notificationInbox. One gate, INotificationEmailAdmission, decides both from one IFeatureDecisions.DecideAll capture and checks the inbox index set (0023–0026) and its own 0027–0030. Off: api/account/notification-email-preferences is unroutable (404), /inbox/email-settings falls through to not-found, the inbox hides its Email preferences link, and the worker reads no preference or ledger row. Rows are kept, so on, off, on loses nothing.
  • Pause. NotificationEmailDelivery:Paused (chart notificationEmailDelivery.paused, default true, unset is also paused). Paused, the worker discovers and admits saved items but sends nothing; lifting it sends each waiting item once (C15-T07). Lifting it per environment is an owner decision (G-NOTIF); outside production the transport must be Mailpit.
  • Age cap (Chris 2026-10-27: immediate email skips items older than 24 h, configurable). NotificationEmailDelivery:MaxItemAgeHours (chart notificationEmailDelivery.maxItemAgeHours, default 24; missing, unparsable or non-positive is 24). An item saved more than that before it is claimed is never sent: the processor checks it right after loading the claimed item, before any consent, authority or resolver read and whatever the kind, and dispatch admission checks it again inside the consent transaction for an item that ages past it meanwhile. So a pause or an off flag longer than the cap does not release a backlog of stale email. The ledger entry is recorded as suppressed with Reason: "tooOld" (additive; an older build keeps it in its extra elements and already treats suppressed as terminal). Dismissing a notice does not cancel an immediate email already due (C15-T14); the cap bounds how late it can arrive.
  • Preferences (pmNotificationEmailPreferences): versioned, keyed by email category (the N1 registry's EmailCategory; none is never a key), every category off by default, consent times set by the server. Tolerant (C15-T06, C15-T10): unknown keys are kept, missing keys are off, the read lists the registry's categories plus saved unknown keys, and the web screen sends back every key it was given. MutedProjectIds (M11) stops email for a project; inbox rows and counts are unchanged; a save without it keeps the saved mutes. There is no mute control on the screen yet (N5 redesigns the screen).
  • Ledger and worker (pmNotificationEmailDelivery, NotificationEmailWorker, every 15 s): discovery over saved inbox rows, insert-only admission, leased claim, a dispatch admission that fences the preferences in one transaction, ambiguous (never retried) for an uncertain send. The processor looks the kind up in the registry: an unknown kind stays ready for a later build (C15-T11, retried after 15 minutes); an in-app-only kind, an opt-out, a mute, an inactive account, an item the kind's resolver no longer shows the recipient, or no address suppresses it; an unavailable authority read retries. The email carries the kind's label and a link to the saved notice (/inbox/{id} in the web app's base64 route form) and nothing else until the content policy (D3-22) lands.
  • Discovery watermark (NotificationEmailDiscoveryWatermarks). Each cycle reads an account's saved items from a watermark, not from its consent time, so a long opt-in history is read once rather than every 15 seconds. The watermark is held in each API replica's memory only: no field, collection, index or migration, so neither flag has any watermark data to keep, and a restart or a new replica starts from the consent time as before. Inbox rows have no monotonic key (_id is a random GUID) and CreatedAtUtc is the writer's clock before its source transaction commits, so a row can become visible after a later-stamped one (transaction time, driver retries, clock skew between replicas). The watermark is therefore the previous cycle's start, by this replica's clock, less a 10-minute overlap, never "the newest item seen"; when a cycle's page of candidates is full it stops at the last returned item (inclusive). Any change to the account's immediate categories or their EnabledAtUtc (opt-out and in again, daily to immediate, which keeps the consent time) resets it to the consent time, and every hour the account is read from the consent time again, so a row delayed beyond the overlap is admitted late rather than never. The ledger, keyed by the item's ID, remains the only guard against a second send, and the send-time checks (EnabledAtUtc, revision, mute, DispatchFence) are unchanged. Discovery is hinted to 0023 ({RecipientId, CreatedAtUtc, _id}), which bounds the read to the watermark; left to the planner, a read from the consent time ties with 0025 and can cache that plan for the shape, which would scan the whole history by key again. Admission already requires 0023.
  • Indexes. Migrations 0027–0030 with Downs (claim, per-recipient mode, recovery, enabled-account page). The repositories build none at start-up; the old start-up claim index is adopted by 0027 (N2 took 0026).
  • What L4 does not change. Export, import, invitation and permission saves are untouched (no email code runs in them); invitation and account emails keep their own routes.
  • Tests. API: NotificationEmailPreferencesControllerTests (flag off 404, schema 503, old and new clients both save an opt-out, malformed keys refused), NotificationEmailProcessorTests (too old is skipped at claim before any other read, an unusable cap is 24 h, unknown kind stays ready, in-app-only and muted suppressed, opt-out and mute after the authority read, authority outage retries, ambiguous sends), NotificationEmailWorkerTests (closed admission reads nothing, paused claims nothing, and replica-set journeys: pause then resume sends each item once, a muted project keeps its inbox row and sends nothing, on, off, on; the second cycle reads none of the ledgered history, the age cap (just inside sends, just outside is recorded tooOld, paused or off for more than the cap skips the stale items and sends the recent ones once, a configured cap applies), a late row inside the overlap is found next cycle and one beyond it by the hourly read, a category moved to immediate is read from its consent time, re-enabling sends what follows and nothing before), NotificationEmailDiscoveryWatermarksTests (full page, consent change, hourly read, floor, retention), NotificationEmailAdmissionTests. Project Management data: ledger and preference repositories (a recent discovery examines only recent rows, on 0023, measured by the profiler; dispatch admission records an item past the cap as tooOld in its transaction; the claim-time skip is owned and terminal), migrations 0027–0030 (Up, re-run, Down, adoption). Chart: notification_email_delivery_pause_test.yaml (pause, and the 24-hour cap by default and as configured). Web: route guard, preferences screen (unrendered keys and mutes echoed), inbox link gate. Every transport in a test is a mock or Mailpit.
  • Follow-ups (not in L4). The content policy (M12) and per-family pause and per-project admission (M13) before any email outside Mailpit; retention (E32); a mute control on the screen (N5); daily digests (L5).

Saved notification email preferences

Recorded fan-out (27 October 2026, notifications restart N6)

#4586 adds C15 v2's recorded fan-out. The permanent record is ADR-026 decision 14. No new flag and no screen change.

  • Capture mode. NotificationCaptureModes enforces N1's inline limits: one occurrence may write at most its kind's limit inline, and never above 200. Above that, the source transaction records one NotificationFanOut intent in pmNotificationFanOut (mongodb-reference.md). Only a reviewAccessGranted grant to more than 200 members reaches it today.
  • Expansion. NotificationFanOutExpander (Project Management data library) leases an intent, pages recipients through an INotificationRecipientSelector (frozen list, or project capability, re-evaluated with current authority), and commits each batch of up to 100 rows with its cursor in one transaction fenced on the lease token. NotificationFanOutWorker runs it in each API replica every 8 to 12 s; off, a pass reads nothing.
  • With L4 email. Expanded rows keep the occurrence's CreatedAtUtc but are written later, so rows expanded more than 10 minutes after the source commit fall behind the email discovery watermark's overlap and are found by its hourly full read: the immediate email for a fanned-out occurrence can be up to about an hour late (the 24-hour age cap counts from the occurrence time). Documented, not changed.
  • For N7. featureRelease registers its own selector type (people the feature is available to, skipping those who dismissed it on What's New) and records one intent per release identity; the expander needs no change.
  • Tests. Unit: the limit boundary (½, 200/201, a kind declaring 500 still fans out at 201), stage capture inline at 200 in order and recorded at 201, row shape equal to inline capture, both selectors' paging and capability checks. Replica set (NotificationFanOutExpanderTests): the 200/201 boundary through the real save path with identical rows; resume after a crash inside and between batches with no row written twice (C15-T02); a taken-over lease writes nothing; two racing workers write each of 600 rows once; a recipient who lost Review before expansion gets no row (the one who kept it does); off, on, off, on claims nothing while off and writes no duplicates; a mid-pass switch-off stops after the batch; missing migration, unknown selector type and failure back-off. Migration 0031 Up, re-run, Down and adoption. Mutations (no progress write, no lease, no capability check, flag ignored, no inline limit) each fail at least one of these.