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.SubmitScreeningAndGetNextStudyForReview — POST api/projects/{projectId}/stages/{stageId}/studies/{studyId}/review, StageReviewPolicy |
src/services/api/SyRF.API.Endpoint/Controllers/ReviewController.cs:226 |
| HTTP (reconciliation) | ReviewController.SubmitScreeningAndGetNextStudyForReconciliation — POST .../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.SubmitSession — PUT 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.RemoveSession — DELETE 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:
pmUnitOfWork.SaveAsync(study)— upserting whole-document replace (ReviewController.cs:82);pmUnitOfWork.ReviewerPresences.SaveAsync(currentPresence, session: null)(:94);pmUnitOfWork.ReviewerPresences.GetOrCreateCurrentAsync(...)(:98);pmUnitOfWork.Studies.SetSlotReservationIdleScheduleTokenAsync(...), a separate targetedUpdateOneAsync(:117; repository atStudyRepository.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.PostScreeningSettings — POST 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.UpdateStudyInclusionInfoForProject — POST api/projects/{projectId}/update-study-inclusion, ProjectEditPolicy |
src/services/api/SyRF.API.Endpoint/Controllers/ProjectController.cs:296 |
| Admin entry point, fleet | ProjectController.UpdateAllStudyInclusionInfoForProjects — POST 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.UpdateStage — PATCH 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.AddStage — POST api/projects/{projectId}/stages, ProjectDesignPolicy |
ProjectController.cs:683 |
| HTTP, selected questions | ProjectController.UpdateStageQuestions — PUT 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
SessionCountTargetalone, 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 theStudyStats.cs:313hardcode is replaced bySessionCountTarget, 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,HideExcludedStudiesFromReviewersandExcludedSessionStatsGroupingimply, and zero signed Study moves.ReviewModeadditionally advances the family-presence set for the stage;Active,Extraction,EnforceAnnotationTarget,IdleSessionTimeoutMinutesand 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.GetS3RequestSignature — POST 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.DeleteSearch — DELETE 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.DeleteSearchImportJob — DELETE 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.UpsertQuestion — PUT 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.CopyQuestion — POST 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.RepositionAnnotationQuestion — PUT 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.DeleteQuestion — DELETE 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.InviteMembers — POST 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.RespondToInvitation — POST 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.RequestToJoin — POST 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.CancelJoinRequest — DELETE api/projects/{projectId}/join-requests (:343) |
Project.CancelPendingJoinRequest (Project.cs:1102) |
synchronous Save (:347) |
| Approve join requests, bulk | ProjectController.ApproveJoinRequests — POST 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.DeclineJoinRequests — POST api/projects/{projectId}/join-requests/decline (:392) |
Project.DeclinePendingJoinRequest (Project.cs:1139) |
one await SaveAsync (:408) |
| Update member groups, single | ProjectController.UpdateMembership — PUT 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.UpdateMemberships — POST api/projects/{projectId}/investigators (:817) |
Project.ReplaceMemberGroups over a list (Project.cs:657) |
synchronous Save (:824) |
| Change project owner | ProjectController.UpdateProject — PATCH 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.Update → ChangeProjectOwnership. 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.UpdateStagePermissions — POST 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.UpdateProjectPermissions — POST 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:
- append the new
SlotReservationtoStudy.SlotReservationswith$concatArrays; - seed a zero-valued
SessionTallyfor the stage when none exists; $mapthe tally array, incrementingNumberOfSlotReservationsby one on the matching stage and recomputing the stored sumsTotalAllocatedSessionCount = NumberOfCandidateSessions + NumberOfSlotReservationsandTotalEngagedSessionCount = 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);$mergeObjectstheAuditsubdocument to incrementAudit.Versionand refreshLastModified, 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.DeleteProject — DELETE 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.UpdateAnnotationAnswerTally — PUT 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.GetBulkStudyUpdateS3RequestSignature — POST 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 | none — ProjectMembership.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.UpdateProject — PATCH 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.