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 are private one-to-one conversations between the reconciler and one candidate reviewer (several selected reviewers open one thread each), 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. Private one-to-one reconciliation questions and response history (own flag, L7 below).
  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 (the note that reading does not answer it was removed by N4 criterion 20) 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} Obsolete; kept for older clients during a rollout (Clear all option B replaced it in the web). The first Clear all: dismisses the 1-100 named notices that are the caller's own and not dismissed; others are ignored; returns {dismissed}
POST api/account/notifications/cleared {asOfUtc, status?, kinds?, projectId?, cursor?, q?} Clear all, option B: dismisses the caller's own, not dismissed notices on the tab, narrowed by kinds, project and (Clear matching, #4731) the search q, saved at or before asOfUtc (rows saved before save times were recorded: created at or before it), except those waiting for the caller's answer; returns {dismissed, ids, waiting, waitingByKind?, more, nextCursor?} (see "Clear all, option B" and "Search and filters on the server")
POST api/account/notifications/read {ids} or {asOfUtc, status?, q?, kinds?, projectId?, cursor?} Mark all as read (N3 B2): marks the 1-100 named notices that are the caller's own and unread; returns {read}. Mark matching as read (#4736): with no ids and a search or filter, marks the caller's unread notices the list query matches on the tab, saved at or before asOfUtc; returns {read, more, nextCursor?} (see "Mark matching as read")
POST api/account/notifications/restored {ids} The Undo of Clear all option B: restores the 1-100 named notices that are the caller's own and dismissed; others are ignored; returns {restored}
GET api/account/notifications?status= all (default: not dismissed), unread (not dismissed, not read), resolved (not dismissed, resolved), dismissed; anything else 400. Since option B the page also carries listedAtUtc, the server time of its read. Since #4731 it also takes q, kinds and projectId (see "Search and filters on the server")
GET api/account/notifications/count?status=&q=&kinds=&projectId= #4731: how many notices the list query matches, {count, capped}, exact up to 999
GET api/account/notifications/projects?status= #4731: the Project filter's choices, {projects: [{id, name}], more}: the projects of the caller's notices on the tab that they can open now, by name, at most 100
  • 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. N5 adds the mute control (the digest's mute link and the Muted projects list).
  • 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 (Amended by N4 criterion 20, the decided card layout of 2 November 2026: the time's place and ladder, icon-only housekeeping on the page, the calm row on a phone page, the context line, and Notice details in place of Activity.) (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. (Since #4731 the search and filters run on the server over all the tab's notices, and the Project chips come from the server: see "Search and filters on the server".)
  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) (since 3 November 2026 Clear all clears the tab and filters on the server except what waits for an answer; see "Clear all, option B"). 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) still draws its unread badge as its own span on M3 roles; the Notifications page's Unread tab uses Angular Material's matBadge with the opt-in .syrf-m3-badge classes from syrf-theme.scss (neutral variant; #4755), and moving the bell onto them is #4760. 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; replaced in N4 by SyRF's dialog layer, criterion 19) 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). (Both changed in #4731: the server searches and filters, and offers the Project chips.) The invitation headline stays N1's "You are invited to join…" (no inviter name). Activity (Notice details' History since N4 criterion 20) 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). (N4, below, lists them under Resolved only, names the inviter, and changes the card's icons, weights, quotation and answer order.) 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; since #4592 it runs in the notification email lane, below). Earlier E2E runs found a refresh loop when changing tab and Restore not clickable on a collapsed card; both are fixed here.

Compact bell panel (28 October 2026, notifications restart N4)

#4651 turns the app bar's bell from a link into a button that opens a compact panel of recent notices over the current page, modelled on the Google Cloud console panel Chris asked for (prototype Main.dc.html, the pop bell popover, v8; COVERAGE row 3). Its rows follow design A′, which Chris chose on 28 October 2026 from the bell-options canvas (variant A, calm rows with small buttons, with the message's own buttons at the right in Material order). It reuses N3's card and service. Flag: notificationInbox (web), unchanged; with it off there is no bell and nothing is fetched.

Changes from the restart plan.

  • Opening a row marks it read (D49). The plan's N4 acceptance 2 ("Opening a row does not mark it read") is superseded by D49 (26 October 2026), as N3's was: expanding an unread row in the panel sends the same Mark read the page sends. Opening never resolves, dismisses, accepts or declines anything.
  • Small icon buttons in the panel (A′). A 400 px panel has no room for the page's labelled pill beside a title, so on a computer the panel uses the small named icon buttons the page already uses on phones (Mark as read or Mark as unread, Dismiss), in the row's top-right place. (Since criterion 20 the page uses icon buttons on desktop too.) The actions behind them are the page's, in one shared service, so they behave and read identically.
  • Answered invitations leave All and the panel (Chris, 28 October 2026). An accepted or declined invitation, and any resolved notice, is listed under the page's Resolved tab only. This is a server rule (the list's status), so the panel and the All tab agree; the unread rule is unchanged.
  • The inviter is named (Chris, 28 October 2026). An invitation's message is attributed "From {the inviter's name}", read when the notice is read; when the account is gone, deactivated or has no name it says "From the person who invited you". This needs one additive nullable field (author on the quote block).
  • The page's card changes with it. Icons, title weights, the quotation style and the answer order below apply on the Notifications page too, because both use the one card.
  • No count beside the title. The unread number is the bell's badge (and the page's Unread tab) only.
  • Phone: a full-width sheet (M3 guidance: on compact windows a menu-like surface with rich content becomes full-width). Below 600 px the same panel opens under the app bar across the whole width and down to the bottom of the screen. The page stays one tap away ("See all in Notifications").
  • Clear all clears the rows shown (as the prototype; Chris confirmed on 28 October 2026). Mark all as read marks every unread notice. (Superseded on 3 November 2026: the panel's Clear all clears all of All except what waits for an answer; see "Clear all, option B".)

Acceptance criteria (each with its verification).

  1. The bell opens the panel. The bell is a button named "Notifications, N unread" ("99+" past 99) with aria-haspopup="dialog" and aria-expanded; selecting it opens a non-modal panel (role dialog, named "Notifications") anchored below it at the right, 400 px wide (at most the window less 16 px) and at most 640 px high, scrolling its rows. Focus moves into the panel but is not trapped there, so Tab reaches a snackbar's Undo. Verify: bell spec (name, state, open, focus, the real snackbar's Undo after the panel with no trap).
  2. Badge equals the N2 count. The badge shows the service's unread count (N2's rule), "99+" when capped and nothing at zero. Every panel list read carries the count; when a panel read fails the count is read on its own, so the badge keeps following arrivals. Verify: bell spec; service spec.
  3. Calm header. A centred title "Notifications" with no count beside it, and an overflow button "Notification options" opening a menu: Mark all as read (enabled while the badge shows a number; marks every unread notice, not only the rows shown, then says how many), Clear all (disabled with no rows; dismisses the rows the panel shows, with "N notices cleared. They stay in your history." and Undo; option B since 3 November 2026: all of All except what waits for an answer, with the words of "Clear all, option B"), and Email preferences (only while notificationEmail is on). Verify: panel spec.
  4. Calm rows (A′). The panel lists the newest open notices of All, at most 8, newest first, with no lines between rows. Each row has the kind's icon coloured by outcome (success for a completed export or import and a resolved notice, warning while it waits for the reader's answer, neutral for a declined one, primary otherwise), the title (BodyMedium, up to two lines) and one detail line (the key fact, or the status when there is none), and at the top right its relative time with the unread dot under it. There is no kind label line; one row shape serves closed and open. The whole row opens the notice. (The time's place on an open row and on a phone is criterion 20's.) Verify: card spec (panel, per outcome) and panel spec.
  5. Rows open in place; opening marks read (D49). (Amended by criterion 20: the time moves to the corner, and the body is the context line, the message and one button row with Notice details.) An open row gets a slightly darker container (surface container highest) with no outline, keeps its title, shows the status chip and then the time in place of the detail line (the summary is not repeated), and below it the message, Activity and the message's own buttons. One row is open at a time, and the list scrolls just enough to show it. Expanding an unread row sends one Mark read and the badge refreshes; closing sends nothing. Answering an invitation (Accept or Decline) removes its row (criterion 14), so the panel, like the page, closes it and moves focus as a dismiss does (the next row, else the previous one, else the panel or the page heading), and the snackbar adds "It is now under Resolved." Verify: card, panel and page specs (accept and decline: focus target, nothing left open).
  6. Housekeeping (A′). Every row has two small icon buttons, each with its name as aria-label and tooltip: Mark as read (an open envelope) or Mark as unread (a plain closed envelope; the envelope with a dot is gone, as the dot is SyRF's unread sign), and Dismiss (×). On a computer they take the time and dot's place on hover or keyboard focus (:focus-visible) and always show on an open notice and on touch screens; on a phone they always show, with the unread dot in the left margin and the time at the end of the detail line (since criterion 20, over the icons at the top right). They are the controls the page shows on phones, with the page's actions behind them (Dismiss offers Undo; focus moves to the next row). Verify: card spec (panel controls equal the page's phone controls in five states; icons; tooltips), panel spec, actions spec.
  7. Only open notices are shown. The panel reads only All, which never lists a dismissed notice, one the reader can no longer open (N3 criterion 14) or a finished one (criterion 14 here); a row that still arrives so is dropped. Verify: panel and service specs.
  8. Closing. Escape (when no menu or dialog is open over the panel; with the options menu open, Escape closes only the menu), a click or tap outside, the bell, and on a phone the sheet's Close button close it and return focus to the bell. "See all in Notifications" (to /notifications, or /notifications/{id} when a row is open) and any link in a card close it and navigate. Verify: bell spec.
  9. Live arrival. While closed, a live hint (or the 30-second refetch) re-reads the count; while open, it re-reads the panel's list, which carries the count. A write made during the panel's first read re-reads the panel rather than leaving it empty. Verify: service spec.
  10. Phone. Below 600 px the panel is a full-width sheet under the app bar reaching the bottom of the screen, with a Close notifications button at the leading edge of its header (M3's full-screen pattern). Verify: bell and panel specs; screens.
  11. Material 3 and quality gates. The panel surface follows M3's menu container (surface container, elevation level 2, 16 px corners); rows have no shadow or outline; state layers 8% hover, 10% focus and pressed. M3 tokens only, light and dark; signals, zoneless, standalone, built-in control flow. Theme, zoneless, help-URL and dialog guard specs and ng lint pass. Verify: the guard specs, lint and the screens.
  12. E2E. The inbox E2E spec opens the bell panel, sees the live notice, opens it (read, badge down, the icon buttons shown), closes it with Escape and a click outside (focus on the bell), and follows See all. The invitation spec checks the inviter's name and that an accepted invitation is under Resolved only. Verify: the specs on the Bramble E2E stack.
  13. Owner screen acceptance. Light and dark PNGs at 1280 px and 390 px of the panel (closed with badge, a mix, hover controls, the options menu, an invitation and a workload change opened, empty) and of the affected Notifications page screens. Verify: owner review.
  14. Answered invitations live under Resolved. The list's All status lists open notices only (not dismissed, openable, not resolved, not an answered workflow); Resolved lists resolved notices and answered workflows, resolution recorded or not (an answer given while the inbox was off has none), reading every row not dismissed. Unread and the count are unchanged. Verify: NotificationRecipientStateControllerTests (answered with and without a resolution, a resolved notice, the positive controls), E2E invitation spec.
  15. The inviter is named. The invitation's quote block carries author, the inviter's display name (preferred or first name, then surname) read through the shared cache once per person per request; null for a missing, deactivated or nameless account; never an ID; never on an unavailable notice (the C15-T04 disclosure fixture now includes the name). The web shows "From {author}", else "From the person who invited you" for an invitation, else the block's label. Verify: NotificationViewTests (named with a preferred name, missing, deactivated, nameless, answered views, unavailable bytes), card spec, E2E invitation spec.
  16. Shared card on the page. On the Notifications page too: the open and closed envelope icons; a person's words under a small muted "From …" line in italics at the normal size and colour, with no rule, box or tint; the message's buttons end-aligned with the dismissive one first (Decline outlined, then Accept filled). Verify: card spec; page screens.
  17. Title weights (Chris, 29 October 2026). A title that counts as unread takes the M3 Expressive emphasized weight of its role (BodyLarge on the page, BodyMedium in the panel: --syrf-typescale-body-large-emphasized-weight and --syrf-typescale-body-medium-emphasized-weight, 500, from Compose's TypeScaleTokens *EmphasizedWeight = Medium); a read or open title the role's base weight (--mat-sys-body-large-weight / --mat-sys-body-medium-weight, 400); nothing else changes weight with read state; page and panel alike. Roboto is loaded at 300, 400 and 500, so every weight is a real face (the earlier 600 was synthesised). Verify: card spec (computed weights, both surfaces), theme spec, the Chromium measurement on the E2E stack.
  18. One right edge (Chris, 29 October 2026). (Superseded by criterion 20's one right margin on the glyphs' edge.) An open notice's header controls (housekeeping and the arrow) and its own buttons end at the same inset from the card's edge (8 px; 4 px on phones), on the page and in the panel; the page's readable measure (48 rem less the body's paddings) applies to the text blocks only. Verify: card spec (shared inset, all four variants); E2E getBoundingClientRect edges within 1 px (export and invitation, page and panel, 1280 and 390).
  19. Dialogs through SyRF's layer. The Decline confirmation opens through openInfoConfirmation over SyrfDialogService (#4599): the m3e alert dialog with expressiveDialogs on, the native dialog off; it is the only dialog the card, panel or page opens. Verify: actions spec (flag on and off).
  20. The decided card layout (Chris, 2 November 2026). Chris decided that N4, N5 and N7 match the layout chosen on the question notice options board: option 1, "The approved card, applied to questions", on its four surfaces (page and panel, desktop and phone). N4 applies the shared card's part of it, for every kind N4 shows; the question kinds' bubbles, composer, titles, alias and stage and study line are L7's (#4673). No new server fields: the context line shows the project the view already carries; the one server change removes the invitation's note block (below). Unflagged beyond notificationInbox: it is the card's layout and one fewer body block, with no data or behaviour change.

    • One time position. On every surface, open or closed, the time sits at the top right on the title's first line, never inside a line that can be cut short (the phone panel's detail line and the open panel's chip line no longer hold it). One elapsed-time ladder (relativeTime in notification-presentation.ts), always with "ago": "just now" under a minute, "N min ago", "N h ago", "1 day ago" / "N days ago", "N wk ago" from 7 days, "N mo ago" from 30 days, "N yr ago" from 365 days; no "Yesterday" and no date fallback. BodySmall (12/16, regular) with tabular figures; the <time> keeps datetime and has the full local date and time as its title ("Friday 30 October 2026, 09:58", fullTime).
    • The corner. On an open card on the desktop page, the desktop panel and the phone panel the time and the icon-only housekeeping (Mark as read or unread, Dismiss; Restore under Dismissed; on the desktop page the open/close arrow last) share one row at the top right, time first, centred on the title's first line; only the phone page stacks the time over the icons, as a closed phone row does on both surfaces. Each icon button is named by aria-label and tooltip. A closed desktop row (page and panel) shows the time with the unread dot; hover or keyboard focus inside the row (:focus-visible) swaps them for the icons (the two share one grid cell, so nothing moves) with a pointer that hovers; on a touch screen at desktop width (a tablet) the row does what a phone row does: the time, with the unread dot before it, heads the corner and the icons sit under it, all shown (review of a3de1458b3). Phones keep their swipes. Restore is always shown beside the time on a closed Dismissed row (it is that tab's only action).
    • The page uses icon buttons on desktop too (question 12): the labelled housekeeping pill is gone, so the page and the panel match. A phone page uses the calm row (question 10): a bare status glyph coloured by outcome, the title in BodyMedium (up to two lines closed), one detail line, no kind line; open, the status chip alone under the title, as in the panel.
    • One right margin per surface, on the icon glyphs' edge: the corner (its last glyph, and a stacked time) and the card's own buttons end on it (16 px from the card's edge on the desktop page, 15 px in the desktop panel, 14 px on phones: the controls' 8 or 4 px inset plus the glyph's inset in its 40, 32 or 44 px button). The panel's See all ends there too (23 px, 22 px on a phone, from the panel's edge).
    • Context line. An open card's body starts with a quiet, unboxed line: a folder icon and the project's name (BodySmall, the name in on-surface at 500, one line), when the notice has a project. It is metadata, never styled like a message; the key fact stays under the title on the desktop page.
    • The Details chip replaces Activity. A labelled "Details" toggle styled as an M3 filter chip (Chris chose it over the i icon button on 3 November 2026, #4712) starts the button row, which the card has even with no actions of its own; its outer edge sits on the text's left edge on the page, a phone and the panel. Off, it is outlined (1 px outline-variant, 8 px corners, 32 px tall) with the outlined info glyph (18 px, primary) and the label in on-surface-variant; on (details open), a primary-container fill with no outline and a check and the label in on-primary-container, the same on every surface (SyRF's secondary container reads as grey; the email preferences page marks chosen options in primary-container too). Hover, focus and pressed state layers, a 2 px primary focus ring and no transitions under reduced motion. Its accessible name stays "Notice details" (which contains the visible label), with aria-expanded and aria-controls carrying the state. It expands the context line in place into About this notice (the project, as a small tree) and History (Sent, Read, Answered for an answered invitation or Resolved, Dismissed; each "ago", with the full date after it on the page and as a tooltip in the panel), side by side on the desktop page and stacked elsewhere, with no rules inside. It closes when the card does. The <details> Activity disclosure is gone.
    • No separate reassurance note. The "Checked against your current access when opened. Reading a notice never accepts, decides or resolves anything." line is gone, and so is the invitation's server note block "Reading this notice does not answer the invitation." (ProjectInvitationNotificationResolver no longer adds it; a pending invitation's blocks are its state and the inviter's message). Reading still answers nothing.
    • Actions stay at the right in Material order with one filled button (Decline outlined, then Accept filled; a lone navigation action tonal); the Details chip sits at the start of the same row, its outer edge on the text's edge.

    Verify: notification-presentation.spec.ts (every boundary of the ladder, the full date); NotificationViewTests.ActionsBlocksAndWorkflowStateFollowEachKind (a pending invitation's blocks are state and quote, no note); card spec (slots and corner per kind, the corner's row or stack per surface, the time on all four surfaces open and closed with its datetime and tooltip, the closed row's swap, the calm phone row, the context line, the Details chip's label, name, aria-expanded and computed off and on styles on all four surfaces, Notice details and History, the margin geometry per surface); panel spec (the phone corner, the footer margin); the E2E capture specs measure the edges with getBoundingClientRect (within 1 px; on one row the time before the icons and centred on them) and recapture the owner screens (closed, hover, keyboard focus, open and details expanded; page and panel, 1280 and 390 px, light and dark).

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

  • Web. NotificationInboxButtonComponent is a button that opens NotificationPanelComponent (syrf-notification-panel) in a CDK connected overlay with a transparent backdrop; it owns opening, closing (Escape through overlayKeydown, backdrop click, the bell, the phone Close button, any NavigationStart) and returning focus. The panel focuses its surface on open and does not trap focus. NotificationCardComponent takes a panel input for the A′ row: on a computer the header button spans the row (glyph, text, and the time and dot at its end) and the controls sit over its top-right corner, shown on hover or keyboard focus; on a phone it is the phone layout without the kind line. Since criterion 20 a calm row (the panel, or a phone page) shares the row's content rules, the corner holds the time over the icons whenever they always show, and the body is the context line (or Notice details, a linkedSignal reset by expanded), the message and the button row.
  • Service. NotificationInboxService keeps a second list for the panel (panelItems, openPanel, closePanel, PANEL_SIZE = 8). refresh() re-reads what is open; writes patch both lists, discard a panel read in flight and re-read the panel when that read was its only data; a failed panel read reads the count while the page is closed (with the page open its own list read carries the count, and a separate count read would discard it). unreadIds() collects the Unread list for Mark all as read.
  • Shared actions. NotificationNoticeActions holds read and unread (with the swipe Undo), Dismiss with Undo, Restore, Clear all, Mark all as read and the inline invitation answer, with the page's words; it has its own spec. After an answer the panel and the page move focus as after a dismiss, because the answered row leaves the list (review of 798dfca01): the actions remove it locally once the server confirms (NotificationInboxService.removeAnswered, which also discards reads in flight), so focus never waits on a re-read, and the page reads the list once afterwards, not twice (review of 1dbb72ee29).
  • API. NotificationReadContext.PersonNameAsync (memoised, cache-backed, display only) names the inviter on the invitation's quote block (NotificationBodyBlockDto.Author, additive, nullable). NotificationsController.Lists splits All and Resolved as in criterion 14. The repository's stored Resolved filter is removed (the API reads All for that tab and decides per item; the repository refuses the status); no index or migration change. The client and the NotificationDto Newtonsoft golden are regenerated (only the new author field).
  • m3e-angular. Used: MatExpIconButton (bell, options, the row buttons, Close), MatExpButton (See all, Retry, the message's buttons) and MatExpLoadingIndicator. Library gaps (Angular Material or the CDK with SyRF M3 tokens instead): a menu (mat-menu), an anchored popover surface (CDK overlay styled as an M3 menu container), a badge (as N3), a full-width phone sheet (the same overlay, full width), and a tooltip on a plain icon button (matTooltip).
  • Differences from the prototype (decisions win): opening marks read (D49); rows follow A′ rather than the prototype's panel (no context line, no labelled action bar in the open card); "See all in Notifications" replaces "See all in Inbox"; no offline banner (the page has none).
  • Review fixes (Opus review of af0f3de26): no focus trap (Undo reachable); the first-read write race; the badge on a failed panel read; the canMarkAllRead rule documented; a spec for the shared actions; the size constants used by the bell spec.
  • Tests. Web (pnpm exec ng test): card 78, panel 18, bell 12, service 37, shared actions 7, page 43, nav 41 and the guard specs (theme 63, zoneless 3, help URLs 2, dialogs 4): 329 across those files; ng lint 0 errors. API on Bramble: 531 notification tests in SyRF.API.Endpoint.Tests. E2E on Bramble: the inbox, invitation and review and workload journeys.

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.

  1. Flag off: no digest loop. While notificationEmail or notificationInbox decides off, or any email index migration (0027-0030, and the digest window index 0032) is missing, a digest cycle reads nothing: no preference page, no window, no ledger row. Verify: NotificationDigestWorkerTests (closed admission makes no repository call).
  2. 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).
  3. 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).
  4. 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).
  5. An item dismissed (or read, or resolved) after the claim stays in the frozen window and is sent with it. Verify: NotificationEmailDigestRepositoryTests and NotificationDigestProcessorTests.
  6. 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.
  7. 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 DispatchFence in the same transaction as the move to dispatching, so a concurrent save conflicts with it. Verify: NotificationEmailDigestRepositoryTests (fence) and NotificationDigestProcessorTests.
  8. 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 (chart digestMaxLookbackHours, 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 recorded suppressed (tooOld) at preparation, and one that ages past it before dispatch admission is recorded the same way. Immediate email keeps MaxItemAgeHours (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 test notification_email_delivery_pause_test.yaml.
  9. 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) and NotificationDigestWorkerTests (sent once by the immediate worker).
  10. 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.
  11. 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.
  12. 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).
  13. Migration, not start-up. The window collection's index is PM migration 0032 with a Down, appended to the lock file; no repository builds an index at start-up. Verify: migration catalogue and lock-file tests, the 0032 Up, re-run and Down test.
  14. Preferences screen. With notificationEmail on, 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 dependency notificationInbox); 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, _id derived from both, so a second preparer on any replica finds it taken. It stores the frozen NotificationIds (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) and DueAtUtc, 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 by digest:{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) or SkipTooOldAsync.
  • Preparation (NotificationEmailDigestRepository.PrepareAsync): one transaction that refuses on a preference revision older than the caller's read and increments DispatchFence (so a concurrent save conflicts), reads the candidates hinted to 0023 and 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 on 843cbb2c9). 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, reason leftOutOfDigest, a new additive value of L4's Reason), 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's UpdatePendingModeAsync, 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 (index 0028); 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): asks INotificationEmailAdmission first (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; then BeginDispatchAsync for the remaining members in one transaction (window owned and live; any member past the cap recorded tooOld and the window retried; each category still daily since before its oldest member; no member's project muted; revision unchanged; DispatchFence incremented), 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. INotificationEmailAdmission now also requires PmMigrations.NotificationEmailDigestIndexes (0032), so immediate email also waits for it; the pre-deploy Job runs it with the rest.
  • Index. Migration 0032_pmNotificationEmailDigest_State_DueAtUtc with a Down; lock entry sha256:1ee32ccdd7930cc713c13e6bb1c57732487d3fb570b0840d8e7db55614b94040. No repository builds an index at start-up.
  • Screen. Email preferences offers Daily digest and saves an IANA timezone and a local HH:mm send time (default 09:00 UTC), echoing every other key as before. It reads and saves through the generated NotificationEmailPreferencesHttpService rather 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, done in its own PR, below; the branch's earlier notification-digest.spec.ts was 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 use now - 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.

