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. (N3 changes the read: an answered invitation stays readable to its invitee and is still not counted; see the N3 section.) 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.

Inbox: one expanding card (27 October 2026, notifications restart N3)

#4603 replaces L1's list and /inbox/:id detail page with the prototype's one expanding notice card, calm header and personal dismiss (owner decisions 3, 4 and 6 of 24 October 2026; prototype Main.dc.html, v10 header and v7 card). The card is a standalone component so N4's bell panel can reuse it. Flag: notificationInbox (web), unchanged; with it off nothing here is reachable.

Named "Notifications" (Chris, 27 October 2026: "Can we not call this 'notifications' rather than 'inbox', as this is not an actual email system?"). Every word a person sees says notifications (page title and heading, menu, messages, the email-preferences link back, the user guide). The routes are /notifications, /notifications/:id and /notifications/email-settings; /inbox, /inbox/:id and /inbox/email-settings redirect to them (route specs for each), so links in emails already sent and bookmarks keep working. The immediate-email link (L4, below) now points at /notifications/{id}. Internal names stay: code, file names, the notificationInbox flag, pmInboxNotification and this engineering terminology. Where the criteria below say /inbox, read /notifications (the old path redirects). The API's SignalR hub is also at /notifications. In production they never meet (NGINX serves the app; the hub is on the API origin), but the local and E2E dev-server proxies forward /notifications to the API, so they now hand a browser page load (a GET asking for HTML) to the app and keep the hub's negotiate POST and WebSocket upgrade on the API (proxy.conf.e2e.js, proxy.conf.local.js). Any future same-origin front door must make the same split.

