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).

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. 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.

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.

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.