Skip to content

ADR-026: Saved notification inbox: capture, admission and live updates

Status

In-Review, 2026-10-24. Records the foundation of the in-app notification inbox as it lands from #3932 (step L1 of the notifications restart that Chris approved on 24 October 2026). The contract it implements is C15 v2 in contracts.md and the integration plan notifications-integration.md. Later steps (the kind registry, per-recipient dismiss and resolution, the expanding card and bell panel, email and digests) extend this record; they do not change the decisions below.

Step L2, 2026-10-24 (#3938, in review). Import completion and known-account invitations capture through the same gate (decision 7). The notificationInbox consumer contract now names Project Management as well as the API, and the runtime report's statistics rule keys on the contract owner rather than the host (decision 8).

Step L3, 2026-10-25 (#3941, in review). Review access and configured workload capture through the same gate (decision 9), keeping #3964's owner-only rule ahead of it.

Step N1, 2026-10-27 (#4581, in review). The C15 v2 core: a kind registry, the generic Source, one capture path and the server-shaped NotificationView (decision 11).

Step N2, 2026-10-27 (#4584, in review). Per-recipient state (dismiss, resolution), the unread rule, and inbox reads that stay available to people with saved items whatever the capture flag (decision 12, which amends decisions 3, 4 and 6 for reads).

Step L4, 2026-10-27 (#3942, in review). Opt-in email preferences and immediate email delivery behind their own flag and a deployment-managed delivery pause (decision 13).

Step N6, 2026-10-27 (#4586, in review). Recorded fan-out: an occurrence above its kind's inline limit is recorded as one durable intent and expanded by a leased worker (decision 14).

Step N3, 2026-10-27 (#4603, in review). The Inbox becomes one list of expanding notice cards; /inbox/:id opens it with that card expanded; opening a notice marks it read (D49); three small account endpoints and fields serve the card (decision 15).

Step L5, 2026-10-28 (#3943, in review). Daily email digests on L4's flag, admission, ledger and pause, with membership frozen when the window is prepared (decision 16).

Step N7, 2026-10-28 (#4669, in review). Feature release notices (programme piece 6G.N): the featureRelease kind, recorded from the What's New outbox handler as one fan-out intent per announcement, expanded to the people the feature is available to, and sharing seen and dismissed with What's New (decision 18).

Context

SyRF has no saved, per-person history of things that happened to someone: an export finishing, an invitation, a change to their review access. The Codex notification stack (#3932 to #3947, #3965) built one in early October 2026, then waited 2,245 commits while main gained the feature registry, IFeatureDecisions, pre-deploy migrations (ADR-023) and the rule that a flag never migrates data. This ADR fixes how the inbox fits those rules.

Decision

  1. One collection, pmInboxNotification, one row per recipient, kind and source occurrence (RecipientId, Kind, ProjectId, SourceId, CreatedAtUtc, ReadAtUtc). The row is written in the same transaction as the source's own terminal write, insert-only ($setOnInsert), so a replay converges on the existing row and never resets its read state. A capture failure rolls the source write back and is reported to the caller. For an export, the file has already streamed by then, so the job stays InProgress. Recovery: an authorised caller streams it again with GET api/projects/{projectId}/data-export/{jobId}, and the new capture reuses the job's notification identity, so it converges on one row.
  2. Reads re-check authority every time. The row holds identifiers only. Each read checks the caller (active, not suspended) and their current project authority; when either fails the row still lists, as "Related item unavailable", with no action and no protected detail. Reading never resolves a workflow.
  3. One flag, notificationInbox, feature notification-inbox in the registry, default off everywhere. It gates capture, the inbox API (unroutable while off, FeatureGated; superseded for reads by decision 12, N2: with the flag off the API serves only callers who have saved rows, and 404s everyone else), the /inbox web routes and the live-update worker. It is read only through IFeatureDecisions, behind one gate, IInboxAdmission. API and Project Management both capture (Project Management from #3938), so the flag is deployment-managed: both hosts read the deployed value and a runtime override is refused. Registry type: Release, Developing, What's New quiet, permission SignedIn, built to recommendation A of the open decision D50 (the alternative, Operational, is a one-line change).
  4. Indexes are pre-deploy migrations 0023–0025 with Downs, never start-up builds. IInboxAdmission refuses while any is missing (no capture, 503 on reads, no change stream), which is the only allowed link between a flag and a migration (a feature may refuse to switch on until its migrations have run).
  5. Live updates are an empty hint. Each API pod watches the collection with its own change stream and sends InboxChanged (no payload) to the recipient's SignalR group; the browser refetches over HTTP on a hint, on reconnect and every 30 seconds, but only while someone is signed in with the inbox on (otherwise no timer runs and no hint is listened to). With no inbox view open it fetches only the unread count (decision 10). There is no backplane and no competing consumer, so no hint is lost to another pod. The worker opens no change stream while the inbox is not admitted, and closes it within one bounded wait (5 s) after the flag goes off.
  6. Flag off is main's behaviour. Export completion makes exactly the single update it made before; nothing reads or writes the collection; saved rows are kept with their read state and served again when the flag is back on. Amended by decision 12 (N2): while off, the inbox API still serves a caller who has saved rows (and answers 404 to anyone who has none); nothing captures or resolves.
  7. Import and invitation capture (L2). The upload signature is returned only after the uploader is bound as the recipient in pmImportNotificationAdmission (keyed by search ID, immutable); S3 metadata and competing start events never choose the recipient. Project Management captures importCompleted inside the import-completion transaction; a capture failure aborts that transaction without the destructive import-failure compensation, so the parsed Studies stay and the message retry completes it. The API captures projectInvitation for each new known-account invitation inside the invitation save, and the existing invitation email (including unknown-address mail) is dispatched only after that commit. Each host decides once per operation, before the transaction; an import admitted while on and completed while off saves no row.
  8. The contract names both hosts. Capture happens in the API and in Project Management, so the reviewed contract lists [api, project-management]; otherwise a Project Management host in Mode=Shared would decide UnsupportedHost (off) while the API decided the deployed value. Both hosts decide from the same authority revision, and the pin keeps the value at the deployed one in both (FeatureDecisionsNotificationInboxParityTests). Naming Project Management made RuntimeReportCompleteness.IsStatisticsKey treat the flag as a statistics key, because it keyed on the host; it now keys on the contract owner (Statistics maintainers), which selects exactly the same four statistics keys as before. The catalog digest moves, as for any catalog change.
  9. Review access and workload capture (L3). A save that can change who may review a stage, or a reviewer's configured workload, compares before and after snapshots of the project's own ACL (no application-administrator shortcut). It saves one reviewAccessGranted per member who gains an effective Review grant and one workloadChanged per reviewer whose configured share or reviews per study changed, in the source save's transaction. Unchanged settings, revoked access and individual study claims save nothing. The writers are the membership, join, invitation-response, stage and stage-permission actions of ProjectController, the guarded review-settings save and the allocation-regime publisher. A project-level permission change and a new invitation cannot change stage review access (a stage's Review permission is the stage's own, and an invitation adds no member), so neither captures: both save exactly as main does, whatever the flag. Rows are stamped from the injected TimeProvider. Each one asks IInboxAdmission once per operation, before it reads the project and before any transaction (the review-settings save asks once for all of its retries). While the inbox is not open, each reads, caches and saves exactly as before: the same synchronous or asynchronous save, and no private read scope where main had none. #3964's refusals (an owner-reserved grant, a non-owner transfer) run before the capture path, so a refused request captures nothing. reviewAccessGranted has a deterministic occurrence identity (#4530, M17, ahead of bulk grant fan-out in R1c): SourceId is SHA-256 over the kind, the project and stage, and the project version the grant was computed from (StageNotificationIdentity), shared by every recipient of the grant; the row ID is SHA-256 over the SourceId and the recipient. A grant is always a project save that is optimistic on that version, so processing the same grant twice upserts onto one row, while a grant made after access was removed is computed from a later version and saves a new row. Rows saved earlier with a random SourceId stay as they are (no data migration). workloadChanged still uses a random SourceId.
  10. Keyset paging and lighter polling (L3, #4547). GET api/account/notifications takes an opaque cursor (the previous page's nextCursor, {CreatedAtUtc ticks}.{Id:N}) instead of an offset, and returns the items strictly after it in (CreatedAtUtc, _id) descending order; a malformed cursor is 400. An empty cursor (?cursor=) means the first page, the same as omitting it. A notification saved while someone browses lands on the first page and never repeats or displaces an item on a later page. Migration 0023's {RecipientId: 1, CreatedAtUtc: -1, _id: -1} index serves the read in order (no in-memory sort), so paging needs no new migration. The page carries the unread count, so the browser's list refresh is one request; the detail view and the bare badge refresh still ask unread-count, because a saved item carries no count.

  11. Kind registry, Source, one capture path and the view (N1, C15 v2).

    • Kinds. Each kind is an INotificationKind (Project Management Core, because both hosts capture): kind ID, email category (none for in-app only; the five foundation kinds map to themselves, which are #3942's preference keys), scope (Legacy, Canonical, Both, Global), admission flag (notificationInbox for all five), inline recipient limit (1 for exports and imports, the C15 fan-out threshold of 200 for invitations, review access and workload; enforced when recorded fan-out lands in N6) and label. The API registers one INotificationResolver per kind and NotificationKindRegistry joins them. The registry test (C15-T03) fails when a kind lacks a category, label, admission flag, positive limit, resolver or disclosure fixture. Reads stay tolerant: an unknown kind or a missing resolver lists as unavailable; nothing validates at start-up, so a registration slip can never stop a pod.
    • Source. An additive, nullable sub-document on the row (type, entity ID, project, stages, task). Global kinds carry a null project and store no top-level ProjectId. Rows saved before N1 have none and are not backfilled (no data migration); reads use the typed fields, which every kind keeps.
    • One capture path. Export, import, invitation, review-access and workload captures all write through InboxNotificationWriter with an InboxOccurrence. Each stores exactly the elements it stored before plus Source, insert-only, in the source transaction; matching (the job ID for exports, the source identity for the rest) is unchanged.
    • View. List and detail return view beside the unchanged fields: kind, label, availability, headline, key fact, typed body blocks (facts, stat, state, note, callout, quote, compare), primary action, type actions (none yet) and workflow state (an invitation is pending). An item the reader can no longer open returns the kind's label as its headline and nothing else (C15-T04, inbox channel). The old fields are byte-identical (the goldens only gain view). The per-request memo now lives in NotificationReadContext; a page of 100 items in one project evaluates that project's authority once per activity it needs. Authority and workflow checks load the Project fresh; a project name shown only for display comes from the shared repository cache (or a fresh load the request already made), once per project per request.
    • No project is Guid.Empty. A global kind stores no ProjectId, so its row reads back as Guid.Empty. Every project read and decision in NotificationReadContext refuses that ID without calling the gate or a repository; a resolver for a global kind keys off Source and its own availability rule.
    • Flag. None new. Everything stays behind notificationInbox; flag off captures and serves nothing, as before.
  12. Per-recipient state, the unread rule and reads independent of the flag (N2; C15 v2 "Per-recipient state", ACD §3.7, owner decisions 1 and 2 of 24 October 2026, M7, D3-21).

    • State. Each row may carry DismissedAtUtc, ResolvedAtUtc and Resolution {Reason, SourceTransition, ResolvedByRef}. All are additive and nullable, stored only once set, never backfilled; a build before N2 keeps them in its extra elements and never writes the whole row, so a rollback reads and keeps them (its count then includes dismissed and resolved items again until the roll-forward).
    • Dismiss is personal. PUT {id}/dismissed sets the time once; DELETE {id}/dismissed removes the element; POST dismissed with 1-100 IDs dismisses the caller's own, not-yet-dismissed ones (the client's visible items, so nothing that arrived unseen is cleared). Each filters on the caller; another person's ID is a 404 (single) or ignored (clear all). Dismiss never touches the related workflow: an invitation stays pending.
    • Resolution belongs to the workflow. Only the owning workflow resolves a notice, inside its own transaction, while the inbox is open (the same decision as capture): an existing unresolved row only, never an upsert, so a replay keeps the first resolution. The first writer is the invitation answer (accepted or declined, including a join request that accepts a pending invitation): reason completed, transition accepted or declined, the invitee as ResolvedByRef. Revocation is not an answer. ResolvedByRef is stored, never returned, until the disclosure policy shapes it.
    • Unread count = unread, unresolved, not dismissed and available. The first three are one query on migration 0026 {RecipientId, ReadAtUtc, DismissedAtUtc, ResolvedAtUtc} (equality on every key). Availability is the kind resolver's own decision at read time, made with the request memo (one authority decision per distinct project and activity) and one batched read per source type, so the count never reads per item. Candidates are resolved newest first and resolution stops at the 100th available item: more than 99 is a count of 99 with unreadCountCapped: true (the badge shows "99+"); exactly 99 is 99 and not capped. That bounds the resolution work of every count and list call. Project loads stay uncached full loads, so revocation is seen at once; a lighter projection was not used because the resolvers decide with the aggregate's own rules (memberships, invitations, stage permissions, deletion). The candidate read itself is whole rows (the resolvers read every stored element, so a projection would drop only RecipientId) and unbounded in rows: every unread, unresolved, not dismissed row of one person, index-bounded on 0026. A deleted project makes every notice in it unavailable whatever the authority mode, so it leaves the count and reads "no longer available".
    • Lists. status = all (default, not dismissed), unread (not dismissed, not read; a resolved unread item is listed, as in the prototype), resolved (not dismissed), dismissed.
    • Reads stay available to people with saved items (M7). The inbox API has no route gate; the in-action IInboxAdmission.CheckReadsAsync decides. With the indexes present: flag on is Open (every active caller; an empty inbox is 200); flag off is SavedItemsOnly, and the controller serves only a caller with at least one saved row (one indexed existence read on RecipientId, limit 1, once per request); a well-formed request from anyone else gets 404, as before the inbox existed, 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, and production with the flag off and no rows answers such requests exactly as it did. A malformed request (a bad cursor, limit or status, an empty or oversized Clear all) is refused with 400 first, because input checks run before admission. Indexes missing: 503 with the flag on, 404 with it off (no row can exist). Capture, resolution, the live-update worker and the web entry points keep CheckAsync and the flag. An anonymous request now meets [Authorize] (401) rather than the old route gate's 404.
    • Migration. 0026 joins the inbox index set (PmMigrations.InboxIndexes), so the inbox refuses to open until it has run; its Down drops only it. 0024 {RecipientId, ReadAtUtc} is now a prefix of 0026 and redundant; it stays (append-only catalogue) and can go in a later clean-up migration.
  13. Opt-in email: preferences, ledger, worker, flag and pause (L4, #3942).

    • Preferences live in pmNotificationEmailPreferences, one document per account, separate from the profile and from inbox read state, saved with a version (a stale save is 409). They are keyed by email category (the kind's EmailCategory), every category off by default, none (in-app only) never a key. They are tolerant (C15-T06, C15-T10): a save keeps every well-formed key, known to this build or not; a missing key is off; a read lists the registry's categories plus any saved key this build does not know; a client sends back the keys it does not render. Consent times (EnabledAtUtc) are set by the server only, so opting in never emails history from before it. MutedProjectIds (D3-24, M11) stops email for a project while its inbox rows, badge and counts stay; a save that omits it (an older client) keeps the saved mutes.
    • Ledger. pmNotificationEmailDelivery holds one entry per saved item, with the item's ID, admitted insert-only by discovery over saved inbox rows (so a missed live event never loses an email). A leased claim, a dispatch admission that fences the preferences in one transaction (so a concurrent opt-out, timing change or mute conflicts with it) and an acknowledgement give at most one send per item; a send whose outcome is uncertain is ambiguous and never retried. Reading an item never acknowledges an email. Discovery reads each account from an in-memory watermark (the previous cycle's start less a 10-minute overlap, reset to the consent time on any consent change and hourly), never persisted, so a long opt-in history is not read every cycle and the flags have no watermark data to keep; it is hinted to the 0023 inbox index, which bounds the read.
    • Send-time checks. The processor looks the item's kind up in the N1 registry: an unknown kind leaves the entry ready (C15-T11) for a build that knows it; an in-app-only kind is suppressed. It then rechecks the category, the mute, the account and the kind's resolver (the item must still be available to the recipient; an unavailable authority read retries rather than suppresses) and the address. The email carries the kind's label and a link to the saved notice only, until the content policy (D3-22, M12) replaces it.
    • Flag. notificationEmail, feature notification-email (Release, Developing, quiet, SignedIn), default off everywhere, read only through INotificationEmailAdmission, which decides it together with its declared dependency notificationInbox in one DecideAll capture and then checks the inbox index set (PmMigrations.InboxIndexes, 0023–0026) and its own 0027–0030, by set rather than by number. Off: the preference routes are unroutable (FeatureGated, then 404), /inbox/email-settings falls through to not-found, the worker reads no preference or ledger row, and every preference and ledger row is kept for the next time it is on. Only the API reads it, so it is runtime-manageable outside production (productionEligible: false).
    • Pause. NotificationEmailDelivery:Paused, deployment-managed, default true in the chart and when unset. Paused, the worker still discovers and admits saved items, so nothing is lost, but recovers, claims and sends nothing; lifting it sends each ready item once with every send-time check. Turning the flag on therefore sends nothing until an environment's values lift the pause, which needs Chris's G-NOTIF approval; outside production the transport must be Mailpit, and production's is SES.
    • Age cap. Chris 2026-10-27: immediate email skips items older than 24 h, configurable. An item saved more than NotificationEmailDelivery:MaxItemAgeHours (chart notificationEmailDelivery.maxItemAgeHours, default 24; a missing or unusable value is 24) before it is claimed is never sent. The processor checks it as soon as it loads the claimed item, before any other read, and dispatch admission checks it again inside the consent transaction, so it holds after a pause and after the flag was off alike. The entry is recorded, not dropped: state suppressed (already terminal in every build) with an additive Reason: "tooOld", which a build without the field keeps in its extra elements. Dismissing a notice does not cancel an immediate email already due for it (C15-T14); the cap bounds how late that email can be.
    • Indexes are migrations 0027–0030 with Downs (claim, per-recipient mode, recovery, enabled-account page). The claim index adopts the one an earlier build of #3942 created at start-up; nothing is built at start-up now.
  14. Recorded fan-out (N6, C15 v2 "Capture modes", C19 class (b)).

    • When. One occurrence (a kind and its SourceId) may write at most its kind's inline recipient limit inline, and never more than 200 (NotificationCaptureModes). Above that, the source transaction writes one NotificationFanOut intent instead of the rows. At or below it, capture is exactly what it was. Today only reviewAccessGranted can exceed it (a grant shared by every member who gained Review on a stage): StageNotificationCapture records it as a frozen list of those members, each re-checked for Review on the stage at expansion. workloadChanged has a random per-recipient SourceId and a per-recipient payload, so every occurrence has one recipient and it never fans out; exports and imports have one recipient by construction.
    • Intent. One document in pmNotificationFanOut, _id = SHA-256 over the kind and SourceId (recording the same occurrence again is a no-op), carrying the row template (kind, SourceId, project, Source, stage, occurrence time) and a recipient selector. Never deleted; a completed intent is the record.
    • Selectors. INotificationRecipientSelector pages one stored selector type with an opaque cursor and evaluates capabilities when the page is read. This build ships a frozen list (cursor = offset; optional capability filter) and a project capability (every active member holding it; cursor = last member ID examined, ordinal). Capabilities are activeMember and stageReview, from the project's own ACL; a deleted or missing project holds none. A later kind adds its own type (N7's feature-availability selector, which can also end the fan-out early by reporting it exhausted). An intent whose type this build does not know is never claimed, so it waits for a build that does.
    • Expansion. NotificationFanOutExpander leases the intent due longest (lease owner, new token, 2-minute expiry), then writes batches of at most 100 rows. Each batch is one transaction: the rows go through InboxNotificationWriter's identity rules (row ID = SHA-256 over SourceId and recipient, upsert on the unique (RecipientId, Kind, SourceId), insert-only), and the new cursor is recorded fenced on the lease token and the starting cursor. A crash before commit leaves neither rows nor progress; after commit, both. A worker whose lease was taken over is refused by the fence and its batch rolls back; only that (a write conflict, or a lease token that has changed) ends a pass as a lost lease. A pass writes up to 20 batches and then gives the lease back so any replica continues. Any other failure, a transient transaction abort included, gives it back with a back-off of 15 s doubling to one hour, counted from the failures stored at that moment (a committed batch resets them).
    • Worker and flag. NotificationFanOutWorker runs in every API replica: a pass every 8 to 12 s, draining while passes progress. A pass asks IInboxAdmission first: off, it does no database work at all (no claim); on but the inbox migrations or the fan-out migration missing, it claims nothing. The flag is re-decided after every batch. No change stream, no Quartz job (notifications integration §4 item 7). Project Management records intents (it captures) but runs no expander.
    • Email (L4). Expanded rows keep the occurrence's CreatedAtUtc but become visible only when written, so rows expanded more than the email discovery watermark's 10-minute overlap after the source commit (back-off, an expired lease, no worker) are found by its hourly full read: an immediate email for a fanned-out occurrence can be up to about an hour late. The 24-hour age cap still counts from the occurrence time. No behaviour change.
    • Migration. {State: 1, DueAtUtc: 1} (0031; numbered 0026 until N2 and L4 landed first) is the only index; nothing builds it at start-up. Down drops it; the intents stay and no earlier build reads them.
    • Flag. None new: notificationInbox gates recording (through the callers' existing admission) and expansion. Off, on, off, on keeps every intent's progress and writes no row twice.
  15. The expanding card, read on open, and the card's account endpoints (N3; owner decisions 3, 4 and 6 of 24 October 2026; D49 of 26 October 2026).

    • One card. The web renders every notice as one expanding row with fixed slots, in the layout Chris chose on 28 October 2026 (Option D on desktop, P1 on phones; it replaced the 24 October action bar): Mark as read or unread and Dismiss (Restore under Dismissed) sit on every row's header line, left of the open/close arrow (on phones as named icon buttons), and the open card holds only the message, Activity and the message's own buttons. (Refined on 2 November 2026 by the decided card layout, technical plan N4 criterion 20: icon-only housekeeping on every surface with the time over it at the top right, a context line, and "Notice details" in place of Activity.) The unread number shows only as a badge on the bell and the Unread tab. The same component serves the page and N4's bell panel. The /inbox/:id detail page is gone.
    • Named "Notifications", not "Inbox" (Chris, 27 October 2026: "this is not an actual email system"). Everything a person sees says notifications. The page is /notifications and /notifications/:id (that card expanded and focused); /inbox, /inbox/:id and /inbox/email-settings redirect there, so emails already sent and bookmarks keep working, and new notification emails link to /notifications/{id}. Code, the flag (notificationInbox), the collection and these engineering records keep the "inbox" name.
    • Read on open (D49, C15-T16). Opening a card, or arriving through /inbox/:id, sends the existing Mark read for that notice. Read state never resolves, dismisses or answers a workflow; an invitation is answered only by its own Accept or Decline, which call the existing invitation endpoint (the server re-checks authority and N2 resolves the notice in the same transaction).
    • Mark as unread. DELETE api/account/notifications/{id}/read sets ReadAtUtc back to null (the shape capture writes), idempotent, the caller's own row only (404 otherwise). The unread rule is N2's, unchanged.
    • 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, like Clear all; others are ignored and nothing is disclosed.
    • Related project. An available NotificationView carries project {id, name}, for the Project filter and the inline invitation answer. An unavailable view carries none (C15-T04); the ID is never shown.
    • Answered invitations stay readable to their invitee. Accepted, while they are still an active member (rechecked at every read): "You joined {project}" with an Open project action. Declined: the project's name and the declined state, no action. Revoked, left, or anyone else: the kind only, as before. An answered invitation (workflow state accepted or declined) never counts as unread, with or without a recorded resolution.
    • Unread follows the count. The unread dot and the Unread tab show exactly what the count counts (unread, unresolved, not dismissed, openable, not an answered workflow).
    • Stale notices are dismissed automatically (Chris, 27 October 2026). At the source event, in its transaction and only while capture writes: a withdrawn invitation dismisses its invitee's notice, and losing review access to a stage dismisses that reviewer's review-access and workload notices for it, with the additive DismissReason = "unavailable" (rows not yet dismissed only; no migration). At read time, any notice the reader can no longer open is listed only under Dismissed and never counted; the list fills a page by examining rows in order (cursor = last row examined, at most 500 rows a request). Restorable or transient causes (project deletion, authority outages) are never written, only applied at read time.
    • Flag. None new. The web entry points follow notificationInbox; the two endpoints follow N2's read admission (decision 12), like Mark read.
  16. Daily digests (L5, #3943).
    • Window. One NotificationEmailDigest per recipient and local day in pmNotificationEmailDigest, _id derived from both, prepared at the first worker cycle after the account's local send time (IANA timezone and HH:mm, default 09:00 UTC; a skipped time moves to the next valid minute, a repeated one uses its first occurrence). Only the current day is prepared: days missed while off, paused or down get no digest of their own, and the next digest takes everything since. A send-time or timezone change that moves the most recent send time back to a day before an already-prepared one prepares nothing (no extra digest at once).
    • Frozen membership, decided at claim time (owner decision, 24 October assumption 3, confirmed 27 October). The preparation transaction takes up to 100 items, oldest first, that are unread, not dismissed (any reason), not resolved, not in a muted project, of a kind whose category is daily since before the item, within the look-back, and with no ledger entry or a ready (unsent) one, whichever mode it was admitted in (a kind daily now, so a switch from Immediately to Daily joins today's window). Later reads, dismissals and resolutions do not change it. An empty day is closed with an empty window.
    • One ledger. Members' L4 ledger entries are held by the window (daily, leased, owner digest:{windowId}, no lease expiry of their own); the window carries the lease. An item therefore has one delivery identity: an immediate entry, queued before a dismissal, is never taken into a digest while its kind stays immediate and is still sent (C15-T14); switched to Daily, the unsent email of a notice already read, dismissed or resolved is finished at preparation as suppressed with reason leftOutOfDigest (with tooOld and pre-consent entries), so no ready entry is left for ever; and a held entry is never claimed or recovered by the immediate worker.
    • Send time. Each member gets L4's checks again (exists, age cap, registered kind that emails, consent and mute, active recipient with an address, resolver availability). Dispatch admission is one transaction over the window and its members that checks the lease, the look-back, each category's consent and every member's project mute at the checked preference revision and increments DispatchFence, as L4's does per item. An expired dispatch is ambiguous, never resent.
    • Complete digests, bounded (Chris, 28 October 2026, option B; supersedes applying the 24-hour cap to digests). A digest takes everything no earlier digest or immediate email took (the ledger is the "since the previous digest" record), saved within NotificationEmailDelivery:DigestMaxLookbackHours (default 48) of its preparation, so the 25-hour day when clocks go back, a send time moved later, a timezone change and a day paused lose nothing, and items over the 100-item limit go in the next digest. Older items are never sent (tooOld, at preparation and at dispatch admission). Immediate email keeps MaxItemAgeHours (24). A literal lower bound at the previous window's due time was not used because it would drop the previous digest's overflow.
    • Flag and pause. No new flag: the digest worker asks INotificationEmailAdmission, which now also requires 0032. Off or missing indexes, a cycle reads nothing; paused, it prepares, claims and sends nothing. Windows and held entries are kept, so on, off, on loses nothing and sends nothing twice.
    • Migration. {State: 1, DueAtUtc: 1} on pmNotificationEmailDigest (0032), a pre-deploy migration with a Down; nothing builds it at start-up.
  17. Reconciliation questions (L6, #3944).

    • Flag. studyAttention (feature study-attention, Release, Developing, quiet), with a reviewed API-only consumer contract and a declared dependency on notificationInbox, pinned (deploymentManaged) until #3965's private one-to-one threads land (L7): every participant of a conversation sees every post (gap C7), so no runtime override may switch it on (removed by decision 22, L7). One gate, IStudyConversationAdmission, decides both flags from one DecideAll capture, then the inbox and conversation indexes. Off: every api/study-conversations route is 404 before any read, the web route is not found, and conversation notices are unavailable and uncounted. Rows are kept.
    • Kinds. reconciliationQuestion and reconciliationResponse, each its own email category, scope Legacy, inline limit 20, registered with one resolver class that shapes the post as a quote with an Open conversation action.
    • Canonical scopes. Conversations read legacy sessions, so a canonical-model stage is refused; until R0's ownership record exists every stage is legacy (NoCanonicalReviewScopes), and R0 replaces that registration.
    • Capture. Through InboxNotificationWriter in the conversation's transaction, keyed on the post, with the conversation as Source.
    • Migration. {ProjectId: 1, StudyId: 1, StageId: 1, DateTimeCreated: -1, _id: -1} on pmStudyConversation (0033), with a Down; nothing builds it at start-up.
  18. Feature release notices (N7, 6G.N; D42 C, D47 A, D48 A).

    • Kind. featureRelease: email category none (in-app only), scope Global (no project, so no per-project admission), inline limit 1 (always recorded as a fan-out), label "New feature", admitted by the new flag featureReleaseNotices (Release, Developing, What's New quiet, SignedIn, default off, productionEligible: false) with its declared dependency notificationInbox, both from one DecideAll capture, plus the deployment switch RuntimeFeatureReleases:Enabled.
    • Recording. RuntimeReleaseWhatsNewHandler (the release programme's MaterialiseWhatsNew handler) calls the host's IRuntimeReleaseWhatsNewNotices before its row completes. The API's recorder writes one intent, insert-only outside any transaction, whose occurrence identity is SHA-256 over the announcement ID and revision, so a re-delivered row records nothing new. A quiet release queues no What's New row, so it never records. A failed recording fails the attempt (the dispatcher's back-off) only while fewer than three attempts are spent; then the row completes without notices, so Done still means announced and the inbox can never fail What's New. Off, a release records nothing and is never backfilled.
    • Audience. The featureReleaseAudience selector pages the Investigators that are not deactivated by _id and keeps each person for whom the 6E.2 availability evaluator (no project, the person as the claimed person) says the feature is available for this notice's announcement and the announcement is not dismissed on What's New. Only a feature whose permission is SignedIn reaches anyone: notices mirror the account-level What's New (D25 A omits project permissions there), and an application permission needs the person's own request. A person who has already seen the announcement on What's New gets the row already read. A stop (an AnnouncementCancelled event for the release, or its capability decided Stopped) ends the expansion with no more rows. With the gate closed, and until the release's What's New row is Done, the selector defers: the expander gives the lease back for a minute with no failure counted and the cursor kept (NotificationRecipientPage.Defer, a new expander outcome Deferred), so no notice is ever written while it would read "no longer available". A person whose availability answer is not Current holds up only themselves: the rows before them are committed and they are retried after a back-off (15 s doubling, attempts kept in the cursor, no failure counted), so a failed discovery read never notifies someone who dismissed it. At 8 attempts (about half an hour) up to three other people are asked first: the last person decided Current, carried in the cursor (@{id}; older cursors without it are still read), then the candidates after this person. If any is Current, the person is skipped, logged at Error with IDs only and counted in the intent's additive SkippedRecipients and SkippedRecipientIds, and the expansion carries on, so a run of undecidable people after a decidable one is skipped one by one; only if all are undecidable is the fault system-wide and nobody is skipped (retried, logged at Warning). An intent still waiting for its What's New row an hour after it was recorded is logged at Warning (IDs and the reason), at most once per hour per replica, so a release that never becomes ready is visible.
    • Reading. The resolver shows the release's current What's New entry (title as headline, "New in SyRF", the body as an untrusted markdown block, view.feature with the feature ID and guide page) only while the gate is open, the announcement is not cancelled and the reader's availability reports this announcement with its entry; otherwise the kind only, so a stopped or superseded release notice is "no longer available" and leaves the count. A notice whose announcement the reader hid on What's New stays readable with workflow state hiddenOnWhatsNew, which never counts as unread. It is not an answered workflow, so it stays under All and in the bell panel; only an accepted or declined invitation moves to Resolved (decision 20; NotificationWorkflowStates.IsAnswered).
    • Shared state, both ways, once. Mark read and Mark all as read record seen, Dismiss and Clear all record seen and dismissed, through IRuntimeFeatureDiscoveryStore.RecordAsync, with What's New's own guard (gate open, announcement current, revision not ahead). In the other direction, What's New recording seen marks the person's notice for that announcement read, and a notice written after the person saw the entry is written read. After that each surface keeps its own state: Mark as unread is respected (What's New records seen once and never re-applies it), and Restore writes nothing to What's New (D48 A). Both directions run after the write the person asked for and never fail it: a single notice is checked by kind before What's New is touched, and a failed lookup or cross-write is logged, IDs only.
    • Migration. None: intents use 0031, release events 0014, the people and discovery reads _id.
  19. One digest template and a read-only preview (N5, #4670).
    • One renderer. NotificationDigestRenderer renders the subject, HTML and plain text of a digest from entries shaped by NotificationDigestComposer (the kind's resolver, then the email content policy). The worker's processor and the preview both call it, so a preview is byte-for-byte the message that would be sent for the same items and clock. The digest is sent as HTML with a plain-text alternative.
    • Content policy, interim. Until the project's content policy (D3-22, M12) lands, a line is the kind's label and a link, and the project's name is the group heading (D3-22's baseline: emails name the project). Headlines, which can carry search and stage names, stay in the app. Groups with something that needs the reader come first; inside a group, items that need the reader (a pending workflow) come first, marked "Needs you", then newest first. Each project has a mute link (/notifications/email-settings?mute={projectId}) that the screen applies on arrival, with Undo.
    • Row layout (Chris, 2 November 2026: the digest matches the decided notice-card layout). Each row has the notice kind's own icon (the card's glyph, a 28 px PNG circle by absolute URL, decorative), the label with the "Needs you" status chip after it ("Needs you", "Completed with problems", or the card's "You accepted the invitation" / "You declined the invitation"), and the absolute time top right; one right margin for the time, group heading and mute link. The email stays label-only (no headline, stage or study).
    • Header logo. The digest header shows the full SyRF title lockup (circle mark and "Systematic Review Facility"), syrf-email-title-white.png: a 266 x 64 PNG shown at 133 x 32 (2x), by absolute URL on the configured UI origin, with alt="SyRF – Systematic Review Facility"; white on the header's navy in both colour schemes.
    • Preview writes nothing. GET api/account/notification-email-preferences/digest-preview (same gate and admission as the preferences) runs the preparation's candidate read outside any transaction and the processor's send-time checks without their writes: no window, ledger entry, claim, settled entry, fence or send. A digest already prepared but not yet sent (for example while delivery is paused) is shown as what goes next, with its scheduled time and what it holds; when none of its members passes the send-time checks, the preview says nothing from it will be sent (the processor suppresses such a window).
    • Data-driven categories. The preferences read returns categoryOptions from the kind registry, offering a category while any of its kinds' admission flag decides on (one IFeatureDecisions.DecideAll capture), and mutedProjects (named only while the reader can view the project). Both are read-only; a save ignores them, and every registered or saved key is still echoed. A kind registered later is offered without UI work; decision 17's two conversation categories appear once studyAttention can be enabled.
    • No new flag, data or index. Everything is behind notificationEmail; nothing new is stored.
  20. The bell panel, answered invitations under Resolved, and the inviter's name (N4, #4651; Chris, 28 October 2026).
    • Bell panel. The bell opens a compact, non-modal panel of the newest open notices (design A′: calm rows, small named icon buttons for Mark as read or unread and Dismiss, the message's own buttons end-aligned in Material order). It reuses the one card and one set of actions with the page; opening a row marks it read (D49).
    • Answered invitations live under Resolved. The list's all status is the open notices only; resolved lists resolved notices and answered workflows (accepted or declined invitations), resolution recorded or not. All and the bell panel no longer show them. The unread rule and count are unchanged; no index or migration.
    • The inviter is named. An invitation's message carries its author, the inviter's display name read at request time (shared cache, display only), on the additive nullable author of the quote block; null for a missing, deactivated or nameless account, never an ID, never on an unavailable notice.
    • Flag. None new: notificationInbox (web) gates the bell and panel; the list and view changes are read-time shaping of saved rows, rollback-safe (older builds ignore author and show the label; an older build would list answered invitations under All again).
  21. Clear all clears everything except what waits for the reader's answer (option B, #4726; Chris, 3 November 2026).
    • A rule, not a list of IDs. POST api/account/notifications/cleared {asOfUtc, status, kinds?, projectId?} dismisses the caller's not dismissed notices on the tab (All, Unread or Resolved; the panel sends All), narrowed by the page's Kind and Project filters, saved no later than asOfUtc, loaded on the client or not. Rows carry an additive StoredAtUtc, stamped once by every insert ($setOnInsert), because CreatedAtUtc is the event's time and a recorded fan-out saves later; the list returns listedAtUtc (the API's time before its first read), and the client sends its current list's first page's value as asOfUtc. A row saved after it is kept whatever its event time; on Resolved, a row resolved after it is kept. Rows saved before StoredAtUtc existed go by CreatedAtUtc (no backfill). The server caps asOfUtc at now. Remaining window: a row whose transaction commits after a list read that began before the commit.
    • Waiting is the server's decision. A notice waits when the kind's resolver, at the time of the clear, shapes it available, unresolved and with workflow state pending (today an invitation the reader can still answer). Waiting notices are kept and counted (waiting, waitingByKind) so the snackbar can say "1 invitation still waits for your answer."; once answered, resolved or no longer answerable a notice is cleared like any other.
    • Bounded calls, exact Undo. One call dismisses at most 1,000 and examines at most 2,000 rows, then answers more with a cursor; the client repeats with the same asOfUtc. Each call marks its rows with a fresh DismissBatchId (removed by every restore), so the answer names exactly the IDs that call dismissed, even when two clears run in the same millisecond; the web also ignores a Clear all while one is running. Undo restores them through POST restored {ids} in batches of 100. Release notices dismissed this way are dismissed on What's New (decision 18).
    • Offered whenever something is shown. Clear all is offered while the list shows a notice, even when every notice shown waits (older ones not loaded may not); the server decides, and says "Nothing to clear." with the waiting sentence when it clears nothing. Not under Dismissed, nor while a search narrows the page.
    • Compatibility. The first Clear all (POST dismissed {ids}, decision 12) stays for older clients during a rollout. No new flag (inside notificationInbox), no data migration, no new index (0023 serves the read). Both new elements are additive and nullable; an older build keeps them in its extra elements.
  22. Search and filters on the server, and Clear matching (#4731, #4736; Chris, 3 November 2026).

    • One match rule. NotificationQuery.Matches decides every notice for the list, the match count and Clear: the tab (Lists), the stored kind among kinds, the stored and the shown project both projectId, and the trimmed search q found, ignoring case (invariant lower case, ordinal), in the view's headline, key fact and label ("Notification" without a view), as the browser searched before. The browser no longer filters by search, kind or project, so for the same query a clear dismisses exactly what the list's pages show, apart from what waits for an answer (decision 21). The menu item reads "Clear matching" whenever a search or filter narrows the list.
    • Mark matching as read (#4736; Chris, 4 November 2026). With a search or filter, Mark all as read reads "Mark matching as read" and marks read every unread notice the same Matches finds on the tab, loaded or not, saved no later than the list (asOfUtc, as Clear): POST read takes the list's query instead of ids ({asOfUtc, status?, q?, kinds?, projectId?, cursor?}, at least one of q, kinds and projectId), bounded and paged as Clear (1,000 marked and 2,000 examined a call, then more and nextCursor). With ids alone it is exactly decision 12's Mark all as read, answer included ({read}; more and nextCursor are left out, not null). No Undo, as Mark all as read has none.
    • Bounded reads. A list page with a search or filter examines up to 2,000 rows (search needs every row shaped), then answers hasMore with a cursor. GET count counts matches, exact up to 999 ("999+"), examining at most 10,000 rows (beyond that the count is a floor, capped). GET projects offers the Project filter's choices: the projects of the caller's notices on the tab they can open now, by name, at most 100.
    • Cheap to keep current. The web limits the search to the server's 200 characters (a test pins the two), so the search box never gets a 400. It reads "N results" only when the query changes or a write changed the list (and retries one that failed on the next tick), never on a plain poll, and reads the Project choices again while the Filters panel is open after a tab change, a list-changing write, or a poll whose re-read changed the notices shown or the unread count (a notice outside the filters, below the unread cap of 99).
    • Rollout. No new flag (inside notificationInbox), no migration, no index change: the Kind and Project filters narrow the list's read on 0023's range; the search is decided on shaped rows. The old list call without the new parameters is unchanged. The page echoes what it applied (q, kinds, projectId). During a skewed rollout (the web ahead of the API, or the API rolled back) an older server ignores all three and lists every notice, and its Clear ignores q; so the web trusts a search or filter only when the shown page echoes it. Otherwise it keeps the chips, says "Search and filters need the latest server, so every notice is shown.", offers nothing to clear and shows no count, until the API is updated.
  23. Private one-to-one reconciliation conversations (L7, #3965 re-applied on main in #4673).

    • Route. Owner decision Q1, option B: #3965's commits are cherry-picked onto main (-x), with conflicts resolved towards main's architecture; #3965's branch is kept and the PR closed with a link after this one merges.
    • Threads. One thread per selected candidate (identity includes the candidate's session), all of one question in one transaction (all, an exact replay, or nothing). A post notifies only the thread's other party. Only reviewers whose session for the study and stage is completed are candidates; a reconciler who reviewed the study in any stage is refused with the typed 409 ReconcilerReviewedStudy. Each candidate is named by a study-local alias, "Reviewer 7Q" (owner decision, 2 November 2026; it replaced L6's "Reviewer N"): a digit and a letter drawn from a hash of the study and the session, collisions resolved in session creation order, one rule in the aggregate shared by every surface.
    • Exposure and marker. The thread records the questioned session, its status then and the first question and reply times, for its reconciler only; every Reconcile holder sees a content-free questioned marker (first questioned, replied). The marker read uses 0033's prefix: no new index, nothing built at start-up.
    • Flag. New reconciliationConversations (feature reconciliation-conversations, Release, quiet), reviewed API-only contract, declared dependency on studyAttention; the one gate decides all three flags from one capture. The routes are FeatureGated on it. L6's deploymentManaged pin on studyAttention is removed: gap C7 is closed.
    • Data. Additive: the one-to-one shape (Reviewer, Exposure) is new; conversations the L6 build saved (a Reviewers list) are kept and skipped on read, never converted.
    • Quick reply (N4's shared card, page and bell panel). A conversation notice carries a reply type action, with the conversation as its Target, only while the reader could reply on the conversation page; the card posts through the conversation's reply route, which decides admission and authority again. NotificationActionDto gains the optional Target.
    • Decided card (option 1 of the question notice board; Chris, 2 November 2026). Speech bubbles (the quote block gains optional AtUtc, and Author is the participant's label or "You"), the message composer (Send inside the box, the privacy line, Cancel), the context line "project, then stage · study" (the view gains the optional Context: stage, study, the form question, a disclosure sentence), headlines naming who wrote, no "reading does not answer" note.
    • About one form question (Q3). A question may name one question of the stage's form (ConversationQuestionRequest .QuestionId, validated against the stage; stored as the thread's optional About); shown as its current text.
    • Replying resolves (Q5). A post resolves, in its transaction, the notices its author received in the thread (reason completed, transition replied); the web holds a replied notice in place until the reader leaves Notifications or closes the panel.
    • The reader's last message (Q11) and read receipts (Q13). A reply notice also quotes the reader's own last earlier message; the reconciler sees when the candidate first really opened each of their messages (the quote's optional ReadAtUtc and the post's ReadAtUtc on the conversation page). A receipt is written once, on the thread (ReadReceipts, additive, top-level so older builds keep it in their extra elements), by a real open only: the notice's card (POST api/study-conversations/{id}/read?notificationId=…), the conversation page (the same route, which also marks the caller's notices of the thread read) or a reply; inbox housekeeping never writes or removes one, and data saved before has none until the next open. The candidate is told so ("The reconciler can see when you read this.") and sees no receipts. No new index.

Consequences

  • Production is unchanged until the flag is switched on there, which needs Chris's G-NOTIF approval.
  • Rolling back past 0023 drops the indexes; the rows stay, and no earlier build reads them. Rolling back past 0026 drops only the N2 index; an N1 build reads the new rows and keeps their N2 fields.
  • The C15 v2 shape (kind registry, generic source, server-shaped view) landed in N1 (decision 11). Dismiss, resolution and the new unread rule (owner decisions 1 and 2 of 24 October 2026) landed in N2 as additive fields (decision 12). The /inbox/:id detail page shipped here was replaced by the expanding card in N3 (decision 15).
  • Retention and account-deletion erasure of saved rows, email preferences and ledger entries are not decided yet (E32); they are follow-ups. The same applies to completed fan-out intents (N6), which keep their frozen recipient lists.
  • Rolling back past 0027 runs the Downs of 0030 to 0027 in that order and drops the email indexes (a claim index that 0027 adopted from an earlier build is kept, as for any adopted change); preference and ledger rows stay, and no earlier build reads them. A rollback that stops above 0027 drops only the email indexes above its target.
  • Rolling back past 0031 drops only the fan-out index (N6); the intents stay, and no earlier build reads them.
  • Rolling back below N7 leaves featureRelease rows and intents in place: an earlier build lists the rows as an unknown kind ("no longer available", not counted) and never claims an intent whose selector type it does not know. Release notices reach only features whose permission is SignedIn; features behind a project permission get no notice until a per-project audience is designed (follow-up).
  • Rolling back past 0032 drops only the digest window index (L5); windows stay, and no earlier build reads them. A member entry an earlier build finds is a leased daily entry it never claims (its immediate claim takes immediate entries only), so a rolled-back build sends none of a held digest's items; they stay in the inbox.
  • A digest never includes an item more than 48 hours old (the look-back bound): digests more than 48 hours apart (email paused or off for longer) leave the older items in the notifications list only. A notice read or dismissed before one digest and later marked unread or restored can appear in the next, if within the bound.

Alternatives considered

  • Capture outside the source transaction (outbox or message). Rejected: a lost message is a missing notice with no repair path, and C15 §1.3 requires the saved row to commit with the source.
  • Build the indexes at start-up. Rejected by ADR-023: start-up builds block pods and cannot be rolled back.
  • A RabbitMQ or Redis backplane for hints. Rejected for now: each pod already has the change stream, hints carry no data, and the refetch repairs anything missed. The realtime change-feed reader (RF.6) may absorb it later.