Mailpit end-to-end journey (28 October 2026, #4592)

L4's acceptance was met with a fake transport and replica-set tests. This adds the end-to-end check into a real SMTP server, which is needed before NotificationEmailDelivery:Paused is lifted anywhere (G-NOTIF) and is reused by L5 and N5. It is test harness only: no product code, configuration default or flag changes, so there is no flag decision to make.

  • Lane. bash e2e/run-local.sh --spec notification-email gives the hermetic stack its own Mailpit (e2e/docker-compose.mailpit.yml, slot-offset MAILPIT_SMTP/MAILPIT_HTTP ports on loopback, no relay), points the API's SMTP transport at it and lifts the pause for that stack only (e2e/scripts/service-env.sh, which lifts it only with a Mailpit URL and a loopback SMTP host). The scoped teardown removes the container with the stack and releases the reservation. Commands and wiring: e2e/README.md.
  • Recipient routing. appsettings.e2etest.json restricts email to an empty developer address (SESSettings:RestrictEmailToDev=true, DevEmail=""), which left every SMTP send with no recipient ("At least one recipient is required") and a digest recorded as ambiguous. The lane alone sets RestrictEmailToDev=false, because its Mailpit can deliver nowhere; the checked-in e2etest and production settings are unchanged.
  • Synthetic recipients. Each test signs in a new account, notify-<uuid>@example.org, which creates its own projects; nothing emails the seed accounts. The spec fails if any message in the stack's Mailpit is addressed outside example.org.

Acceptance criteria. Each is verified by e2e/tests/notification-email.spec.ts in that lane, on an isolated stack with notificationInbox deployed on and notificationEmail as a runtime override.

  1. Opted in: exactly once. With "Completed exports" set to immediately, a completed export's notice arrives in Mailpit once, to the recipient only, subject Export completed, with the link to the expanded notice; its ledger entry is sent; another user's read of it is 404. Still one message after a further worker period.
  2. Muted project: nothing. The muted project's notice is admitted and suppressed (the positive control that the worker processed it), stays in the inbox, and sends no message.
  3. Link through sign-in. Opened in a signed-out browser (checked to have no session), the link goes through the mock OIDC sign-in as the recipient and lands on the same expanded notice.
  4. Opt-out: nothing. After "Off", the next notice is neither sent nor admitted after a full worker period.
  5. Pausing again stops delivery. With the API restarted with NotificationEmailDelivery__Paused=true, a new notice is admitted (ready) and no message is sent after a full worker period; restarted with the pause lifted, it is sent once and recorded sent.
  6. Teardown leaves nothing. After the run no container, network, volume, listener, pid file or port reservation of the stack remains (the PR records the check).

CI. Not run in CI. The E2E entries have no Mailpit and keep the pause on, so the file skips at file scope. Adding it would need a Mailpit in the CI stacks, the overlay in their private copy and the workflow's port-binding checks widened, for a serial lane that restarts the API; the PR explains the decision.

Reconciliation questions on main's rules (28 October 2026, notifications restart L6)

#3944 (selected-reviewer reconciliation questions and replies, StudyConversation) brought up to main and amended per the restart plan's step L6 (handover/notifications-implementation-plan-2026-10-24.md §3, L6 row: "Merge main; register its kinds; rewire flags; conversation index as migration"). The slice itself is described in reconciliation-questions-plan.md; the permanent record is ADR-026 decision 17.

Known limit (gap C7): every participant sees every post. A reconciler can ask several selected reviewers at once, and each reviewer's reply reaches the others. That conflicts with VS1 (other candidates' answers stay hidden) and is fixed by #3965 (one-to-one threads, completed sessions only, its own reconciliationConversations flag), re-applied on a new PR from main after this one merges (restart L7; owner decision Q1, option B). Until L7 is on main, studyAttention is deployment-managed (pinned): no runtime override, in Legacy or Shared mode, can switch it on, the Feature flags admin page shows it as requiring deployment configuration, and enabling it anywhere is a GitOps change that needs owner approval. It is off in every environment. Superseded by L7 (below): private one-to-one threads close gap C7, the pin is removed, and conversations need their own reconciliationConversations flag as well.

