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¶
- Inbox and export completion (this PR).
- Import recipient capture and completion; known-account invitation capture. Preserve existing transactional invitation mail, including delivery to unknown addresses.
- New review access and workload-change capture based on committed meaningful transitions.
- Separate atomic account preferences and leased immediate-email delivery.
- Daily digest with persistent scheduling and a durable per-recipient window/item ledger.
- Selected-recipient reconciliation questions and response history.
- Study issue reports, administrator response/resolution and accepted correction proposals.
- 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.

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.

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.
notificationInboxis featurenotification-inboxinsrc/charts/syrf-common/feature-definitions.yaml(Release,Developing, What's New quiet, permissionSignedIn; 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 throughIFeatureDecisions, behind one gate,IInboxAdmission; theFeatureFlagsproperty 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,503on reads, no change stream. - Worker.
InboxInvalidationWorkeropens 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/inboxand/inbox/:idroutes 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/:iddetail 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
IInboxAdmissiononce per operation, before the source transaction. Off, or on with migrations0023–0025missing, does exactly whatmaindoes: 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
notificationInboxcontract 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.tsrelies 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
ProjectControllermembership, join, invitation-response, stage and stage-permission actions, the guarded review-settings save and the allocation-regime publisher askIInboxAdmissiononce per operation, before they read the project and before any transaction. The review-settings save asks once for all of its retries. Nothing reads theFeatureFlagsPOCO. - Off is main. Off, or on with migrations
0023–0025missing, gives the same synchronous or asynchronous save asmain, no transaction, no capture, and no private read scope wheremainhad 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'sSourceIdis 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.workloadChangedkeeps a randomSourceId. - Not changed here. The pending-invitation join
response fix (
PendingInvitationAcceptedInsteadanswers 200 withaccessGranted) 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, asProjectControllerdoes; 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
InviteInvestigatorsToProjectAsyncandUpdateProjectPermissionsno longer snapshot or open a capture transaction. Both are byte-for-bytemainagain. - 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
reviewAccessGrantedsaved 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=replacesoffsetwith the previous page's opaquenextCursor({CreatedAtUtc ticks}.{Id:N}); the page reads strictly after it in(CreatedAtUtc, _id)descending order, and a malformed cursor is400. Migration0023already 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 asksunread-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.
offsetis gone from the list endpoint andNotificationPageDtogainsnextCursor(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 byNotificationKindRegistry. Source. Every capture from N1 on stores an additiveSourcesub-document; rows from L1-L3 have none and are read from their typed fields (mongodb-reference.md).- One capture path.
InboxNotificationWriterserves the export, import, invitation, review-access and workload writers; each stores exactly what it stored before, plusSource. - View. List and detail return
viewbeside the unchanged fields. The copy follows the inbox prototype'spresent()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 noProjectId, and its resolver keys offSource. - 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.
InboxNotificationgainsDismissedAtUtc,ResolvedAtUtcandResolution {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 migration0026'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 withunreadCountCappedtrue (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 onlyRecipientId) 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.
NotificationReadContexttreats 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
projectInvitationrow with reasoncompletedand transitionacceptedordeclined, 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 asmaindoes; 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
FeatureGatedroute gate and asksIInboxAdmission.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.
NotificationDtogainsdismissedAtUtc,resolvedAtUtcandresolution {reason, sourceTransition}afterview, andNotificationPageDtoandNotificationCountDtogainunreadCountCapped(old fields byte-identical; the goldens only gain these).ResolvedByRefis stored but never returned until the disclosure policy shapes it. - Tests.
InboxRecipientStateTests(replica set: dismiss/restore idempotence and scoping, clear-all scoping, each status, the0026plan, 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 for0026.
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(featurenotification-email,Release,Developing, quiet,SignedIn, default off everywhere), declaring its dependency onnotificationInbox. One gate,INotificationEmailAdmission, decides both from oneIFeatureDecisions.DecideAllcapture and checks the inbox index set (0023–0026) and its own0027–0030. Off:api/account/notification-email-preferencesis unroutable (404),/inbox/email-settingsfalls 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(chartnotificationEmailDelivery.paused, defaulttrue, 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(chartnotificationEmailDelivery.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 assuppressedwithReason: "tooOld"(additive; an older build keeps it in its extra elements and already treatssuppressedas 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'sEmailCategory;noneis 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 staysreadyfor 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 (_idis a random GUID) andCreatedAtUtcis 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 theirEnabledAtUtc(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 to0023({RecipientId, CreatedAtUtc, _id}), which bounds the read to the watermark; left to the planner, a read from the consent time ties with0025and can cache that plan for the shape, which would scan the whole history by key again. Admission already requires0023. - Indexes. Migrations
0027–0030with Downs (claim, per-recipient mode, recovery, enabled-account page). The repositories build none at start-up; the old start-up claim index is adopted by0027(N2 took0026). - 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 off404, schema503, 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 recordedtooOld, 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, on0023, measured by the profiler; dispatch admission records an item past the cap astooOldin its transaction; the claim-time skip is owned and terminal), migrations0027–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).

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.
NotificationCaptureModesenforces 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 oneNotificationFanOutintent inpmNotificationFanOut(mongodb-reference.md). Only areviewAccessGrantedgrant to more than 200 members reaches it today. - Expansion.
NotificationFanOutExpander(Project Management data library) leases an intent, pages recipients through anINotificationRecipientSelector(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.NotificationFanOutWorkerruns it in each API replica every 8 to 12 s; off, a pass reads nothing. - With L4 email. Expanded rows keep the occurrence's
CreatedAtUtcbut 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.
featureReleaseregisters 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. Migration0031Up, 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/:idlink, 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).
- One destination.
/inboxand/inbox/:idrender the same Inbox page; no detail page or detail template remains./inbox/:idexpands 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. - 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. - Opening marks read (C15-T16). Expanding an unread card sends one Mark read and the count refreshes; collapsing
sends nothing; an
/inbox/:idarrival 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. - Calm header. Title "Notifications" and a menu (Mark all as read, Clear all, and Email preferences while
notificationEmailis 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. - 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.
- 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.
- 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.
- Flag off.
/inboxand/inbox/:idfall through to not-found as before; nothing is fetched. Verify: existing route and guard specs. - 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 lintpass. Verify: the guard specs, lint, and the screens below. - 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.
- B1 Mark as unread.
DELETE api/account/notifications/{id}/readremovesReadAtUtcfrom 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. - 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. - 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. - 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,readChangedwith{read, via},dismissed,restored,answered) and takes acompactinput (P1 below 600 px, from the page'sBreakpointObserver), 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'smatBadgestill renders M2 under SyRF's compatibility theme.NotificationInboxComponentis the page for both/inboxand/inbox/:id.NotificationInboxService(root) now owns the list per tab (serverstatus), keyset "Show older notices", the per-recipient writes and the unread count (withunreadCountCapped, 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.tsholds the client-only presentation (icon, accent, status chip, times). - Invitations. Accept and Decline dispatch the existing
respondToInvitationaction (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) andMatExpLoadingIndicator. Its button, icon-button and dialog styles are a separate global entry (src/global-styles/m3e-angular.scssinangular.json), because its Sass resolves through the packageexports, 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
stateblock 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
ProjectInvitationNotificationResolvernow 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 carryworkflowStateacceptedordeclined. 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.IsConcludedin 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
unreadfilter 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;ProjectControllerTestsproves it, and that nothing is written with the inbox closed). The row getsDismissedAtUtcand the additiveDismissReason = "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 insrc/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 wholeSyRF.API.Endpoint.Testsproject, 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.
Daily digests (28 October 2026, notifications restart L5)¶
#3943 brought up to main and amended per the restart plan's step L5
(handover/notifications-implementation-plan-2026-10-24.md §3 L5). Owner decisions folded in: resolved and dismissed
items are left out of the daily digest, decided when the digest is claimed (24 October assumption 3, confirmed 27
October); staging stays in-app only, with email off and paused; immediate email sends nothing older than 24 hours
(Chris 27 October, the L4 age cap). Chris, 28 October 2026: (1) complete digests (option B): each digest takes
everything since the previous digest, up to about 48 hours, so nothing is left out of email when two digests are more
than 24 hours apart, and items over a digest's 100-item limit go in the next one instead of being dropped; the flat
24-hour cap stays for immediate email only; (2) the interim email preferences screen with the digest controls is
accepted (option A); screen sign-off happens at N5. N5 (preview and the shared digest template) is not in scope.
Acceptance criteria. Each is verified by the named tests; every transport in a test is a mock or Mailpit.
- Flag off: no digest loop. While
notificationEmailornotificationInboxdecides off, or any email index migration (0027-0030, and the digest window index0032) is missing, a digest cycle reads nothing: no preference page, no window, no ledger row. Verify:NotificationDigestWorkerTests(closed admission makes no repository call). - Gated like L4. The digest worker asks the same
INotificationEmailAdmission, re-checks the flag before each claim, and honours the same pause (NotificationEmailDelivery:Paused, default paused): paused, it creates, claims and sends no window. Lifting the pause prepares the current day's window only; missed days are not caught up. Verify:NotificationDigestWorkerTests(paused creates and claims nothing; resumed sends once). - One window per recipient and local day. The window's ID is derived from the recipient and the local day, so
concurrent workers create one window and one claim wins. Verify:
NotificationEmailDigestRepositoryTests(concurrent creators and claimants, replica set). - The claim skips dismissed, resolved and read items. When the window is prepared it takes up to 100 of the
recipient's saved items that are, at that moment: of a kind whose email category is daily since before the item,
unread, not dismissed (for any reason, including an automatic "no longer available" dismissal), not resolved, not in
a muted project, saved within the digest's look-back bound, and with no ledger entry or a ready (unsent) one. Verify:
NotificationEmailDigestRepositoryTests(one item in each excluded state; only the eligible one is claimed). - An item dismissed (or read, or resolved) after the claim stays in the frozen window and is sent with it.
Verify:
NotificationEmailDigestRepositoryTestsandNotificationDigestProcessorTests. - Unavailable items are not sent. Before dispatch each claimed item is shaped with its kind's resolver, as L4
does; an item the resolver no longer shows the recipient (left project, deleted project, unknown source) is removed
from the send and its ledger entry suppressed. An unavailable authority read retries later. Verify:
NotificationDigestProcessorTests. - Send-time consent, fenced like L4. Opt-out, mute or a switch to immediate after the claim removes the item from
the unsent digest (immediate returns it to the immediate ledger, ready); dispatch admission checks the preference
revision and increments
DispatchFencein the same transaction as the move to dispatching, so a concurrent save conflicts with it. Verify:NotificationEmailDigestRepositoryTests(fence) andNotificationDigestProcessorTests. - Complete digests, bounded at 48 hours (replaces the strict 24-hour criterion, Chris 28 October). A digest
takes every eligible item no earlier digest or immediate email took (the ledger is the "since the previous digest"
record), saved within
NotificationEmailDelivery:DigestMaxLookbackHours(chartdigestMaxLookbackHours, default 48; missing, unparsable or non-positive is 48) of its preparation: the 25-hour day when clocks go back, a send time moved later, a timezone change and a day paused all lose nothing, and items over the 100-item limit go in the next digest. An item older than the bound is never sent: a ready entry for one is recordedsuppressed(tooOld) at preparation, and one that ages past it before dispatch admission is recorded the same way. Immediate email keepsMaxItemAgeHours(24). A send-time or timezone change never sends an extra digest for the previous day. Verify:NotificationDigestWorkerTests(clocks going back, send time moved later, timezone change, a day paused, more than 100 carried over, the 48-hour bound),NotificationDigestProcessorTests(the bound at send time, a configured and an unusable value, the immediate cap not applied), chart testnotification_email_delivery_pause_test.yaml. - An immediate email already queued is not cancelled by dismiss, and is never taken into a digest while its kind
stays immediate; a daily item in a window is never claimed by the immediate worker. Verify:
NotificationEmailDigestRepositoryTests(an immediate ledger entry for a dismissed item stays ready and outside the window) andNotificationDigestWorkerTests(sent once by the immediate worker). - No replay. An expired pre-dispatch lease keeps the window's membership for the next claim; an expired dispatch
is recorded ambiguous and never sent again. Verify:
NotificationEmailDigestRepositoryTests. - DST and timezone tests pass unchanged (UTC fallback, local-day boundaries, summer offset, one-hour and
thirty-minute gaps, both occurrences of a repeated time). Verify:
NotificationDigestScheduleTests. - On, off, on loses nothing and sends nothing twice: windows and ledger rows are kept while off and resumed when
on. Verify:
NotificationDigestWorkerTests(replica set). - Migration, not start-up. The window collection's index is PM migration
0032with a Down, appended to the lock file; no repository builds an index at start-up. Verify: migration catalogue and lock-file tests, the0032Up, re-run and Down test. - Preferences screen. With
notificationEmailon, Daily digest is selectable and the timezone and send time can be saved (the existing route guard still hides the screen when off). Verify: component spec (pnpm exec ng test).
As built. The permanent record is ADR-026 decision 16.
- Flag decision: flagged, on the existing
notificationEmail(with its dependencynotificationInbox); no new flag. The digest is one more consumer of L4's gate, ledger and pause, so switching email on or off switches both timings together, and email stays paused by default everywhere (staging included: in-app only). - Window (
pmNotificationEmailDigest,NotificationEmailDigest): one per recipient and local day,_idderived from both, so a second preparer on any replica finds it taken. It stores the frozenNotificationIds(at most 100, oldest first; the rest go in the next digest), the day's send time in UTC, the state (ready, leased, dispatching, then sent, ambiguous or suppressed) andDueAtUtc, when a worker may next act on it (claimable from, or lease expiry). A day with nothing eligible is closed with an empty, suppressed window, so an item saved after the send time waits for the next day. - Ledger, not a parallel mechanism. Each member's L4 ledger entry (
pmNotificationEmailDelivery, keyed by the item's ID) is held by the window: daily, leased, owned bydigest:{windowId}, with no lease expiry of its own. So the immediate claim (immediate mode only) and the immediate recovery (lease expiry set) never touch it, and the item has one delivery identity whichever timing sends it. Removing a member uses the ledger repository's own calls: release to suppressed, release to ready immediate (switched to immediate), release to ready daily (a kind this build does not know, C15-T11) orSkipTooOldAsync. - Preparation (
NotificationEmailDigestRepository.PrepareAsync): one transaction that refuses on a preference revision older than the caller's read and incrementsDispatchFence(so a concurrent save conflicts), reads the candidates hinted to0023and bounded by the look-back, holds their entries and inserts the window. Candidates are the recipient's items of a kind whose category is daily, saved at or after that category's consent time and no later than now, unread, not dismissed (any reason, so N3's automatic dismissal is covered), not resolved, not in a muted project, and with no ledger entry or a ready one. A ready entry is unsent work of a kind that is daily at that moment (the transaction fences the preferences), whichever mode it was admitted in: switching Immediately to Daily keeps the consent time, so the kind counts for today, and taking its ready immediate entries here (as daily) means they join today's window instead of racing L4's reconciliation of the mode and missing it (review finding on843cbb2c9). A leased, dispatching or finished entry is never taken, and an immediate kind is not in the window at all, so dismissing never cancels an immediate email while its kind stays immediate. A category turned on from Off after the day's send time does not count until the next day. - No entry waits ready for ever (review finding on
626e2d462). In the same transaction, before choosing members, preparation finishes the recipient's ready entries of the daily kinds that no digest will take: notice read, dismissed or resolved (suppressed, reasonleftOutOfDigest, a new additive value of L4'sReason), saved before the look-back (tooOld) or before the kind's consent time (suppressed, as L4's opt-out). This is the one owner of that end, rather than L4's mode reconciliation: it covers every route to daily (L4'sUpdatePendingModeAsync, its processor releasing a claimed entry as daily, and an entry still immediate because the switch raced it), decides on the same state and revision as the digest's own membership, and leaves the email alone if the reader switches back to Immediately before the next digest. At most 500 entries per preparation (index0028); any rest go at the next. The user guide says so: reading or dismissing does not cancel an email unless the kind is then switched to Daily digest. - Worker (
NotificationDigestWorker, every 15 s, each API replica): asksINotificationEmailAdmissionfirst (off or missing indexes: reads nothing); paused: does nothing; otherwise recovers expired dispatches, pages the enabled accounts (0030), prepares each account's current local day once (NotificationDigestSchedule.MostRecent, unchanged from the branch; missed days are not caught up), then claims up to 50 windows, re-checking the pause and flag before each. Which day each account last settled is held in memory only. Per page of accounts not yet settled today, one batched existence read on_id(ExistingAsync) settles those whose window another cycle or replica already prepared, and only the rest start a preparation transaction (#3950 item 4). Reads per cycle: steady state, one account page per 100 accounts and nothing per account; on a new local day, a restart or a new replica, one batched read per page plus one transaction per window still missing. - Processor (
NotificationDigestProcessor): the L4 send-time checks per member (exists, age cap, registered kind that emails, still daily since before it and not muted, recipient active with an address, the kind's resolver shows it available), re-reading consent just before dispatch; thenBeginDispatchAsyncfor the remaining members in one transaction (window owned and live; any member past the cap recordedtooOldand the window retried; each category still daily since before its oldest member; no member's project muted; revision unchanged;DispatchFenceincremented), one email, and the transport result on the window and its members. Unavailable authority retries the window; an uncertain send is ambiguous and never replayed. Content is the kind label and a link per member only, as L4; N5 brings the shared template and preview. - Admission.
INotificationEmailAdmissionnow also requiresPmMigrations.NotificationEmailDigestIndexes(0032), so immediate email also waits for it; the pre-deploy Job runs it with the rest. - Index. Migration
0032_pmNotificationEmailDigest_State_DueAtUtcwith a Down; lock entrysha256:1ee32ccdd7930cc713c13e6bb1c57732487d3fb570b0840d8e7db55614b94040. No repository builds an index at start-up. - Screen. Email preferences offers Daily digest and saves an IANA timezone and a local
HH:mmsend time (default 09:00 UTC), echoing every other key as before. It reads and saves through the generatedNotificationEmailPreferencesHttpServicerather than hand-rolled HTTP (#3950 item 8). The layout is L4's interim screen, accepted as interim by Chris on 28 October 2026 (option A); screen sign-off and the Material 3 redesign happen at N5. - Not in L5. Preview and the shared template (N5); the Mailpit end-to-end journey (#4592; the branch's earlier
notification-digest.spec.tswas written for the pre-amendment design and is removed); retention of closed windows (with L4's E32). - Look-back bound (
NotificationDigestMaxLookback, Chris 28 October: complete digests). Preparation, the clean-up of ready entries and dispatch admission all usenow - DigestMaxLookbackHours(48 h) as the oldest saved time, never the 24-hour immediate cap. "Since the previous digest" is the ledger: whatever a digest or an immediate email took has an entry and is never taken again, so a flat bound gives a complete digest without reading the previous window. A literal "since the previous window's due time" lower bound was not used: it would exclude the previous digest's overflow beyond 100 (saved before that time), which must carry over. Consequence: a notice saved before the previous digest that was read or dismissed then, and later marked unread or restored, can appear in the next digest if still within 48 hours (it was never emailed). - No extra digest on a schedule change. Moving the send time later or changing timezone can make the most recent send time fall on the previous local day, whose window was never prepared. The worker's batched read also asks for the following day's window and prepares nothing when it exists, so the change takes effect at the next send time.