Skip to content

Persistence and messaging data-integrity plan (October 2026)

This plan covers the persistence and messaging defects from the architecture review (architecture-review-2026-10.md: V5, V13, Appendix B B6–B8, Appendix C item 3, Appendix D B4–B8) and issues #3984 and #3985. It also covers the durability of domain events once #3973 (await the dispatch, owned by the backend bug plan) has landed. It is planning only; nothing starts until Chris approves and answers the decisions in §10.

Every item was re-checked against main@85e6facf7 (3 Oct 2026) by reading the code. Counts come from grep over production sources (tests, bin/ and obj/ excluded), so they are approximate.

1. Scope

Persistence

ID Problem Impact Evidence (main@85e6facf7) Status Issue
P1 SaveAsync/Save replace on {_id, Audit.Version} with IsUpsert = true; bulk models do the same A document hard-deleted after it was loaded is silently re-inserted MongoExtensions.cs:262-299 (:282,:289), :328-354; MongoUnitOfWorkBase.cs:498-516 (PrepareSaveMany), :631-633 (batched) VERIFIED. Counts CORRECTED: about 117 production .Save(/.SaveAsync( calls (not 106) against 25 TrySaveExistingAsync (not 38). The live hard-delete paths are narrower than the review implied: public project and search deletion answer DeletionLifecycleUnavailable (ProjectController.cs:419, SearchController.cs:122,134). What remains: guarded account deletion (AccountController.cs:378), import rollback (ProjectManagementService.cs:592,603 → :1542) and the seeder. The exposure grows when the durable deletion kernel (#2934) enables deletion #3985
P2 OnSaving bumps Audit.Version in memory before the write and never restores it A failed or missed save leaves the instance at N+1. A retry on that instance filters on N+1 and can overwrite another writer's N+1 MongoExtensions.cs:270-271,313-314; Audit.cs:28-34 VERIFIED, partly mitigated: the unit of work now evicts the cache and the isolated scope on every attempt (MongoUnitOfWorkBase.cs:301-308, :434-440, :664-669). Any holder of the reference, including the caller, keeps N+1 #3985
P3 Direct Study writes don't bump Audit.Version A concurrent whole-document save silently undoes them: annotations of a deleted question come back, or CustomId/PdfRelativePath/inclusion elements are lost StudyRepository.cs:1306-1319 ($pull), :1321-1350 (ApplySimpleUpdates), :1618-1650 (three inclusion passes) VERIFIED. Line numbers CORRECTED: the inclusion passes moved from :1626-1646 to :1618-1650. Other members do bump (:494,:1980,:2013,:2821). InvestigatorRepository has 1 direct write with no bump (not yet read) #3985
P4 The shared, mutable 2-second RepositoryCache hands one instance to every caller Unsaved mutations are visible to, and publishable by, concurrent requests RepositoryCache.cs:43; .claude/rules/repository-cache.md VERIFIED. Count CORRECTED: 4 explicit BeginIsolatedReads sites (ProjectManagementService.cs:1384, StageWorkloadSharesController.cs:65, ProjectController.cs:374,885) plus 13 calls to the (Await)UpdateAndSaveAsync helpers, which open the scope themselves. There are about 141 cached Projects/Studies/Investigators/SystematicSearches.Get* calls in controllers, consumers and Core services —
P5 Mutate the cached aggregate, then return 409 without saving Partial changes and their pending domain events stay on the cached instance, and the next request to save that Project within 2 s publishes and dispatches them ProjectController.cs:515-532 (ApproveJoinRequests: mutate in loop, Conflict at :526) VERIFIED for ApproveJoinRequests. DeclineJoinRequests :539, ResendInvitationsBulk :647, RevokeInvitationsBulk :674 and UpdateInvitationGroupsBulk :695 were located but not each re-read (NOT VERIFIED individually) new
P6 ResetCache swaps the MemoryCache and the lock map A load that started before an invalidation writes stale data into the new cache for 2 s; a reader on the disposed old cache throws ObjectDisposedException, which gets wrapped RepositoryCache.cs:120-166 (_cache.Set reads the field after the await), :330-337 VERIFIED (mechanism); not reproduced new
P7 Every save flushes the whole type's cache Lower hit rate. Each flush widens the P6 window MongoRepositoryBase.cs:479-482; 120 production InvalidateCache() calls VERIFIED new
P8 Batched and multi saves drop domain events Cancellation after committed batches, or an exception, skips DispatchEvents, and GetDomainEvents has already cleared them MongoUnitOfWorkBase.cs:624-627 (return), :671; SaveMany :414-443, SaveManyAsync :455-494; Entity.cs:63-74 VERIFIED new
P9 The "unit of work" is a process singleton registered by naming convention No per-request identity map or transaction boundary MongoLamarRegistry.cs:82-102 (AddSingleton) VERIFIED — (structural)

Messaging and domain events

ID Problem Impact Evidence Status Issue
M1 No bus-wide retry or redelivery A transient Mongo blip or a lost optimistic race sends the message straight to _error MassTransitHelpers.cs:30-60 (ConfigureEndpoints, no AddConfigureEndpointsCallback) VERIFIED. About 12 of about 35 consumers configure retry: 4 session consumers (ReviewSessionConsumerRetry.cs), 4 bulk-PDF consumers, 3 job consumers (SetRetry) and the saga (SearchImportJobStateMachine.cs:298-302) #3984
M2 Saga CreatePartitioner(1); the Parsing state has no timeout Every search import in the process is serialised through one partition. A lost parse result leaves the job in Parsing forever SearchImportJobStateMachine.cs:308-318, :154-166 (the only schedule is UploadTimeout, :41) VERIFIED #3984
M3 Consumers that aren't idempotent Redelivery (once M1 adds it) could double-apply effects Read so far: UpdateStudyScreeningStatsConsumer (a redelivery after completion fails its precondition at :65-68 and lands in M5). Import terminal-failure claims are idempotent per .claude/rules/materialized-stats.md PARTLY VERIFIED; the other consumers are NOT VERIFIED (PR-9 produces the inventory) #3984
M4 The inclusion recalculation can latch forever The project stays CalculatingInclusionInfo, and UpdateAgreementMode answers UpdateAlreadyPending forever No retry in the definition (UpdateStudyScreeningStatsConsumer.cs:150-160); the precondition reads the cached Project (:65); the comments at :83-85,:96-101,:133-136 assume "the redelivery resumes", but no redelivery is configured VERIFIED #3984
M5 Recording a project error always throws The real failure is masked and the error save fails UpdateStudyScreeningStatsConsumer.cs:139-146 adds an anonymous object to List<Object> Errors (Project.cs:122). Driver 3.10's default ObjectSerializer rejects types outside its allow-list VERIFIED (code); serializer behaviour not re-run. PR-2's first test is the repro #3984
M6 Message contracts embed domain types; no round-trip tests Silent field loss on the wire, as already seen with the agreement threshold (V7) 8 of 27 SyRF.ProjectManagement.Messages files reference ProjectAggregate/Core; the .csproj references ProjectManagement.Core (:9). No STJ round-trip test for PM contracts found VERIFIED #3984
E1 Domain events are neither awaited nor durable Event handler failures are lost; a crash between the save and the dispatch loses the event MongoUnitOfWorkBase.cs:731-741; EventManager.cs:21-31. 14 IHandles<> handlers: 10 mail or notification, 2 bus sends (ProjectAgreementThresholdUpdatedHandler, LivingSearchHandler), 1 save (UserHasRegisteredHandler) VERIFIED. Awaiting is #3973 (backend bug plan); durability is this plan #3984, #3973

Precedents and constraints confirmed in code: - In-house outboxes already exist: ActivityClaimRevocationOutbox (lease, attempts, TTL), ProjectStatisticsNotificationOutbox with its ProjectStatisticsOutboxDispatcher, Identity's RecoveryEmailOutbox, and BulkPdfHoldRetirementOutboxIdentity. - MongoDbReplicaSetTestFixture (src/libs/testing/SyRF.Testing.Common/Fixtures/) and the replica-set E2E Mongo (e2e/docker-compose.yml:35, --replSet rs0) are already available. - FEAT-024 relies on today's failure shape. StudyUpsertConflictRetry.IsLostUpsertRace (:123-128) and MongoConcurrencyConflict.IsConcurrencyConflict treat E11000 as "lost race". - ReservationChangeConflictException is the precedent for a typed replacement of that E11000. - The fold command-budget tests pin the exact command count of each save. - CORRECTED: the comment in MongoConcurrencyConflict.cs blames MassTransit.MongoDb for the MongoDB.Driver.Core 2.x type collision. MassTransit.MongoDb 8.4.0 actually depends on MongoDB.Driver ≥ 3.2.1 (nuspec). PM's local project.assets.json shows Driver.Core 2.28 arriving through MongoDB.Analyzer 1.5.0. That file may be stale, so PR-10's spike re-checks it.

2. MVP boundary, out of scope, flag decision

MVP (releases R0–R2): no silent loss of writes or events on the paths we know about. - A deleted aggregate is never resurrected. - A failed save never leaves a version that can overwrite another writer's. - Direct writes participate in optimistic concurrency. - A transient failure or a lost race is retried and then redelivered rather than dropped. - The inclusion recalculation and search imports cannot hang forever. - Every contract survives the bus serializer. - A domain event raised by a committed save is delivered at least once.

Hardening (R3): cache correctness (P4–P7) and the cache-leak detector.

Out of scope, and where it is tracked:

Item Where
Awaiting domain events and surfacing handler failures #3973, backend bug plan (prerequisite for PR-10)
ValueObject equality and AgreementMeasure serialization (V6/V7) #3980 (PR-3 records them as expected failures)
Scoped identity map replacing RepositoryCache and the singleton unit of work (P9) Synthesis Phase 3 "Persistence"; follows the DI decision (Lamar exit). New issue on approval
PM.Contracts split, one writer per collection, job state out of Project Synthesis Phase 3 "Module boundaries"
MassTransit after v8 #3986 (ADR being written in parallel; PR-10 is designed to survive any outcome)
Import progress conflict (Appendix D B7) CORRECTED, so out of this plan: progress callbacks are now drained and counted (ProjectManagementService.cs:614-621), not run under a blocking wait
Identity-service persistence Identity uses its own stores and outbox; owned by the authentication migration session
Durable deletion lifecycle #2934 (must not enable deletion before PR-5 is on; see §8)

Flag decision. Backend, hot-path, changed observable semantics, so the default is flagged. These are startup configuration switches, set per environment through cluster-gitops for both API and PM, read once at start-up, defaulting to the legacy behaviour:

Switch (PROPOSAL names) PRs Default Why
Persistence:Strict:NonUpsertSaves PR-5 off Changes the failure type and outcome of every save of an existing aggregate
Persistence:Strict:DirectWriteVersionBump PR-6 off Raises the conflict rate for in-flight saves during project-wide passes
Messaging:GlobalRetry:Enabled PR-7 off Changes delivery semantics for about 20 endpoints
DomainEvents:Outbox:Enabled PR-10 off New write path (a transaction) on event-raising saves
Persistence:Cache:PerKeyEviction PR-11 off Changes cache behaviour on the hottest read path
Persistence:Cache:LeakAudit PR-12 off, staging only Costs one BSON serialisation per put and per expiry

Not flagged (with the reason): - PR-1: telemetry and tests only; zero extra commands. - PR-2: restores intended behaviour on a path that always throws today. - PR-3: tests only. - PR-4: failure path only; it restores the invariant the cache eviction already assumes. - PR-8: parsing timeout and partitions; the saga has no feature surface. The timeout value is configuration. - PR-9: tests, plus fixes the inventory finds, each argued in its own PR.

Production rollout of any switch is a separate Chris-approved step.

3. Common acceptance criteria (every PR)

# Criterion Verification
C1 The regression test is written first, fails on main (or characterises the defect), then passes Red and green runs quoted in the PR body
C2 Focused tests only, run niced on this host: nice dotnet test <one project> --filter <class>; never the full solution Commands and output in the PR body; CI Test Summary green
C3 FEAT-024 invariants untouched: the fold command-budget tests (FoldSaveCommandBudgetTests, FoldSaveEligibilityCommandBudgetTests, ProjectStatisticsFoldCommandBudgetTests) pass unchanged; no statistics write moves Test output; the FEAT-024 session reviews any PR that touches StudyRepository.cs or the classifiers
C4 ADR-020 lock guards untouched: StudyWriteLockArchitectureTests and StudyBulkUpdateLockHonouringTests pass Test output
C5 With every switch off, behaviour is byte-identical (same commands, same exceptions) A Testcontainers test runs each changed path with the switch off and asserts the command list and the exception type
C6 Docs ride in the PR: .claude/rules/repository-cache.md, docs/architecture/mongodb-reference.md, and a new ADR for PR-5 and PR-10; the PR body states the flag decision PR review
C7 Reviews settled on the head (pr-review-settled.sh exit 0), CI green, and the bot summary verdict read Settled-gate output
C8 Staging rollout: the switch is turned on for API and PM through cluster-gitops, the named metric is watched for 7 days (PROPOSAL), and the result is recorded on the issue. No production change Staging rollout check

4. Releases and pull requests

Numeric values marked PROPOSAL need Chris's confirmation.

R0: prove it and stop the cheap bleeding (three PRs, in parallel)

PR-1: concurrency harness and resurrection detector (P1, P2, P3, P5, P8; effort M)

Files: - new src/libs/mongo/SyRF.Mongo.Common.Tests/Concurrency/* on MongoDbReplicaSetTestFixture; - MongoExtensions.cs (SaveAsync, Save): detector only; - MongoUnitOfWorkBase.cs (bulk paths): detector only; - a new PersistenceMetrics meter, named in both hosts' AddMeter.

Approach: 1. Characterisation tests, one per defect, each asserting today's (wrong) outcome under [Trait("Defect", …)]. Later PRs flip them. 2. Detector: when ReplaceOneResult.UpsertedId is non-null and the version before the save was greater than 0, the save has just re-inserted a deleted document. Log a warning with type and id, and increment syrf.persistence.resurrections{aggregate}. 3. The same check for bulk results (BulkWriteResult.Upserts).

# Acceptance criterion Verification
1.1 A Study is loaded, deleted, then saved → the test observes the re-inserted document (characterises P1) Testcontainers
1.2 That save → the resurrection counter goes up by 1 and one warning names the type and id Testcontainers plus a MeterListener
1.3 An ordinary existing save or a new-aggregate save → no counter increment and no extra command Testcontainers command-listener assertion
1.4 Characterisation tests exist for P2 (retry at N+1 overwrites), P3 ($pull undone by a whole save), P5 (409 leaves mutation and events on the cached instance) and P8 (cancel after batch 1 → batch-1 events never dispatched) Testcontainers
1.5 Staging, 7 days after deploy → the resurrection count is recorded on #3985 (expected 0; non-zero raises PR-5's priority) Staging rollout check

PR-2: inclusion recalculation cannot latch; project errors serialise (M4, M5; effort M)

Files: - UpdateStudyScreeningStatsConsumer.cs; - Project.cs (Errors becomes List<ProjectError>, a record with a tolerant class map that reads legacy BSON elements); - ProjectRepository.cs (class map).

Approach: 1. Read the precondition uncached (GetUncachedAsync). 2. A redelivery that finds the job complete for the same threshold acknowledges as a no-op. 3. Keep the FEAT-024 fence semantics exactly: the token is derived as today, and the fence is never released on failure. 4. Add consumer retry and scheduled redelivery for MongoConcurrencyConflict.IsRetryableSaveFailure only. 5. Write the error record through an uncached load and TrySaveExistingAsync.

Coordination: the FEAT-024 session reviews it (fence code); #3941 touches Project.cs.

# Acceptance criterion Verification
2.1 The consumer fails after the fence → the error is persisted on the Project and the original exception is rethrown (no BsonSerializationException) Testcontainers (red on main)
2.2 The precondition was true in Mongo but the cache held a stale false → the job runs Testcontainers
2.3 The same command is delivered twice after success → the second delivery writes nothing and does not fault MassTransit test harness plus Mongo
2.4 A transient save failure on completion → redelivered, and the project leaves CalculatingInclusionInfo Harness with fault injection
2.5 Existing pmProject documents with legacy Errors entries → still deserialise Unit test with captured BSON shapes; a dry-run count on syrftest (§7) sizes them
2.6 The FEAT-024 inclusion-fence tests pass unchanged Test output

PR-3: contract round-trip tests (M6; effort S)

Files: new SyRF.ProjectManagement.Messages.Tests (or a folder in an existing test project), covering every ICommand/IEvent in PM, API and Quartz message assemblies.

Approach: - Reflect over the contract assemblies. - Build each message with non-default values for every property, recursively. - Serialize with MassTransit's SystemTextJsonMessageSerializer options, then assert deep equality. - Known failures (the AgreementMeasure protected setters) are listed as expected failures linked to #3980, so the test fails when #3980 lands until the entry is removed.

# Acceptance criterion Verification
3.1 Every contract type → round-trips with all properties equal, except the listed known failures Contract test
3.2 A new contract with a non-public setter → the test fails and names the property Contract test (one red case shown in the PR)
3.3 The 8 contracts that reference domain types → listed in the test output as "domain-coupled", feeding the Phase 3 PM.Contracts issue Test report

R1: persistence correctness (three PRs; PR-4 → PR-5 sequential, PR-6 in parallel)

PR-4: restore the audit on a failed or missed write (P2; effort S)

Files: Audit.cs (an internal Snapshot/Restore), MongoExtensions.cs (SaveAsync, Save, TrySaveExistingAsync), MongoUnitOfWorkBase.cs (bulk paths).

Approach: - Snapshot the audit before OnSaving. - Restore it when the call throws, or when MatchedCount == 0 without an upsert. - An unknown commit result also restores. A retry with filter N then either matches (the write never happened) or conflicts (it did), and both are safe.

# Acceptance criterion Verification
4.1 A save loses a race → the instance's Version, LastModified and LastModifiedBy equal their pre-save values Testcontainers
4.2 The P2 characterisation (retry at N+1 overwrites another host's N+1) → now conflicts instead Testcontainers (flipped from 1.4)
4.3 Successful saves → unchanged commands and version Command-listener assertion

PR-5: non-upsert saves for existing aggregates (P1; effort L; flag Persistence:Strict:NonUpsertSaves)

Files: - MongoExtensions.cs, MongoUnitOfWorkBase.cs; - new AggregateConcurrencyException in SharedKernel. It carries no driver type, because the endpoint assemblies cannot name driver types; - MongoConcurrencyConflict.cs, StudyUpsertConflictRetry.cs; - the other 13 files that catch MongoWriteException/MongoBulkWriteException in the API, PM and Core: each is audited, and only those that classify saves of SaveAsync are changed.

Approach: 1. With the switch on, Version > 0 uses ReplaceOne(IsUpsert = false), and bulk models likewise. 2. A miss runs the existing AggregateWriteGuards.ThrowIfGuardedAsync (the guard read the E11000 path already does). It then throws AggregateConcurrencyException, the same command count as today's E11000 path. 3. Version == 0 keeps today's upsert (D2). 4. Both classifiers recognise the new exception as a lost race, mirroring the ReservationChangeConflictException precedent. 5. A reload that returns null is the caller's existing not-found path. 6. ADR written in the PR.

# Acceptance criterion Verification
5.1 Switch on; a loaded aggregate is deleted, then saved → no document is inserted and AggregateConcurrencyException is thrown Testcontainers (flips 1.1)
5.2 Switch on; a stale-version save → AggregateConcurrencyException, and MongoConcurrencyConflict.IsRetryableSaveFailure and StudyUpsertConflictRetry.IsLostUpsertRace return true Unit plus Testcontainers
5.3 Switch on; a locked study → still StudyLockedByBulkUpdateException, never the concurrency exception Testcontainers (ADR-020 honouring tests)
5.4 Switch on; the fold, screening and annotation saves → command lists identical to the pinned budgets FEAT-024 budget tests run with the switch on and off
5.5 Switch on; a new aggregate (Version == 0) → inserted as today Testcontainers
5.6 Switch on; a hub screening save that loses a race with the fold flag on → reloaded and retried within today's budget Existing StudyUpsertConflictRetry tests, parameterised on the switch
5.7 Switch on in staging for 7 days → resurrections stay 0, and the concurrency-exception rate is within 2× of the previous E11000 rate (PROPOSAL) Staging rollout check
5.8 Reviewer screening and annotation journey with the switch on → passes E2E (hermetic e2e/ stack, targeted specs)

PR-6: direct writes bump the version (P3; effort M; flag Persistence:Strict:DirectWriteVersionBump)

Files: - StudyRepository.cs (:1306, :1321, :1618-1650); - InvestigatorRepository.cs and ProjectRepository.cs direct writes, after reading each; - new DirectWriteVersionArchitectureTests, modelled on StudyWriteLockArchitectureTests, with an allow-list that carries reasons. Fold bookkeeping already bumps the version.

Approach: - Add $inc Audit.Version and the audit $set (the existing WithAuditCeremony helpers) to each update. - Pipeline updates use WithAuditCeremonyPipeline.

Coordination: StudyRepository.cs sits in the FEAT-024 rule's paths. Its IsFoldOnlyChange free-retry rule stays correct, because a direct-write bump is charged. #2934 also touches this file.

# Acceptance criterion Verification
6.1 Switch on; RemoveAnnotationsFromStudiesInProjectForQuestionAsync runs while a stale whole-Study save is in flight → the stale save conflicts and the annotations stay removed Testcontainers (flips 1.4 P3)
6.2 Switch on; ApplySimpleUpdates and the three inclusion passes → every modified Study's version goes up by exactly 1 per pass that modified it Testcontainers
6.3 A new direct Study, Project or Investigator write without a version bump → the build fails Architecture test
6.4 Switch on; an inclusion recalculation during active screening (two contexts) → no screening decision is lost; the reviewer sees at most one retry E2E (hermetic stack)

R2: messaging resilience and durable events (four PRs)

PR-7: global retry and redelivery (M1; effort M; flag Messaging:GlobalRetry:Enabled)

Files: MassTransitHelpers.cs, a new ISyrfOwnsRetryPolicy marker on the definitions that already configure retry, and a cluster-gitops alert on _error queue depth (a separate cluster-gitops PR).

Approach: - Use AddConfigureEndpointsCallback. - Skip endpoints whose definition carries the marker: session, bulk PDF, jobs, the saga, and FEAT-024 consumers, whose typed refusals must never become faults. - Apply UseMessageRetry with intervals of 200 ms, 1 s and 5 s (PROPOSAL), then UseScheduledRedelivery at 30 s, 2 min and 10 min (PROPOSAL), both for IsRetryableSaveFailure plus AggregateConcurrencyException only. - Business exceptions still fail fast.

# Acceptance criterion Verification
7.1 Switch on; an unmarked consumer throws a transient Mongo exception twice → it succeeds on the third attempt MassTransit harness
7.2 Switch on; a consumer throws a business exception → exactly one attempt, then _error Harness
7.3 A marked endpoint → its own policy only (no stacked retry) Harness inspecting the configured pipe (GetProbeResult)
7.4 A message lands in any _error queue → an alert fires in Grafana within 15 min (PROPOSAL) Staging rollout check
7.5 Switch on in the hermetic stack → the full smoke spec set passes E2E (run:e2e-smoke)

PR-8: saga timeout and partitions (M2; effort S)

Files: SearchImportJobStateMachine.cs and its handler tests. Coordinate with #2858, which edits the same file.

Approach: - Schedule(ParsingTimeout) on entering Parsing, set to 2 h (PROPOSAL, configurable; above the parse job's 5-minute timeout with 4 retries). - On expiry: FailSearchJobActivity, then Error. A late completion in Error is already ignored (:190-196). - Partition count 8 (PROPOSAL), keyed by SearchId, so ordering per search is preserved. - The saga state gains one additive field, the timeout token.

# Acceptance criterion Verification
8.1 No parse result arrives within the timeout → the job ends in Error and the Project's import job shows the failure Saga harness with a test scheduler
8.2 The completion arrives before the timeout → the timeout is unscheduled; Completed Saga harness
8.3 Two searches import concurrently → both progress without waiting on each other, and per-search order is kept Saga harness
8.4 Import a RIS file in the hermetic stack → it completes as today E2E

PR-9: consumer idempotency inventory (M3; effort M)

Files: a test-only RedeliveryContractTests per host, plus a table appended to docs/architecture/messaging.md (or the nearest existing messaging doc).

Approach: for each of the about 35 consumers, record its effect, idempotency key and mechanism. Deliver the same message (same MessageId) twice and assert one effect. Non-idempotent consumers are fixed in follow-up PRs, one per consumer family, each flagged only if the fix changes semantics.

# Acceptance criterion Verification
9.1 Every consumer → appears in the table, marked idempotent / not / not applicable with its reason Docs plus a test asserting table and registry agree
9.2 Each consumer marked idempotent → its double-delivery test passes Harness plus Testcontainers
9.3 Each consumer marked not idempotent → it has a follow-up issue, and PR-7's switch is not turned on in production until that issue is closed or the endpoint is marked Governance (issue links in the table)

PR-10: durable domain-event outbox (E1, P8; effort L; flag DomainEvents:Outbox:Enabled; needs #3973 and PR-5)

Files: - SharedKernel: IDomainEventOutbox (stage events in a session) and IDomainEventSink (deliver one envelope); - Mongo.Common: MongoDomainEventOutbox (collection pmDomainEventOutbox) and DomainEventOutboxDispatcher (a hosted service); - MongoUnitOfWorkBase.cs save paths; - BSON class maps for the event types.

Approach: 1. Write. With the switch on, a save whose aggregate graph has pending events writes the aggregate and the event envelopes in one transaction. It joins the caller's transaction if one is active, as the FEAT-024 deferred save does; otherwise it opens one and commits with the unknown-result retry. A save with no events takes today's path unchanged. 2. Envelope. EventId, type discriminator, payload (BSON), OccurredAtUtc, aggregate type and id, and HostRole (API or PM), because the two hosts register different handlers. 3. Dispatch. Claim with a lease, invoke each handler through IDomainEventSink, record per-handler completion so a retry re-runs only the failed handlers, back off, abandon after 10 attempts (PROPOSAL) with an alert, and expire delivered rows by TTL. This copies the ActivityClaimRevocationOutbox pattern. 4. Batched saves. Envelopes are staged per batch inside that batch's write, so cancellation after a committed batch no longer loses its events (P8). 5. Bus-agnostic by construction. Only IDomainEventSink knows about MassTransit: the in-process EventManager sink today, and the two bus-sending handlers through the bus. #3986 is decided (ADR-021: v9 rejected 2026-10-03; Wolverine with a single switch-over chosen 2026-10-04), so the sink sends through MassTransit 8.5.11 until the switch and then through Wolverine. The store, the envelope and the write path stay as they are. 6. Spike first (1 day, inside the PR): confirm the driver-3 session sharing and the transaction cost on Atlas. Record the measured command count per event-raising save.

# Acceptance criterion Verification
10.1 Switch on; the process is killed after the commit and before dispatch → after restart, the event's handlers run exactly once per handler Testcontainers (dispatcher restarted in-test)
10.2 Switch on; the transaction aborts → neither the aggregate nor the envelope is persisted Testcontainers replica set
10.3 Switch on; a handler throws → retried with backoff; the other handlers of that event are not re-run Testcontainers
10.4 Switch on; a save with no pending events → the command list is identical to switch-off, including every FEAT-024 budget Command-listener plus budget tests
10.5 Switch on; BatchedSaveManyAsync is cancelled after batch 1 → batch 1's events are delivered (flips 1.4 P8) Testcontainers
10.6 Switch on; a threshold change → the recalculation command is sent even when the bus is down at save time (the latch case of Appendix D B3) Testcontainers plus harness with the bus stopped
10.7 Every domain event type → round-trips through its BSON envelope Contract test (extends PR-3)
10.8 Invite a member and approve a join request in the hermetic stack → the mails arrive in Mailpit once E2E
10.9 Staging, 7 days → zero abandoned envelopes and a p95 dispatch lag ≤ 5 s (PROPOSAL) Staging rollout check

R3: cache hardening (post-MVP; two PRs, in parallel)

PR-11: cache invalidation without the race (P6, P7; effort M; flag Persistence:Cache:PerKeyEviction)

Files: RepositoryCache.cs, MongoRepositoryBase.cs, MongoUnitOfWorkBase.cs (the invalidator calls).

Approach: - Single-aggregate saves evict their key; bulk and direct writes keep the whole-type reset. - A generation counter is captured before a load, and the load caches its result only if no invalidation of that key or type happened since. - Never swap or dispose the live MemoryCache. Clear it instead, and prune the lock map.

# Acceptance criterion Verification
11.1 A load starts, a save of that key completes, then the load finishes → the stale result is returned to its caller but not cached Unit test with a controllable factory
11.2 A concurrent reset during reads → no ObjectDisposedException Stress unit test (1,000 iterations, PROPOSAL)
11.3 Switch on; saving Project A → a cached Project B is still a cache hit Unit
11.4 The _locks map → bounded after 10,000 distinct keys Unit

PR-12: stop the 409 leaks and measure the rest (P4, P5; effort M; flag Persistence:Cache:LeakAudit for the detector)

Files: ProjectController.cs (5 bulk endpoints plus UpdateStagePermissions), NotificationHub.cs, StudyController.cs (each re-read first), and RepositoryCache.cs (the detector).

Approach: - Validate before mutating, or open BeginIsolatedReads before the load, per endpoint. - Detector: on put, record a BSON hash; on expiry (not explicit eviction), re-hash. A mismatch means a cached instance was mutated and never saved. Log the type, the key and the stack captured at put time. Staging only.

# Acceptance criterion Verification
12.1 Approving two join requests where one is invalid → 409, and a concurrent read sees neither approval; no join-approved mail is sent Testcontainers (flips 1.4 P5) plus an API integration test
12.2 The same for each of the other located endpoints API integration tests
12.3 Detector on, in the hermetic stack → the full smoke run reports the leaking call sites; each becomes a follow-up issue E2E run plus issue list
12.4 Detector off → no hashing and no allocation on put Unit (allocation assertion)

5. Order and critical path

flowchart LR
  PR1[PR-1 harness + detector] --> PR4[PR-4 audit restore] --> PR5[PR-5 non-upsert] --> PR10[PR-10 outbox]
  T3973[#3973 await events] --> PR10
  PR2[PR-2 inclusion latch]
  PR3[PR-3 contract tests] --> PR10
  PR1 --> PR6[PR-6 direct-write bump]
  PR7[PR-7 global retry] --> PR9[PR-9 idempotency]
  PR8[PR-8 saga]
  PR5 --> PR11[PR-11 cache]
  PR1 --> PR12[PR-12 409 leaks]
  • Start immediately, in parallel (disjoint files): PR-1 (Mongo.Common), PR-2 (consumer and Project.cs), PR-3 (new test project), PR-7 (MassTransitHelpers.cs) and PR-8 (saga).
  • Sequential on MongoExtensions.cs/MongoUnitOfWorkBase.cs: PR-1 → PR-4 → PR-5 → PR-10, plus #3973, which edits DispatchEvents in the same file. Critical path: #3973 and PR-1 → PR-4 → PR-5 → PR-10.
  • PR-6 follows PR-1 and runs alongside PR-4/PR-5 (StudyRepository.cs and the repositories only).
  • PR-9's not-idempotent list gates turning on PR-7's switch in production, not its merge.
  • PR-11 waits for PR-5 (same files). PR-12 needs only PR-1.
  • One worker per worktree. This host is the CI runner, so workers run single test classes niced.

6. Issues

  • 3985 → PR-1, PR-4, PR-5, PR-6.

  • 3984 → PR-2, PR-3, PR-7, PR-8, PR-9, PR-10 (split into six on approval).

  • New issues on approval: PR-11 and PR-12, and the Phase 3 "scoped identity map" issue.
  • Each issue copies its acceptance-criteria table from this plan.

7. Production data: separate, Chris-approved steps

Nothing in this plan writes production data. Two read-only dry runs on syrftest are proposed, each run only after Chris approves it. Each uses countDocuments with maxTimeMS, at a quiet hour, and GUIDs as CSUUID(...):

Dry run Query (sketch) Informs
D-a: resurrection residue pmStudy whose ProjectId has no pmProject; pmStudy whose SystematicSearchId has no pmSystematicSearch (a bounded $lookup per project, sampled first) Whether a cleanup of orphans is needed (P1)
D-b: Project.Errors shapes pmProject.countDocuments({"Errors.0": {$exists: true}}), then a $project of Errors element types on at most 50 documents PR-2's tolerant class map (2.5)

Any cleanup they reveal becomes its own plan: - dry-run counts, plus a list of ids exported off-cluster; - the delete is reversible, by restoring from that export; - a staging rehearsal first; - Chris's explicit approval before it runs.

8. Risks

Risk Mitigation
PR-5 changes the failure type that FEAT-024 retries depend on The classifiers change in the same PR; the budget and conflict-retry tests are parameterised on the switch (5.4, 5.6); the FEAT-024 session reviews it
PR-6 raises conflict rates for reviewers during project-wide passes Flagged; E2E 6.4; the staging metric is compared with the E11000 baseline
PR-10 adds a transaction to event-raising saves (invitations, join requests, threshold changes) Only saves with pending events pay; the spike measures it; flagged per host
Outbox events are delivered at least once, so a mail could be sent twice Per-handler completion records; mail handlers keyed on EventId (10.3, 10.8)
The global retry stacks on endpoints with their own policy, or retries FEAT-024 typed refusals The marker opts them out; 7.3 inspects the pipe
Durable deletion (#2934) enabled before PR-5 is on would widen P1 a lot Recorded as a dependency on #2934; D1 asks Chris to make it a gate
The MassTransit decision (#3986) lands mid-programme Only the sink touches the bus; the store and the write path are bus-agnostic
Version-0 upsert kept (D2) leaves a tiny window for never-saved aggregates Version-0 documents are new by construction; the PR-1 detector keeps counting

9. Coordination with other active work

Open PRs touching these files (gh pr list --state open, 128 PRs checked on 3 Oct):

PR Overlap Action
#2934 durable deletion kernel StudyRepository.cs, IStudyRepository.cs, Project.cs Whichever lands second rebases. Deletion must not be enabled before PR-5's switch is on (D1)
#2858 legacy upload metadata contract SearchImportJobStateMachine.cs and its tests PR-8 rebases on it, or the other way round
#3941 notifications inbox capture Project.cs, ProjectController.cs PR-2 and PR-12 rebase; the notifications stack (#3932, #3938–#3947, #3965) is in flight
#3939 progressive shared review batches IStudyRepository.cs Trivial rebase
#2572, #2224, #2469 (dormant) Project.cs, ProjectController.cs None now

Owning streams: - FEAT-024 ("materialised project statistics" session): reviews PR-2, PR-5, PR-6 and PR-10, which touch StudyRepository.cs, the conflict classifiers, the inclusion fence and the deferred-dispatch seam. - Backend bug plan: #3973 (prerequisite) and #3980. - MassTransit ADR (#3986): PR-10's sink. - Authentication migration session: none of these PRs touch Identity. Account deletion uses InvestigatorRepository saves (P1) but no identity code.

10. Decisions needed from Chris

  • D1. Gate durable deletion on PR-5. Recommended: yes. #2934's enablement waits until Persistence:Strict:NonUpsertSaves has run clean in staging.
  • D2. New aggregates (Version == 0): keep the upsert (recommended: smallest change, and the resurrection window needs a never-saved document to be deleted), or switch to InsertOne with an E11000 fallback?
  • D3. Outbox mechanism: an in-house transactional outbox collection behind IDomainEventOutbox (recommended), or wait for #3986 and use the MassTransit Mongo outbox? The in-house store matches four existing outboxes and survives any bus decision.
  • D4. PR-4 unflagged? Recommended: yes. It acts only on the failure path, and the cache eviction already assumes the invariant it restores.
  • D5. PR-7 production order: turn global retry on in production only after PR-9's inventory is closed (recommended), or right after staging proof?
  • D6. Cache direction: do R3 (PR-11/PR-12) now, or skip to the Phase 3 scoped identity map? Recommended: R3 now. It is cheap, it produces the leak list the identity map needs, and the identity map waits on the DI decision.
  • D7. Read-only dry runs D-a and D-b on syrftest (§7): approve, or defer until PR-1's staging detector has data?
  • D8. PROPOSAL values:
  • 7-day staging soak (C8);
  • conflict rate within 2× of baseline (5.7);
  • retry intervals 200 ms / 1 s / 5 s and redelivery 30 s / 2 min / 10 min (PR-7);
  • error-queue alert within 15 min (7.4);
  • parsing timeout 2 h and 8 partitions (PR-8);
  • outbox abandon after 10 attempts and p95 lag ≤ 5 s (PR-10);
  • stress loop of 1,000 iterations (11.2).