Acceptance criteria. Each is verified by the named tests.

  1. Flag registered, deployment-only. studyAttention is feature study-attention in src/charts/syrf-common/feature-definitions.yaml (Release, Developing, What's New quiet, permission SignedIn, added by pnpm feature:new), with a reviewed consumer contract (hosts: [api], productionEligible: false), a declared dependency on notificationInbox and a deploymentManaged pin. A runtime change is refused with the pin's message and its effective value is always the deployed value. Verify: RuntimeFeatureFlagRegistryControlsTests (StudyAttentionIsPinnedSoNoRuntimeOverrideCanSwitchItOn), the generator tests in validate:generated.
  2. One gate, read through IFeatureDecisions. The routes and the two notice resolvers ask IStudyConversationAdmission, which decides studyAttention and notificationInbox from one DecideAll capture and then checks the inbox index set (0023-0026) and the conversation index. Undecidable is off; off never touches the database. The FeatureFlags POCO property and the runtime-provider mutation from the original branch are gone. Verify: StudyConversationAdmissionTests.
  3. Flag off: every endpoint 404, nothing read or saved. api/study-conversations (candidates, create, get, list, reply) is unroutable while studyAttention decides off (FeatureGated), and every action repeats the admission first: off (either flag) is 404 and missing indexes are 503, both before any account, project, study or conversation read. The web /attention/:id route falls through to not-found and the question panel on the reconcile screen is hidden. Verify: StudyConversationsControllerTests (TheRoutesAreFeatureGatedOnStudyAttention, ClosedAdmissionAnswersEveryRouteWithoutAnyRead), study-attention-route.guard.spec.ts, reconciliation-question.component.spec.ts.
  4. Canonical scopes refused. Conversations read legacy annotation sessions, so a stage in the canonical review model is refused like a missing one: 404 on candidates, create, get and reply, absent from the list, and its notices read as unavailable. The kinds declare scope Legacy. The canonical ownership record (R0) is not on main, so the registered ICanonicalReviewScopes (NoCanonicalReviewScopes) answers "legacy" for every stage; R0 replaces that registration. Verify: StudyConversationsControllerTests.ACanonicalStageIsRefusedOnEveryRoute, NotificationViewTests.AConversationNoticeOnACanonicalStageIsUnavailable (both with a fake reporting canonical).
  5. Kinds registered (N1). reconciliationQuestion ("Reconciliation question") and reconciliationResponse ("Reconciliation reply") are declared in StudyAttentionNotificationKinds (own email category, scope Legacy, admission flag studyAttention, inline limit 20) and registered with StudyConversationNotificationResolver by AddStudyAttentionNotificationKinds. Verify: NotificationKindRegistryTests (TheReconciliationQuestionKindsAreDeclaredAsAgreed), NotificationKindHostRegistrationTests (the real Lamar container serves exactly the seven declared kinds).
  6. Server-shaped view; unavailable shows the kind only. Available to a participant who is an active member with the stage activity they hold in the conversation (Reconcile for the reconciler, Review for a reviewer), on a legacy stage, with the study still in the project and the post still in the conversation. Otherwise the kind's label only and no protected detail in the bytes (C15-T04). Verify: NotificationViewTests (the two kinds' disclosure fixtures, AConversationNoticeQuotesItsPostAndOpensTheConversation, AConversationNoticeIsUnavailableWhenItsSourceBindingNoLongerHolds).
  7. Off: notices unavailable and uncounted; on again: back. With the flag off a conversation notice is "no longer available" and leaves the unread count (N2 acceptance 3); on again, the same row is available and counted. Verify: NotificationViewTests.WithTheFlagOffAConversationNoticeIsUnavailableAndUncounted_AndOnAgainItIsBack.
  8. One capture path. A question or reply and its notices for the other participants commit in one transaction through InboxNotificationWriter (N1), keyed on the post, with Source {studyConversation, conversation, project, [stage]}; a replay converges, a conflicting request writes nothing, and a notice that cannot be written rolls the post back. Verify: StudyConversationRepositoryTests (replica set).
  9. Index as a migration, nothing at start-up. {ProjectId, StudyId, StageId, DateTimeCreated: -1, _id: -1} on the new pmStudyConversation collection is PM migration 0033 with a Down and one new lock entry; no repository builds an index and no hosted service is added. Verify: PmMigrationsTests (catalogue, shape, lock file), PmStudyConversationIndexMigrationsTests (Up, re-run, Down, adoption), PmRuntimeReleaseIndexMigrationsTests (the full Up order), StudyConversationRepositoryTests.TheListReadUsesTheConversationIndex (explain plan).
  10. On, off, on, off, on keeps everything. Conversations created while on are kept while off (every route 404) and read back and accept replies when on again, with a reply saved in the second on state surviving the next round trip. Verify: StudyConversationsControllerTests.OnOffOnOffOnKeepsEveryConversationAndAnswersNotFoundWhileOff, StudyConversationAdmissionTests (decided on every call).
  11. Screens unchanged (UI). The question panel and conversation page are #3944's screens, captured at 1440 and 390 px, light and dark, for the record; their Material 3 rework (D3-01) is due before production, not here. Verify: the screenshots listed in the PR, and the Material 3 follow-up list in the PR body.

As built (differences from the original branch).

  • Flag model. FeatureFlags.StudyAttention, the hand-written catalogue entry and the provider mutation are gone (main generates the catalogue). Reads go through the gate above. The original branch let Get and List answer with the flag off ("history survives disable"); the restart plan's L6 criterion is "flag off: endpoints 404", so every route now answers 404 while off and the history comes back when on.
  • Notices. The typed ConversationId and StudyId fields the branch added to InboxNotification are dropped; the conversation is the row's Source (N1). They never reached main, so no stored row has them. The per-kind branch of the deleted NotificationDetailResolver is now StudyConversationNotificationResolver, which decides authority through the request's NotificationReadContext (so email retries an authority outage, as for the other kinds) and memoises the conversation and study per request (LoadOnceAsync). NotificationReadContext gains CanStageActivityAsync (Review keeps its existing memo through it).
  • Deleted projects. StudyConversationAccess now treats a project marked deleted as absent (as NotificationReadContext does), so conversations are unavailable in a deleted project.
  • Email. Each kind keeps its own email category (the integration catalogue: "the existing kinds keep their own keys"), but the preferences screen does not list them yet, so nobody can opt in from the web; an email would carry only the kind's label and a link (L4).
  • Migration. 0033_pmStudyConversation_ProjectId_StudyId_StageId_DateTimeCreated_Id, the next free number after L5's 0032 when this merged main (re-check migrations.lock.json at merge time); lock entry sha256:bddff1f7c9750b1608151ed1d7cd5680da8388968ee2699366fe96a07bcdde2a, printed by PmMigrationsLockFileTests.
  • No worker, no start-up work. Nothing reads studyAttention outside a request.

