Skip to content

FEAT-024 Phase 0 Mutation Ownership and Event Matrix

Purpose and Phase 0 gate

This document is the second of the two method-level deliverables required by the separate documentation-only Phase 0 completion review described in Phase 0: inventory, catalogue, baselines, and architecture. Its sibling, the calculation and consumer catalogue, names what is calculated and who reads it; this document names what can change a project statistic. For every mutation family it records the exact owning method or methods on main, the transactional shape of the write today, the source event or message that triggers it, the catalogue families it affects using the exact names from Catalogue baseline, the consistency strategy the plan assigns it under Event and invalidation contract, and whether the current write pattern conflicts with the rule in Transaction and idempotency rules that a retryable transaction callback must contain no irreversible external side effect. It records evidence only. It does not authorize Phase 1, does not re-decide anything the technical plan fixes, and proposes no product code. Where this matrix and the technical plan disagree, the plan wins and the discrepancy is recorded in Section 5.

Evidence base. Every path:line citation below was re-verified by reading the named file in the main worktree at /home/chris/workspace/syrf/main, commit 7e0ed90c9 (ci: migrate high-value Actions jobs to self-hosted runners (#3027)), on 2026-09-01 and 2026-09-02. Paths are repo-relative and a bare line number names a declaration line, with attributes excluded, unless a range is given. Anchors carried incorrectly in the earlier read-only research notes or in stale March–April planning prose are listed in Appendix A: corrections applied. No repository file was modified while producing this document, and it contains no clinical data, report contents or participant identifiers.

Classification vocabulary. Each family is assigned exactly one consistency strategy, using the plan's own terms:

  • Synchronous point path — the plan's ordinary transaction: source write, source-operation receipt, targeted signed bucket moves and the immutable delta record in one MongoDB transaction. Admissible only when the canonicalized effect is at most 500 signed moves, at most 100 projection documents and at most 256 KiB of canonical delta-record BSON (Transaction and idempotency rules).
  • Staged and fenced asynchronous — the durable staged-publication protocol in Bulk and import operations: all-family fence acquisition, per-scope Active fences, restartable child batches with their own namespaced receipts, and one bounded publication transaction.
  • Source-visibility fence — the specific behaviour the plan assigns to the inclusion recalculation: the active token fences dependent families and forces typed fallback until every source pass durably completes.
  • Path absent — the plan lists the event, but no implementation exists on main; nothing can be instrumented until the path is built.
  • Outside the programme — the mutation changes no approved catalogue family.

1. Shared persistence primitives

Three primitives are prerequisites for reading every row. Almost every statistics-affecting mutation in SyRF is a whole-document replace of an aggregate guarded by an optimistic-concurrency version filter, not a targeted field update.

Primitive file:line Behaviour that matters to FEAT-024
MongoExtensions.GetFilter<TAggregateRoot, TId> src/libs/mongo/SyRF.Mongo.Common/MongoExtensions.cs:483 Builds the optimistic-concurrency filter _id == id AND Audit.Version == currentVersion, with a special arm for version 0 or an absent Audit.Version
MongoExtensions.SaveAsync<TAggregateRoot, TId> MongoExtensions.cs:262 OnSaving(...) then ReplaceOneAsync(filter, aggregateRoot, IsUpsert = true). A version miss does not silently insert or overwrite: the replacement carries the aggregate's own _id, so the upsert attempts an insert that fails the unique _id index with a duplicate-key error (E11000). Accepts an optional IClientSessionHandle
MongoExtensions.TrySaveExistingAsync<TAggregateRoot, TId> MongoExtensions.cs:295 Same version filter with IsUpsert = false, so a version miss is a silent no-op rather than a duplicate-key error; the caller inspects MatchedCount to detect the conflict and decide whether to reload and retry
MongoUnitOfWorkBase.SaveAsync<TAggregateRoot, TId> src/libs/mongo/SyRF.Mongo.Common/MongoUnitOfWorkBase.cs:254 Wraps the above, invalidates the aggregate cache, then calls DispatchEvents at :269
MongoUnitOfWorkBase.TrySaveExistingAsync<TAggregateRoot, TId> MongoUnitOfWorkBase.cs:281 Returns bool; dispatches domain events at :311 only on a matched save
MongoUnitOfWorkBase.SaveManyAsync<TAggregateRoot, TId> MongoUnitOfWorkBase.cs:369 Builds one ReplaceOneModel<T> { IsUpsert = true } per aggregate and issues a single BulkWrite
MongoUnitOfWorkBase.BatchedSaveManyAsync<TAggregateRoot, TId> MongoUnitOfWorkBase.cs:481 The same, chunked by a caller-supplied batch size

What IsUpsert = true actually does on a version miss. This matters everywhere the matrix reasons about concurrency, so it is stated once here. GetFilter builds _id == id AND Audit.Version == currentVersion (MongoExtensions.cs:483-506). When a concurrent writer has already advanced Audit.Version, the filter matches no document and the upsert falls through to an insert. The replacement document is the serialized aggregate, whose Id member maps to _id under the driver's default convention, so the insert carries the same _id as the document already stored and is rejected by the unique _id index with E11000 duplicate key error. The failure mode is therefore a loud write failure, not a silent insert and not a silent overwrite. Two corollaries follow. Only a genuinely absent document — a first save, or a document deleted between load and save — is inserted by this path. And for the bulk variants, which issue BulkWrite with the driver's default ordered semantics (MongoUnitOfWorkBase.cs:383, :526), one stale row raises the same duplicate-key error and aborts the remainder of that batch, leaving the batch partially applied.

Two further consequences are load-bearing for the whole programme.

First, domain events are dispatched in process, immediately after the Mongo write and before the caller commits. MongoUnitOfWorkBase.SaveAsync calls DispatchEvents at MongoUnitOfWorkBase.cs:269 regardless of whether an ISessionHandle was supplied, so a SaveAsync(aggregate, session) executed inside an open transaction runs its handlers pre-commit. There is no transactional outbox on this path. This is exactly the pattern the plan's notification-outbox rule replaces, and it is the mechanism behind M8.

Second, there are exactly four StartTransaction call sites in the project-management domain, and two of them are unreachable from any live route. They are enumerated in Section 4.

2. Mutation families

Sixteen families are catalogued. Each section names its owning methods, the shape of the write on main, its trigger, the affected catalogue families, the plan's consistency strategy, and whether the current write pattern conflicts with the no-irreversible-side-effect rule.

2.1 Screening submit, correction and rescreen

Owner methods.

Layer Method file:line
HTTP (ordinary) ReviewController.SubmitScreeningAndGetNextStudyForReviewPOST api/projects/{projectId}/stages/{stageId}/studies/{studyId}/review, StageReviewPolicy src/services/api/SyRF.API.Endpoint/Controllers/ReviewController.cs:226
HTTP (reconciliation) ReviewController.SubmitScreeningAndGetNextStudyForReconciliationPOST .../studies/{studyId}/reconcile, StageReconcilePolicy ReviewController.cs:279
Retry orchestrator ReviewController.TrySaveScreeningAsync, three attempts ReviewController.cs:355
Domain service ReviewSubmissionService.AddScreening, active-membership guard at :69 src/libs/project-management/SyRF.ProjectManagement.Core/Services/ReviewSubmissionService.cs:54
Aggregate Study.AddScreening src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Study.cs:194
Aggregate ScreeningInfo.ScreenStudy src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/ScreeningInfo.cs:111
Repository StudyRepository.SaveScreeningWithCapacityGuardAsync src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:1620

Trigger and source event. Two HTTP endpoints; no message or job path exists. The plan's historical reason is screening-submitted / screening-corrected.

Correction and rescreen are the same code path. ScreeningInfo.ScreenStudy branches on HasBeenScreenedByScreener (ScreeningInfo.cs:115): an existing screening is mutated in place through screening.ChangeScreeningDecision(decision, stageId) (ScreeningInfo.cs:126), and only a first-time screening appends a new Screening (ScreeningInfo.cs:137-138). Two screenings by the same screener in one project are treated as corrupt and throw (ScreeningInfo.cs:128-133). A rescreen therefore changes an existing decision value and its StageId without changing Screenings.Count, and Screening.ChangeScreeningDecision retains no prior value and no timestamp (src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Screening.cs:32-37). A delta cannot be derived from a count; the before-classification must be captured from the loaded document.

Transaction shape today. No MongoDB transaction. One atomic FindOneAndReplaceAsync of the whole Study document (StudyRepository.cs:1648) filtered by the version filter combined with a capacity guard. On a filter miss a single projected diagnostic read, StudyRepository.DiagnoseCapacityGuardFailureAsync (StudyRepository.cs:1670, invoked at :1655), disambiguates NotFound, VersionStale and AtCapacity. When active-reviewer tracking is unavailable the guard degrades to the version filter alone. Reconciliation submissions deliberately skip both the capacity guard and the slot-reservation removal (ReviewController.cs:355-404; ReviewSubmissionService.cs:54-65).

Affected catalogue families. Project screening; Membership screening; Reviewer screening; and, through the included/excluded boundary after Phase 3, Stage annotation, Membership-stage annotation, Reviewer annotation and Domain reconciliation. The ordinary path also removes a SlotReservation in the same replace, which touches the Reviewer annotation allocation counts of 2.13.

Domain reconciliation is not tied to the /reconcile endpoint. The /reconcile route is screening reconciliation and performs the identical aggregate mutation as the ordinary route: both call ReviewSubmissionService.AddScreening, which calls study.AddScreening(...); the only difference is that the reconciliation branch skips RemoveSlotReservation and the capacity guard (ReviewSubmissionService.cs:54-64; ReviewController.cs:365-367, :373). Neither route writes AnnotationSession.Reconciliation or SessionTally.ReconciliationStarted/.ReconciliationCompleted, which are what the Domain reconciliation counters read (catalogue §3.10). What does move those counters from this family is the inclusion reclassification: the reconciliation counters are computed separately over the unexcluded and excluded populations (StudyStats.cs:367-368), so a Study that crosses the included/excluded boundary moves between them. That can happen on either screening path. Attaching the reclassification only to /reconcile would omit it for an ordinary screening submission that crosses the boundary, and would contradict the consolidated row in Section 3.

Plan consistency strategy: synchronous point path. One Study changes and the before/after screening classification is deterministic, so the canonicalized effect is a small constant number of signed moves across the project, membership and reviewer screening scopes — far inside the 500-move, 100-document, 256 KiB admission limits. The plan requires the dependent annotation population buckets to move in the same transaction when the transition is bounded, and otherwise to fence every affected annotation scope.

Side-effect rule. No conflict in the write itself: the repository call performs only Mongo work. Two structural gaps must be closed before a delta can be attached. First, there is no transaction at all today, so Phase 2 must place the source replace, the receipt and the delta in one transaction while preserving the capacity guard filter. Second, the same HTTP request issues a second, independent, non-transactional write when it hands back the next study through StageReviewService.GetRandomStudyAsync (ReviewController.cs:260-262 and :312-314; service at src/libs/project-management/SyRF.ProjectManagement.Core/Services/StageReviewService.cs:41); that claim belongs to Section 2.13 and must carry its own operation envelope.

2.2 Screening decision deletion or reset

Owner methods. None. There is no path on main that deletes or resets a screening decision. ScreeningInfo exposes no removal method — its only mutator is ScreenStudy (ScreeningInfo.cs:111) — and the only ways a screening record disappears are whole-Study deletion (2.8, 2.14) or an in-place overwrite by Screening.ChangeScreeningDecision (2.1).

Trigger and source event. The plan reserves screening-reset; nothing emits it.

Transaction shape today. Not applicable.

Affected catalogue families. Would be Project screening, Membership screening, Reviewer screening and the dependent annotation families.

Plan consistency strategy: path absent. The plan's synchronous inverse bucket moves have no owner to attach to. Recorded as M2.

Side-effect rule. Not applicable.

2.3 Candidate annotation session save and complete

Owner methods.

Layer Method file:line
HTTP ReviewController.SubmitSessionPUT api/projects/{projectId}/stages/{stageId}/studies/{studyId}/session/{sessionId}, StageReviewPolicy, ?reconciliation= flag ReviewController.cs:131
Orchestrator (transactional owner) SubmitAnnotationSessionService.SubmitAsync src/services/api/SyRF.API.Endpoint/Services/SubmitAnnotationSessionService.cs:36
Domain service ReviewSubmissionService.AddSessionData ReviewSubmissionService.cs:13
Aggregate Study.AddSessionData Study.cs:216
Aggregate ExtractionInfo.AddSessionData src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/ExtractionInfo.cs:88
Aggregate ExtractionInfo.AddAnnotations, the sole writer of session status via AnnotationSession.UpdateStatus (AnnotationSession.cs:36) at :169 ExtractionInfo.cs:106
Repository StudyRepository.SaveWithCapacityGuardAsync, session-aware StudyRepository.cs:1517
Presence side effect SubmitAnnotationSessionService.ApplyPresenceGraduationAsync SubmitAnnotationSessionService.cs:182

Trigger and source event. One HTTP endpoint. There is no separate "complete" route: session status is carried in the submitted DTO and applied by the aggregate. Reconciliation submissions use the same endpoint with reconciliation=true. The plan's historical reasons are annotation-session-* and domain-reconciliation-*.

Transaction shape today. This is the one genuinely multi-document transactional write in the statistics surface. SubmitAnnotationSessionService opens a client session and starts a transaction (SubmitAnnotationSessionService.cs:97-99), saves the whole Study in the session (:104-106, repository at StudyRepository.cs:1599-1600), closes the pre-save ReviewerPresence and inserts the post-save presence in the same session (:109), and commits only on CapacityGuardSaveResult.Saved (:114), otherwise aborting (:116). The retry loop at :60 re-reads the study each attempt with maxAttempts = 3 (:43) and retries on version staleness and on presence or Mongo concurrency conflicts (:121-123). When active-reviewer tracking is unavailable the transaction is skipped entirely and a plain upserting _pmUnitOfWork.SaveAsync(attemptStudy) runs (:91). Capacity enforcement is !reconciliation && stage.EnforceAnnotationTarget (:51).

Affected catalogue families. Stage annotation; Membership-stage annotation; Reviewer annotation; Domain reconciliation when the reconciliation flag is set; Question answers, because submitted annotations change the per-question tally that 2.15 materializes.

Plan consistency strategy: synchronous point path. One Study plus its presence rows change, and the before/after session-state classification is deterministic, so the signed moves stay far inside the admission limits. This family is also the closest existing precedent for the plan's ordinary transaction and the natural first implementation target in Phase 3.

Side-effect rule. No conflict. The transaction body contains no SignalR send, broker publish or other external call — verified by inspection of SubmitAnnotationSessionService.cs:96-120 and by the absence of any publish or hub reference in the file. The repository is called directly rather than through MongoUnitOfWorkBase.SaveAsync, so the pre-commit DispatchEvents hazard of M8 does not arise here. The non-transactional fallback branch at :89-95 is the residual risk: with tracking disabled the source write would commit without the delta unless Phase 3 keeps a transaction on that branch too.

2.4 Annotation and reconciliation session deletion

Owner methods.

Layer Method file:line
HTTP ReviewController.RemoveSessionDELETE api/projects/{projectId}/stages/{stageId}/studies/{studyId}/session/{sessionId}, StageReviewPolicy ReviewController.cs:56
Aggregate, reviewer still connected Study.DeleteSessionAndRestoreSlotReservation, which re-adds a restored SlotReservation so capacity is never released and explicitly excludes reconciliation sessions (:282) Study.cs:275
Aggregate, reviewer gone Study.DeleteSession Study.cs:263
Aggregate ExtractionInfo.DeleteSession, which also filters out the session's Annotations and OutcomeData (:305-307) and swallows InvalidOperationException (:310-314) ExtractionInfo.cs:301
Connection check IReviewSessionConnectionRepository.HasRemainingConnectionsAsync called at ReviewController.cs:75

Trigger and source event. One HTTP endpoint; historical reason annotation-session-*.

Transaction shape today. A multi-document, non-transactional sequence of four independent writes plus one broker publish:

  1. pmUnitOfWork.SaveAsync(study) — upserting whole-document replace (ReviewController.cs:82);
  2. pmUnitOfWork.ReviewerPresences.SaveAsync(currentPresence, session: null) (:94);
  3. pmUnitOfWork.ReviewerPresences.GetOrCreateCurrentAsync(...) (:98);
  4. pmUnitOfWork.Studies.SetSlotReservationIdleScheduleTokenAsync(...), a separate targeted UpdateOneAsync (:117; repository at StudyRepository.cs:1455, write at :1481).

Between steps 3 and 4 the controller issues messageScheduler.SchedulePublish<IMarkSessionIdleCommand>(...) (:108). The comment at :101-103 records the reconciliation strategy for a partially applied restore: a published-but-unpersisted schedule is made harmless by the replacement generation identifier.

Affected catalogue families. Stage annotation; Membership-stage annotation; Reviewer annotation; Domain reconciliation; Operational progress/presence for the reservation and presence rows.

Plan consistency strategy: synchronous point path, with restructuring required. The effect is bounded — one Study, one presence row, a small set of annotation scopes — so it is admissible under the 500-move and 100-document limits, but the plan's ordinary transaction cannot simply wrap the current sequence.

Side-effect rule: conflict. SchedulePublish is a broker publish and therefore an irreversible external side effect. Today it sits between two source writes with no transaction at all; if the four writes were naively wrapped in one retryable callback, the publish would be re-executed on every retry. Phase 3 must move the schedule into the durable transactional notification outbox, or emit it strictly after a confirmed commit while keeping the generation identifier as the idempotency fence. This family is the clearest example of a mutation that cannot be made atomic with a delta record without restructuring.

2.5 Screening threshold and agreement setting change

Owner methods.

Layer Method file:line
HTTP ScreeningController.PostScreeningSettingsPOST api/projects/{projectId}/screening/settings, ProjectEditPolicy, body is an AgreementMode src/services/api/SyRF.API.Endpoint/Controllers/ScreeningController.cs:32
Aggregate Project.UpdateAgreementMode src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:1306
Aggregate, fence acquire Project.StartInclusionInfoCalculation Project.cs:170
Aggregate, fence release Project.CompleteInclusionInfoCalculation Project.cs:182
Aggregate, fence state Project.ActiveInclusionInfoCalculationJob property; token class at Project.cs:1573; CalculatingInclusionInfo predicate at :200 Project.cs:194
In-process domain-event handler ProjectAgreementThresholdUpdatedHandler.HandleAsync, which resolves the conventional command queue and Sends IUpdateStudyScreeningStatsCommand (:26-29) src/services/api/SyRF.API.Endpoint/Handlers/ProjectAgreementThresholdUpdatedHandler.cs:19
Asynchronous worker UpdateStudyScreeningStatsConsumer.Consume src/services/project-management/SyRF.ProjectManagement.Endpoint/Consumers/UpdateStudyScreeningStatsConsumer.cs:17
Repository StudyRepository.UpdateStudyInclusionInfoForProjectAsync src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:1224
Admin entry point, project ProjectController.UpdateStudyInclusionInfoForProjectPOST api/projects/{projectId}/update-study-inclusion, ProjectEditPolicy src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:296
Admin entry point, fleet ProjectController.UpdateAllStudyInclusionInfoForProjectsPOST api/projects/update-all-study-inclusion, BatchAdminProjectsPolicy ProjectController.cs:306
Service, admin entry points ProjectManagementService.UpdateStudyInclusionInfoForProjectAsync (:477) and UpdateStudyInclusionInfoForAllProjectsAsync (:490) src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:477

Trigger and source event. One HTTP endpoint, then an in-process domain-event handler, then a MassTransit command sent to a conventional queue rather than published; plus two direct HTTP admin entry points that reach the same three Study update passes without any event. The plan's historical reasons are screening-configuration-changed and inclusion-info-recalculated.

Transaction shape today. No MongoDB transaction on any leg. PostScreeningSettings persists the Project with one upserting version-filtered replace, and only when UpdateAgreementMode returned Started (ScreeningController.cs:45-48); an UpdateAlreadyPending result answers 409 (:40-43). MongoUnitOfWorkBase.SaveAsync then dispatches ProjectAgreementThresholdUpdatedEvent in process at MongoUnitOfWorkBase.cs:269, and the handler's Send is therefore an external broker call made immediately after the source write with no outbox. The worker re-reads the project and throws when CalculatingInclusionInfo is false (UpdateStudyScreeningStatsConsumer.cs:21-24), runs the repository method, invalidates the aggregate cache, re-reads, releases the token and saves (:27-30). The repository method takes no IClientSessionHandle at all and issues three independent, unbounded, project-wide UpdateManyAsync calls:

Pass file:line Filter Update
1 StudyRepository.cs:1227 MissingOrNullInclusionInfoFieldFilter AddInclusionInfoFieldUpdate — creates the absent ScreeningInfo.InclusionInfo field
2 StudyRepository.cs:1231 InclusionInfoFieldExistsButNoMatchingElementFilter InsertInclusionInfoElementUpdate — appends the element for this threshold
3 StudyRepository.cs:1241 MatchingInclusionInfoElementFilter ReplaceMatchingInclusionInfoElementUpdate, applied only when replaceExisting is true; otherwise the pass is skipped and null is reported (:1240-1245)

The three passes are not atomic with each other and not atomic with the token release at UpdateStudyScreeningStatsConsumer.cs:29-30, so a project can be observed with a partially rewritten InclusionInfo population. On worker failure the catch block appends an anonymous object to Project.Errors, saves and rethrows (:35-51); it never clears the token.

Affected catalogue families. Project screening and Membership screening directly, through every Study's ScreeningInfo.InclusionInfo; Reviewer screening as the compatible view of the same source; and, at the included/excluded boundary, Stage annotation, Membership-stage annotation and Reviewer annotation, whose authoritative populations are partitioned by inclusion class. Project/stage derived summaries follow. The token itself and CompletedInclusionCalculationJobs (Project.cs:195) are project-scope control state rather than a catalogue family.

Plan consistency strategy: source-visibility fence. This is the one family the plan names explicitly for that treatment. ActiveInclusionInfoCalculationJob is a source-visibility fence: the start transaction fences every dependent family and invalidates clients, and while a matching token is active the affected bundles must return the typed StatisticsInclusionRecalculationInProgress response rather than a value, because the three passes can expose a partial population. Only after every pass durably completes may a matching-token worker capture a coherent boundary and republish. The point path is not merely inadmissible here but irrelevant: a single agreement-mode change rewrites one element of ScreeningInfo.InclusionInfo on every Study in the project, so the canonicalized effect exceeds both the 500-move and the 100-document limits for any project of realistic size.

Side-effect rule: conflict. Two distinct problems. First, the Send in ProjectAgreementThresholdUpdatedHandler.cs:26-29 is an irreversible broker call executed by the in-process dispatcher that MongoUnitOfWorkBase.SaveAsync invokes at MongoUnitOfWorkBase.cs:269; if Phase 2 wraps the fence-acquiring source write in a retryable transaction callback without first moving this dispatch into the plan's durable transactional notification outbox, the command is re-sent on every retry. Second, and more serious for the fence itself, the token is acquired and released by two different writes separated by an entire message round trip, and the failure path deliberately leaves the token set. That behaviour is a fail-closed fence rather than a leak, but it has no reset owner; see M1. The two admin entry points are a third gap: neither touches the token, so both can rewrite the same population while a consumer-driven recalculation is in flight — M9.

2.6 Stage and session settings change

Owner methods.

Layer Method file:line
HTTP, most settings ProjectController.UpdateStagePATCH api/projects/{projectId}/stages/{stageId}, StageDesignPolicy, Consumes("application/json-patch+json") src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:701
HTTP, stage creation ProjectController.AddStagePOST api/projects/{projectId}/stages, ProjectDesignPolicy ProjectController.cs:683
HTTP, selected questions ProjectController.UpdateStageQuestionsPUT api/projects/{projectId}/stages/{stageId}/questions, StageDesignPolicy ProjectController.cs:741
Feature-flag guard ProjectController.ContainsActiveReviewerTrackingSetting, rejecting a patch that touches EnforceAnnotationTarget or IdleSessionTimeoutMinutes while the flag is off (checked at :704-705) ProjectController.cs:726
Aggregate Project.UpdateStage src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:357
Aggregate Project.UpdateStageAnnotationQuestions Project.cs:369
Entity Stage.UpdateStage, which assigns ReviewMode, Active, Extraction, MaxInProgress, SessionCountTarget, StudySelectionMode, HideExcludedStudiesFromReviewers, EnforceAnnotationTarget and IdleSessionTimeoutMinutes (:54-62) and jointly validates ExcludedSessionStatsGrouping against HideExcludedStudiesFromReviewers (:63-73) src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/StageEntity/Stage.cs:49
Entity Stage.UpdateQuestions, a full-set replace of the AnnotationQuestions hash set Stage.cs:76

Trigger and source event. HTTP endpoints only; nothing in this family is message- or job-driven. The plan's historical reason is stage-configuration-changed.

Transaction shape today. Every path is an in-memory mutation of the loaded Project aggregate followed by one upserting whole-document ReplaceOne filtered on _id and Audit.Version. AddStage and UpdateStage use the synchronous _pmUnitOfWork.Save(project) (ProjectController.cs:691, :721; MongoUnitOfWorkBase.cs:226, which dispatches domain events at :241); UpdateStageQuestions uses await _pmUnitOfWork.SaveAsync(project) (:755). No MongoDB session or transaction appears anywhere in ProjectController.cs.

UpdateStageQuestions carries an application-level optimistic check independent of Audit.Version: the X-SyRF-Expected-Stage-Questions-SHA256 request header is compared against HashStageQuestionIds over the stage's current assignment set (:745-753, helper at :759) and the request is answered 409 Conflict on mismatch (:751). Because Stage.UpdateQuestions replaces the whole set, omitting an identifier from the payload is how a question is detached from a stage; there is no separate detach route.

Two settings the plan's event row names are not writable on main at all. Stage.AllowSelfReconciliation (Stage.cs:157) has a private setter, is absent from StageUpdateDto and is assigned nowhere in production code; its only non-test references are five reads (src/libs/project-management/SyRF.ProjectManagement.Core/Services/StageReviewService.cs:93, src/libs/project-management/SyRF.ProjectManagement.Core/Model/ValueObjects/MembershipAnnotationSessionStats.cs:98, and src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/StudyStats.cs:163, :168, :171). Stage.PartitionFilter (Stage.cs:180) and Stage.SystematicSearchFilter (:181) have public setters and are surfaced read-only on StageDto, but they too are absent from StageUpdateDto and are never assigned. They must not be confused with the query-time StudyFilters DTO, which is never persisted. Both facts are recorded as M5.

Affected catalogue families, field by field. Not all of them, and not uniformly. Treating every writable stage setting as reclassifying every Study would produce unnecessary fences, typed 503s and rebuilds, so each field on Stage.UpdateStage (Stage.cs:49-73) and Stage.UpdateQuestions (:76) was checked against the formulas that actually read it.

Writable field What actually reads it Effect on the authoritative result
SessionCountTarget NewStudyFilters.SufficientlyAllocated (Filters.cs:563-581) via GetReviewerStatsForStageAsync (StudyRepository.cs:235-251); the capacity guard (:1570, :1725) Reclassifies Reviewer annotation for every Study in the stage. It does not reclassify Stage annotation or Membership-stage annotation: those use MNS, hardcoded to 2 at StudyStats.cs:313 behind an explicit TODO deferring the SessionCountTarget switch to a later PR
Selected question IDs (Stage.UpdateQuestions) Stage.AllStageAnnotationQuestions at submission time Reclassifies nothing already stored. The project-wide question tally groups by Annotations.QuestionId with no stage dimension (StudyRepository.cs:951-956) and is not refreshed by this route at all; no existing session is reset, because the only writer of session status is a reviewer's own submission (M6)
MaxInProgress, HideExcludedStudiesFromReviewers Stage.HasReachedMaxInProgress / HasReachedMaxInProgressReconciliation (Stage.cs:130-148), surfaced on MembershipStageStats (:20-21) and ReviewerAnnotationStats (StudyRepository.cs:280-282) No Study moves buckets. They flip a boolean per membership-stage scope, recomputed from counts that do not themselves change
ExcludedSessionStatsGrouping MembershipAnnotationSessionStats merged presentation (MembershipAnnotationSessionStats.cs:60-102) No Study moves buckets. It re-combines already-computed per-membership-stage counts
ReviewMode Stage.HasScreeningReview / HasAnnotationReview (Stage.cs:190-194), used to decide whether the reviewer payloads are produced (StudyRepository.cs:190, :194) No formula reads it. It changes whether the Reviewer screening and Reviewer annotation families are emitted for the stage at all — a family presence change, not a reclassification
Active StageReviewService assignment gating only (:51, :302) No statistic reads it
Extraction, EnforceAnnotationTarget, IdleSessionTimeoutMinutes submission validation and reservation scheduling No statistic reads them

Project screening is affected only indirectly, through SessionCountTarget inheriting Project.AgreementThreshold.NumberScreened when the stage's own value is unset (Stage.cs:183-188).

Plan consistency strategy: split by field, not by route. The plan asks for reclassification from approved raw tallies where possible and a configuration fence/rebuild of every dependent scope otherwise, with digest mismatch as a second fail-safe rather than the primary invalidation path. Read against the table above and the admission rule, that resolves to two arms:

  • Staged and fenced asynchronous for SessionCountTarget alone, and only for the Reviewer annotation scopes of that stage: it reclassifies every Study in the stage at once, which exceeds the 500-move limit and usually the 100-document limit. If and when the StudyStats.cs:313 hardcode is replaced by SessionCountTarget, Stage annotation and Membership-stage annotation join this arm — Phase 3 must not assume they are already in it, and must not assume they will stay out.
  • Synchronous point path for every other field, as a configuration-digest advance carrying at most the bounded per-membership-stage recomputation that MaxInProgress, HideExcludedStudiesFromReviewers and ExcludedSessionStatsGrouping imply, and zero signed Study moves. ReviewMode additionally advances the family-presence set for the stage; Active, Extraction, EnforceAnnotationTarget, IdleSessionTimeoutMinutes and a stage description carry the digest advance alone.

The earlier classification of this whole family as staged and fenced is superseded by the split above.

Side-effect rule: no conflict. The write itself performs only Mongo work; there is no broker publish, SignalR send or object-storage call on any of these routes. The residual hazard is the shared one: Save/SaveAsync dispatch domain events in process immediately after the replace (MongoUnitOfWorkBase.cs:241, :269), so any future handler attached to a stage-configuration event would inherit M8. Phase 4 must also decide what the projection stores for a setting that cannot change, since a materialized scope keyed on AllowSelfReconciliation would freeze a default that no route can move.

2.7 Systematic-search import

Owner methods. This family has the longest owner chain in the matrix, spanning four processes.

Stage Method file:line
HTTP, upload signing SearchController.GetS3RequestSignaturePOST api/projects/{projectId}/searches/getSignature; stamps uploadKind = UploadKind.ReferenceUpload (:144) and publishes ISearchUploadStartedEvent (:156) src/services/api/SyRF.API.Endpoint/Controllers/SearchController.cs:104
AWS Lambda, S3 ObjectCreated:* S3FileReceivedHandler.HandleEvent; kind validation at :163, dispatch at :185 src/services/s3-notifier/SyRF.S3FileSavedNotifier.Endpoint/S3FileReceivedFunction.cs:42
Lambda, kind resolution and dispatch UploadEventDispatcher.TryResolveUploadKind (:120) and DispatchAsync (:212); an unknown or absent uploadkind is a logged no-op by design (:26-30) src/services/s3-notifier/SyRF.S3FileSavedNotifier.Endpoint/UploadEventDispatch.cs:120
Lambda, live processor LiveUploadKindProcessor, routing ReferenceUpload to ProcessSearchUploadReceived (:230, publishing ISearchUploadSavedToS3Event at :271) and ReferenceUpdate to ProcessSearchUpdateReceived (:200, submitting IStartBulkStudyUpdateJobCommand at :221 — that branch is 2.16, not this family) S3FileReceivedFunction.cs:467
Saga SearchImportJobStateMachine, states Uploading → Uploaded → Parsing → Completed/Error, correlated on SearchId (:28, :31, :34, :37, :40) with a scheduled UploadTimeout (:41-46) src/services/project-management/SyRF.ProjectManagement.Endpoint/Sagas/SearchImportJobStateMachine.cs:15
Saga activities CreateSearchImportJobActivity (Sagas/CreateSearchImportJobActivity.cs:11), SetFileReceivedActivity (Sagas/SetFileReceivedActivity.cs:10), StartParseJobsActivity, which sends IStartParsingReferenceFileCommand at :38 (Sagas/StartParseJobsActivity.cs:13), CompleteSearchJobActivity (Sagas/CompleteSearchJobActivity.cs:8), FailSearchJobActivity (Sagas/FailSearchJobActivity.cs:8) as listed
Job consumer ReferenceFileParseJobConsumer : IJobConsumer<IStartParsingReferenceFileCommand>; calls ParseReferenceFile (:22) then publishes a completed (:38) or faulted (:49) event src/services/project-management/SyRF.ProjectManagement.Endpoint/Consumers/ReferenceFileParseJobConsumer.cs:11
Fault consumers SearchImportJobErrorConsumer (Consumers/SearchImportJobErrorConsumer.cs:12) and SearchUploadSavedFaultConsumer (Consumers/SearchUploadSavedFaultConsumer.cs:11), both calling FailSearchImportJob as listed
Study persistence ProjectManagementService.ParseReferenceFile, whose save callback is BatchedSaveManyAsync(studies, saveBatchSize, null, …) at :250 with saveBatchSize = 200 declared at :188 src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:167
Visibility gate ProjectManagementService.CompleteSearchImportJob ProjectManagementService.cs:370
Aggregate Project.CompleteSearchImportJob, which adds the identifier to SystematicSearchIds src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:327
Parser dispatch StudyReferenceFileParser.ParseStudiesAsync, selecting the IStudyParseImplementation whose HandlesTypes contains the job's LibraryType (:72) src/libs/project-management/SyRF.ProjectManagement.Core/Services/StudyReferenceFileParser.cs:63

Trigger and source event. HTTP signing, then a published ISearchUploadStartedEvent, then an AWS S3 object-created event, then a published ISearchUploadSavedToS3Event, then the saga, then a MassTransit job command, then published completion or fault events. The plan's historical reasons are studies-imported and search-studies-changed, with source-import-aborted for terminal failure.

Transaction shape today. Study inserts are non-transactional batched upserting bulk writes: each batch becomes one ReplaceOneModel<T> { IsUpsert = true } per aggregate issued as a single BulkWrite (MongoUnitOfWorkBase.cs:369, :481), and the callback passes session: null, so no import batch is ever inside a transaction. Only the terminal completion pair is transactional: CompleteSearchImportJob opens a session (ProjectManagementService.cs:374), starts a transaction (:375), saves the Project and the new SystematicSearch in that session (:379-380), commits (:381), and on failure aborts (:386) and runs the compensating DeleteSystematicSearchStudiesAsync and FailSearchImportJob (:387-388). Parse failure or cancellation instead calls the private DeleteSearchStudies (:567, invoked at :257 and :269), which reaches StudyRepository.DeleteStudiesWithSearchAsync (StudyRepository.cs:743) — one unbounded DeleteManyAsync with no session supplied.

Staged visibility is two-tier, and it is incomplete. Study.SystematicSearchId (src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Study.cs:114) is stamped as each Study is parsed and bulk-upserted, that is, well before the search completes. The SystematicSearch document is created only at completion, so search-level listing cannot see an in-flight import. But project-wide Study queries are not gated at all: StudyRepository.GetStudiesForProject (StudyRepository.cs:572, and the overload at :702), GetStudiesForProjectWithCountAsync (:653) and GetStudiesForProjectWithCountPagedAsync (:666) filter on ProjectId alone, with no join against Project.SystematicSearchIds or any import-state marker. Half-imported Studies are therefore already visible to project-level counts. This is a first-order constraint on the plan's staged-invisibility design and is recorded as M13.

Affected catalogue families. Search/population directly, through study-population and per-search counts; Project screening and Membership screening, because every new Study enlarges the screening denominator; Stage annotation, Membership-stage annotation, Reviewer screening and Reviewer annotation for the same reason; Project/stage derived summaries downstream. Operational progress/presence carries the import's own job progress and stays outside the programme.

Plan consistency strategy: staged and fenced asynchronous. An import is the plan's canonical multi-document operation: allocate one operation identity outside any retryable callback, compare-and-set every affected family publication guard to Fenced, persist per-scope Active fences, commit restartable child batches each carrying its own namespaced child receipt and per-batch signed delta, and publish once through a bounded transaction. No realistic import fits the 500-move, 100-document or 256 KiB point path, and the current 200-document batch is already an order of magnitude past the document limit.

Side-effect rule: conflict. The chain is built out of irreversible external side effects — an S3 signature handed to a browser, a Publish at SearchController.cs:156, a Publish at S3FileReceivedFunction.cs:271, a Send at StartParseJobsActivity.cs:38, and the completion and fault publishes in ReferenceFileParseJobConsumer.cs:38 and :49 — and the compensating deletions in ParseReferenceFile (:257, :269) and CompleteSearchImportJob (:387) are best-effort non-transactional cleanups rather than a rollback. None of them may be moved inside a retryable transaction callback. The plan's answer is exactly the staged protocol above: the broker work stays outside the callbacks and behind the durable notification outbox, and the rollback of a failed import becomes a paged manifest of stable inverse child identifiers rather than a bare DeleteManyAsync.

2.8 Systematic-search deletion

Owner methods. The HTTP surface exists but is fail-closed; the implementation exists but is unreachable.

Layer Method file:line
HTTP, search SearchController.DeleteSearchDELETE api/projects/{projectId}/searches/{searchId}, ProjectRemoveSearchPolicy; the whole body is throw DeletionLifecycleUnavailableException.ForSystematicSearch(_featureFlags.DeletionLifecycle) (:85-86) src/services/api/SyRF.API.Endpoint/Controllers/SearchController.cs:81
HTTP, import job SearchController.DeleteSearchImportJobDELETE api/projects/{projectId}/searches/importJobs/{searchId}, same unconditional throw (:97-98) SearchController.cs:93
Response mapping DeletionLifecycleUnavailableExceptionFilter, mapping to 503 application/problem+json with Extensions["code"] = "deletion_lifecycle_unavailable" (:40, :49, :55, :59); registered globally at src/services/api/SyRF.API.Endpoint/Program.cs:125 src/services/api/SyRF.API.Endpoint/Infrastructure/DeletionLifecycleUnavailableExceptionFilter.cs:38
Orphaned implementation ProjectManagementService.RemoveAndDeleteSystematicSearchFromProjectAsync src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:616
Orphaned implementation ProjectManagementService.RemoveAndDeleteSearchImportJobWithStudiesFromProject ProjectManagementService.cs:583
Private helpers ProjectManagementService.DeleteSearchStudies (:567, the parse-rollback helper) and DeleteSystematicSearchStudiesAsync (:599) as listed
Repository StudyRepository.DeleteStudiesWithSearchAsync (:743), DeleteStudiesFromReferenceFileAsync (:755), DeleteStudiesFromSystematicSearch (:769, which takes no session parameter at all) as listed

Trigger and source event. Two HTTP endpoints, both currently answering 503; the parse-rollback helper is reached only from inside ParseReferenceFile. The plan's historical reason is search-studies-changed.

Transaction shape today. Nothing runs. The orphaned implementation is worth recording because it is what a future delta would have to hook and it already has an atomicity gap: RemoveAndDeleteSystematicSearchFromProjectAsync opens a session and starts a transaction (:618-619), reads the Project and SystematicSearch in that session (:623-624), removes the search from the project, then calls await _unitOfWork.SaveAsync(project) at :626 without passing the session, so the Project replace is not part of the transaction it appears to belong to; only the SystematicSearches.DeleteAsync at :627-628 is. It commits at :640 and only then deletes the Study rows at :641, outside the transaction. Study-row deletion is therefore never atomic with the search delete under any code path. Both facts are recorded as M10.

Affected catalogue families. Once re-enabled: Search/population, Project screening, Membership screening, Stage annotation, Membership-stage annotation, Reviewer screening, Reviewer annotation, Question answers and Domain reconciliation — a search delete removes Studies carrying every one of them — plus Project/stage derived summaries.

Plan consistency strategy: path absent, with a staged and fenced asynchronous design waiting behind it. The plan's row assigns a Project-level hidden-import token, pending-deletion semantics, signed bulk deltas where bounded and a fenced rebuild otherwise; none of that has an owner today, and the PendingDeletion marker the plan's staged protocol names does not exist in the code (M13). Nothing in this family can be instrumented until the deletion lifecycle is built.

Side-effect rule. Not applicable while the endpoints are fail-closed. When the lifecycle is built, the unbounded DeleteManyAsync calls listed above must not become the delete leg of a point-path transaction: an unbounded delete both exceeds the 100-document admission limit and offers no per-Study delta emission point.

2.9 Single Study creation

Owner methods. None. No endpoint or service creates a single Study outside the import pipeline. Excluding tests and build output, new Study( resolves only to the aggregate's own factory (src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Study.cs:96), five parser implementations under src/libs/project-management/SyRF.ProjectManagement.Core/Services/ParserImplementations/ (RisParseImplementation.cs:222, PubmedXmlParseImplementation.cs:126, EndnoteXmlParseImplementation.cs:177, NewParser/StudyEndnoteRecordProcessor.cs:153, NewParser/StudySpreadsheetRecordProcessor.cs:180) and one commented-out BSON class-map creator (src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:2225). StudyController exposes table data, PDF upload signing, PDF-correction submit/approve/reject, risk-of-bias add and bulk-update signing, but no create route.

Trigger and source event. The plan's row is "Study import or single Study creation"; only the import half has an owner. The historical reason is studies-imported.

Transaction shape today. Not applicable.

Affected catalogue families. Would be Search/population and, through the population denominator, every screening and annotation family.

Plan consistency strategy: path absent. The plan assigns transactional signed deltas to a single create, which is the textbook synchronous point path — one document, a handful of signed moves — but there is no owner to attach the envelope to. The practical consequence is that Search/population changes only through import (2.7) and deletion (2.8, 2.14) on main.

Side-effect rule. Not applicable.

2.10 Question create, edit, copy, reorder, detach and delete

Owner methods.

Operation Method file:line
Create and edit, one endpoint ProjectController.UpsertQuestionPUT api/projects/{projectId}/annotationQuestion/{questionId}; aggregate call at :618, SaveAsync at :619 src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:614
Create and edit, aggregate Project.UpsertCustomAnnotationQuestion; the edit branch refuses a parent change (:440-441), refuses editing a system question (:442-444), runs ValidateReferencedChildConditions (:445, method at :450) and then mutates in place via AnnotationQuestion.Update (:446) src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:401
Copy ProjectController.CopyQuestionPOST api/projects/{projectId}/annotationQuestion/{questionId}/copy?recursive=; aggregate call at :639, SaveAsync at :640, result DTO at :661 ProjectController.cs:636
Copy, aggregate Project.CopyAnnotationQuestion, recursing into subquestions at :521 Project.cs:513
Reorder ProjectController.RepositionAnnotationQuestionPUT api/projects/{projectId}/annotationQuestion/{questionId}/{questionAtDestinationId}; aggregate call at :584, SaveAsync at :589 ProjectController.cs:570
Reorder, aggregate Project.RepositionQuestion, which reorders the project-wide question list and, when the question is parented, the parent's SubquestionIds, and blocks moving or targeting a system question Project.cs:1069
Detach from a stage ProjectController.UpdateStageQuestions — see 2.6; omitting an identifier from the full-set replace is the detach ProjectController.cs:741
Delete ProjectController.DeleteQuestionDELETE api/projects/{projectId}/annotationQuestion/{questionId}, ProjectDesignPolicy; carries a live // TODO: optionally retain annotations of deleted questions on studies at :671 and calls the service at :672 ProjectController.cs:669
Delete, service ProjectManagementService.DeleteQuestionAsync src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:39
Delete, repository StudyRepository.RemoveAnnotationsFromStudiesInProjectForQuestionAsync, one PullFilter on ExtractionInfo.Annotations (:909-910) applied by a single project-wide UpdateManyAsync (:912), with no session parameter on the method src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:905
Delete, aggregate Project.DeleteQuestion, which detaches from the parent's SubquestionIds (:538), recurses into children (:542-545), removes the question (:548) and calls RemoveQuestionFromAllStages (:549, method at :894) Project.cs:527

Trigger and source event. HTTP endpoints only. The plan's historical reason is definition-changed.

Detach from a parent question has no operation. UpsertCustomAnnotationQuestion explicitly rejects a parent change (Project.cs:440-441), and the only code that removes a question from its parent's SubquestionIds is deletion, through AnnotationQuestion.UpdateSubquestions (.../ProjectAggregate/AnnotationQuestion.cs:361, called at Project.cs:538). Question order is project-wide, never per-stage: Stage.AnnotationQuestions is an unordered HashSet<Guid> (Stage.cs:96).

Question versioning does not exist. Content is mutated in place with no history by AnnotationQuestion.Update (.../ProjectAggregate/AnnotationQuestion.cs:218). AQVersion, QuestionSetVersion and SessionVersion return zero matches across src. The immutable identity-plus-versions pattern is target architecture, not current state, which bears directly on the plan's requirement that question scopes carry immutable definition and version identifiers.

Transaction shape today. Create, edit, copy and reorder are each an in-memory aggregate mutation followed by one upserting whole-Project replace — bounded, single-document, no transaction. Delete is different and is the largest atomicity gap in the surface: DeleteQuestionAsync computes the affected identifier set from Project.GetDescendantAnnotationQuestions (ProjectManagementService.cs:41-43; aggregate helper at Project.cs:510), issues one project-wide UpdateManyAsync that pulls every matching annotation off every Study in the project (:46-47), and only then mutates and saves the Project (:51-52). The two writes are to two different collections with no transaction between them, so a failure after the first leaves annotation data already destroyed while the question and its stage assignments still exist. There is no compensating action — M4.

Neither delete nor any other question operation refreshes Project.AnnotationQuestionAnswerTally; the stored tally simply goes stale until the manual refresh of 2.15 is invoked by hand. Verified by inspection: none of UpsertQuestion (ProjectController.cs:614), CopyQuestion (:636), RepositionAnnotationQuestion (:570), DeleteQuestion (:669) or UpdateStageQuestions (:741) references UpdateAnnotationQuestionAnswerTally; the only caller in the controller is the dedicated manual endpoint, which invokes the service at ProjectController.cs:657.

Affected catalogue families. Question answers only.

Delete does not move the session-statistic families. RemoveAnnotationsFromStudiesInProjectForQuestionAsync is a single UpdateManyAsync whose only update is PullFilter(s => s.ExtractionInfo.Annotations, …) (StudyRepository.cs:905-915). It removes Annotation entries and nothing else: it does not touch ExtractionInfo.Sessions, does not change any AnnotationSession.Status, and does not rewrite ExtractionInfo.SessionTallies, which are derived from Sessions and Study.SlotReservations alone (ExtractionInfo.cs:30-77). Those three are the sole inputs to the Stage annotation, Membership-stage annotation, Reviewer annotation and Domain reconciliation calculations (StudyStats.cs:284-287; StudyRepository.cs:225-282), so all four are unchanged by a question delete under current authoritative behaviour. Classifying them as affected would invite Phase 3 to emit synthetic session-reset moves that the authoritative calculation never makes, and parity would fail on exactly those moves — the concrete form of the absent reset semantics recorded as M6.

This narrows the families, not the fence. The fence classification below is unchanged and is mandated by the plan for a different reason: an unbounded project-wide annotation rewrite is visible to readers while it is partially applied, so the Question answers family must be fenced for the duration regardless of how many other families the rewrite moves.

Plan consistency strategy: split. Create, edit, copy, reorder and stage detach are a synchronous point path: each touches one Project document and a bounded set of question-scope buckets, comfortably inside the 500-move, 100-document and 256 KiB limits, and the plan's requirement is to retain old history identity rather than to move population buckets. Delete is a source-visibility fence: the plan's definition-change row classifies a whole-Project annotation rewrite as a fence with the same semantics as the inclusion recalculation, carried by a durable definition-rewrite token recorded on the Project control row before the first Study write, with typed 503 for affected authoritative bundles and checkpoint capture/publication rejected while it is active, released only by the matching Project definition save. It is not the bulk/import staged publication protocol: the $pull is an unbounded whole-project rewrite whose affected Study count is unknown before execution, so it can never be admitted to the point path, and Phase 3 is required to reorder the current owner to fence first, rewrite second, save-and-release third.

The plan's row also asks implementers to "apply known session-reset bucket moves". No such moves exist to apply: the completed-to-incomplete session reset semantics the earlier planning prose assumed are not implemented on main. The only writer of session status is AnnotationSession.UpdateStatus (.../StudyAggregate/AnnotationSession.cs:36), whose sole caller is ExtractionInfo.AddAnnotations (.../StudyAggregate/ExtractionInfo.cs:164-169) — a reviewer's own submission, never a question or stage edit. Recorded as M6.

Side-effect rule: no conflict, but delete cannot be wrapped as written. No question route performs a broker publish, SignalR send or object-storage call, so nothing in the family conflicts with the no-irreversible-side-effect rule. The delete path nevertheless cannot become a single retryable callback in its current shape, because the unbounded UpdateManyAsync breaches the 100-document admission limit; it must record the definition-rewrite token and fence every affected scope first, and release only with the Project definition save.

2.11 Membership add, update, disable and remove

Owner methods. Two of the four operations the plan's row names do not exist on main. Everything that does exist is an action on ProjectController mutating the Project aggregate.

Operation HTTP owner Aggregate owner Persist
Invite ProjectController.InviteMembersPOST api/projects/{projectId}/invitations (ProjectController.cs:427) Project.Invite per address (Project.cs:1169), via ProjectManagementService.InviteInvestigatorsToProjectAsync (ProjectManagementService.cs:460) one SaveAsync for the whole batch (ProjectManagementService.cs:474)
Accept invitation ProjectController.RespondToInvitationPOST api/projects/{projectId}/invitations/respond (:465) Project.RespondToInvitation (Project.cs:1239) → CreateMembership(invitation) (:1251, method at :1163) await SaveAsync (ProjectController.cs:483)
Self-serve join request ProjectController.RequestToJoinPOST api/projects/{projectId}/join-requests (:316) Project.RequestToJoin (Project.cs:565) → CreateMembership on auto-approval (:587, method at :1157) synchronous Save (ProjectController.cs:320)
Cancel join request ProjectController.CancelJoinRequestDELETE api/projects/{projectId}/join-requests (:343) Project.CancelPendingJoinRequest (Project.cs:1102) synchronous Save (:347)
Approve join requests, bulk ProjectController.ApproveJoinRequestsPOST api/projects/{projectId}/join-requests/approve (:368) Project.ApprovePendingJoinRequest per identifier (Project.cs:1119) → CreateMembership(joinRequest) (:1135) one await SaveAsync (ProjectController.cs:383)
Decline join requests, bulk ProjectController.DeclineJoinRequestsPOST api/projects/{projectId}/join-requests/decline (:392) Project.DeclinePendingJoinRequest (Project.cs:1139) one await SaveAsync (:408)
Update member groups, single ProjectController.UpdateMembershipPUT api/projects/{projectId}/investigators/{investigatorId}/groups (:803) Project.ReplaceMemberGroups (Project.cs:650) → ProjectMembership.ReplaceGroups (ProjectMembership.cs:212) synchronous Save (:810)
Update member groups, bulk ProjectController.UpdateMembershipsPOST api/projects/{projectId}/investigators (:817) Project.ReplaceMemberGroups over a list (Project.cs:657) synchronous Save (:824)
Change project owner ProjectController.UpdateProjectPATCH api/projects/{projectId} (:258) Project.Update (Project.cs:749) calling ChangeProjectOwnership when OwnerId differs (:757-760, method at :770, which requires existing membership at :772-773 and raises ProjectOwnerChangedEvent at :776) synchronous Save (ProjectController.cs:273)
Disable membership none ProjectMembership.DisableMembership is a NotImplementedException stub (ProjectMembership.cs:237-240), reachable only from Project.DisableMembership (Project.cs:597), which no controller calls not applicable
Remove member none no RemoveMember, RemoveMembership or RemoveInvestigator exists anywhere in src; the nearest operation is revoking a pending invitation not applicable

Trigger and source event. HTTP endpoints only. The plan's historical reason is membership-changed.

The non-obvious owner is the ownership change. It is not on a membership route at all: it rides the generic project JSON-Patch route through Project.UpdateChangeProjectOwnership. A delta keyed only to the /investigators and /invitations routes would miss it entirely (M11).

Transaction shape today. Every operation is a single-aggregate in-memory mutation followed by one upserting whole-Project ReplaceOne filtered on _id and Audit.Version. There is no transaction on any of them. The bulk operations — invite N addresses, approve or decline N join requests, regroup N members — mutate N sub-entities and commit them in a single document replace. That is convenient for the programme, because one HTTP request yields exactly one delta, but it also means a single Audit.Version increment can represent an arbitrary number of membership changes, so the delta record must enumerate the affected membership scopes rather than infer them from the revision step.

Affected catalogue families. Membership screening, Membership-stage annotation, Reviewer screening and Reviewer annotation directly, since each is scoped by membership. Project screening, Stage annotation and Domain reconciliation are affected only through the active-membership guard: ReviewSubmissionService refuses both screening and annotation submissions from a non-active member (.../Services/ReviewSubmissionService.cs:67-72, predicate Project.IsActiveMember at Project.cs:606), so a membership change alters who can subsequently move any of those counters. Project/stage derived summaries follow.

Plan consistency strategy: point path when the canonical effect is admitted, staged and fenced otherwise. One Project document changes and the affected membership scopes are enumerable before the write, which makes the point path available — but availability is not admission, and this family cannot be classified as unconditionally synchronous.

Nothing bounds the size of a bulk request. AcceptJoinRequestsDto.InvestigatorIds and UpdateProjectMembershipsDto.InvestigatorIds are plain IEnumerable<Guid> with no length attribute and no validation (ProjectController.cs:413, :896), and the controller iterates the collection directly (:373-377, :822-823). Each accepted member creates a membership scope plus a reviewer scope plus one membership-stage scope per stage, so a request approving n investigators in a project with s stages yields on the order of n * (2 + s) affected scopes. At eight stages — the stats-large-v1 shape — 50 approvals already reach 500 moves, and the projection documents those scopes land in can exceed the 100-document limit sooner still. "A realistic cohort" is a prediction about user behaviour, not an enforceable admission condition, and the plan's admission rule is evaluated per request, not per expectation.

Phase 3 must therefore compute the canonical effect before admission — enumerate the membership, reviewer and membership-stage scopes the request will touch, and count the signed moves and target documents — and route the request over the point path only when that count is inside the 500-move, 100-document and 256 KiB limits. An over-limit batch takes the staged and fenced asynchronous path with the same operation identity and receipt discipline as any other bulk operation. Nothing here changes the write itself: the source mutation remains one Project replace either way.

The plan additionally requires the project-wide visibility slot to advance whenever access is granted or revoked, and current authorization to be re-evaluated at read time rather than baked into the stored row. Because no disable or remove path exists, the "fence only non-additive effects" arm of the plan's row has no live trigger today.

Side-effect rule: no conflict, with one dispatcher caveat. No membership route performs a broker publish, SignalR send or object-storage mutation inside its write. ChangeProjectOwnership appends a ProjectOwnerChangedEvent (Project.cs:776) which the in-process dispatcher runs immediately after the replace (MongoUnitOfWorkBase.cs:241 for the synchronous Save used by this route), so any handler attached to it inherits M8 once the write is wrapped in a transaction.

2.12 Project and stage graph-permission change

Owner methods.

Layer Method file:line
HTTP, stage ProjectController.UpdateStagePermissionsPOST api/projects/{projectId}/stages/{stageId}/permissions, ProjectAssignPermissionsPolicy, body IEnumerable<UpdateStagePermissionDto>; loops the aggregate call at :837-838 and persists with the synchronous Save at :841 src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:831
HTTP, project ProjectController.UpdateProjectPermissionsPOST api/projects/{projectId}/permissions, same policy; loops at :857-858, persists at :861 ProjectController.cs:850
Aggregate, stage Project.UpdateStagePermission (singular), delegating at :1297 src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:1293
Aggregate, project Project.UpdateProjectPermission (singular), delegating at :1303 Project.cs:1300
Entity, stage StageSecuritySettings.UpdatePermission, which calls into the permission collection at :76 src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/StageEntity/Security/StageSecuritySettings.cs:74
Entity, project ProjectSecuritySettings.UpdatePermission, which calls into the permission collection at :134 src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Security/ProjectSecuritySettings.cs:132
Collection PermissionCollectionWithDefaults<T>.UpdatePermission(string activity, Action<T> updateAction) src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/PermissionCollectionWithDefaults.cs:177

The aggregate methods keep the singular names the plan's event row uses; the controller actions are plural. A plan or ticket citing UpdateStagePermission as an endpoint name is stale by one character.

Trigger and source event. Two HTTP endpoints. The plan's historical reason is statistics-authorization-changed.

Transaction shape today. An in-memory mutation of N permission entries on the loaded Project, followed by one upserting whole-document ReplaceOne filtered on _id and Audit.Version. Both actions are declared async yet persist through the synchronous _pmUnitOfWork.Save(project) (MongoUnitOfWorkBase.cs:226). No MongoDB session or transaction appears anywhere in ProjectController.cs.

Affected catalogue families. No statistic value changes. What changes is the authorized response shape across every family a permission can expose: Project screening, Membership screening, Stage annotation, Membership-stage annotation, Reviewer screening, Reviewer annotation, Question answers, Search/population, Domain reconciliation and Project/stage derived summaries. The permission decides which membership and reviewer scopes a caller may legitimately be served, which is exactly the boundary the plan's authorization rules police.

Plan consistency strategy: synchronous point path, in its degenerate form. The plan is explicit that this event re-evaluates current authorization without rewriting statistics values, and that the source transaction must advance ClientInvalidationRevision and the project-wide visibility slot observed by every family subscription, plus any narrower affected-family slots the catalogue requires. The canonicalized effect is therefore zero signed bucket moves and a small fixed number of control-document updates — trivially inside the 500-move, 100-document and 256 KiB limits.

Side-effect rule: no conflict. The write performs only Mongo work; there is no publish, send or external call on either route. The one design constraint the plan imposes here is that the invalidation must be durable: because the visibility-slot advance is the only observable effect of this family, it cannot be delivered by a best-effort SignalR send after the fact and must go through the transactional notification outbox with the source write.

2.13 Durable slot reservation claim, release, timeout and expiry

Owner methods.

Phase Method file:line
Claim, SignalR NotificationHub.JoinStudyReview, StageReviewSignalRPolicy; resolves the stage target, chooses the screening or annotation claim through StageReviewService.ShouldClaimAsScreening, then calls the repository claim src/services/api/SyRF.API.Endpoint/SignalR/NotificationHub.cs:418
Claim, HTTP and direct navigation StageReviewService.GetRandomStudyAsync (:41), AtomicAssignStudyAsync (:118), AtomicAssignRandomStudyForAnnotationAsync (:138), AtomicAssignRandomStudyForScreeningAsync (:194), AtomicAssignRandomStudyForScreeningOrAnnotationAsync (:260), EnsureActiveReviewSessionForDirectNavigationAsync (:293) src/libs/project-management/SyRF.ProjectManagement.Core/Services/StageReviewService.cs:41
Claim, repository StudyRepository.TryAtomicAssignStudyAsync (:1400) and TryAtomicAssignScreeningStudyAsync (:1413), both delegating to TryAtomicAssignStudyCoreAsync src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:1426
Claim, pipeline StudyRepository.BuildAssignmentCapacityFilter (:1710) and BuildAssignmentPipeline (:1861), with the tally map at BuildTallyMapExpression (:2002) as listed
Release, voluntary NotificationHub.LeaveStudyReview; Study.RemoveSlotReservation at :678, persisted by the non-upserting TrySaveExistingAsync at :681, which throws a HubException on a version conflict, then CloseReviewerPresence at :688 NotificationHub.cs:643
Disconnect handling NotificationHub.HandleReviewSessionDisconnect, reached from OnDisconnectedAsync (:87, call at :99) NotificationHub.cs:117
Presence repository ReviewerPresenceRepository.GetOrCreateCurrentAsync, a FindOneAndUpdateAsync upsert .../Repositories/ReviewerPresenceRepository.cs:108
Connection repository ReviewSessionConnectionRepository.SaveConnectionAsync, an upserting ReplaceOneAsync .../Repositories/ReviewSessionConnectionRepository.cs:143
Schedule tokens StudyRepository.SetSlotReservationIdleScheduleTokenAsync (:1455, write at :1481) and SetSlotReservationSuspendedScheduleTokenAsync (:1487, write at :1511) as listed

Four MassTransit consumers own the expiry transitions, all under src/services/project-management/SyRF.ProjectManagement.Endpoint/Consumers/:

Consumer file:line Message Scheduled by
MarkSessionIdleConsumer MarkSessionIdleConsumer.cs:20 IMarkSessionIdleCommand NotificationHub.ScheduleMarkSessionIdle (NotificationHub.cs:1084) and ReviewController.ScheduleReservationIdleCleanupAsync (ReviewController.cs:652)
RemoveIdleSessionConsumer RemoveIdleSessionConsumer.cs:24 IRemoveIdleSessionCommand MarkSessionIdleConsumer
RemoveSuspendedSessionConsumer RemoveSuspendedSessionConsumer.cs:23 IRemoveSuspendedSessionCommand NotificationHub.cs:227 and CheckConnectionLivenessConsumer
CheckConnectionLivenessConsumer CheckConnectionLivenessConsumer.cs:27 ICheckConnectionLivenessCommand NotificationHub.ScheduleCheckConnectionLiveness (NotificationHub.cs:1105), called from JoinStudyReview (:617) and Heartbeat (:858)

Trigger and source event. SignalR hub methods (JoinStudyReview, LeaveStudyReview, Heartbeat, OnDisconnectedAsync), HTTP claim paths on ReviewController, and MassTransit scheduled commands. The plan's historical reason is annotation-reservation-*.

The durable timer is a message, not a job class. All four scheduling call sites use MassTransit IMessageScheduler.SchedulePublish<T>. The bus is configured with cfg.AddPublishMessageScheduler() and rCfg.UsePublishMessageScheduler() (src/libs/webhostconfig/SyRF.WebHostConfig.Common/Extensions/MassTransitHelpers.cs:43, :56), and the scheduling message is consumed by the separate SyRF.Quartz service (src/services/quartz/SyRF.Quartz/QuartzServiceCollectionExtensions.cs:139), which persists it to SQL Server and republishes the command at the due time. There is no IJob implementation to hook: the durable timer lives in a different database from every statistic it will eventually move.

Transaction shape today. Single-document atomic operations throughout, and never more than one document at a time. The claim is the only genuine server-side pipeline update in the whole statistics surface: one FindOneAndUpdateAsync with an update pipeline and ReturnDocument.After (StudyRepository.cs:1443-1446), gated by a feature-flag early return (:1435-1436). Its four stages are worth naming exactly, because they are the closest thing on main to a signed bucket move:

  1. append the new SlotReservation to Study.SlotReservations with $concatArrays;
  2. seed a zero-valued SessionTally for the stage when none exists;
  3. $map the tally array, incrementing NumberOfSlotReservations by one on the matching stage and recomputing the stored sums TotalAllocatedSessionCount = NumberOfCandidateSessions + NumberOfSlotReservations and TotalEngagedSessionCount = NumberOfCandidateSessions + NumberOfDirtySlotReservations — for every tally in the array, matching or not, so a claim on one stage silently repairs another stage's stored sums (BuildTallyMapExpression, :2002-2067);
  4. $mergeObjects the Audit subdocument to increment Audit.Version and refresh LastModified, so a concurrent optimistic-lock save using the pre-claim version fails its filter (:1944-1982).

Every other path in this family writes the Study and the ReviewerPresence as two separate, non-transactional operations, and every presence write passes session: null explicitly: clean disconnect at NotificationHub.cs:192-195; involuntary suspension at :219-246, which schedules the removal command at :227 before persisting the study at :241 and the presence at :246; RemoveIdleSessionConsumer.cs:98 and :139; RemoveSuspendedSessionConsumer.cs:62 and :67; CheckConnectionLivenessConsumer.cs:150 and :155. The sole exception anywhere in the codebase is the annotation submission of 2.3.

The two schedule-token writers are a distinct write shape that the plan's revision clock must model: each is a positional SlotReservations.$ UpdateOneAsync that explicitly increments Audit.Version and stamps Audit.LastModified, LastModifiedBy and LastAppVersion without going through OnSaving or a whole-document replace (StudyRepository.cs:1473-1481, :1503-1511). A reservation-bookkeeping change therefore advances the source revision that a delta record would be fenced against (M12).

Affected catalogue families. Two, not six. Each was checked against the formula that would have to move, because a delta emitted for a family whose authoritative result does not change is a guaranteed parity failure.

Family Moves on a claim or release? Evidence
Reviewer annotation Yes NewStudyFilters.SufficientlyAllocated reads SessionTally.TotalAllocatedSessionCount (Filters.cs:563-575), which is NumberOfCandidateSessions + NumberOfSlotReservations (SessionTally.cs:57). It feeds the available and allocated-by-others counts of GetReviewerStatsForStageAsync (StudyRepository.cs:235-251). Gated on the ActiveReviewerTrackingAvailable flag: with tracking off the filter falls back to NumberOfCandidateSessions (Filters.cs:575-581) and this family stops moving too
Stage annotation Yes, indirectly A claim on a stage with no existing tally seeds a zero-valued SessionTally (StudyRepository.cs:1878-1879; the same row is produced in-aggregate by the reservation-only branch of the SessionTallies getter, ExtractionInfo.cs:68-75). That row enters SessionedGroupStage, so Sessioned.Count and Sessioned.Available each gain one, and the (NCS, NCCS) distribution gains a real (0, 0) cell (StudyStats.cs:286-287, :369-383)
Membership-stage annotation No The availability formula is allCount − sessioned.Count + sessioned.Available − investigatorStats.NumberOfStudiesSessionAvailable (StudyStats.cs:153-156). The seeded tally increments Count and Available together, so the two cancel exactly. Full, Completed and CompletedAndNotStartedReconciliation all test NCS/NCCS against MNS = 2 and are unmoved by a row whose NCS and NCCS are zero
Membership screening, Reviewer screening No The screening filters read screening decisions only — Filters.AvailableForScreening is InsufficientlyScreenedStudies & !ScreenedByInvestigator (Filters.cs:140-147) — and never consult SlotReservations or SessionTallies
Domain reconciliation No Reconciliation availability is HasMinCompletedStageSessions, i.e. NumberOfCompletedCandidateSessions >= MNS (Filters.cs:313-324), and the stage counters gate on the same field plus ReconciliationStarted/ReconciliationCompleted. A reservation changes none of them, and reconciliation submission explicitly does not remove a reservation (ReviewSubmissionService.cs:60-64)

Classifying the four "No" families as affected would make Phase 3 emit synthetic moves that the authoritative calculation never makes, so they are recorded as unaffected here.

Operational progress/presence carries the ReviewerPresence rows and connection records themselves and stays outside the programme, which is exactly the plan's distinction: "presence alone stays operational".

One unresolved interaction with Stage annotation. The synthetic no session cell that CreateAnnotationSessionStats appends is (0, 0, totalCount − sessioned.Count) (StudyStats.cs:230), and the reservation-only tally described above contributes a real (0, 0) cell to the same distribution. Both then flow into GroupBy(NCS).ToDictionary(key, gp => gp.ToDictionary(NCCS, …)) (:231-232), which admits only one entry per (NCS, NCCS) pair. Phase 3 must not assume the two cells are distinguishable. This is a property of the authoritative calculation on main, not of the projection, and is out of scope for FEAT-024; it is recorded here so the parity work does not silently inherit it, and is raised for a separate correctness issue.

Plan consistency strategy: synchronous point path. The plan requires a synchronous signed transition in the reservation transaction wherever a live formula depends on durable reservation state, and the claim already computes precisely such a transition: one Study, one stage tally, a fixed handful of signed moves — far inside the 500-move, 100-document and 256 KiB limits. Phase 3's work is not to bound this family but to give it a transaction it does not currently have, so that the pipeline update, the receipt and the delta commit together.

Side-effect rule: conflict. The involuntary-disconnect path calls SchedulePublish<IRemoveSuspendedSessionCommand> at NotificationHub.cs:227, before both of its source writes, and ScheduleMarkSessionIdle (:1084) and ScheduleCheckConnectionLiveness (:1105) do the same on the join and heartbeat paths. Each is an irreversible external side effect that would be re-executed on every retry of a wrapped callback. The existing mitigation is a generation identifier that makes an unpersisted schedule harmless when it fires, and Phase 3 should keep it as the idempotency fence while moving the schedule itself into the durable transactional notification outbox. The claim pipeline itself is clean: TryAtomicAssignStudyCoreAsync performs only Mongo work.

2.14 Project deletion

Owner methods.

Layer Method file:line
HTTP ProjectController.DeleteProjectDELETE api/projects/{projectId}, ProjectDeletePolicy; the whole body is throw DeletionLifecycleUnavailableException.ForProject(_featureFlags.DeletionLifecycle) (:289-290), with the rationale in the comment at :286-288 src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:284
Response mapping DeletionLifecycleUnavailableExceptionFilter — the same 503 problem+json mapping as 2.8 .../Infrastructure/DeletionLifecycleUnavailableExceptionFilter.cs:38
Feature flag FeatureFlags.DeletionLifecycle, read only to choose a message string (DeletionLifecycleUnavailableExceptionFilter.cs:21-25); there is no live if (flag) branch src/libs/kernel/SyRF.SharedKernel/Settings/FeatureFlags.cs:35
Unreachable implementation ProjectManagementService.DeleteProjectAsync, declared on IProjectManagementService and with zero production callers src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:538

The fail-closed behaviour is proven by test rather than asserted: ProjectControllerTests runs a [Theory] over both flag values and additionally verifies DeleteProjectAsync is never called (src/services/api/SyRF.API.Endpoint.Tests/ProjectControllerTests.cs:96-111).

Trigger and source event. One HTTP endpoint, currently answering 503. The plan's row states that project deletion produces no new user-visible checkpoint.

Transaction shape today. Nothing runs. The unreachable implementation is recorded because it is what a delta would eventually hook and because its cascade is incomplete. It builds SystematicSearchRemovedFromProjectEvents (:541-543), opens a session and starts a transaction (:545-546), deletes the project's SystematicSearch documents and the Project itself inside it (:549-550), aborts and rethrows on failure (:554), commits (:561), dispatches the events (:562) and only then, outside the transaction, loops DeleteSystematicSearchStudiesAsync per search (:563-564) into a plain sessionless DeleteManyAsync (StudyRepository.cs:743). The helper's own error message concedes the gap, telling the operator that orphaned studies "should be deleted manually" (ProjectManagementService.cs:607-609). Investigator and membership documents, pmDataExportJob rows, bulk-PDF job records and all external S3 and Quartz-scheduled state are not touched at all.

Affected catalogue families. Every one — Project screening, Membership screening, Stage annotation, Membership-stage annotation, Reviewer screening, Reviewer annotation, Question answers, Search/population, Domain reconciliation and Project/stage derived summaries — plus the projection's own project scope. A project delete is the single operation that must retire an entire FEAT-024 project control and summary document rather than move buckets within it.

Plan consistency strategy: path absent. The plan assigns "tombstone and deny reads, then policy-driven purge", which is a lifecycle this codebase does not have: PendingDeletion returns zero matches across src, there is no soft-delete marker and no sweep. Nothing can be instrumented here until the deletion lifecycle is built, and the plan's tombstone semantics should be designed together with it rather than retrofitted onto the unreachable method.

Side-effect rule. Not applicable while the endpoint is fail-closed. When the lifecycle is built, the per-search DeleteManyAsync loop must not sit inside a retryable callback: it is unbounded, it exceeds the 100-document admission limit, and it currently runs after a commit it cannot be rolled back into.

2.15 Legacy manual question tally refresh

Owner methods.

Layer Method file:line
HTTP ProjectController.UpdateAnnotationAnswerTallyPUT api/projects/{projectId}/update-annotation-answer-tally, ProjectDesignPolicy; calls the service at :657 src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:655
Service ProjectManagementService.UpdateAnnotationQuestionAnswerTally — reads the project, runs the aggregation (:517), applies it (:518) and saves (:519) src/libs/project-management/SyRF.ProjectManagement.Core/Services/ProjectManagementService.cs:514
Aggregate Project.UpdateAnnotationQuestionAnswerTally; stored state at Project.AnnotationQuestionAnswerTally (:205), derived dictionary at :207-209, field-name constant at :211 src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/Project.cs:213
Read model StudyRepository.GetAnnotationQuestionAnswerTally, a pmStudy aggregation that matches the project and unwinds ExtractionInfo.Annotations src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:947

Trigger and source event. One HTTP endpoint, invoked manually. Nothing schedules it, and — as 2.10 records — no question or annotation mutation calls it. The plan's historical reason is legacy-question-tally-refresh.

Transaction shape today. A read-then-write with no transaction: an unbounded project-wide aggregation over pmStudy, then one upserting whole-Project ReplaceOne filtered on _id and Audit.Version. Annotation submissions that commit while the aggregation is running are simply absent from the tally until the next manual refresh, and nothing records that the stored value is stale.

Affected catalogue families. Question answers directly; Stage annotation and Membership-stage annotation only in the sense that the tally is derived from the same submitted annotations.

Plan consistency strategy: staged and fenced asynchronous until cutover retires it. The plan's row is unambiguous that this path is superseded after the transactional materializer cutover and remains authoritative before it, so the programme's obligation is to preserve its current answer, not to instrument it. Classified against the admission rule, the refresh is a whole-project recompute whose document count is unbounded, so it could never be admitted to the point path; if it must run during the migration window it belongs behind the same fence as any other rebuild.

This family is nonetheless the most useful precedent in the codebase, because it is the only existing materialized statistic stored on Project, and it demonstrates the exact failure mode the programme exists to remove: a derived value with no revision, no digest, no staleness marker and a human refresh button.

Side-effect rule: no conflict. The path performs only Mongo work.

2.16 Bulk Study update paths

Owner methods. The statistics-relevant path is the ReferenceUpdate upload kind, which is the only route on main that writes screening decisions in bulk.

Stage Method file:line
HTTP, upload signing StudyController.GetBulkStudyUpdateS3RequestSignaturePOST api/projects/{projectId}/studies/getSignatureForBulkStudyUpdate src/services/api/SyRF.API.Endpoint/Controllers/StudyController.cs:203
AWS Lambda S3FileReceivedHandler.ProcessSearchUpdateReceived, submitting IStartBulkStudyUpdateJobCommand at :221 src/services/s3-notifier/SyRF.S3FileSavedNotifier.Endpoint/S3FileReceivedFunction.cs:200
Job consumer StartBulkStudyUpdateJobConsumer : IJobConsumer<IStartBulkStudyUpdateJobCommand>, declared in the misleadingly named file StudyUpdateJobFileSavedToS3Comsumer.cs (endpoint pinned by the definition class at :75) src/services/project-management/SyRF.ProjectManagement.Endpoint/Consumers/StudyUpdateJobFileSavedToS3Comsumer.cs:14
Worker StudyReferenceFileParser.ParseBulkStudyUpdateAsync; partitions rows by whether they carry a ScreeningUpdate (:166-167), then batches 400 (:173, :214) with at most 8 concurrent batches behind a semaphore (:169-170) src/libs/project-management/SyRF.ProjectManagement.Core/Services/StudyReferenceFileParser.cs:137
Screening rows Study.AddScreening per screener/decision pair (:195), persisted by _unitOfWork.SaveManyAsync(studiesToSave) (:200) as listed
Non-screening rows StudyRepository.ApplySimpleUpdates, a BulkWriteAsync of one UpdateOneModel<Study> per row setting CustomId and PdfRelativePath (:939), invoked at StudyReferenceFileParser.cs:231 src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:917

For completeness, the other direct bulk writers against pmStudy are MarkBulkPdfDeliveredAsync (StudyRepository.cs:106, BulkWriteAsync at :152, bulk-PDF markers only), the three deletion methods of 2.8 (:743, :755, :769), the question-delete $pull of 2.10 (:905), the three inclusion passes of 2.5 (:1224) and the reservation writers of 2.13 (:1426, :1455, :1487). Every one of them issues its write without a session.

Trigger and source event. HTTP signing, then an S3 object-created event, then a MassTransit job command. The plan's historical reason is studies-bulk-changed.

Transaction shape today. Non-transactional throughout, and uniquely unguarded. Rows carrying a screening update are applied by Study.AddScreening on in-memory aggregates and committed by SaveManyAsync, which builds one upserting ReplaceOneModel<Study> per aggregate and issues a single BulkWrite per batch (MongoUnitOfWorkBase.cs:369). That path therefore bypasses both guards that protect an interactive screening submission: the capacity filter and the retry loop of StudyRepository.SaveScreeningWithCapacityGuardAsync (2.1) are never involved. The version filter itself is not bypassed: SaveManyAsync builds each ReplaceOneModel from GetFilter(ar.Id, ar.Version) (MongoUnitOfWorkBase.cs:411-415), so a stale row does not silently overwrite — it fails the batch with a duplicate-key error, as Section 1 explains, and because the bulk write is ordered by default it aborts the rest of that batch mid-flight. Up to eight batches of 400 documents run concurrently. Recorded as M3.

Affected catalogue families. Project screening, Membership screening and Reviewer screening directly, because AddScreening is the same aggregate mutation the interactive path uses; Stage annotation, Membership-stage annotation, Reviewer annotation and Domain reconciliation at the included/excluded boundary once the rewritten decisions change inclusion class. The non-screening branch changes CustomId and PdfRelativePath only and touches no approved catalogue family, so it is outside the programme.

Plan consistency strategy: staged and fenced asynchronous. The plan names this operation explicitly: a two-phase operation with stable operation and child identifiers, per-batch signed deltas and an Active fence until final reconciliation. A single 400-document batch already exceeds the 100-document admission limit four times over, and a full file exceeds the 500-move limit, so no part of this family can take the point path. The plan's child-receipt rule matters here more than anywhere else: each of the eight concurrent batches needs its own namespaced receipt, because the parent operation identity cannot resolve which of eight in-flight batches an unknown commit belongs to.

Side-effect rule: conflict. The chain is initiated by an S3 upload and a SubmitJob broker call (S3FileReceivedFunction.cs:221), neither of which may enter a retryable callback. Two further properties make this family the hardest in the matrix to instrument. First, the concurrency is unsynchronized with respect to interactive screening: nothing prevents a reviewer from submitting a decision on a Study that a bulk batch is simultaneously writing. Whichever write loses the race fails its version filter and raises a duplicate-key error rather than losing quietly — but the bulk path has no retry loop, so on the bulk side the loss surfaces as an aborted ordered batch with the earlier rows already applied, and the operation as a whole is neither atomic nor resumable. Second, Study.AddScreening reaches ScreeningInfo.ScreenStudy, so a bulk row for a screener who has already screened the Study is an in-place decision overwrite with no prior value retained (2.1); the before-classification for every affected Study must therefore be captured from the loaded documents inside each batch, not derived from counts.

3. Consolidated event matrix

This table supersedes the baseline matrix in Event and invalidation contract by naming the actual owner of each row on main. It changes no strategy the plan fixes; where a row's owner does not exist, that is recorded as an absence rather than treated as licence to redesign the row. The Phase column is the earliest phase at which the row must be instrumented, derived from the affected families' dispositions in Catalogue baseline; a row that spans families carries the earliest phase, with the later one noted.

Source event or operation Owner method on main Affected families Consistency strategy Historical reason Phase
Screening submit, correction or rescreen StudyRepository.SaveScreeningWithCapacityGuardAsync (StudyRepository.cs:1620), entered from ReviewController.cs:226/:279 via ReviewSubmissionService.AddScreening (ReviewSubmissionService.cs:54) Project screening; Membership screening; Reviewer screening; then Stage annotation, Membership-stage annotation, Reviewer annotation, Domain reconciliation at the included/excluded boundary Synchronous point path screening-submitted / screening-corrected 2, annotation dependants 3
Screening decision deletion or reset none would be the same families Path absent (M2) screening-reset
Candidate annotation session save or complete SubmitAnnotationSessionService.SubmitAsync (SubmitAnnotationSessionService.cs:36) Stage annotation; Membership-stage annotation; Reviewer annotation; Domain reconciliation; Question answers Synchronous point path annotation-session-* 3
Candidate annotation session delete ReviewController.RemoveSession (ReviewController.cs:56) Stage annotation; Membership-stage annotation; Reviewer annotation; Domain reconciliation; Operational progress/presence Synchronous point path, restructuring required annotation-session-* 3
Screening threshold or agreement setting change Project.UpdateAgreementMode (Project.cs:1306) → UpdateStudyScreeningStatsConsumer.Consume (UpdateStudyScreeningStatsConsumer.cs:17) → StudyRepository.UpdateStudyInclusionInfoForProjectAsync (:1224) Project screening; Membership screening; Reviewer screening; every annotation family at the inclusion boundary; Project/stage derived summaries Source-visibility fence screening-configuration-changed / inclusion-info-recalculated 2
Statistics-affecting stage configuration change ProjectController.UpdateStage (:701), AddStage (:683), UpdateStageQuestions (:741) → Stage.UpdateStage (Stage.cs:49) / Stage.UpdateQuestions (:76) Reviewer annotation for SessionCountTarget; per-membership-stage booleans and presentation for MaxInProgress, HideExcludedStudiesFromReviewers and ExcludedSessionStatsGrouping; family presence for ReviewMode. Stage annotation and Membership-stage annotation are not reclassified while MNS stays hardcoded (2.6) Staged and fenced asynchronous for SessionCountTarget; synchronous point path for every other writable field stage-configuration-changed 3
Study import ProjectManagementService.ParseReferenceFile (:167) with the visibility gate at CompleteSearchImportJob (:370), driven by SearchImportJobStateMachine (:15) and ReferenceFileParseJobConsumer (:11) Search/population; all screening and annotation families through the population denominator Staged and fenced asynchronous studies-imported 2, annotation dependants 3
Terminal source-import failure before reveal ProjectManagementService.DeleteSearchStudies (:567) and the compensating branch of CompleteSearchImportJob (:386-388) every family fenced by the import Staged and fenced asynchronous; current behaviour is a best-effort DeleteManyAsync, not a manifest rollback source-import-aborted 2
Single Study creation none would be Search/population and all dependants Path absent studies-imported
Systematic search deletion HTTP 503 (SearchController.cs:81, :93); unreachable ProjectManagementService.RemoveAndDeleteSystematicSearchFromProjectAsync (:616) Search/population; all screening, annotation, question and reconciliation families Path absent search-studies-changed
Bulk Study update StudyReferenceFileParser.ParseBulkStudyUpdateAsync (:137) via StartBulkStudyUpdateJobConsumer (StudyUpdateJobFileSavedToS3Comsumer.cs:14) Project screening; Membership screening; Reviewer screening; annotation families at the inclusion boundary Staged and fenced asynchronous studies-bulk-changed 2, annotation dependants 3
Question create, edit, copy, reorder or stage detach Project.UpsertCustomAnnotationQuestion (Project.cs:401), CopyAnnotationQuestion (:513), RepositionQuestion (:1069), UpdateStageAnnotationQuestions (:369) Question answers Synchronous point path definition-changed 3
Question delete ProjectManagementService.DeleteQuestionAsync (:39) → StudyRepository.RemoveAnnotationsFromStudiesInProjectForQuestionAsync (:905) Question answers only — the $pull touches ExtractionInfo.Annotations alone and leaves Sessions, session status and SessionTallies unchanged (2.10) Source-visibility fence with a durable definition-rewrite token (typed 503 and checkpoint rejection while active; released by the definition save); never point path definition-changed / definition-rewrite-completed 3
Annotation content or suppression change no owner distinct from session submission; AnnotationQuestion.Update (AnnotationQuestion.cs:218) mutates definitions in place with no version identity Question answers; annotation state profiles Synchronous point path for the submission owner; the suppression/version half is Path absent (M6) annotation-content-changed 3
Membership add or update ProjectController membership actions (:316, :343, :368, :392, :427, :465, :803, :817) and ProjectController.UpdateProject (:258) for ownership Membership screening; Membership-stage annotation; Reviewer screening; Reviewer annotation; Project/stage derived summaries Point path when the pre-computed canonical effect is admitted; staged and fenced when an unbounded bulk request exceeds the limits (2.11) membership-changed 3 for stage scopes, 4 for the rest
Membership disable or remove noneProjectMembership.DisableMembership is a stub (:237) with no live caller and no remove path exists would be the same families Path absent membership-changed
Project or stage graph-permission change ProjectController.UpdateStagePermissions (:831) and UpdateProjectPermissions (:850) → Project.UpdateStagePermission (:1293) / UpdateProjectPermission (:1300) authorized response shape across every family Synchronous point path with zero signed moves; advance ClientInvalidationRevision and the project-wide visibility slot statistics-authorization-changed 2, extended each phase
Durable slot-reservation allocate, release, disconnect-timeout or expiry StudyRepository.TryAtomicAssignStudyCoreAsync (:1426); NotificationHub.LeaveStudyReview (:643) and HandleReviewSessionDisconnect (:117); the four expiry consumers Reviewer annotation, via SessionTally.TotalAllocatedSessionCount when active-reviewer tracking is enabled; Stage annotation, via the seeded reservation-only tally row. Membership-stage annotation, both screening families and Domain reconciliation are not affected — see 2.13. Operational progress/presence stays outside Synchronous point path annotation-reservation-* 3
Effective active-reviewer tracking mode change (FeatureFlags.ActiveReviewerTrackingAvailable, FeatureFlags.cs:25, read by StudyRepository.GetInvestigatorAnnotationStats at :243 and :248) no owner on main: the flag is evaluated at read time from configuration and runtime overrides; no consumer observes a transition. Recorded as M15 Reviewer annotation; Membership-stage annotation availability and capacity Two-stage transition per the plan: singleton mode epoch plus global invalidation slot, then a restartable bounded batch marking affected scopes Stale; the mode epoch is a compatibility-digest input active-reviewer-mode-changed 1 (epoch and digest), 3 (batch and reclassify)
Domain reconciliation start, save, complete or delete no distinct owner: the same SubmitAnnotationSessionService.SubmitAsync (:36) and ReviewController.RemoveSession (:56) with a boolean flag Domain reconciliation; Stage annotation; Membership-stage annotation Synchronous point path domain-reconciliation-* 3
Project deletion HTTP 503 (ProjectController.cs:284); unreachable ProjectManagementService.DeleteProjectAsync (:538) all families plus the projection's own project scope Path absent no new checkpoint
Manual legacy question-tally refresh ProjectManagementService.UpdateAnnotationQuestionAnswerTally (:514) Question answers Staged and fenced asynchronous until cutover retires the path legacy-question-tally-refresh 3
Retry, broker redelivery or unknown commit result no owner: there is no source-operation receipt, no operation identity and no delta record on main the original operation's scopes Path absent until Phase 1 builds the envelope no duplicate checkpoint 1

Two plan rows have no line in this table because no mutation on main can reach them. Outcome-level statistics and Agreement/kappa are excluded families with no approved formula, and Operational progress/presence is intentionally outside the programme even though several owners above write presence and connection rows as a side effect.

4. Transactional-shape summary

Exactly four StartTransaction call sites exist in the project-management domain, and two of them are unreachable from any live route:

  • SubmitAnnotationSessionService.cs:97-99 — annotation submission, live (2.3);
  • ProjectManagementService.cs:374-375 — search-import completion, live (2.7);
  • ProjectManagementService.cs:545-546 — project deletion, unreachable (2.14);
  • ProjectManagementService.cs:618-619 — systematic-search deletion, unreachable (2.8).

Every other statistics-affecting write on main is non-transactional.

Family Primary owner Write shape Transaction Irreversible side effect in the write
2.1 Screening submit, correction, rescreen StudyRepository.SaveScreeningWithCapacityGuardAsync:1620 single-document FindOneAndReplaceAsync under a combined version and capacity filter no none in the write; a second independent claim write in the same request
2.2 Screening decision deletion or reset path absent n/a n/a
2.3 Annotation session save and complete SubmitAnnotationSessionService.SubmitAsync:36 Study replace plus two ReviewerPresence writes in one session yes — the only live statistics transaction none
2.4 Annotation session deletion ReviewController.RemoveSession:56 four independent writes plus one scheduled publish no SchedulePublish between two source writes
2.5 Threshold and agreement setting change StudyRepository.UpdateStudyInclusionInfoForProjectAsync:1224 three sequential project-wide UpdateManyAsync; the method has no session parameter no Send dispatched in process immediately after the source save
2.6 Stage and session settings ProjectController.UpdateStage:701, UpdateStageQuestions:741 whole-Project upserting ReplaceOne, version-filtered no none
2.7 Systematic-search import ProjectManagementService.ParseReferenceFile:167 batched upserting BulkWrite, batch 200, session: null no for studies; yes for the terminal Project and SystematicSearch pair four broker publishes and a Send across the chain
2.8 Systematic-search deletion SearchController.DeleteSearch:81 throws 503 n/a n/a; the unreachable implementation commits its Project save outside its own transaction
2.9 Single Study creation path absent n/a n/a
2.10 Question operations ProjectManagementService.DeleteQuestionAsync:39 project-wide UpdateManyAsync on studies, then whole-Project ReplaceOne no — a two-collection partial-failure window none
2.11 Membership ProjectController membership actions whole-Project upserting ReplaceOne, N sub-entities per replace no none
2.12 Graph permissions ProjectController.UpdateStagePermissions:831, UpdateProjectPermissions:850 whole-Project ReplaceOne via the synchronous Save no none
2.13 Slot reservation lifecycle StudyRepository.TryAtomicAssignStudyCoreAsync:1426 single-document FindOneAndUpdateAsync with an update pipeline; Study and presence always two separate writes no three SchedulePublish call sites
2.14 Project deletion ProjectController.DeleteProject:284 throws 503 n/a n/a; the unreachable implementation deletes study rows after commit
2.15 Legacy question-tally refresh ProjectManagementService.UpdateAnnotationQuestionAnswerTally:514 unbounded project-wide aggregation read, then whole-Project ReplaceOne no none
2.16 Bulk Study update StudyReferenceFileParser.ParseBulkStudyUpdateAsync:137 upserting SaveManyAsync bulk writes, 400 per batch, up to 8 concurrent no S3 upload and SubmitJob upstream of the write

Three structural conclusions follow for Phase 1 and Phase 2 planning. First, the plan's ordinary transaction has exactly one precedent in the codebase, and it is the annotation submission, which is why that path is both the natural first implementation target and the only family where the transaction boundary is already correct. Second, the two most damaging atomicity gaps — question delete and bulk screening update — are both writes to pmStudy that no version filter or capacity guard protects, so they cannot be fixed by adding a transaction alone. Third, every write that touches two collections today does so without a session, so the receipt and delta record cannot be attached anywhere until each of those pairs is restructured.

5. Open questions and unresolved owners

Each item is a decision the Phase 0 completion review must record. None is a licence to change product code in this pull request. Recommended defaults follow the plan's scope-discipline posture: mirror current behaviour, record the divergence, and route any correction to a separate approved change. Where this matrix and the technical plan disagree, the plan wins and the disagreement is stated here.

M1: A stranded ActiveInclusionInfoCalculationJob token has no reset path

Evidence. Project.StartInclusionInfoCalculation sets the token (Project.cs:178) and Project.CompleteInclusionInfoCalculation is the only code that clears it (:190); the latter throws when no token is active (:184-185) and throws again when the token's threshold does not equal the supplied threshold (:187-189). The worker's failure path re-reads the project, appends the failure to Project.Errors and rethrows without clearing the token (UpdateStudyScreeningStatsConsumer.cs:35-51). CalculatingInclusionInfo therefore stays true, and every subsequent UpdateAgreementMode returns UpdateAlreadyPending (Project.cs:1309), which ScreeningController maps to 409 (ScreeningController.cs:40-43). No admin endpoint, consumer, job or migration clears a stranded token: the non-test call sites of ActiveInclusionInfoCalculationJob, StartInclusionInfoCalculation, CompleteInclusionInfoCalculation and CalculatingInclusionInfo are fully enumerated in 2.5 and contain no such path.

Impact. The plan makes this token a source-visibility fence and states that "timeout or a mismatched token never clears the fence". That is deliberately fail-closed, but the plan also assumes the fence is eventually released by a matching-token worker. If a stranded token is today cleared by a manual database edit, then the projection must tolerate a fence disappearing without a completion record; if it has simply never happened in production, the programme is adding a permanent typed-503 failure mode to a family that currently only blocks further setting changes.

Options. (a) Model the fence exactly as the plan states and accept that a stranded token blocks the family until an operator intervenes. (b) Add a matching-token expiry with an explicit failed-completion record, which is a product change requiring separate approval. © Add a read-only diagnostic that surfaces a stranded token so the condition is at least observable before Phase 2 relies on it.

Recommended default: (a), with © tracked separately. The plan fixes the fence semantics and this document does not re-decide them; making the condition observable is cheap and does not alter behaviour.

M2: The screening-reset event has no owner on main

Evidence. ScreeningInfo exposes no removal method — ScreenStudy (ScreeningInfo.cs:111) is its only mutator and Screenings has a private setter. Searching src for RemoveScreening, DeleteScreening, ResetScreening, ClearScreening and CorrectScreening returns no matches at all, and a case-insensitive search for rescreen returns only two comments (ScreeningInfo.cs:116, IStudyRepository.cs:281) and one test name (.../AtomicAssignmentIntegrationTests.cs:989). A screening record disappears only through whole-Study deletion (2.8, 2.14) or an in-place overwrite by Screening.ChangeScreeningDecision (2.1).

Impact. The plan reserves screening-reset and assigns it synchronous inverse bucket moves. There is nothing to attach that to. If Phase 2 builds inverse-move machinery for an event that cannot fire, it is untested code on a hot path; if it does not, a future reset feature will land without a delta.

Options. (a) Record the event as reserved-but-unowned and build no inverse-move path in Phase 2. (b) Build the inverse moves anyway, exercised only by tests, so a future reset feature inherits them. © Treat ChangeScreeningDecision as the reset event, since it is an in-place value change that a naive append-only model would miss.

Recommended default: (a) plus ©. The genuine before/after transition that exists today is the overwrite, and 2.1 already requires the previous classification to be captured from the loaded document; a separate reset path should be built with the feature that needs it.

M3: Bulk file-path screening writes bypass the capacity guard

Evidence. StudyReferenceFileParser.ParseBulkStudyUpdateAsync applies Study.AddScreening to in-memory aggregates (:195) and persists them with _unitOfWork.SaveManyAsync(studiesToSave) (:200), in batches of 400 (:173) with up to eight concurrent batches (:169-170). SaveManyAsync builds one ReplaceOneModel<Study> { IsUpsert = true } per aggregate and issues a single BulkWrite (MongoUnitOfWorkBase.cs:369). Neither StudyRepository.SaveScreeningWithCapacityGuardAsync (:1620) nor the three-attempt retry loop of ReviewController.TrySaveScreeningAsync (:355) is involved. The optimistic-concurrency filter is applied — SaveManyAsync builds each model from GetFilter(ar.Id, ar.Version) (MongoUnitOfWorkBase.cs:411-415) — so a stale version does not overwrite; it raises E11000 and, under the default ordered BulkWrite, aborts the remainder of the batch.

Impact. Screening decisions can be created or overwritten in bulk with no capacity guard and no serialization against a reviewer submitting interactively on the same Study. The concurrency failure mode is not a lost update: it is a partially applied, non-resumable batch that throws part-way through, with no retry, no compensating action, and no record of which rows landed. For the programme this is both the canonical staged-and-fenced operation and the strongest case for the plan's per-batch child receipts, since only a durable receipt can establish where an aborted batch stopped.

Options. (a) Route the family to the staged and fenced asynchronous path exactly as the plan requires, and accept that a batch racing an interactive submission may abort part-way as it does today. (b) Add the capacity guard and a retry/resume loop to the bulk path, which is a behaviour change to an existing import feature and needs separate approval. © Fence interactive screening for the affected scopes for the duration of a bulk job.

Recommended default: (a), with © as the design to evaluate in Phase 2. The plan already requires an Active fence on every affected scope until final reconciliation, which delivers most of © without changing the bulk path's own semantics; (b) is a separate correctness fix and must not expand this programme.

M4: Question delete is two unlinked writes with no compensating action

Evidence. ProjectManagementService.DeleteQuestionAsync issues a project-wide UpdateManyAsync that pulls every matching annotation off every Study in the project (:46-47, repository at StudyRepository.cs:905 with the write at :912) and only then mutates and saves the Project (:51-52). The two writes target different collections, there is no session on either, and the repository method has no session parameter at all. The controller carries a live // TODO: optionally retain annotations of deleted questions on studies (ProjectController.cs:671).

Impact. A failure between the two writes destroys annotation data while leaving the question and its stage assignments in place — the largest annotation-family blast radius in the codebase, and one with no compensating action. It is also unbounded, so it can never be admitted to the point path.

Options. (a) Treat question delete as the plan's definition-rewrite source-visibility fence, leaving the underlying two-write shape unchanged until Phase 3 reorders it. (b) Make the delete transactional as a separate correctness fix before Phase 3. © Implement the controller's own TODO and retain annotations for deleted questions, which removes the destructive write entirely.

Recommended default: (a), with (b) recorded as a separate pre-Phase-3 correctness issue. The programme must not own the fix, but Phase 3 cannot attach a delta to this path while the two writes can diverge, so (b) should be scheduled rather than merely noted.

M5: Two stage settings named by the plan's event row are unwriteable

Evidence. The plan's stage-configuration row lists SelfReconciliation and stage filters among the settings whose change must invalidate annotation families. Stage.AllowSelfReconciliation (Stage.cs:157) has a private setter, is absent from StageUpdateDto and is assigned nowhere in production code; its only non-test references are five reads (StageReviewService.cs:93, MembershipAnnotationSessionStats.cs:98, StudyStats.cs:163, :168, :171). Stage.PartitionFilter (:180) and Stage.SystematicSearchFilter (:181) have public setters and appear read-only on StageDto, but are likewise absent from StageUpdateDto and never assigned.

Impact. Materializing a scope keyed on a setting no route can change freezes a default into the projection contract. If a write path is added later, every stored scope becomes stale in a way the configuration digest may or may not detect, depending on whether the digest includes a field that could not previously vary.

Options. (a) Include both settings in the configuration digest even though they cannot change, so a future write path invalidates correctly. (b) Exclude them from the digest as constants, keeping the digest smaller. © Model self-reconciliation as a constant in the formulas and revisit if a write path appears.

Recommended default: (a). The digest is a fail-safe, not the primary invalidation path, and including a currently-constant field costs nothing while removing a silent-staleness trap.

M6: The completed-to-incomplete session reset has no implementation

Evidence. The plan's question row asks implementers to "apply known session-reset bucket moves". ResetSessionsForStages and SessionsForStage return zero matches across src. The only writer of session status is AnnotationSession.UpdateStatus (.../StudyAggregate/AnnotationSession.cs:36), whose sole caller is ExtractionInfo.AddAnnotations (.../StudyAggregate/ExtractionInfo.cs:164-169) — a reviewer's own submission. No question edit, question delete or stage-question change moves a completed session back to incomplete. Separately, AQVersion, QuestionSetVersion and SessionVersion return zero matches, and AnnotationQuestion.Update (AnnotationQuestion.cs:218) mutates definitions in place with no history.

Impact. This is a plan-versus-code divergence, not a gap in this survey. The plan's annotation-content-changed row and the question row both presuppose stable definition and version identity plus known reset semantics; neither exists. Carrying the reset forward as an existing behaviour would produce bucket moves that mirror nothing the source ever does.

Options. (a) Record the divergence, implement no reset moves, and treat question edits as definition-identity changes only. (b) Build the reset semantics as part of the programme, which is a product behaviour change well outside the stated scope. © Defer the question family until the target question-versioning architecture lands.

Recommended default: (a). The plan wins on architecture, but it cannot require the preservation of a behaviour that does not exist; the reset moves become live only when a reset feature is built, and the question family's definition identity must be sourced from what main actually stores.

M7: Domain reconciliation has no distinct owner or type

Evidence. DomainReconciliation, domain-reconciliation and ReconciliationSessionVersion return zero matches across src. Reconciliation is a boolean flag on the same aggregates — the reconciliation query parameter on ReviewController.SubmitSession (:131), the reconcile screening endpoint (:279), AnnotationSession.Reconciliation and Annotation.Reconciled — and Study.DeleteSessionAndRestoreSlotReservation explicitly excludes reconciliation sessions from slot restore (Study.cs:282).

Impact. The catalogue lists Domain reconciliation as a family "included with annotation families", which the code supports: it shares every owner with the annotation family and there is nothing separate to instrument. The risk is the inverse of a missing owner — building a distinct reconciliation scope key implies a source distinction that does not exist.

Options. (a) Define the reconciliation scope entirely against the existing boolean flags and share the annotation owners. (b) Introduce a reconciliation-specific source type first, which is a product change. © Fold reconciliation into the annotation profiles with no separately identified states.

Recommended default: (a). It matches both the plan's "reconciliation states remain separately identified" requirement and the source's actual shape; © would lose a distinction the catalogue requires.

M8: Domain event dispatch runs inside an open transaction

Evidence. MongoUnitOfWorkBase.SaveAsync calls DispatchEvents at MongoUnitOfWorkBase.cs:269 regardless of whether an IClientSessionHandle was supplied; the synchronous Save does the same at :241, and TrySaveExistingAsync at :311 on a matched save. A SaveAsync(aggregate, session) issued inside an open transaction therefore runs its handlers before the commit. The live consequence is 2.5: the handler for ProjectAgreementThresholdUpdatedEvent performs a MassTransit Send (ProjectAgreementThresholdUpdatedHandler.cs:26-29) that is executed in process immediately after the source write. There is no transactional outbox on this path.

Impact. The plan's rule is that a retryable callback contains no irreversible external side effect and that the dispatcher is never invoked inside it. Any family whose source write is moved into a transaction while still persisting through MongoUnitOfWorkBase inherits both a pre-commit dispatch and a re-executed side effect on retry. The annotation submission avoids this today only because it calls the repository directly rather than through the unit of work.

Options. (a) Build the durable transactional notification outbox in Phase 1 and require every transactional family to persist through a session-aware path that defers dispatch to after commit. (b) Keep the in-process dispatcher and forbid transactions on any aggregate with domain-event handlers. © Change MongoUnitOfWorkBase to defer dispatch whenever a session is supplied.

Recommended default: (a). The plan already mandates the outbox; © is an attractive follow-up but is a change to shared persistence infrastructure used far beyond this programme and must be approved separately.

M9: Two admin inclusion-recalculation routes bypass the fence

Evidence. ProjectController.UpdateStudyInclusionInfoForProject (:296, ProjectEditPolicy) and ProjectController.UpdateAllStudyInclusionInfoForProjects (:306, BatchAdminProjectsPolicy) call ProjectManagementService.UpdateStudyInclusionInfoForProjectAsync (:477) and UpdateStudyInclusionInfoForAllProjectsAsync (:490) respectively, reaching the same three UpdateManyAsync passes as the consumer. Neither route reads or sets ActiveInclusionInfoCalculationJob. The fleet-wide route fans out across every project in parallel.

Impact. The plan's source-visibility fence is only as strong as the set of writers that respect it. A route that rewrites ScreeningInfo.InclusionInfo without a token can expose a partial population while every family reports Fresh, which is precisely the condition the fence exists to prevent, and the fleet-wide variant is the global-invalidation blast radius the plan's bounded-invalidation rule must cover.

Options. (a) Require both admin routes to acquire the same token before Phase 2 activates the fence, as a separate correctness change. (b) Treat any run of either route as a fleet-wide rebuild trigger. © Restrict or retire the admin routes.

Recommended default: (a), tracked as a separate pre-Phase-2 correctness issue. The fence cannot be relied upon while a bypass exists, but closing the bypass is a change to an existing admin feature and does not belong inside this documentation deliverable.

M10: The orphaned search delete saves its Project outside its own transaction

Evidence. ProjectManagementService.RemoveAndDeleteSystematicSearchFromProjectAsync opens a session and starts a transaction (:618-619), reads the Project and SystematicSearch in that session (:623-624), and then calls await _unitOfWork.SaveAsync(project) at :626 without passing the session, so only the SystematicSearches.DeleteAsync at :627-628 is transactional. Study-row deletion runs after the commit (:641). ProjectManagementService.DeleteProjectAsync has the same post-commit study deletion (:563-564) and concedes it in the helper's error message (:607-609).

Impact. Both methods are currently unreachable (2.8, 2.14), so nothing is broken in production. They matter because they are the implementations a future deletion lifecycle will most likely resurrect, and resurrecting them as written would give the programme a source write that commits outside the transaction carrying its delta.

Options. (a) Record the defect against the deletion-lifecycle work and require the transaction boundary to be redesigned when the lifecycle is built. (b) Fix the session argument now as an isolated correctness change to dead code. © Delete the orphaned methods so they cannot be resurrected accidentally.

Recommended default: (a). Neither method is reachable, so neither is a regression or a security issue; the correct place to settle the boundary is the deletion-lifecycle design, and the plan already requires tombstone-then-purge rather than an immediate cascade.

M11: Project ownership change rides the generic project PATCH route

Evidence. Project.Update (Project.cs:749) calls ChangeProjectOwnership whenever the patched OwnerId differs (:757-760); ChangeProjectOwnership (:770) requires the new owner to be a member already (:772-773) and raises ProjectOwnerChangedEvent (:776). Its only entry point is ProjectController.UpdateProjectPATCH api/projects/{projectId} (:258) — which persists with the synchronous Save at :273. No membership route can change ownership.

Impact. A membership delta keyed to the /investigators and /invitations routes would silently miss an ownership change, and ownership is an authorization input that the plan requires to advance the project-wide visibility slot.

Options. (a) Treat PATCH api/projects/{projectId} as a membership-family mutation owner and emit the membership delta from Project.Update when the ownership branch fires. (b) Emit the delta from the ProjectOwnerChangedEvent handler chain instead. © Split ownership onto its own endpoint, a product change.

Recommended default: (a). Keying the delta to the aggregate transition rather than the route is robust against exactly this class of miss, and it keeps the source write and the delta in one transaction, which (b) cannot guarantee while dispatch is in process (M8).

M12: Schedule-token writes advance Audit.Version outside OnSaving

Evidence. StudyRepository.SetSlotReservationIdleScheduleTokenAsync (:1455, write at :1481) and SetSlotReservationSuspendedScheduleTokenAsync (:1487, write at :1511) are positional SlotReservations.$ UpdateOneAsync calls that explicitly .Inc(s => s.Audit!.Version, 1) and stamp Audit.LastModified, LastModifiedBy and LastAppVersion (:1473-1479, :1503-1509) without going through OnSaving or a whole-document replace. The claim pipeline does the same by a different mechanism, incrementing Audit.Version in a $mergeObjects stage (:1944-1982).

Impact. The plan's ordering model uses the source before/after revision as part of the operation envelope. A reservation-bookkeeping write that advances the revision without changing any statistic will cause a concurrent delta to fail its staleness-floor admission guard and retry, and a naive reader could also mistake the revision advance for a statistics-affecting change.

Options. (a) Model these writes as revision-advancing but statistics-neutral, and let the retry reclassify deterministically as the plan already requires. (b) Exclude token writes from the revision clock, which would require a second version field. © Move token writes into the reservation transaction so they carry their own delta of zero moves.

Recommended default: (a). The plan's rule that a write conflict reloads and reclassifies rather than reapplying an old delta already handles this correctly; (b) introduces a second clock for no benefit.

M13: The plan's staged-import tokens do not exist on main

Evidence. The plan's bulk and import protocol requires writing every Study with a stable PendingImportJobId while the Project carries a matching ActiveHiddenImportJobId, and requires PendingDeletion semantics for deletion. PendingImportJobId and PendingDeletion both return zero matches across src. What exists is two-tier: Study.SystematicSearchId (Study.cs:114) is stamped during parsing, and the SystematicSearch document plus Project.SystematicSearchIds appear only at completion (ProjectManagementService.cs:370-381). Crucially, project-wide Study queries are not gated on any of it: StudyRepository.GetStudiesForProject (:572, :702), GetStudiesForProjectWithCountAsync (:653) and GetStudiesForProjectWithCountPagedAsync (:666) filter on ProjectId alone, so half-imported Studies are already visible to project-level counts.

Impact. This is a plan-versus-code divergence with a correctness consequence, not merely a naming one. The authoritative live count that the projection must match today already includes in-flight import rows, so a materialized count built on the plan's hidden-token model would diverge from the authoritative fallback during every import unless the hidden token is added to the authoritative queries at the same time.

Options. (a) Build the hidden-import token as the plan specifies and change the authoritative project-level Study queries to exclude it in the same change, so materialized and authoritative agree. (b) Mirror current behaviour and count in-flight import rows, deferring staged invisibility. © Gate only the materialized path, accepting a known divergence during imports.

Recommended default: (a). The plan fixes staged invisibility and this document does not re-decide it; what this survey adds is the requirement that the query change ship with the token, since © would guarantee a parity failure on every import and (b) contradicts the plan.

M14: Project.Errors is an unbounded untyped sink beside the projection's neighbour state

Evidence. Project.Errors is declared public List<Object> Errors { get; set; } = new(); (Project.cs:117) and UpdateStudyScreeningStatsConsumer.cs:41-46 appends an anonymous object containing the whole failed message on every failure, then saves. The same document already carries CompletedInclusionCalculationJobs (Project.cs:195) and AnnotationQuestionAnswerTally (:205).

Impact. The Project document is where the programme's project control and summary state will sit next to existing unbounded growth, against MongoDB's 16 MB document limit. A project that repeatedly fails inclusion recalculation grows its Errors list without bound, and every whole-document replace rewrites all of it.

Options. (a) Keep the FEAT-024 control and summary documents in their own collections so they never share a document with Errors, as the plan's logical data model already implies. (b) Bound Errors as a separate correctness change. © Take no action and monitor document size.

Recommended default: (a), with (b) recorded separately. The plan already places the projection in dedicated documents, so the programme does not inherit the risk; bounding Errors remains a genuine pre-existing issue for whoever owns the inclusion-recalculation worker.

M15: The active-reviewer mode transition has no owner

Evidence. FeatureFlags.ActiveReviewerTrackingAvailable (FeatureFlags.cs:25) is a derived property of ActiveReviewerTrackingEnabled && SignalRActive, evaluated wherever it is read, including both SufficientlyAllocated predicates in StudyRepository.GetInvestigatorAnnotationStats (:243, :248). Runtime flag overrides are delivered as snapshot revisions, but no Project Management consumer reacts to a change of this particular effective value; StageReviewService merely reads it per request.

Impact. The plan's active-reviewer-mode-changed row requires a durable two-stage transition (mode epoch on the singleton, then a restartable batch). Without an owning consumer, rows published under the old mode would stay Fresh; the plan's compatibility-digest rule makes them Incompatible instead, which is safe but means the primary path does not exist yet.

Options. (a) Phase 1 adds the mode epoch and digest input (fail-safe only) and Phase 3 adds the consumer of the runtime flag snapshot revision that runs the two-stage transition. (b) Treat any change as a global epoch bump. © Freeze the flag for enabled projects.

Recommended default: (a). It matches the plan's phase split and keeps fleet-wide invalidation off the hot path.

Appendix A: corrections applied

The following anchors and claims were carried in the read-only research notes, in the drafting brief or in stale March–April planning prose, and are corrected here. Each was re-checked directly against main at 7e0ed90c9.

# Claimed Verified reality Source of the error
1 PendingImportJobId marks staged import visibility Zero matches in src. Visibility is two-tier: Study.SystematicSearchId (Study.cs:114) is stamped during parsing; the SystematicSearch document and Project.SystematicSearchIds appear only at ProjectManagementService.CompleteSearchImportJob (:370). Project-wide Study queries are gated on neither (M13) Stale March–April plan naming, repeated in the drafting brief
2 PendingDeletion marks a reversible deletion Zero matches in src. Both deletion endpoints throw unconditionally and are mapped to 503 (SearchController.cs:81, :93; ProjectController.cs:284; filter at DeletionLifecycleUnavailableExceptionFilter.cs:38) Stale March–April plan naming, repeated in the drafting brief
3 ResetSessionsForStagesAsync implements a completed-to-incomplete session reset Zero matches in src. No question or stage edit resets session status; the only writer is AnnotationSession.UpdateStatus (AnnotationSession.cs:36), called solely from ExtractionInfo.AddAnnotations (ExtractionInfo.cs:164-169) (M6) Stale March–April plan naming, repeated in the drafting brief
4 DisableMembership does not exist It does exist as a NotImplementedException stub with zero live call sites: ProjectMembership.cs:237-240, reachable only from Project.DisableMembership (Project.cs:597), which no controller calls. "Remove member", by contrast, genuinely does not exist anywhere Drafting brief; the research notes had it right
5 SearchImportJobConsumer owns the import Zero matches. The owners are the saga SearchImportJobStateMachine (Sagas/SearchImportJobStateMachine.cs:15) with five activities, and ReferenceFileParseJobConsumer (Consumers/ReferenceFileParseJobConsumer.cs:11) Stale March–April plan naming
6 ActiveInclusionInfoCalculationJob is a scheduled job It is a field on the Project aggregate acting as an in-flight mutual-exclusion token (Project.cs:194; class at :1573). The asynchronous worker is the MassTransit consumer UpdateStudyScreeningStatsConsumer (Consumers/UpdateStudyScreeningStatsConsumer.cs:17) Stale March–April plan naming, repeated in the drafting brief
7 Stage.UpdateStage at Stage.cs:184-208, with the ExcludedSessionStatsGrouping joint validation at :194-201 Stage.UpdateStage is at Stage.cs:49; the assignments run at :54-62 and the joint validation at :63-73. Line 184 is inside the SessionCountTarget property (:183) Research notes, badly drifted
8 Stage.PartitionFilter / SystematicSearchFilter at Stage.cs:176-178; Stage.AnnotationQuestions at :36 The filters are declared at :180 and :181; :176 is inside IsUsingSearchFilter(). AnnotationQuestions is declared at :96; :36 is a constructor assignment Research notes, off by a few lines
9 StageSecuritySettings.UpdatePermission at .../StageEntity/StageSecuritySettings.cs:74 The path is .../ProjectAggregate/StageEntity/Security/StageSecuritySettings.cs:74. The project-level twin, ProjectSecuritySettings.UpdatePermission (.../ProjectAggregate/Security/ProjectSecuritySettings.cs:132), was omitted from the research notes entirely Research notes, wrong path and one missing owner
10 Project.ApprovePendingJoinRequest at Project.cs:1135, DeclinePendingJoinRequest at :1139-adjacent, RespondToInvitation at :1251 The declarations are at :1119, :1139 and :1239; :1135 and :1251 are the CreateMembership call sites inside those methods, and CancelPendingJoinRequest is at :1102 Research notes, conflating call sites with declarations
11 NotificationHub.HandleReviewSessionDisconnect at NotificationHub.cs:87-261 :87 is OnDisconnectedAsync, which calls HandleReviewSessionDisconnect at :99; the method itself is declared at :117 Research notes
12 Expiry consumer ranges MarkSessionIdleConsumer.cs:20-136, RemoveIdleSessionConsumer.cs:24-141, RemoveSuspendedSessionConsumer.cs:23-90, CheckConnectionLivenessConsumer.cs:27-186 Declaration lines :20, :24, :23, :27 are correct; the ranges overshoot, because each file's consumer definition class begins at :122, :143, :76 and :172 respectively Research notes, end-of-range drift
13 ParseBulkStudyUpdateAsync at .../Services/ParserImplementations/StudyReferenceFileParser.cs:166-211, batch 400 at :205/:217 The file is .../Core/Services/StudyReferenceFileParser.cs (not under ParserImplementations/); the method is at :137, the partition at :166-167, concurrency 8 at :169-170 and the two Batch(400) loops at :173 and :214 Research notes
14 The ReferenceUpdate dispatch is at S3FileReceivedFunction.cs:212, with LiveUploadKindProcessor at :467 and validation at :141 LiveUploadKindProcessor is at :467 and ProcessSearchUpdateReceived at :200, but the dispatch call is at :185 and the validation call at :163; UploadEventDispatcher.DispatchAsync is at UploadEventDispatch.cs:212, which is what :212 referred to Research notes, conflating two files
15 RemoveAndDeleteSystematicSearchFromProjectAsync "saves Project + deletes the SystematicSearch" inside its transaction (:623-628) Only the delete is transactional. The Project save at :626 is await _unitOfWork.SaveAsync(project) with no session argument, so it commits outside the transaction opened at :618-619 (M10) Research notes, missed the omitted session
16 Research open question: which counters does the claim pipeline increment? Closed. BuildAssignmentPipeline (StudyRepository.cs:1861) appends the reservation, seeds a zero tally for the stage, then BuildTallyMapExpression (:2002-2067) increments NumberOfSlotReservations on the matching stage and recomputes TotalAllocatedSessionCount = NumberOfCandidateSessions + NumberOfSlotReservations and TotalEngagedSessionCount = NumberOfCandidateSessions + NumberOfDirtySlotReservations for every tally, matching or not; a final stage increments Audit.Version via $mergeObjects (:1944-1982) Research notes, left open
17 Research: a search for screening-removal verbs returns "exactly one hit, a test name" The five removal verbs return no matches; the single test-name hit belongs to a case-insensitive search for rescreen, which also returns two comments (ScreeningInfo.cs:116, IStudyRepository.cs:281) Research notes, imprecise grep attribution
18 Research: CopyQuestion refreshes the question tally It does not. ProjectController.CopyQuestion (:636) ends at :650 with no tally call; the only controller caller of UpdateAnnotationQuestionAnswerTally is the dedicated manual endpoint, which invokes the service at :657 Drafting inference, corrected against the file
19 An earlier revision of this document: because SaveAsync/SaveManyAsync upsert, a version miss silently inserts or overwrites It does neither. The replacement carries the aggregate's own _id, so the upsert's insert branch violates the unique _id index and raises E11000; the ordered BulkWrite variants abort the remainder of the batch. Corrected in Section 1, 2.16 and M3 Drafting inference from IsUpsert = true without tracing the _id in the replacement
20 An earlier revision: question delete affects Stage annotation, Membership-stage annotation, Reviewer annotation and Domain reconciliation RemoveAnnotationsFromStudiesInProjectForQuestionAsync pulls only from ExtractionInfo.Annotations (StudyRepository.cs:905-915); Sessions, session status and the derived SessionTallies (ExtractionInfo.cs:30-77) are untouched, and those are the sole inputs to all four families. Narrowed to Question answers in 2.10; the definition-rewrite fence classification is unchanged Drafting inference that removing annotations must move session statistics
21 An earlier revision: a slot claim or release affects six families, including Membership-stage annotation availability, both screening families and Domain reconciliation Only Reviewer annotation (SufficientlyAllocated reading TotalAllocatedSessionCount, Filters.cs:563-575) and Stage annotation (the seeded reservation-only tally row) move. Membership-stage availability cancels exactly (StudyStats.cs:153-156); the screening filters never read reservations (Filters.cs:140-147); reconciliation gates on NumberOfCompletedCandidateSessions (Filters.cs:313-324). Corrected in 2.13 Drafting inference from the tally being shared, without evaluating each formula
22 An earlier revision: Domain reconciliation moves on the /reconcile endpoint specifically /reconcile is screening reconciliation and performs the same AddScreening mutation as the ordinary route (ReviewSubmissionService.cs:54-64). Domain reconciliation counters move on either path, through the included/excluded reclassification (StudyStats.cs:367-368). Corrected in 2.1 Route name read as a domain-reconciliation owner
23 An earlier revision: every writable stage setting reclassifies every Study, so the whole family is staged and fenced Only SessionCountTarget reclassifies, and only Reviewer annotation, because Stage and membership-stage formulas use the hardcoded MNS = 2 (StudyStats.cs:311-313). MaxInProgress, HideExcludedStudiesFromReviewers and ExcludedSessionStatsGrouping move per-membership-stage booleans only; ReviewMode changes family presence; Active, Extraction, EnforceAnnotationTarget and IdleSessionTimeoutMinutes are read by no statistic. Split by field in 2.6 Drafting inference from the plan's event row rather than from the formulas
24 An earlier revision: a bulk membership approval is unconditionally a synchronous point path because a realistic cohort is small AcceptJoinRequestsDto.InvestigatorIds and UpdateProjectMembershipsDto.InvestigatorIds are unbounded IEnumerable<Guid> (ProjectController.cs:413, :896) and each member creates a membership, a reviewer and one membership-stage scope per stage, so a large request exceeds the 500-move and 100-document limits. Admission now requires computing the canonical effect first (2.11) Expectation about user behaviour substituted for an enforceable bound

Two further items are recorded for the record. MongoUnitOfWorkBase declares both bulk savers twice, as a TAggregateRoot overload and a TAggregateRoot, TId overload: SaveManyAsync at :360 and :369, BatchedSaveManyAsync at :468 and :481. Section 1 cites the two-parameter overloads (:369, :481) and is correct; the research notes' :442-465 for SaveManyAsync matches neither declaration. And ProjectManagementService.CompleteSearchImportJob, DeleteProjectAsync, DeleteQuestionAsync and UpdateAnnotationQuestionAnswerTally are at :370, :538, :39 and :514 exactly as claimed.

Appendix B: verification method

Every citation in this document was produced by reading the named file in /home/chris/workspace/syrf/main at commit 7e0ed90c9f6c1ffa1a5bd0c0a8c5b8eb25a53b0d (ci: migrate high-value Actions jobs to self-hosted runners (#3027)) on 2026-09-01 and 2026-09-02, either with a line-addressed read or a grep -n whose output was inspected. A bare line number names the declaration line of the method, property or field, with attributes excluded; a range names an inclusive span that was read in full. Absence claims ("zero matches") were produced with grep -rn --include='*.cs' <name> src, excluding obj/ build output, and are stated only where the search returned no non-excluded hits.

The worktree was not modified while producing this document: no file was written, no branch was switched and no git state was changed. The only file written was this document, under the drafting scratchpad. No clinical data, report contents or participant identifiers appear anywhere in it; every quotation is of code, schema or design text.