Changes from the restart plan.

  • Opening marks read (D49, decided 26 October 2026; C15-T16). The plan's "Mark-read-on-open off" is superseded: expanding a card, or arriving through an /inbox/:id link, marks that notice read for the reader. Opening never resolves, dismisses, accepts or declines anything. Mark as read and Mark as unread stay on the card.
  • Material 3 Expressive through @camaradesuk/m3e-angular (ADR-027): buttons and icon buttons, the Decline confirmation dialog and the loading indicator come from the library. It has no tabs, chips, menu, snackbar, checkbox, inline filter field or expanding card, so those use Angular Material M3 with SyRF tokens; the gaps are listed in the PR and fixed in the library later.
  • Three small backend additions (account scope, the caller's own rows only), because the card needs them and no endpoint or field provides them: B1 Mark as unread, B2 Mark all as read for the shown notices, B3 the related project (ID and name) on an available view, for the Project filter and the inline invitation answer.

Acceptance criteria (each with its verification).

  1. One destination. /inbox and /inbox/:id render the same Inbox page; no detail page or detail template remains. /inbox/:id expands that card and moves focus to it: from the loaded list, or loaded on its own and shown first when it is older than the loaded pages; a dismissed notice opens under Dismissed. An ID the reader does not own shows "We couldn't find that notice in your inbox" and nothing about it. Verify: page spec (deep link in list, older, dismissed, not found); E2E specs updated to the card.
  2. One row and card layout for every kind: Option D on desktop, P1 on phones (Chris chose it on 28 October 2026 from the options canvas; it replaces the 24 October layout, whose expanded card ended in an action bar with Mark as read and Dismiss on the right). Row: kind tile (40 px, round, primary container; grey when unavailable), kind line (11/16, 500), headline (16/24, 500 when it counts as unread or is open), key fact, status chip (workflow state or the reader's resolution). Desktop (600 px and wider): every row, closed or open, has its housekeeping at the right edge of its header line, left of the open/close arrow (a 40 px round button named "Open: …"/"Close: …", in a tinted disc when open). At rest a closed row shows only its time and, if it counts as unread, a filled unread dot; on hover or keyboard focus (and always on touch screens) a pill (group "Notice actions") with labelled Mark as read or Mark as unread and Dismiss replaces them; an open row shows the pill all the time. Under Dismissed the control is Restore, next to the time; a notice dismissed automatically has no control and never opens. Phone (below 600 px, P1): the housekeeping controls are icon buttons (envelope for Mark as read or unread, × for Dismiss, undo for Restore) at the top right of the header line, named and with a tooltip on hover or long press; no arrow and no separate line; the time moves into the kind line; the unread dot sits in the left margin. Open card: outlined (1 px outline-variant, 16 px corners, no shadow), with inset dividers (16 px) between closed rows and none beside the open card; it holds only the message (typed body blocks: facts, stat, state, note, callout, quote, compare as label, was, arrow, now or "no change"; an unknown block type is skipped), Activity (a disclosure, closed by default) and the message's own buttons, start-aligned under it, main button first and the only filled one (Accept filled, Decline outlined; Open stage, Open export page, Open searches and Open project tonal). State layers: hover 8%, focus and pressed 10%; one 2 px focus ring, on-surface round a filled button. Verify: card spec, one case per kind (exportCompleted, importCompleted, projectInvitation, reviewAccessGranted, workloadChanged, an unknown kind, an unavailable notice) asserting the slot order and control positions open and closed, at desktop and phone width; the screens below.
  3. Opening marks read (C15-T16). Expanding an unread card sends one Mark read and the count refreshes; collapsing sends nothing; an /inbox/:id arrival marks read; Mark as unread keeps the card open and unread; Mark all as read (Inbox menu) marks the shown unread notices read. Nothing else changes: no accept, decline, resolve or dismiss. Verify: page and service specs.
  4. Calm header. Title "Notifications" and a menu (Mark all as read, Clear all, and Email preferences while notificationEmail is on). The unread number shows only as a Material 3 badge: on the app bar's bell (a small pill over the bell's top-right, 16 px high at least, label small, error colours, "99+" past 99, none at zero; the bell keeps its name "Notifications, N unread") and on the Unread tab; there is no count beside the title or as plain text beside the bell (Chris, 28 October 2026). Tabs All, Unread (with the badge), Resolved and Dismissed, each a server status; a search field (headline, key fact and kind); one Filters button showing the active count and opening a panel with Kind chips (Exports and imports, Invitations, Access and workload) and Project chips (projects of the loaded notices, by name); active filters as removable chips with Clear filters. (The "Hide notices you can no longer open" option was dropped with criterion 14.) Search and filters apply to the loaded notices; "Show older notices" loads the next page. Verify: page spec.
  5. Dismiss, Undo, Clear all, Restore, swipe. Dismiss and the read toggle sit on every row's header line (criterion 2). On a closed row a horizontal swipe of more than 72 px (mouse, pen or touch) acts without opening the card: left dismisses, right marks read or unread; an open card, a dismissed one and one dismissed automatically do not swipe. Each dismiss shows "Notice dismissed. It stays in your history." with Undo, which restores it; a swipe right shows "Marked as read." or "Marked as unread." with Undo, which reverses it (the buttons say nothing on success). Clear all dismisses the shown notices ("3 notices cleared…", Undo restores each). The Dismissed tab lists them with Restore. After a dismiss, focus moves to the next card (the previous one when it was last, the list heading when none is left). A failed call puts the card back and says so in plain words. Verify: card and page specs with keyboard, pointer and touch events.
  6. Invitations answered inline. A pending invitation's open card shows Accept (filled) and Decline (outlined) as its own buttons and no navigation action. Accept calls the existing invitation response endpoint (authority checked again by the server), the server resolves the notice (N2), the list refreshes and a snackbar offers Open project. Decline asks for confirmation (m3e dialog: "You will not be able to accept this invitation later.") and then calls the same endpoint. Refusals are translated (already answered, claimed by another account, no longer exists). Reading or dismissing an invitation answers nothing. Verify: page spec; E2E invitation spec.
  7. Unavailable notices show the kind only. (Amended by criterion 14.) The kind label and "Dismissed automatically: no longer available", with no body block, no primary or type action, no read toggle and no Restore. A resolved notice shows the reader's own answer ("You accepted") even when unavailable. Verify: card spec.
  8. Flag off. /inbox and /inbox/:id fall through to not-found as before; nothing is fetched. Verify: existing route and guard specs.
  9. Material 3 and quality gates. M3 tokens only (no hard-coded colours), light and dark, 1280 px and 390 px; signals, zoneless, standalone, built-in control flow. Theme, zoneless and help-URL guard specs and ng lint pass. Verify: the guard specs, lint, and the screens below.
  10. Owner screen acceptance. Light and dark PNGs at 1280 px and 390 px of the All tab, one expanded card per kind, the Unread, Resolved and Dismissed tabs, the Filters panel, the Undo snackbar, an unavailable card and the empty state, for Chris's acceptance before merge. Verify: owner review.
  11. B1 Mark as unread. DELETE api/account/notifications/{id}/read removes ReadAtUtc from the caller's notice (204, idempotent; 404 when not the caller's). It returns to the unread count only when unresolved, not dismissed and available (N2's rule, unchanged). Verify: repository (replica set) and controller tests.
  12. B2 Mark all as read. POST api/account/notifications/read {ids} marks those of the 1-100 named notices that are the caller's own and unread, ignores others, and returns {read}; empty or oversized lists are 400. Verify: repository and controller tests.
  13. B3 Related project. An available view carries project {id, name} (the related project); an unavailable view carries none and the project's ID appears nowhere in its bytes (C15-T04). The ID is used only as a filter key and for the invitation answer, never shown. Verify: NotificationViewTests.
  14. Stale notices are dismissed automatically; unread follows the count (Chris, 27 October 2026). A withdrawn invitation, and a reviewer's lost access to a stage, dismiss the notices they make unopenable in the source transaction (DismissReason = "unavailable"); any notice the reader can no longer open is listed only under Dismissed and never counted; the unread dot and the Unread tab show exactly what the count counts. Verify: StageNotificationSnapshotTests, InboxRecipientStateTests (replica set), ProjectControllerTests (revoke), NotificationRecipientStateControllerTests (tabs, paging, reason), card and page specs, E2E review and workload spec.

What was built (status: implemented in #4603, awaiting review and owner screen acceptance).

  • Web. NotificationCardComponent (syrf-notification-card, src/app/notifications/notification-card/) is the one card; it reports intent through outputs (toggled, readChanged with {read, via}, dismissed, restored, answered) and takes a compact input (P1 below 600 px, from the page's BreakpointObserver), so the bell panel (N4) can host it unchanged. The bell (NotificationInboxButtonComponent) draws the unread badge itself: m3e-angular has no badge and Angular Material's matBadge still renders M2 under SyRF's compatibility theme. NotificationInboxComponent is the page for both /inbox and /inbox/:id. NotificationInboxService (root) now owns the list per tab (server status), keyset "Show older notices", the per-recipient writes and the unread count (with unreadCountCapped, shown as "99+"); a refresh keeps the expanded card in place when the server no longer lists it (reading a card in the Unread tab does not make it vanish). notification-presentation.ts holds the client-only presentation (icon, accent, status chip, times).
  • Invitations. Accept and Decline dispatch the existing respondToInvitation action (the same effect the invitation page uses), keyed by a correlation ID, so the project lists update as they do there.
  • m3e-angular. Used: MatExpButton, MatExpIconButton, MatExpDialog.confirm (Decline) and MatExpLoadingIndicator. Its button, icon-button and dialog styles are a separate global entry (src/global-styles/m3e-angular.scss in angular.json), because its Sass resolves through the package exports, which a plain Sass load path (the theme guard spec's) does not follow. Library gaps (Angular Material with SyRF M3 tokens used instead, to be added to the library): underline tabs, filter and input chips, a menu (it has only the FAB menu), a snackbar, a checkbox, a tooltip on a plain icon button, a badge, an inline filter field (its search opens a separate search view) and an expanding list item.
  • Differences from the prototype (decisions above win): opening marks read (D49; the prototype's harness option was off). Clear all sits in the Inbox menu as the plan asks (the prototype shows it only in the bell panel's menu). Only the Unread tab shows a count (the server has no dismissed count). Kind chips list today's kinds only (Exports and imports, Invitations, Access and workload); Project chips come from the loaded notices. Search and filters apply to the loaded notices, with "Show older notices" for more (the prototype has no paging). The invitation headline stays N1's "You are invited to join…" (no inviter name). Activity has no email ledger lines (no API exposes them to the web). A status shows once, in the header chip: the card leaves out a body state block that repeats it. Card layout Option D and P1 (28 October 2026) follow the options canvas, with two deliberate differences: an unread notice's tile keeps the softer 12 px square the canvas draws (the M3 audit row says round), and the open card's status, resolved and dismissed boxes are gone (the header chip and Activity carry them).
  • Answered invitations stay readable (as the prototype keeps them; lead review of #4603). N1's ProjectInvitationNotificationResolver now shows its own invitee an accepted invitation, while they are still an active member (rechecked at every read), as "You joined {project}" (no key fact, so the name is not repeated), the state "You accepted the invitation" and an Open project action (/projects/{id}); a declined one as "Invitation to join {project}" with "You declined the invitation" and no action. Both carry workflowState accepted or declined. A revoked invitation, an accepted one whose member has since left, and every other reader still get the kind only. An answered invitation is never counted as unread, whether or not its notice recorded a resolution (NotificationWorkflowStates.IsConcluded in the count; an answer given while the inbox was off records none). Tests: NotificationViewTests (accepted, declined, accepted then left with no project ID or detail in the bytes, another reader), NotificationRecipientStateControllerTests (answered without a resolution: readable, not counted).
  • Unread follows the count (Chris, 27 October 2026). A notice shows the unread dot and bold title only if it counts as unread under N2's rule: unread, unresolved, not dismissed, openable and not an answered workflow. The Unread tab lists exactly those notices, so the tab and its badge always agree: the repository's unread filter now also requires no resolution, and the API keeps only the openable rows that are not an answered workflow (an invitation answered while the inbox was off has no resolution but is never listed or counted).
  • Stale notices are dismissed automatically (Chris, 27 October 2026: "stale notifications can be automatically dismissed").
  • At the source event, in the source transaction and only where capture already writes (the inbox open): a withdrawn (revoked) invitation dismisses its invitee's notice, and losing review access to a stage (the stage's Review permission withdrawn, a group change, a disabled membership) dismisses that reviewer's review-access and workload notices for the stage. Every save that takes the snapshot does this, including DisableMembership (wired in the second review; ProjectControllerTests proves it, and that nothing is written with the inbox closed). The row gets DismissedAtUtc and the additive DismissReason = "unavailable" (nullable, ignored by older builds); only rows not yet dismissed change, so a replay or an earlier personal dismiss is kept. StageNotificationSnapshot.AutoDismissals, InboxNotificationWriter.DismissAsync; the revoke endpoint now saves through the same path. No migration, no backfill.
  • Other events that make a notice unopenable today, left to the read-time rule: an export whose job is gone or whose reader lost ExportData; an import whose search left the project or whose reader lost every search activity; an accepted invitation whose member later left; project deletion (restorable, so never written); an authority outage (transient, never written).
  • At read time, every notice the reader can no longer open is listed only under Dismissed (as dismissed automatically), never under All, Unread or Resolved, and never counted. Availability is known only after a resolver runs, so the list reads the caller's rows in list order in batches of 100 and keeps the matching ones; the cursor is the last row examined (no repeats or gaps across pages, and "Show older notices" continues from it), and one request examines at most 500 rows, then returns what it found with hasMore (an empty page with a cursor is possible after a long run of unopenable rows). Sources are prefetched only for rows the page can still use. The Dismissed tab reads every row and keeps the dismissed and the unopenable ones.
  • UI: an automatically dismissed notice is a static row: the kind only with "Dismissed automatically: no longer available" and its time, no Restore, no read toggle, no arrow and no swipe; it never opens. A personally dismissed notice that can still be opened keeps Restore. The "Hide notices you can no longer open" filter is gone (such notices are never in All).
  • Tests. Web (pnpm exec ng test): card, page, service, route and refusal specs in src/app/notifications/, the nav specs and the repo guard specs, covering each criterion above, the review fixes (batches, kept pages, swipe gating, real-client refusals) and the unread and automatic-dismissal rules. API (Bramble): the whole SyRF.API.Endpoint.Tests project, Project Management Core's stage-notification tests and the Mongo data tests for inbox, notification and capture (replica set). E2E on Bramble (local stack): the inbox, import and invitation, and review and workload specs (the email spec skips without a local Mailpit). Earlier E2E runs found a refresh loop when changing tab and Restore not clickable on a collapsed card; both are fixed here.