Follow-ups (not in L6). L7 (#3965 re-applied from main): private one-to-one threads and the end of the pin; the quick reply in the N3 card. Material 3 rework of the question panel and conversation page (D3-01, before production). The email preferences screen lists the two categories once conversations can be enabled (N5: done, offered while studyAttention decides on). R0 replaces NoCanonicalReviewScopes; R4a rebinds conversations to the reconciliation task.

Daily digest preview and the shared digest template (28 October 2026, notifications restart N5)

#4670 implements the restart plan's step N5 (handover/notifications-implementation-plan-2026-10-24.md §3 N5): Preview daily digest on Email preferences, one server renderer for the preview and the digest that is sent, and the Material 3 redesign of the Email preferences screen. Owner decisions applied: the page is "Notifications", never "Inbox"; new UI is Material 3 (Expressive where possible, m3e-angular components first), in light and dark; minimal UI with one line of help and details on request; no raw IDs on screen; every control of L5's interim screen (accepted as interim on 28 October) is kept.

Acceptance criteria. Each is verified by the named tests; every transport in a test is a mock or Mailpit.

  1. One renderer. The preview and the digest that is sent are rendered by NotificationDigestRenderer (subject, HTML and plain text) from entries shaped by the same code (NotificationDigestComposer). For the same items and clock the preview's subject, HTML and text are byte-for-byte the message the transport is given. Verify: NotificationDigestPreviewTests (replica set: preview, then the worker sends; the bytes are equal).
  2. The preview sends nothing and writes nothing. GET api/account/notification-email-preferences/digest-preview reads the caller's preferences and saved items and returns the rendered digest. It creates no ledger entry, window or claim, changes no preference revision or DispatchFence, and calls no transport. Verify: NotificationDigestPreviewTests (every document of the inbox, ledger, window and preference collections is unchanged afterwards; the transport mock is never called).
  3. What the claim skips is absent. The preview takes the candidates the digest's preparation would take now (the same pipeline, read-only) and applies the dispatch checks without writing: unavailable items (the resolver no longer shows them), dismissed, resolved and read items, items in a muted project, items older than the look-back bound (DigestMaxLookbackHours, 48 h), items of a category that is not daily or was turned on after the item, and items that already have a ledger entry that is not ready (sent, held by a window, suppressed) are all absent. A digest already prepared and not yet sent is shown as what goes next, with its scheduled time, never as "nothing to send". Verify: NotificationDigestPreviewTests (one item in each state; only the eligible one is shown; a prepared digest shown with its time and then sent as shown; a prepared digest with nothing left to send reported as such, and the worker then sends nothing).
  4. Gated like the preferences. The preview is on the preferences controller, so it inherits [FeatureGated] (notificationEmail, 404 while off) and the in-action admission check (503 while an index migration is missing), as L4 left the preference endpoints. Verify: NotificationEmailPreferencesControllerTests.
  5. Template. Grouped by project (name only, D3-22), items that need the reader first with a "Needs you" chip after the label, the notice kind's own icon (the glyph the app's card uses), the time at the top right, each item's line shaped by the email content policy, a "Mute email for {project}" link per project, and "Manage email preferences". Until the project content policy (M12) lands the policy is the L4 one: the kind's label and a link, plus the project's name as the group heading. Every value is HTML-encoded. Verify: NotificationDigestRendererTests.
  6. Plain-text part. The digest is sent as HTML with a plain-text alternative (SMTP and SES). Verify: NotificationDigestRendererTests, SmtpEmailServiceTests.
  7. Categories come from the server. The preferences read returns categoryOptions (key and label, registry order) for each registered category whose kinds' admission flag decides on, and the screen renders exactly those, so a kind registered later appears without UI work. L6's two conversation categories (reconciliationQuestion, reconciliationResponse) are therefore not offered while studyAttention is off (pinned off until L7) and appear, as "Reconciliation questions" and "Reconciliation replies", once it can be enabled, as L6's follow-up list asks; their keys are still read and echoed, so a saved choice survives. Verify: NotificationEmailPreferencesControllerTests (ConversationCategoriesAreNotOfferedWhileStudyAttentionIsOff_ButTheirKeysAreKept, ConversationCategoriesAreOfferedOnceStudyAttentionIsOn), notification-email-preferences.component.spec.ts.
  8. Muted projects by name. The read returns mutedProjects, each named only when the reader can still view the project ("A project you can no longer open" otherwise). The screen lists them with Unmute; the email's mute link (/notifications/email-settings?mute=…) mutes that project on arrival. Both save at once and offer Undo. Verify: NotificationEmailPreferencesControllerTests, component spec.
  9. Material 3 screen, every L5 control kept. Per category Off, Immediately or Daily digest (a connected button group), the digest's timezone and send time, Save with the same saved, conflict and error messages, Reload after an error, and the route still hidden while either flag is off. Verify: component spec, route guard spec, the notification-email.spec.ts E2E journey.
  10. Promotable. No new flag, collection, field or index (no migration); flag off, on, off, on changes nothing the preview reads. Verify: tests above; migration lock file unchanged.
  11. Owner screen acceptance of the preferences screen (digest off, digest on, preview open) and the digest email as Mailpit receives it, light and dark, 1440 px and 390 px. Verify: handover/n5-evidence/index.md.

As built. The permanent record is ADR-026 decision 19.

  • Flag decision: flagged, on the existing notificationEmail (with its dependency notificationInbox); no new flag. The preview is one more action on the preferences controller, so it is unroutable while the flag is off and answers 503 while an email index migration is missing, exactly like the preferences (L4 left the preference endpoints flagged on notificationEmail, not unflagged, and N5 does the same). The screen keeps its route guard.
  • No migration. No collection, field or index is added; the preview reads with the L5 candidate pipeline (hinted to 0023) and by ID. migrations.lock.json is unchanged.
  • Renderer (NotificationDigestRenderer, NotificationDigestComposer, NotificationEmailContentPolicy). The processor shapes each member with the composer and renders once; the transport gets the subject, the HTML and a plain-text part (IProjectManagementEmailService.SendSimpleEmail(to, subject, html, text), multipart over SMTP and SES). Subject: SyRF daily digest: 3 notifications, 1 needs you (counts only). Each row follows the decided notice-card layout (Chris, 2 November 2026): the kind's own icon (the card's Material glyph: download, upload, person_add, key, bar_chart, notifications for an unknown kind) as a white glyph in an accent circle, a 56 x 56 PNG shown at 28 x 28 (assets/images/syrf-email-kind-*.png, by absolute URL like the logo, alt="" because the label follows); the label with the "Needs you" chip after it on the title line; the time top right in tabular figures; one 14 px right padding shared by the time, the group heading and the mute link. The outcome glyphs are gone; the outcome reads as a status chip after the label (as the card's status chip does): "Needs you" (amber), "Completed with problems" (amber, an export with errors), "You declined the invitation" (grey; the card's words) and "You accepted the invitation" (green; the card's words; "Completed" for any other success, not shown for an export or import, whose label already says it); plain information has no chip. Each chip has a dark-scheme rule. The plain text keeps the meaning in words (the same words, or "Update"). The email stays label-only and keeps absolute times. Times are in the reader's digest timezone: today's as HH:mm, an earlier day's as its date and time ("27 Oct, 18:05"). A prefers-color-scheme: dark block covers clients that honour it. The header carries the SyRF logo (Chris, 29 October): the full title lockup (circle mark and "Systematic Review Facility") in white, rasterised in headless Chromium from the Identity service's wwwroot/img/SyRF-Title-white.svg (the white variant of the web app's SyRF-Title.svg) to assets/images/syrf-email-title-white.png, a 266 x 64 PNG shown at 133 x 32 (2x, explicit width and height), by absolute URL on the configured UI origin (not the request host, not base64; PNG because Gmail and Outlook do not render SVG). The header stays navy in a dark scheme, so one white lockup serves both. The alt text is SyRF – Systematic Review Facility, sized to wrap inside the image box, so the header still names SyRF with images blocked. (The first version used the circle mark alone at 32 px, which cropped its lettering and had no wordmark; it is gone.) L4's immediate email has its own two-line body, not this template, and is unchanged (follow-up: give it the same header).
  • Preview (NotificationDigestPreview, GET …/digest-preview). The controller passes the application groups from the authority snapshot it already read for the request, so the snapshot is read once. A digest already prepared but not yet sent (the reader's window for the current local day, or the next after a schedule change, is ready, leased or dispatching, for example while delivery is paused) is what goes next: the preview shows what it holds (its held members, checked as the processor checks them at send time, without writes) and preparedScheduledAtUtc, and the dialog says "This digest was prepared for … and is waiting to be sent". When none of its members passes those checks the processor suppresses the whole window and sends nothing, so the preview returns no digest with the prepared time and the dialog says "The digest prepared for … has nothing left to send"; it never claims a digest is waiting when nothing will go out. Otherwise daily kinds are those enabled by now; the candidates are INotificationEmailDigestRepository.CandidatesAsync (the preparation's pipeline without a session); each is then checked as the processor would (registered kind that emails, daily since before the item, not muted, the resolver shows it) without any write. An unavailable authority read is 503, never "nothing to send". The response carries whether any category uses the digest, the next send time, the counts, and the rendered subject, HTML and text (null when nothing would be sent).
  • Screen (NotificationEmailPreferencesComponent, NotificationDigestPreviewDialogComponent). Material 3 settings page (840 px, outlined groups, one line of help each): "Email for each kind of notification" (m3e-angular connected button group per category, a check on the selected button), "Daily digest" (until a category uses the digest it shows one line instead), "Muted projects" (names only; "A project you can no longer open" when the server withholds the name), and Save preferences. The digest schedule is one row of two outlined fields (stacked below 600 px): Time zone, a mat-autocomplete over the browser's IANA list (Intl.supportedValuesOf('timeZone'), plus UTC) whose options show each zone's current offset ("America/New_York (GMT−05:00)", timeZoneName: 'longOffset'), filtered on the ID with case, spaces and underscores ignored; a typed zone not in the list (or the saved one) is an error and Save is refused. Send time is Angular Material's timepicker (MatTimepickerModule, 30-minute options, typing works), 24-hour, on a component-level native date adapter: the app-wide date-fns adapter reads an empty or unreadable time as "now", so it could never refuse one. The picker's Date becomes the stored HH:mm at save (notification-digest-time.ts). When the saved zone is still the server default UTC and no category has ever been daily, the browser's zone is pre-filled (unsaved until Save preferences); a chosen zone is never replaced. Below the fields an aria-live line, "Next digest: Wednesday 4 November at 08:00 (Europe/London)", is computed from the unsaved zone and time with the server's rules (a skipped local time moves to the first valid minute, a repeated one takes its first occurrence), and is empty while either field is invalid. Storage and the API are unchanged (IANA timeZoneId, local dailySendTime). The preview is an adaptive SyrfDialogService dialog; the email is the srcdoc of an iframe sandboxed with allow-same-origin allow-popups allow-popups-to-escape-sandbox (no scripts, forms or top navigation), set by property. The chosen button is lightly tinted, with its check (the UI standards' CTL-1, D14 c): a chosen Immediately or Daily digest takes the primary-container tint, so the kinds that send email stand out, and a chosen Off stays neutral (surface container highest; SyRF's secondary container is too close to grey to read as colour). Chris asked for this on 29 October and again on 3 November. m3e-angular has no token for the selected outlined container yet (its button/tokens/_common.scss sets background-color: var(--mat-sys-inverse-surface) directly, and outline-container-color is commented out as not yet available), so the component's styles outrank that rule; they move to the token when the library adds one (m3e-angular#110).
  • Tests. API: NotificationDigestRendererTests (6), NotificationDigestPreviewTests (7, replica set: byte-for-byte equal to the sent message, writes nothing, every skipped state absent, nothing eligible, authority outage, a prepared digest shown with its time and sent as shown, a prepared digest with nothing left to send), NotificationEmailPreferencesControllerTests (gating of the preview, category options, a later kind, muted names), the L5 processor and worker tests on the shared renderer, SmtpEmailServiceTests (multipart). Core: NotificationDigestScheduleTests (next send time). Web: preferences component spec (16: the read-only fields never sent back; the form following categories that change between load and save or mute) and preview dialog spec (6: a prepared digest, with and without showable content). E2E: notification-email.spec.ts on the new controls. Owner screens: handover/n5-evidence/index.md.
  • Not in N5. The project content policy (M12) that would let a line carry the headline; a "what if" example preview when no category uses the digest (the prototype's fallback); muting from the in-app notice.

Feature release notices (28 October 2026, notifications restart N7)

#4669 adds the featureRelease notice: when a feature release is confirmed and its What's New entry is published, the people the feature is available to get one saved notice in the inbox (programme piece 6G.N; restart plan §3 N7; Q3 decided A, MaterialiseWhatsNew). Reading and dismissing it are shared with What's New, so nobody is told about the same release twice.

Flag decision: flagged. New featureReleaseNotices (feature feature-release-notices, Release, Developing, What's New quiet, SignedIn, default off everywhere, productionEligible: false), declaring its dependency on notificationInbox. Every consumer decides both in one DecideAll capture. It also needs the deployment settings RuntimeFeatureReleases:Enabled (availability and What's New) and RuntimeFeatureReleases:OutboxDispatch:Enabled (the handler only runs when the outbox dispatches). No migration: every read is by _id or uses an existing index (fan-out 0031, release events 0014, inbox 0023-0026).

Acceptance criteria (each with how it is verified).

  1. One notice per available person; a replay makes none. A confirmed, non-quiet release's What's New row records exactly one fan-out intent, keyed on the release identity (announcement ID and revision); the expander then writes one featureRelease row per person the feature is available to. Delivering the outbox row again (a crash between the handler and completion) records nothing new and writes no row. Verify: FeatureReleaseNoticeTests (replica set: record, re-deliver, expand twice, count rows); RuntimeReleaseWhatsNewHandlerTests (the handler still completes, and re-delivery is a no-op).
  2. A quiet release creates none. A quiet Apply queues no MaterialiseWhatsNew row (unchanged), so the handler never runs and nothing is recorded. Verify: FeatureReleaseNoticeTests (a quiet release's outbox has no What's New row, and recording is reached only from that row); the runtime-fleet harness counts.
  3. Permission, then targeting. Recipients are the active people for whom the 6E.2 availability evaluator says the feature is available without a project, after permission (SignedIn only, see below). A person outside the targeting, or without the permission, gets none. Verify: FeatureReleaseNoticeTests (a deactivated person, a feature whose permission needs a project, a capability that is off: none).
  4. Dismissed on What's New: none; dismissed in the inbox: dismissed on What's New. Expansion skips anyone whose discovery row is dismissed for the announcement. Dismissing (or Clear all) in the inbox records dismissed (and seen) through IRuntimeFeatureDiscoveryStore.RecordAsync. A notice whose announcement the reader later hides on What's New stays readable but no longer counts as unread ("Hidden on What's New"). Verify: FeatureReleaseNoticeTests and NotificationsController tests.
  5. Seen is shared both ways, once. Opening (mark read) or Mark all as read records seen for the announcement on What's New. In the other direction (review of #4669), a person who has already seen the entry on What's New gets their notice already read, and seeing it on What's New after the notice exists marks the notice read; neither counts. After that each surface keeps its own state: Mark as unread in Notifications is respected, because What's New records seen once per announcement and never re-applies it. Verify: FeatureReleaseNoticeTests (seen on What's New first, then the notice; the notice first, then What's New; mark unread respected; flag off writes nothing) and the controller tests (other kinds never touch What's New).
  6. A stop halts expansion; existing notices become "no longer available". Expansion completes with no more rows once the release has an AnnouncementCancelled event or its capability is stopped. Existing notices read as unavailable (kind only, listed under Dismissed as dismissed automatically) and leave the count. Verify: FeatureReleaseNoticeTests (stop mid-expansion; resolver after the stop).
  7. Off, on, off, on: no duplicates. Off: the handler records nothing, a pending intent waits (deferred, not failed) and resumes when on, and saved notices read as unavailable without any write. Rows are keyed on the source identity, so a release seen in every state yields at most one row per person. Verify: FeatureReleaseNoticeTests (releases recorded in each state, the flag toggled between expansions).
  8. Owner screen acceptance. The release card (Material 3, light and dark) in the bell panel and on the Notifications page, at 1440 px and 390 px. Verify: owner review of the screenshots listed below.

Limits of this slice (MVP boundary). Only features whose permission is SignedIn reach anyone: notices mirror the account-level What's New, which omits features behind a project permission (D25 A), and an application permission cannot be decided without the person's own request. Such features record an intent that completes with no rows. "Try it" needs a destination the registry does not have: the card offers it only where the feature has a walkthrough that can be started without a project; "Read the guide" appears when the entry names a guide page.

As built. The permanent record is ADR-026 decision 18.

  • Kind (FeatureReleaseNotifications, Project Management core): featureRelease, label "New feature", category none, scope Global, inline limit 1, admitted by featureReleaseNotices. The occurrence identity is SHA-256 over feature-release:v1|{announcementId}|{revision}; the row's Source is featureRelease with the release ID and the additive FeatureId, AnnouncementId and AnnouncementRevision (no project).
  • Recording (RuntimeReleaseWhatsNewHandler, IRuntimeReleaseWhatsNewNotices, FeatureReleaseNoticeRecorder). The handler validates its item as before, then asks the host's recorder, which writes the intent insert-only outside any transaction (NotificationFanOutStore.RecordAsync without a session; a lost insert race is a no-op). The dispatcher registration builds the handler with the recorder through an explicit factory. Recording failures: the attempt fails while the row has used fewer than three attempts (NoticeAttempts), then the row completes without notices and the failure is logged at Error with IDs only.
  • Audience (FeatureReleaseAudienceSelector, type featureReleaseAudience). Candidates are IInvestigatorRepository.GetActiveIdsAsync (not deactivated, _id order, IDs only); the cursor is the last ID examined. Each candidate is kept when FeatureReleaseAudience.Receives: the evaluator's no-project answer for the person is available for this announcement and not dismissed; one whose answer says seen gets the row already read (NotificationRecipientPage.ReadRecipients, written as ReadAtUtc on insert). Gate closed: NotificationRecipientPage.Defer (the expander's new Deferred outcome gives the lease back for DeferDelay, one minute, with no failure). Not ready (review question 5): until the release's What's New row is Done in the release read, expansion defers the same way, so no notice is written while its resolver would still call it "no longer available"; a release superseded by a newer announcement ends with no rows. Stop: no more rows. An undecidable person (an availability answer that is not Current; review finding 2): the rows before them are committed, the cursor records them and their attempt count ({lastExamined}~{person}:{attempts}), and the intent is given back after the expander's back-off for that attempt (15 s doubling; NotificationRecipientPage.RetryAfter, no failure counted), so a transient fault retries. The cursor is {lastExamined}[~{person}:{attempts}][@{lastCurrent}]: it also carries the last person decided Current (review of 3836594015); a cursor written before that (no @ part) is still read, with no last decided person. At MaxUndecidedAttempts (8, about half an hour) up to ProbeCount (3) other people are asked first: the last person decided Current (this pass or an earlier one, from the cursor), then the candidates after this person (reading further when the page ends). If any answers Current, the person alone is undecidable: they are skipped, logged at Error with IDs only, and counted in the intent's SkippedRecipients (the latest 100 IDs in SkippedRecipientIds, written with the batch); everyone else carries on. Only if every one asked is undecidable too is the fault system-wide (a missing bundle, the discovery store down): nobody is skipped, the person keeps being retried at the ceiling's back-off, and each retry is logged at Warning (review of be0f05142d, finding 2). With nobody else to ask (a one-person environment) the person is skipped. Because someone already decided Current is always asked first, a run of individually undecidable people of any length after a decidable person is skipped one by one (tested with five). The one case that still depends on the people after the run is a run at the very start of the list, before anyone has been decided: it is treated as system-wide only if the next three people are undecidable too. A stuck wait is visible (finding 1): an intent still waiting for its What's New row (the release read failed, the release is not found, or its row never reaches Done) is logged at Warning with its ID, the release ID and the reason once it has waited an hour since RecordedAtUtc, then at most once per further hour per API replica (StuckWarningAfter; the window is held in memory, so a restart may log one window again, and it is forgotten on every path that ends or readies the intent: not reached, stopped, cancelled, superseded, What's New done).
  • Reading (FeatureReleaseNotificationResolver). One availability evaluation per feature and one cancellation read per announcement per request (NotificationReadContext.OnceAsync). The view: headline = the entry's title, key fact "New in SyRF", one markdown block with the entry's body, no primary action, feature {id, guidePath}, and workflow state hiddenOnWhatsNew when the reader hid the entry on What's New (concluded: not counted, not under Unread; not answered, so it stays under All and in the bell panel, never under Resolved).
  • Shared state (FeatureReleaseDiscoveryWriter). Inbox to What's New (ShareAsync, called by NotificationsController after Mark read, Mark all as read, Dismiss and Clear all): nothing is read while the gate is closed; a single notice is read by ID and checked by kind before What's New is touched; several are read with IInboxNotificationRepository.GetManyOfKindAsync (one query on _id); then each release notice's announcement is recorded through the discovery store with What's New's own guard. What's New to the inbox (MarkNoticeReadAsync, called by AccountAnnouncementsController after it records seen): marks the person's notice for that announcement read, by its derived row ID (no notice: nothing). Review finding 3: both run after the write the person asked for succeeded and never fail it (tested for both directions: a throwing lookup in the inbox, a throwing mark-read under What's New's PUT, which still answers 204); a failed lookup is logged at Warning and a failed cross-write at Error, IDs only. Known window: a person who sees the entry on What's New in the seconds between their availability being read for a batch and that batch committing gets the notice unread (What's New's write found no row yet); it is read the next time they open it.
  • Wire. NotificationViewDto.feature (NotificationFeatureDto {id, guidePath}) and the markdown block type are additive; swagger.json and the generated client are regenerated. Older clients skip the unknown block and ignore the field (C16 floor).
  • Web. The release notice uses N4's decided card unchanged on all four surfaces (page and bell panel, desktop and phone; Chris, 2 November 2026: "N4, N5 and N7 should match the layouts decided in the question options artifact"): the time at the top right on the "ago" ladder with the full date as its tooltip, icon-only corner buttons, the closed-row hover and focus swap, the calm phone row and one right margin. A release has no project, so the open card has no context line and Notice details (the Details chip at the start of the action row) shows History only (Sent, Read). The card renders markdown through MarkdownPipe in untrusted mode (no image, raw HTML or remote fetch), in the body's type (BodyMedium, on-surface) with no outer margins, so the body's own gap spaces it, and 8 px between its paragraphs. The actions sit at the right in Material order with exactly one filled button: Read the guide (outlined, new tab, UserGuideUrlPipe) then Try it (filled) when WalkthroughLauncherService.canReplay(featureId, null), which opens the walkthrough's page and replays it; when the guide is the only button it is filled. The kind's icon is auto_awesome (the prototype's "spark", the releases family) in the primary accent, on every surface; a hidden release shows the chip "Hidden on What's New". The Filters panel gains a New features kind chip while featureReleaseNotices is on.
  • Harness. e2e/harness/runtime-fleet/release-lifecycle.mjs now also counts the release's notice intents and rows and requires zero (the fleet runs with featureReleaseNotices and notificationInbox off), so a real What's New delivery through the real handler proves the flag-off path. The fleet scenario itself was not run for this change.
  • Tests. API (replica set, FeatureReleaseNoticeTests): acceptance 1 to 7, seen shared both ways with mark unread respected, the wait for What's New and its hourly stuck warning, an undecidable person retried then skipped while everyone else is notified, a system-wide fault that skips nobody, What's New's PUT surviving a failed mark-read, and the handler's retry bound; NotificationRecipientStateControllerTests (a non-release read or dismiss never touches What's New and never fails when the release-notice lookup throws); NotificationViewTests (the release disclosure fixture, four states), NotificationKindRegistryTests, NotificationKindHostRegistrationTests (the real container: six kinds, three selectors, the recorder), RuntimeReleaseOutboxDispatchRegistrationTests (the registered handler records through the host's recorder). Project Management: NotificationFanOutTests (identity, intent, defer page), NotificationFanOutExpanderTests (deferral resumes from its cursor; a page inserts read rows, records skipped candidates and continues after a delay; recording outside a transaction is insert-only), GravatarBackfillSelectionTests (active ID paging). Web: card spec (Markdown sanitising and rhythm, Read the guide and Try it in Material order with one filled button, the kind icon, no context line and History-only Notice details on all four surfaces, hidden on What's New, unavailable), inbox page spec (the New features chip follows the flag). Mongo common: the catalog digest pin, the schema-one goldens, the global-decisions golden and the adoption key set move with the new catalogued key.
  • Not in N7. Release notices for features behind a project or application permission (owner decision needed: a per-project audience); a registry destination for "Try it" where a feature has no walkthrough (no feature has a walkthrough that starts without a project yet, so the owner screens cannot show Try it; the card spec covers it). N7 merges N4's branch (#4651) for the shared card; once #4651 merges, main is merged into N7 so its diff shrinks back to N7's own work.

Clear all, option B: everything except what waits for your answer (3 November 2026)

#4726. Owner decision, 3 November 2026: option B on the "Clearing Notifications" page, as recommended; read notices stay in the panel (no change to which notices the panel lists). The permanent record is ADR-026 decision 21.

Behaviour. Clear all (the bell panel's menu and the Notifications page's menu) dismisses every notice of the reader that is in the set they are looking at (the panel: All; the page: the current tab, All, Unread or Resolved, narrowed by the Kind and Project filters), was saved no later than the list they are looking at was read (and, on Resolved, was resolved no later), is not already dismissed, and is not waiting for their answer (today a project invitation they can still accept or decline; later, reconciliation questions). "Waiting" is decided on the server at the time of the clear, by the same kind resolvers that shape the list. Read or unread, loaded on the client or not, all count. Clear all is offered whenever the list shows a notice, even when every notice shown waits (older ones not loaded may not), except under Dismissed and while a search narrows the page (#4731 lifts the second: with a search it is Clear matching). The snackbar says "14 notices cleared." (singular "1 notice cleared."), adding "1 invitation still waits for your answer." or "2 invitations still wait for your answer." when something waits; when the server finds nothing to clear it says "Nothing to clear." (with the waiting sentence when one applies: "Nothing to clear. 1 invitation still waits for your answer."). Undo restores exactly the notices that clear dismissed. A second Clear all while one is running (a double click, or the panel and the page at once) is ignored.

Flag decision: no new flag. It replaces the existing Clear all inside the inbox, so it stays behind notificationInbox like the rest of the inbox (the web entry points follow the flag; the API routes follow the read admission, M7). No data migration and no new index: the candidates read is the list's own query plus upper bounds on the save, creation and (on Resolved) resolve times, served by 0023's range. Two additive, nullable elements (StoredAtUtc on insert, DismissBatchId on a clear, removed by restore) are never backfilled; a row without StoredAtUtc goes by CreatedAtUtc, and an older build keeps both in its extra elements, so off, on, off, on works with either shape.

Acceptance criteria (each with how it is verified).

  1. The rule-based clear. POST api/account/notifications/cleared with {asOfUtc, status, kinds?, projectId?} dismisses the caller's own, not dismissed notices in that set saved at or before asOfUtc (rows saved before save times were recorded: created at or before it), except those waiting for the caller's answer, and returns {dismissed, ids, waiting, waitingByKind?} (waiting = how many matching notices were kept because they wait). Verify: NotificationRecipientStateControllerTests (ClearDismissesEveryNoticeInTheSet_…).
  2. A waiting invitation is kept. A pending invitation the caller can still answer is never dismissed; the same invitation once answered (accepted or declined, resolution recorded or not) or resolved is cleared like any other notice (under Resolved), and one no longer answerable (withdrawn) is, like any notice the caller can no longer open, on no tab but Dismissed. Verify: controller tests with a positive control in each (a plain notice in the same request is dismissed).
  3. Exclusions. Another recipient's notices, notices saved after asOfUtc even when dated before it (a fan-out saved after a retry; a legacy row without a save time goes by its creation time), on Resolved notices resolved after asOfUtc, notices newer than asOfUtc (and an asOfUtc in the future is capped at now), dismissed notices (which keep their first dismissal and are not in ids), and notices outside the tab, the kinds or the project are untouched. Verify: controller tests, one per exclusion, each with a positive control (ANoticeSavedAfterTheListWasReadIsKeptEvenWhenDatedBefore, OnResolvedANoticeResolvedAfterTheListWasReadIsKept); InboxRecipientStateTests (ClearCandidatesAreExactlyTheScopedRows, ClearCandidatesGoBySaveTime_LegacyRowsByCreationTime_ResolvedRowsByResolveTime) for the stored half; InboxNotificationWriterTests.EveryInsertStampsItsSaveTimeOnceAndAReplayKeepsIt.
  4. Idempotent. A second identical call dismisses nothing and returns dismissed: 0. Verify: controller test.
  5. Bad input is 400 before admission. A missing asOfUtc, an unknown status, dismissed, malformed kinds (an empty list, a blank or non-alphanumeric name, more than 20), an empty project ID or a malformed cursor gets 400 before the caller or any row is read, like the other inbox routes. Verify: controller theory.
  6. What's New. Dismissed feature release notices are dismissed on What's New too, as Clear all did (N7); only the release notices among the dismissed are looked up. Verify: FeatureReleaseNoticeTests (TheRuleBasedClearAllDismissesReleaseNoticesOnWhatsNew, replica set).
  7. A ceiling per call. One call dismisses at most 1,000 notices (MaxClearedPerCall) and examines at most 2,000 rows (MaxExaminedPerClear); with matching notices left it answers more: true and a nextCursor. The client repeats the call (same asOfUtc, the cursor) until more is false (at most 50 calls), then shows one snackbar for the total. Verify: controller tests at the ceiling and at the examine limit; service spec.
  8. Undo. Undo restores exactly the returned IDs in batches of 100 through POST api/account/notifications/restored {ids} (1-100 IDs, the caller's own only, mirroring the first Clear all endpoint). Two clears at the same instant report disjoint IDs (each call marks its rows), and a second Clear all while one runs sends nothing. Verify: controller test, InboxRecipientStateTests (TwoClearsAtTheSameInstantReportDisjointIds_…), service spec (a second click sends no request) and notice-actions specs.
  9. Both surfaces. The panel and the page both use the new call; the page passes its tab and filters (the Kind filter as its group's concrete kinds); the panel passes All and no filters. Both offer Clear all whenever they show a notice (the server decides what can go). The snackbar text is the Behaviour section's, exactly. Verify: service, notice-actions, page and panel specs.
  10. The first endpoint stays. POST api/account/notifications/dismissed {ids} stays for older clients during a rollout and is marked obsolete above. Verify: its existing tests still pass.
  11. Flag, data, index. Everything stays behind notificationInbox like the rest of the inbox; no data migration; no new index (the read is served by 0023). Verify: review; TheClearCandidatesAreServedByMigration0023sIndex (no collection scan).
  12. Docs. This section, the endpoint rows above, ADR-026 decision 21 and the user guide (what Clear all does and what it keeps). Verify: validate-docs.
  13. Screens. The snackbar on the panel and on the page, light and dark, desktop and phone, captured on the local e2e stack for owner acceptance. Verify: gallery.

MVP boundary. In: the server rule-based clear, the client wiring on both surfaces, the snackbar text, Undo, docs, tests, screens. Out (follow-ups): a confirmation dialog, clearing more than 50 calls' worth (50,000 notices) in one click (the snackbar then says the rest could not be cleared), any change to which notices the panel lists.

As built.

  • Save time (review of 06f5c84263). CreatedAtUtc is the event's time, and the recorded fan-out (N6: reviewAccessGranted to many recipients, featureRelease) saves rows later, sometimes after a retry, so it cannot bound a clear. Every insert through InboxNotificationWriter now stamps StoredAtUtc from the saving host's clock ($setOnInsert, never changed), and the list returns listedAtUtc, the API's clock taken before the page's first read. The client sends its current list's first page's listedAtUtc as asOfUtc (reset by a refresh or a tab change, never moved by "Show older notices"), so a row saved after it is kept even if a later page shows it. Rows saved before this change have no StoredAtUtc and go by CreatedAtUtc. Limits: the stamp is taken when the insert runs inside its transaction, so a row whose transaction commits after a list read that began before the commit (a window as long as that transaction) can still be cleared; the save and list times come from different hosts' clocks (NTP-synchronised); and a notice restored or marked unread elsewhere after the read keeps its old save time.
  • API (NotificationsController.Clear). Input is checked first (status parsed as the list's, so an empty status is All); then the read admission. asOfUtc is read as UTC (an offset is converted, an unspecified kind is taken as UTC) and capped at the server's now. The repository's ClearCandidatesAsync returns the caller's not dismissed rows saved and created at or before asOfUtc (and, on Resolved, not resolved after it), with the tab's stored filter (Unread's; Resolved reads All, as the list does), the kinds and the project, newest first after the cursor, in batches of 100. Each row is shaped by its kind's resolver under the request's memo with the list's batched prefetch, and kept for dismissal when the list would show it on that tab (Lists) and, with a project filter, the view's project is that project (the web filters on the view's project). It is counted as waiting instead when WaitsForAnswer: available, not resolved and workflow state pending. The chosen rows are dismissed in one write (DismissNamedAsync), which marks them with a fresh DismissBatchId and returns exactly the IDs it dismissed (when another request dismissed one meanwhile, the rows carrying this call's marker are read back; a millisecond timestamp could be shared by two clears), so Undo never restores a notice this clear did not dismiss. Every restore removes the marker with the dismissal. nextCursor is the last row examined, as the list's.
  • Web (NotificationInboxService.clearAll, restoreMany, pageAsOfUtc, panelAsOfUtc; NotificationNoticeActions.clearAll). asOfUtc is the listedAtUtc of the first page of the list the reader is looking at (page or panel; the reader's clock never moves it); against a server that sends none, the newest notice's createdAtUtc (listedAsOf). Each call's dismissed rows leave the lists as it is confirmed; the lists are re-read at the end. The service ignores a Clear all while one is running (clearing), for the page and the panel alike. Clear all is offered whenever the list shows a notice (the server decides); on the page it is not offered while a search narrows the list, because the server cannot apply the search and would clear notices the search hides. (#4731 moves the search to the server, so Clear matching is offered during a search.)
  • Tests. API: NotificationRecipientStateControllerTests (criteria 1 to 8 and the flag-off admission), FeatureReleaseNoticeTests (criterion 6), InboxRecipientStateTests (the candidates filter and its 0023 plan, DismissNamedAsync exactness, RestoreManyAsync scoping, the save-time bound with legacy rows and the resolve-time bound, two clears at the same instant), InboxNotificationWriterTests (the insert-only save time), the goldens of the four new DTOs and NotificationPageDto's listedAtUtc. Web: service (scope on the wire, repeat with the cursor, the call bound, Undo in batches, the first page's listedAtUtc kept by "Show older notices" and moved by a refresh, the panel's, the running-clear guard, the listedAsOf fallback), notice actions (every sentence), page and panel specs (offered when every notice shown waits, "Nothing to clear." with the waiting sentence).

Search and filters on the server, and Clear matching (3 November 2026)

#4736, issue #4731. Owner decision, 3 November 2026: Clear covers everything the search and filters match, with an exact one-to-one match between what the page shows and what the server clears. The label is "Clear matching" when a search or filter narrows the list, and "Clear all" otherwise. It follows option B (#4726) and comes before multi-select (#4732). The permanent record is ADR-026 decision 22.

Before. The search and the Kind and Project filters ran in the browser, over the pages already loaded (25 a page); the search matched the lower-cased query in the lower-cased "headline keyFact label" of each server view (label "Notification" without a view); the Project filter's choices were the loaded notices' projects. Clear all could not apply the search, so it was not offered during one.

One rule, on the server. NotificationQuery.Matches(item, shaped, query) is the only match rule, used by the list, the match count and Clear matching:

  • status: the tab, as Lists decides it;
  • kinds: the stored kind is one of them;
  • projectId: the stored project and the view's project are both it (as option B's project filter);
  • q: trimmed; empty is no search. Otherwise it matches when it appears, ignoring case (invariant lower case, ordinal comparison), in NotificationQuery.SearchText: the view's headline, key fact and label, empty parts left out, joined by spaces, with the "Notification" fallback, exactly as the browser built it. Nothing else is searched (not the body, the old typed title or the project name). There is no accent folding: "u" does not find "ü".

The browser no longer filters by search, kind or project: the page shows exactly the server's pages, so what is shown and what is cleared can never differ.

Behaviour.

  • GET api/account/notifications takes q (up to 200 characters), kinds (1 to 20 stored kind names) and projectId, besides status, cursor and limit, and returns the matching notices newest first, page by page, with the same cursor. A search needs each row shaped (headlines and key facts are worked out at read time, not stored), so with a search or filter a page examines rows until it has limit matches or has examined 2,000 (MaxExaminedPerSearchPage), then returns what it has with hasMore and a cursor, as Clear all does. Without the new parameters the list is exactly as before (the same repository read, 500 rows a page). The page carries what it applied: q (trimmed), kinds (without repeats) and projectId, each null for none.
  • POST cleared takes q too and decides each row with the same Matches, so Clear matching dismisses exactly what the list shows for the same tab, search, kinds and project. It still keeps what waits for an answer and still has Undo (option B).
  • GET api/account/notifications/count takes the list's status, q, kinds and projectId and returns {count, capped}: exact up to 999 (MatchCountCap), and 999 with capped past it, resolving one past the cap at most. One request examines at most 10,000 rows (MaxExaminedPerCount); with more left it returns the count so far as capped (at least that many). #4732 will use it for "Select all N matching".
  • GET api/account/notifications/projects?status= lists the Project filter's choices: the projects (ID and name, by name) of the caller's notices on that tab that they can open now, at most 100 (MaxProjectChoices), the most recent first when there are more, with more. A project is offered only when choosing it lists at least one notice (the stored and the shown project are both it), so a project the caller can no longer open is never offered, even under Dismissed. Rows are read newest first (at most 10,000 a request); once a project is offered its other rows are passed over without resolving them.
  • Web. The search field sends q after a 300 ms pause (at once when emptied); a Kind or Project choice applies at once. The field is limited to 200 characters (INBOX_SEARCH_MAX_LENGTH, kept equal to NotificationQuery.MaxQueryLength by NotificationQueryTests.TheWebSearchLimitIsTheServers), and every search sent is trimmed and cut to it (never through a surrogate pair), so the search box can never get the 400 that would empty the list behind an error. Each change reads the list again from its first page; "Show older notices" pages it as before. While a search or filter is on, a line under the search field says "N results" ("1 result", "999+ results" past the cap). The menu item reads "Clear matching" with a search or filter and "Clear all" without; Clear matching is offered during a search or filter once the shown page echoes it (see the flag decision), and sends the search, kinds and project the shown list was read with (not a search still being typed). "N results" is read when the query changes (a new tab is a new query) and after a write that changed the list (a dismiss, a restore, a clear, an answered invitation, or a read or unread toggle on Unread), never on a plain tick or hint: each count can resolve up to 10,000 rows. A count that failed is read again on the next tick (never while a count read is in flight), only while the query narrows a list the server applied it to. The Project chips come from projects for the tab when the Filters panel opens and, while it is open, on each listChanged: a tab change, a list-changing write, or a tick or hint whose re-read changed the notices shown or the unread count, which every list read carries (so a notice arriving outside the filters, in another project, still counts while the unread count is below its cap of 99; above the cap such an arrival waits for the next write, tab change or reopening of the panel; a tick that changed nothing reads nothing, since each read can resolve up to 10,000 rows). So a project with nothing left is not offered. A new query closes an open card and drops a deep-linked notice shown first. Leaving the page forgets the query. The bell panel is unchanged: it never sends a query.

Flag decision: no new flag. It changes the inbox's own search, filters and Clear all, so it stays behind notificationInbox like the rest of the inbox (the web entry points follow the flag; the API routes follow the read admission, M7). No data migration and no index change: the Kind and Project filters narrow the list's read on 0023's range (ListFilter's kinds and project, as Clear all's), and the search is decided on the shaped rows. Additive and rollback-safe: an older client calling the list without the new parameters gets exactly today's behaviour.

Rollout with an older server. A server from before #4731 (the web deployed first, or the API rolled back) ignores q, kinds and projectId on the list and lists every notice, has no count or projects, and its Clear ignores q (it applies kinds and project, from option B). The web therefore trusts a search or filter only when the page it shows echoes it back (q, kinds, projectId; the kinds in any order). Otherwise, chosen honestly over hiding the chips: the chips stay as the reader set them, one line under the search field says "Search and filters need the latest server, so every notice is shown.", nothing is offered to clear (Clear matching is greyed out, since what is shown is not what was asked for), and "N results" is left out (the count read fails). Once the API is updated the next read applies the query and the line goes.

Acceptance criteria (each with how it is verified).

  1. List and clear share one match rule. For every combination of status, q, kinds and project, the list (all its pages) and the set Clear matching dismisses are identical, apart from notices waiting for an answer. Verify: NotificationRecipientStateControllerTests.TheListAndClearMatchingShareOneMatchRule (a mixed inbox and a table of 18 queries, read two at a time; the count equals the list's length; known answers as positive controls).
  2. Search matches as described. Verify: NotificationQueryTests (each field with a positive control, trimming, a non-ASCII case, empty and blank queries, the fallback label, nothing else searched).
  3. The browser no longer filters. Verify: page spec ("shows every notice the server returns, even one the old client-side search would have hidden").
  4. Paging with search stops at limit matches or 2,000 examined rows and continues by cursor, with no notice skipped or repeated. Verify: APageWithASearchStopsAtItsExamineBoundOrItsLimit_AndContinuesByCursor, and the table test's two-at-a-time paging.
  5. Project choices come from the server, list only openable notices' projects on the tab, and never show a raw ID. Verify: TheProjectChoicesAreTheOpenableProjectsOnTheTab_ByName, TheProjectChoicesAreCappedAt100; page spec.
  6. The count is the match count, capped at 999. Verify: TheMatchCountIsExactUpTo999AndThenCapped, TheMatchCountStopsAtItsExamineBound.
  7. Label. "Clear matching" with any search or filter, "Clear all" without; offered during a search; the snackbar and Undo are option B's. Verify: page spec.
  8. Bad input is 400 before admission: q over 200 characters, bad kinds, an empty project ID, a bad cursor or an unknown tab, on the list, the count, the project choices and Clear. Verify: ABadQueryIsRefusedBeforeAnyRead, NotificationQueryTests.TryCreateRefusesBadInput.
  9. Rollout safety. Behind notificationInbox; no migration and no index change; the old list call without the new parameters behaves exactly as before. Verify: the existing list, count and Clear tests unchanged and passing; WithoutTheNewParametersTheListReadsExactlyAsBefore, WithTheFlagOffTheCountAndProjectChoicesFollowTheReadAdmission, APageSaysWhichSearchKindsAndProjectItApplied, the page spec "with a server that did not apply the search or a filter, says every notice is shown and offers no Clear" and the service spec "says the query was not applied when the server echoes none of it" (an older server); InboxRecipientStateTests.TheNarrowedListIsExactlyTheFilteredRows_ServedByMigration0023sIndex (no collection scan).
  10. Docs. This section, the endpoint rows above, ADR-026 decision 22 and the user guide. Verify: validate-docs.
  11. Screens for owner acceptance: a search with results, the count line, the menu showing Clear matching and the snackbar after it, light and dark, desktop and phone. Verify: gallery.

  12. Mark matching as read (#4736; Chris, 4 November 2026: "Why is it mark all as read and not 'mark matching as read' when a filter/query is applied?"). With a search or filter the menu item reads "Mark matching as read" and marks read exactly the unread notices the list shows for the same tab, search, kinds and project, loaded or not, and nothing else (not another person's); without one it is "Mark all as read" and sends the shown IDs exactly as before, answer included. Verify: MarkMatchingReadMarksExactlyTheUnreadNoticesTheListShows (a table of 12 queries over the mixed inbox, every tab), MarkMatchingReadPagesAtTheCeilingAndTheNextCallMarksTheRest, MarkMatchingReadLeavesNoticesSavedAfterTheListUnread_AndCapsAsOfAtNow, WithoutASearchOrFilterMarkAllReadIsExactlyAsBefore_AndAnUnclearRequestIsRefused, WithTheFlagOffMarkMatchingReadFollowsTheReadAdmission, ABadQueryIsRefusedBeforeAnyRead (route read), the NotificationMarkAllReadResultDto golden ({"read":0} unchanged); service spec "Mark matching as read sends the query and repeats…"; page spec "reads Mark matching as read…"; E2E notification-inbox.spec.ts "Mark matching as read…".

MVP boundary. In: server-side q, kinds and project on the list and Clear; the count and project choices endpoints; the web on server queries; the label; Mark matching as read; docs; tests; screens. Out: multi-select (#4732), saved searches, highlighting matches in the text, accent-insensitive search.

Mark matching as read (4 November 2026)

Chris, on the #4736 screens: "Why is it mark all as read and not 'mark matching as read' when a filter/query is applied?" Mark all as read marked every shown unread notice whatever the search, against this section's rule that what is shown and what is changed agree.

  • API. POST api/account/notifications/read keeps {ids} (1 to 100; exactly as before, and its answer is still {read}: more and nextCursor are left out of it, not sent as null). Sent instead with no ids and the list's query, {asOfUtc, status?, q?, kinds?, projectId?, cursor?}, with at least one of q, kinds and projectId, it reads the list's own candidates in list order and marks read the unread rows NotificationQuery.Matches takes, saved no later than asOfUtc (Clear's bound: the save time, else the creation time, and never dated after it; capped at now), on any tab, Dismissed included. Rows already read, or saved later, are passed over without being resolved. One call marks at most 1,000 (MaxClearedPerCall) and examines at most 2,000 rows (MaxExaminedPerClear), then answers more with a nextCursor, as Clear. ids with a query, a query that narrows nothing, no asOfUtc, an empty kinds list and the list's bad inputs are 400 before admission. Release notices among those marked are shared with What's New (N7), as Mark all as read does.
  • Web. The item reads "Mark matching as read" while a search or filter narrows the list (markReadLabel, as clearLabel). It is offered, like Clear matching, only once the shown page echoes the query, and while a shown notice is unread or older pages remain (the server decides). It sends the tab, the search, kinds and project the shown list was read with and its listedAtUtc (NotificationInboxService.markMatchingRead), repeats while the server says more (at most 50 calls), re-reads the unread count after each call (the bell and the Unread tab) and the list and its "N results" once at the end. The snackbar says it as Mark all as read does ("12 notices marked as read. Nothing was accepted, decided or resolved.", "Nothing to mark as read.", or that the rest could not be marked); like Mark all as read it has no Undo. The bell panel's Mark all as read is unchanged.
  • Flag and rollout. No new flag (inside notificationInbox), no migration, no index. Older clients send {ids} and get exactly today's answer. A server from before this change would refuse a request without ids (400): the page then says the notices could not be marked, and changes nothing.

As built.

  • API (NotificationQuery, NotificationsController). NotificationQuery.TryCreate checks the input of the list, the count, the project choices and Clear (Clear also refuses dismissed and an empty kinds list, as before) before the read admission. The list, the count, the project choices and Clear share one loop (ScanAsync): rows in list order from a batch read, sources prefetched only for rows the call can still use, each row shaped under the request's memo and offered to the caller's rule; the cursor is the last row examined. The list reads with the plain ListAsync unless kinds or a project are given (then the narrowed overload); Clear reads ClearCandidatesAsync as before.
  • Web (NotificationInboxService.setQuery, listQuery, matchCount, projectChoices; NotificationInboxComponent). The service holds the page's query and sends it on every read of the page's list (first page, refresh, older pages) and reads the count with each refresh and after each write while it narrows; an answer for an older query is dropped. The component turns the search box (debounced), the Kind filter (its group's concrete kinds) and the Project filter into one query.
  • Tests. API: NotificationQueryTests, NotificationRecipientStateControllerTests (criteria 1, 4 to 6, 8, 9, Clear matching with a search, the flag-off admission), InboxRecipientStateTests (the narrowed read and its 0023 plan), the goldens of NotificationMatchCountDto, NotificationProjectChoicesDto and NotificationClearDto's q. Web: service spec (the query on every page read and the count, an unchanged query sends nothing, a stale answer dropped, the count after a write, Clear matching's q, the panel never narrowed, the project choices, the query forgotten on leaving), page spec (the 300 ms pause, the server's answer shown as is, the count line and its cap, the label, the search the list was read with, the server's project choices per tab and their failure).
  • Review of #4736 (Opus review at b36cfa0cea). The search box's limit and the cut (page spec "limits the search to the server's longest search…", service spec "never sends a search longer…", TheWebSearchLimitIsTheServers); the count read only on a query change or a list-changing write (service specs "never reads the count on a tick or a hint…", "reads the count after a read on Unread…"); the Project choices read again after the list changes (page spec "reads the project choices again…"); the full echo and the older server line (above). Second round (approval at 9137a4dd99): a tick counts as a list change only when it changed the notices shown (service spec "counts a tick only when it changed the notices shown…"); a failed count is retried on the next tick (service spec "reads a failed count again on the next tick…"); the rollout line is an <output> (an implicit status role); the cut checks the last code point and the kinds compare in ordinal order (SonarCloud). Third round (approval at 54cf7408a5): a tick whose unread count changed counts as a list change (service spec "counts a tick whose unread count changed…"), and a failed count is retried only once it has failed, never while one is in flight (service spec "starts no second count read while one is in flight…").
  • Limits. A long run of rows that do not match can leave a first page empty with more to come (the page then follows up, as for unopenable rows). The project choices read at most 10,000 rows and the count at most 10,000, so a very long history can make either a floor (more, capped).

Private one-to-one reconciliation conversations (28 October 2026, notifications restart L7)

#3965 (private one-to-one threads behind their own flag) re-applied on main by cherry-pick in #4673, per the restart plan's step L7 (handover/notifications-implementation-plan-2026-10-24.md §3, L7 row: "Route per Q1; quick reply in the N3 card | reconciliationConversations | All #3965 tests; reply notifies the counterpart only; first Claude review on a main-targeted head") and owner decision Q1, option B (a new PR from main; #3965's branch and history are kept and the PR is closed with a link after this merges). It closes gap C7 (every participant saw every post), so the L6 pin on studyAttention is lifted here. The slice is described in reconciliation-questions-plan.md; the permanent record is ADR-026 decision 22.

Flag decision. Flagged: conversations are partly built (their screens' Material 3 rework is due before production), they expose review content to other people, and they need a kill switch. reconciliationConversations (#3965's own flag) is a new feature reconciliation-conversations with a declared dependency on studyAttention, which keeps its dependency on notificationInbox. Study attention stays the family switch (questions now, study issues and checked PDFs in L8 and L9); conversations need their own flag as well, so switching study attention on never opens them by itself.

Acceptance criteria (each with its verification).

  1. Own flag, registered on main's tooling, default off. reconciliationConversations is feature reconciliation-conversations in src/charts/syrf-common/feature-definitions.yaml (Release, Developing, What's New quiet, permission SignedIn, added by pnpm feature:new), with a reviewed API-only consumer contract (productionEligible: false) and a declared dependency on studyAttention; it is runtime-manageable (not pinned) and off in every environment. Verify: RuntimeFeatureFlagRegistryControlsTests (ReconciliationConversationsIsRuntimeManageableAndNeedsStudyAttention), the generator tests in validate:generated.
  2. Pin lifted. studyAttention is no longer deployment-managed: a runtime override in a non-production environment switches it on and off, and the Feature flags page offers its toggle. Switching reconciliationConversations on while studyAttention is off is refused with the typed unsatisfied-dependency answer unless the administrator confirms the dependency, and its effective value is off while studyAttention is off. Verify: RuntimeFeatureFlagStudyAttentionRuntimeManagementTests (replaces L6's pinning tests), RuntimeFeatureFlagRegistryControlsTests.
  3. One gate, all three flags. IStudyConversationAdmission decides reconciliationConversations, studyAttention and notificationInbox from one DecideAll capture, then the inbox and conversation index sets. The routes are FeatureGated on reconciliationConversations; the two notice resolvers ask the same gate. With studyAttention on and reconciliationConversations off, every route is 404 before any read and conversation notices are unavailable. Verify: StudyConversationAdmissionTests, StudyConversationsControllerTests (TheRoutesAreFeatureGatedOnReconciliationConversations, ClosedAdmissionAnswersEveryRouteWithoutAnyRead).
  4. One private thread per candidate, all or nothing. Asking N candidates opens N threads (the reconciler and exactly one candidate each) in one transaction: all commit, or every requested thread already exists unchanged (replay), or nothing is written (a changed or partly saved retry conflicts). Verify: StudyConversationTests, StudyConversationRepositoryTests (replica set).
  5. A post notifies the counterpart only. A reconciler's post notifies that thread's candidate; a candidate's reply notifies the reconciler; nobody else gets a notice, and another candidate (or another Reconcile holder) gets 404 on the thread. Verify: StudyConversationRepositoryTests, StudyConversationsControllerTests.
  6. Candidates and stable aliases. Only reviewers whose non-reconciliation session for the study and stage is completed, with current Review authority, are candidates. Each is named by a study-local alias, "Reviewer 7Q" (owner decision, 2 November 2026: never "Candidate 7Q", never a running number): a digit and a letter drawn from a hash of the study and the session, taken in the creation order of all the stage's non-reconciliation sessions, so status, authority and eligibility never rename anyone and a later session can never rename an earlier one; the picker, the reconciler's thread, the exposure record, summaries and notices use the same rule. Verify: StudyConversationParticipantLabelTests, StudyConversationsControllerTests, NotificationViewTests.
  7. A reconciler who reviewed the study is refused. Any non-reconciliation session of theirs on the study, in any stage and status: typed 409 (ReconcilerReviewedStudy) on candidates, create and the reconciler's replies; the web form says why and hides itself. Verify: StudyConversationsControllerTests, reconciliation-question.component.spec.ts, study-conversation.component.spec.ts.
  8. Questioned marker and exposure record. Every Reconcile holder for the study and stage sees, per candidate, only when that candidate was first questioned (by anyone) and whether a reply exists; the read projects the exposure fields only. The exposure record (session status when first questioned, first question and first reply times) is shown to the thread's reconciler only. Verify: StudyConversationRepositoryTests, StudyConversationsControllerTests, questioned-badge.component.spec.ts, stage-reconcile.component.spec.ts.
  9. Read-only context. A candidate's context path and every notice link are the thread (/attention/{id}), never an editable review session; project, stage and study title come back as text. Verify: StudyConversationsControllerTests, study-conversation.component.spec.ts.
  10. Group threads saved before L7 are kept and never read. A conversation saved by the L6 build (several reviewers in one thread) has no single candidate: lists skip it, GET is 404, a reply conflicts, and its notices are unavailable. Nothing rewrites or deletes it. Verify: StudyConversationRepositoryTests (ADocumentSavedBeforeOneToOneThreadsIsKeptButNeverRead), NotificationViewTests.
  11. Quick reply in the card, on the page and in the bell panel. A conversation notice the reader can still reply to carries a reply type action whose target is the conversation. Reply, the last and only filled button, opens the message composer in the card; Send (inside the box) reads the thread's current version and posts through POST api/study-conversations/{id}/replies, so the server re-checks authority and notifies the counterpart only. The shared notice actions (NotificationNoticeActions.reply) serve the page and the panel alike. A refusal is shown in plain words and Open conversation stays. No Reply is offered when the reader cannot reply now (stage inactive, candidate session gone, a reconciler who reviewed the study, 100 posts) or has already replied to this notice. Verify: NotificationViewTests (AConversationNoticeOffersAQuickReplyOnlyWhileTheReaderCanReply), notification-card.component.spec.ts, notification-notice-actions.service.spec.ts, notification-inbox.component.spec.ts, notification-panel.component.spec.ts, the E2E journey.
  12. Off, on, off, on keeps everything. Threads and notices created while on are kept while off (every route 404, notices unavailable and uncounted) and come back when on; a reply saved in the second on state survives the next round trip. Switching studyAttention off has the same effect. Verify: StudyConversationsControllerTests.OnOffOnOffOnKeepsEveryConversationAndAnswersNotFoundWhileOff, NotificationViewTests.WithTheFlagOffAConversationNoticeIsUnavailableAndUncounted_AndOnAgainItIsBack.
  13. No migration, nothing at start-up. The marker read and the list read use L6's 0033 index prefix (project, study, stage), so no new index is needed; #3965's start-up index worker is not carried (ADR-023). Verify: StudyConversationRepositoryTests (explain plans), PmMigrationsTests (the lock file still ends at 0033).
  14. Screens (UI). The notice card (question, details, composer, replied, the reconciler's reply notice and its composer), the conversation page for each participant and the question panel, captured at 1440 and 390 px, light and dark, by e2e/tests/reconciliation-questions.spec.ts with E2E_CAPTURE_SCREENS=1. The question panel stays the plain test-stack screen until the reconcile page's Questions sheet is designed (owner decision Q2, 2 November 2026). Verify: the evidence index and the PR's screenshot list.
  15. End to end. e2e/tests/reconciliation-questions.spec.ts passes on the local stack with reconciliationConversations on: a selected candidate gets the question, an unselected one gets nothing and cannot read the thread, a quick reply from the card reaches the reconciler only. Verify: the run on Bramble listed in the PR.
  16. The decided card (option 1 of the question notice board, owner decision 2 November 2026). A conversation notice on N4's shared card: the headline says who wrote ("Question from the reconciler", "Reviewer 7Q replied"); the detail line and the context line's second line are "Stage · Study"; each message is a speech bubble with its author and the time since it was sent (the reader's own on the end side); there is no separate "reading does not answer" note (the composer's privacy line says it); the composer is a rounded box with Send inside, the privacy line naming the one reader, and Cancel; the Details chip expands About this notice (project, stage, study and the form question, as a nested tree) and History; housekeeping is icon-only, the time is top right, and the actions end on the one right edge, as N4 built them. Verify: NotificationViewTests (AConversationNoticeQuotesItsPostWithItsAuthorAndTimeAndNamesItsStageAndStudy), notification-card.component.spec.ts (a reconciliation conversation on the decided card (L7)), conversation-bubble.component.spec.ts, message-composer.component.spec.ts.
  17. About one form question (owner decision Q3). A question may name one question of the stage's form: the panel offers "About" with "The whole study" first; the server refuses any ID outside the stage's form (400) and stores it on every thread of the question (About, additive). Both participants see its text on the conversation page, the reconciler's list and the notice details; a question later removed from the form reads "A question no longer in the form", never an ID. Verify: StudyConversationsControllerTests.AQuestionMayNameOneQuestionOfTheStagesFormAndAnyOtherIsRefused, NotificationViewTests.ANoticeAboutAFormQuestionNamesItAndSaysWhenItLeftTheForm, reconciliation-question.component.spec.ts, study-conversation.component.spec.ts.
  18. Replying resolves (owner decision Q5). A post resolves, in its own transaction, every notice its author received in the thread and had not resolved (reason completed, transition replied, at the post's time); the other party's notices are untouched and a replay changes nothing. The web marks the notice "You replied" at once and holds it where it is on the page and in the panel until the reader leaves Notifications or closes the panel; then it lives under Resolved. A new message from the other party is a new, unread notice. Verify: StudyConversationRepositoryTests.AReplyResolvesTheNoticesItsAuthorReceivedInTheThreadAndNoOthers, notification-inbox.service.spec.ts, notification-notice-actions.service.spec.ts, the E2E journey.
  19. The reader's last message above a reply (owner decision Q11). A reply notice carries, before the reply, the reader's own latest earlier message (labelled "You", with its time), drawn as the reader's bubble and clamped to three lines with Show all. Verify: NotificationViewTests.TheReconcilersReplyNoticeShowsTheirLastMessageWithItsReadReceiptAboveTheReply, NotificationViewTests.ACandidatesFollowUpNoticeShowsTheirOwnLastReplyWithoutAReceipt, notification-card.component.spec.ts.
  20. Read receipts (owner decision Q13; first real open, once, per review). The reconciler sees "Read 10:22" under each of their messages the candidate has opened, on the conversation page and on their reply notice. A receipt is the first time the candidate really opened that message: its notice's card (page or panel, POST api/study-conversations/{id}/read?notificationId=…), the conversation page (the same route without the ID, which also marks the caller's notices of the thread read), or a reply after it (in the reply's transaction). It is stored on the thread (ReadReceipts, post ID to time, additive and top-level) and written once: a later open keeps the first time, and inbox housekeeping (mark read or unread, Mark all as read, a swipe) never writes or removes one. Threads saved before receipts have none until the next real open (no backfill). The candidate sees no receipts and is told "The reconciler can see when you read this." in the notice details and on the conversation page. Receipts are read from the thread the request already loads once, so a page of reply notices from one thread makes one conversation read. Verify: StudyConversationRepositoryTests (AReceiptIsTheFirstRealOpenAndASecondOpenKeepsIt, MarkAsUnreadKeepsAReceiptAndMarkAllAsReadCreatesNone, AReplySetsTheReceiptOfEveryEarlierPostWithoutOne), StudyConversationTests.ReceiptsCoverTheOtherParticipantsPostsWithoutOneAndReadTimesListThem, StudyConversationsControllerTests (OnlyTheReconcilerSeesReadReceiptsAndTheCandidateIsToldSo, OpeningTheConversationMarksTheCallersNoticesReadRecordsReceiptsAndOutsidersAreRefused, OpeningOneNoticesCardRecordsThatPostsReceiptOnce), NotificationViewTests.SeveralReplyNoticesFromOneThreadMakeOneConversationRead, notification-notice-actions.service.spec.ts, notification-inbox.component.spec.ts, study-conversation.component.spec.ts.
  21. The conversation page, redesigned. /attention/:id reads as the same exchange as the card: a back arrow to Notifications, "Conversation with the reconciler" (or the alias), the context as quiet text (project, "Stage · Study", About), a one-line privacy note, speech bubbles grouped by day, and the message composer pinned at the foot; the reconciler's exposure record sits behind a Details chip and "Open study" leads to their reconciliation page. Material 3 roles only, light and dark, a single column that works at 390 px. Verify: study-conversation.component.spec.ts, the captures.

As built (differences from #3965 and from the restart plan).

  • Commits. #3965's nine commits after its init commit are cherry-picked with -x, in order; each message records how its conflicts were resolved. The flag commit (4f19b56721) is re-implemented instead, because main generates the catalogue from the registry (pnpm feature:new); that commit cites the original SHA.
  • Flag model. #3965's FeatureFlags.ReconciliationConversations POCO property, hand-written catalogue entry and provider mutation are not carried; reads go through IStudyConversationAdmission (three flags, one capture) and the route gate. #3965 made reconciliationConversations depend on notificationInbox only and kept studyAttention for study issues and PDFs; on main it depends on studyAttention (the family switch, which depends on the inbox), so the study attention family has one off switch. With studyAttention on alone, conversations stay closed (#3965's own rule).
  • No start-up index. #3965's StudyConversationIndexWorker built a (Reviewer.SessionId, StageId) index at API start-up; main forbids start-up index builds (ADR-023). The marker read filters project, study and stage first, which is 0033's prefix, so no index or migration is added (explain-plan test). The lock file still ends at 0033.
  • Notices. #3965 changed the deleted NotificationDetailResolver; on main the conversation kinds' StudyConversationNotificationResolver already links both parties to /attention/{id}. The kinds' admission flag becomes reconciliationConversations. The rollback note follows restart plan amendment A2: hidden notices leave the unread count and stay in history.
  • Labels. The one label rule lives in the aggregate (StudyConversation.ReviewerAliases, LabelOf, ParticipantLabel(participant, study)), keeping L6's one-rule review fix; notices use it too. It was "Reviewer N" until the owner's 2 November decision replaced the running number with the study-local alias ("Reviewer 7Q"); the label stored on a thread stays the fallback once its session is gone.
  • Not carried. NotificationConversationFlagWiringTests (it resolved the deleted resolver and the POCO from a container; on main the email and digest processors use the registered resolver, which asks the admission on every call), StudyConversationIndexWorkerTests (no worker), and the checked-PDF and study-issue plan edits (those slices are L8 and L9).
  • Quick reply. New in L7 (restart plan L7 row). NotificationActionDto gains an optional Target; the card shows Reply and the shared message composer (shared/conversation/message-composer): Send is an m3e filled icon button inside a rounded M3 box, Cancel an m3e text button, and the box a native textarea styled with M3 roles, because @camaradesuk/m3e-angular has no text field yet (a library follow-up). One request ID per draft (ConversationReplyRequestId, shared with the conversation page): a retry of the same text after a lost response reuses it, so the API replays the saved reply (the post ID is derived from the conversation and the request ID) instead of posting and notifying twice; edited text or a confirmed send starts a new one. An unknown outcome is reported as "may not have been sent", never "not sent".
  • Placement. StudyConversationApiService lives in core/services/study-conversation/ and the questioned badge in shared/questioned-badge/, because the reconcile page and Notifications use them (no feature-to-feature imports).

Rebuilt on the owner's decided design (3 November 2026). On 29 October Chris asked for significantly more design work on these screens (left-aligned buttons, bare pages); on 2 November he chose option 1 of the question notice board, with every board recommendation accepted, plus Q3 (an optional form question), Q11 (the reader's last message above a reply), Q13 (read receipts) and the alias naming "Reviewer 7Q". #4673 was brought up to date with main (N4's shared card and bell panel, N5, N7, the Details chip #4712 and Clear all #4726) and its question-notice parts rebuilt on N4's card, not a fork of it: criteria 16 to 21 above. Additive data only: StudyConversation.About, the conversation notices' resolution (replied), the thread's optional ReadReceipts, the view's optional Context and the quote block's optional AtUtc and ReadAtUtc; no migration and no new index (receipts live on the thread; marking the caller's notices read matches their deterministic IDs). The Q-number of a form question ("About Q2 · …" on the board) is not shown: SyRF's two annotation forms number questions differently, so the conversation names the question by its text until the reconcile page's Questions sheet (owner decision Q1, the prototype session) settles the numbering.