Skip to content

Round-2 adversarial review DC — data consistency and concurrency (verbatim report)

Temporary planning review record. The report below is reproduced verbatim as returned by the independent read-only reviewer (Plan agent, Opus model, launched 3 October 2026 about 14:35 BST). Only this front matter and note were added. Line numbers refer to the package as it stood when the reviewer read it. Resolutions are in the round-2 resolution matrix.

1. Verdict

In the consistency dimension the plan isn't ready for F1. It names most of the right concerns: Study coupling, bulk-lock write skew, compare-and-set on drafts, two-phase publication, CAS on the gold pointer, receipts, ownership as data and as-of watermarks. Several of the mechanisms it then picks don't hold up:

  • They contradict measured evidence in this codebase. A per-project counter is written in every canonical transaction. FEAT-024 measured that exact pattern and then designed it away.
  • They can't work with the existing Study model. The plan puts a "Study summary projection" into fields that every binary recomputes at serialisation.
  • They're unsound under MongoDB snapshot isolation. A legacy writer that only reads a separate ownership record can still commit after cutover, and the code already says so.
  • They rest on a dark, asynchronous programme. The plan makes FEAT-024's receipts the canonical idempotency authority.

The five most important changes:

  1. Replace the per-project commit sequence with an explicit concurrency-control model.
  2. Every study-scoped command serialises on that study's Study document.
  3. Definition-dependent derived state carries input-version stamps and is swept until no stale record remains.
  4. The few project, form or stage boundaries that need quiescence use a scoped fence-and-drain or an ADR-020-style operation record.
  5. A hybrid logical clock orders records for as-of exports.
  6. M0 gates the model with FEAT-024's write-gate shape.
  7. Redesign the Study summary projection. The R0 floor must make the legacy computed getters merge a canonical summary, or every reader must be cut over before R2a.
  8. Replace E35 with a canonical command ledger, written inside the commit transaction.
  9. Move canonical ownership onto the documents legacy writers write. Use an aggregate check, the version compare-and-set, a write guard and an architecture test. Run cutover with ADR-020's lock protocol.
  10. Write a canonical "transaction admission" ADR at F1. It covers write concern, retry discipline, indeterminate commits, bypassing the shared cache and durable side effects. Alongside it, publish a consistency matrix and add failure-injection, contention, mixed-version, cache and restore acceptance criteria.

2. Findings

Path legend (absolute roots):

  • PKG = /home/chris/workspace/syrf/pr/pr3617.research-screening-as-specialised-annotation-gxgahs/docs/planning/integrated-review-plan-2026-10/
  • PLN = /home/chris/workspace/syrf/pr/pr3617.research-screening-as-specialised-annotation-gxgahs/docs/planning/
  • MAIN = /home/chris/workspace/syrf/main/ (HEAD 0f5c61073)
  • Core = MAINsrc/libs/project-management/SyRF.ProjectManagement.Core/
  • Mongo = MAINsrc/libs/project-management/SyRF.ProjectManagement.Mongo.Data/
  • Common = MAINsrc/libs/mongo/SyRF.Mongo.Common/
  • API = MAINsrc/services/api/SyRF.API.Endpoint/
  • STATS = MAINdocs/features/materialized-project-statistics/
  • NOTIF = /home/chris/workspace/syrf/pr/pr3947.checked-pdf-proposals-and-explicit-administrator-a-r6bdl2/ (head ae3c6749d)
ID Severity Location Finding Evidence Recommended change
DC-01 Blocker PKG contracts.md C11 "Ordering (F1)" l.444-446; open-questions E25 l.127; domain-model ProjectCommitSequence l.143 and §5 l.174; integrated-plan R2a l.384 and M0 l.263; contracts.md C8 "fence or high-water mark" l.340 The counter serialises every commit in a project. Every canonical transaction increments one per-project counter document: Save, Complete, Fix, Withdraw, decisions, gold, publication, phase-2 batches, adoption and merges. Under snapshot isolation, any single per-project document written in every save's transaction serialises the write path. FEAT-024 measured exactly this: 399 of 1,000 submissions exhausted their retries at ten reviewers on different studies. The owner then replaced it with single-document saves plus an asynchronous fold. The counter's two jobs are never stated. C11 uses it for ordering; C8's "high-water mark" and AC-R2c-05 rely on it, without saying so, to serialise saves against publication. Neither role is a stated design property, so a later change for throughput would silently remove protections. STATS technical-plan.md:1022-1038 ("any one of the four sufficient to serialize the write path"; superseding decision); STATS async-point-fold-design.md:71-83, 93 (owner gate (b): zero exhausted submissions at ½/5/10 reviewers), 461-462 (a transaction turns document-lock waits into WriteConflict); STATS screening-write-overhead-diagnosis.md:143-167, 274-276 ("Three monotonic counters on one per-project document is the true serialisation point; no batching fixes it") Remove the per-project counter from the interactive commit path and adopt the model in Improvements §1: CR-1 per-study serialisation; CR-2 consumption-time detection; CR-3 fence-and-drain; CR-7 hybrid logical clock (HLC) ordering. If a counter survives for publication-class events, state its serialisation role, pre-create it, claim it as the first write in long transactions, and gate it with AC-DC-02. Reword C8's "fence or high-water mark" as the fence-and-drain boundary.
DC-02 Blocker PKG contracts.md C1 "Study summary projection" l.108 and invariants l.118-121; E20 l.122; domain-model Study row l.159; integrated-plan R2a "Study coupling" l.406-408; AC-R2a-12 l.165; R0 MVP l.279-293 The projection can't live in the fields legacy readers query. These are computed getters mapped for serialisation: ExtractionInfo.SessionTallies and ScreeningInfo.Inclusion, IncludedCount, NumberOfScreenings and AgreementMeasure. Every whole-Study replace, by any binary, recomputes them from the embedded Sessions, SlotReservations and Screenings, and canonical data deliberately leaves those empty. Three kinds of replace wipe canonical contributions: the canonical commit's own replace, any legacy whole-document writer (capacity save, bulk update, risk of bias, PDF), and the R0 rollback image. Effect: pool selection, the capacity predicate in the write filter and reconciliation readiness all read the stored values. A canonical project would over-admit reviewers past the target and misreport readiness, so AC-R2a-12 fails by construction. R2b's form claims, projected per bound stage, have the same problem. Core Model/StudyAggregate/ExtractionInfo.cs:31-80; ScreeningInfo.cs:64-74; Mongo Repositories/StudyRepository.cs:3176-3179, 3212, 3222-3227 ("On deserialization, stored values are ignored; the getters recompute"); capacity filter StudyRepository.cs:2089-2119; Mongo Filters.cs:172, 192 (pool selection), 241-247 (reconciliation readiness), 330-372 At F1, choose and record one of two options. (a) Behavioural floor in R0: the getters merge a top-level CanonicalSummary sub-document. Per bound stage it holds candidate, completed, claim and reconciliation values; per profile it holds outcomes. Entity [BsonExtraElements] preserves it in older binaries. Repeat the floor before R2b (form claims counted per bound stage) and before R3a (screening aggregates). (b) Reader cutover: move every E20 reader to canonical sources for admitted projects before R2a. Add AC-DC-05 and AC-DC-06. Binding, target and policy changes then need a projection-rewrite operation (DC-06, DC-08).
DC-03 Major PKG E35 l.137; contracts.md C1 l.116-117 and baseline l.90-91; domain-model l.147-148; AC-R2a-07 l.160 FEAT-024 receipts can't be the canonical idempotency authority, for four reasons:
(1) They exist only when statistics writes, all annotation families and the project allowlist are all on, and that combination is dark in production.
(2) In fold mode the save writes no receipt. The fold worker writes it later, and overflowed or quarantined saves get none, which leaves "Unresolved" outcomes.
(3) The operation ID comes from the Study's source revision, deliberately so that resubmitting the same payload counts as a new operation.
(4) Receipts are reclaimed behind an idempotency floor.
Effect: a client retry after an indeterminate commit would create a second immutable version. A delayed Save retry arriving after a newer Complete would make the session incomplete again and remove its qualification.
Core Services/ProjectStatistics/Families/Annotation/ProjectAnnotationStatisticsWriter.cs:76-79, 95-98, 128; API Services/SubmitAnnotationSessionService.cs:152-183; Core Services/ProjectStatistics/Fold/ProjectAnnotationFoldSave.cs:79-91, 341-375; STATS technical-plan.md:1042-1046, 1063-1065; MAIN .claude/rules/materialized-stats.md:146-148, 297-303 Replace E35 with a canonical command ledger:
- One record per (actor, commandId, command type), inserted in the commit transaction with the result (new version IDs and HLC stamp).
- Look the record up before any CAS.
- On a CAS failure, if the current head or session version carries the same commandId, return the original result.
- On an indeterminate commit, return a typed "outcome unknown" that keeps the draft, so the client retries with the same ID.
- Retain records for at least the client retry horizon (proposal: 7 days). After expiry, the base CAS is the backstop.
- Store the FEAT-024 operation ID of the pending entry the command produced, for correlation only. That isn't a second idempotency system.
- Derive inbox SourceIds from the commandId.
Add AC-DC-03.
DC-04 Major PKG integrated-plan R0 item 2 l.282-289; contracts.md C16 l.553-555; domain-model CanonicalOwnership l.142; migration §4 step 5 l.126-129; AC-R0-02/03 l.99-100; AC-R6-05 l.395 The ownership check can't work as written. The plan says every legacy writer checks the ownership record "inside its transaction". With statistics writes off, most legacy Study writers open no transaction at all. And under snapshot isolation, reading a separate record doesn't conflict with a concurrent ownership flip, so a legacy write that read "legacy" can commit after cutover. The codebase states this explicitly and forces a write instead. "Switch the read/write binding atomically" by writing one record has the same write skew. Mongo Repositories/StudyRepository.cs:164-200 (no transaction when writes are off); API SubmitAnnotationSessionService.cs:321-338; Mongo Repositories/ProjectStatistics/ProjectStatisticsProjectVersionSource.cs:15-17 ("A snapshot read cannot serialize admission with later membership/configuration edits"); Common AggregateWriteGuards.cs:31-50 (one guard per document type); Common MongoExtensions.cs:262-326, 508-516; Mongo Repositories/StudyBulkUpdateLockGuard.cs; MAIN .claude/rules/bulk-study-locks.md:11-31; MAIN docs/decisions/ADR-020-bulk-study-update-all-or-nothing.md:113-120, 161-168 Put a canonical-scope marker on the documents legacy writers write: a top-level Study.CanonicalScopes, and the same on Project.
- Check it in the legacy aggregate methods (AddSessionData, Study.AddScreening at Core Model/StudyAggregate/Study.cs:245, DeleteSession, reconciled-answer writes, question delete). The check and the Audit.Version CAS are then one document write.
- For direct legacy-field writers, add a composite IAggregateWriteGuard. The registry holds one guard per type, so compose it with the bulk-lock guard. Extend StudyWriteLockArchitectureTests to cover them.
- Keep pmCanonicalOwnership as the audited registry, with a reconciliation check against the markers.
- Set markers for seeded and pilot projects, and at R6 cutover, with ADR-020's protocol: an operation record with a generation, batches that bump Audit.Version, refusal of busy studies, and rollback before commit.
Add AC-DC-07.
DC-05 Major PKG integrated-plan R3a "Admission" l.511-514, §5.11 X-ELIG l.740; contracts.md C6 admission row l.290; Q-24 l.65 R3a inherits a measured per-project hotspot. The admission service "extends ReviewEligibilityPolicy", and that policy's save path writes Project.StatisticsAdmissionToken on every review write to serialise eligibility against settings. Measured with statistics off and eligibility on: 199 of 500 submissions exhausted at five reviewers on different studies, and 700 of 1,000 at ten. X-ELIG lists only the flag, the reservation migration and the D7 tool, so R3a production would inherit this unchanged. STATS screening-write-benchmark.md:549-554; STATS async-point-fold-design.md:412-428, 1899-1902 (deferred item 2); Mongo Repositories/StudyRepository.ActivityReviewWrites.cs:87-91; ProjectStatisticsProjectVersionSource.cs:15-21 Add to F3: canonical admission never writes a per-project document per save. Use consumption-time detection (the FEAT-024 fold precedent, which checks context digests at consumption) or a per-stage fence-and-drain on settings changes (Q-DC3). Add AC-DC-02 with eligibility on to R3a. List "eligibility Project-token redesign" as an X-ELIG prerequisite, agreed with the eligibility owner.
DC-06 Major PKG contracts.md C4 publication l.216-223; C8 l.340-350; integrated-plan R2c l.466-479; AC-R2c-01/05/06 l.189-194; domain-model FormPublishOperation l.77 and §5 l.177 Publication consistency is asserted, not designed. Four gaps:
(1) Nothing makes phase 1 conflict with a concurrent Save or session creation. A session can be pinned to v1 after the manifest freezes, so "never in between" can't be tested. Autosave drafts write nothing phase 1 writes, so the draft-only category is always racy.
(2) "Evaluate lazily on read" doesn't work for stored-field readers (pool filters, the capacity filter) that the plan keeps for coexistence. So "qualification follows immediately" holds only for canonical-sourced readers.
(3) A multi-second phase-1 transaction that writes the shared document last can be starved by short commits; one that writes it first blocks them for its whole duration.
(4) Phase 2's host, lease, crash takeover and bulk-lock handling aren't specified.
Ledger PLN review-form-owner-decisions-2026-10-02.md:52-53, 166-184 (PS2/PS3: currentness must survive concurrent writes; scoped pause allowed); STATS screening-write-overhead-diagnosis.md:148-151, 238-245; ADR-020:118-127; MAIN .claude/rules/bulk-study-locks.md:23-24 Phase 1:
- Set a per-form fence (status Publishing).
- Drain for at least the server's transaction lifetime plus its expired-transaction sweep plus a margin.
- Recompute impact in a pinned snapshot and compare it with the previewed digest; re-confirm with the admin if it changed.
- One transaction writes the version, the policy, the manifest (an audit and preview record only), the FEAT-024 definition-rewrite fence and the notices.
- Release the fence.
Phase 2:
- An ADR-020-style operation record (lease, generation), driven by a durable predicate (pinnedVersion < current ∧ appliedPolicyOp < op) that repeats until it matches nothing.
- Each transition is a normal session commit. Locked studies are deferred and counted.
Late Saves: a Save based on a superseded version applies the recorded policy in its own transaction (E1).
Qualification: canonical admission and statistics derive it on read from (current version, pinned form version, recorded policies). Phase 2 rewrites the Study projection, and legacy readers are labelled eventual.
Call ThrowIfBulkUpdateInProgressAsync before raising the fence. Restate AC-R2c-05 as "every session pinned to a prior version ends in the recorded policy's state", and AC-R2c-06 as "the pause is at most the drain plus 2 s".
DC-07 Major PKG contracts.md C5 drafts l.250-256; E21 l.123; domain-model SessionDraft l.91 and §5 l.174-175; AC-R2a-06 l.159 The drafts contract contradicts itself and misses its coupling to the commit.
- "One draft per session" plus a "tab lease" can't also "keep both" drafts after a two-tab conflict, unless the losing tab's edits are persisted somewhere.
- The Save/Complete transaction doesn't consume or rebase the draft, so after a Save the draft is still pinned to the old base. The next autosave then conflicts spuriously, or work is silently lost.
- An autosave delayed in transit can arrive after the Save and recreate a stale draft.
- Autosave retries on a flaky network conflict with themselves.
- Creating the first draft races on the session's natural key.
Ledger PLN review-form-owner-decisions-2026-10-02.md:42 (SL1), 342 (draft concurrency open); MAIN src/services/web/CLAUDE.md:343, 357; Mongo Authorization/GuardedTransaction.cs:159-165 (racing inserts) Define the model:
- One draft record per session, with a lease holder (tab or device ID), an etag and a per-holder write sequence.
- A non-holder's edits are stored as a bounded conflict copy, retained for N days, so "keeps both" is literal. "Take over editing" transfers the lease.
- Save and Complete present the draft etag and consume the draft atomically in the commit transaction.
- An autosave with a stale etag, arriving after a newer explicit version, is rejected and discarded by the client.
- A duplicate write sequence counts as success.
- Draft-only sessions are created by upsert on the session's natural key.
Add AC-DC-08. The product choice is Q-DC4.
DC-08 Major PKG domain-model ScreeningOutcome l.92 and §5 l.176; Q-26 l.66; E5 l.107; integrated-plan R3b l.539-545; contracts.md C8 l.343-345 Derived state has no input-version stamps and no repeatable sweeps. Three cases leave a derived record stale with nothing to detect it:
- A decision commits under profile v1 while v2 publishes. Snapshot reads don't conflict.
- A study is recomputed before a late decision lands.
- A binding or target change alters projected tallies without any Study write.
The codebase already carries this bug class: the inclusion recalculation's UpdateMany doesn't bump Audit.Version, so concurrent saves can overwrite it.
STATS async-point-fold-design.md:1896-1898 (deferred item 1), 412-421 (consumption-time detection replaced Project-version admission); MAIN .claude/rules/materialized-stats.md:285-289 Rule: every derived record stores the definition-version vector it was evaluated under (profile, form, stage settings, threshold digest). This covers ScreeningOutcome, the Study projection, readiness, the task input set, and any stored outdated or needs-updating classification. Readers compare it with current versions in the same snapshot, and admission fails closed on a stale record. Sweeps are predicate-driven (evaluatedUnder < current), version-guarded and write the Study; they repeat until nothing matches. Add fixtures for a decision racing a profile publication and a threshold change racing a decision.
DC-09 Major PKG integrated-plan R3c l.551-561; E29 l.131; AC-R3c-01..04 l.244-247; domain-model §5 "Stage change approval" l.179 Stage completion can admit changes LC1 says must be held. LC1 requires holding a change before it reaches a Completed stage. But completion reads readiness across all studies, and concurrent commits don't write StageLifecycle. A Save after Complete, a Fix, a correction or an import that started before completion committed can land after it. The result is a Completed stage with unresolved work, or a change admitted without approval. E29 offers "transactional or eventual", and neither is safe on its own. Ledger PLN review-form-owner-decisions-2026-10-02.md:853-859; ProjectStatisticsProjectVersionSource.cs:15-17 Complete a stage in two steps: Completing (a fence) → drain → verify readiness from authoritative records in a pinned snapshot → Completed (or back to Active). Every readiness-relevant command reads stage status in its snapshot. Under Completing it's refused as retryable; under Completed it becomes a StageChangeRequest. Approval re-validates bases at commit. New arrivals (imports, pool entry, binding changes) take the same path. Add AC-DC-09.
DC-10 Major PKG E30 l.132; domain-model §5 "Afterwards" column l.174-180, principle 4 l.40-43; contracts.md C15 l.534-546 Post-commit work has no durable mechanism. Domain events dispatched after commit are explicitly not crash-durable in this codebase. Phase-2 triggers, readiness re-evaluation, projection rewrites and outcome sweeps would all be lost on a crash between commit and dispatch. Outdated-annotation flags are made eventual (by fan-out) although they can be derived exactly on read. And change streams here restart from "now" on an invalid resume token, so they can't carry obligations. MAIN .claude/rules/materialized-stats.md:153-160 ("no crash-durable or exactly-once event delivery guarantee"); Mongo Repositories/ActivityClaimRevocationOutbox.cs:16-55; Core Services/ReviewEligibility/Revocations/ActivityClaimRevocationDispatcher.cs:9-24; Common MongoContext.cs:279-287 Classify every post-commit effect into one of three kinds:
(a) Derived on read, with no fan-out: outdated flags (pinned revision ≠ head's current revision), task drift, needs updating.
(b) Durable intent: inserted in the commit transaction and handled by a leased, idempotent dispatcher (reuse the claim-revocation outbox pattern), or by an ADR-020 operation record for multi-batch work. Phase 2 and all sweeps are this kind.
© Best-effort hints (SignalR, the change-stream InboxChanged hint) that never carry correctness.
Add AC-DC-10.
DC-11 Major PKG contracts.md C1 invariants l.113-125; domain-model §5 l.170-183; integrated-plan §9.2 l.1004-1006 The canonical engine has no transaction-admission contract, unlike FEAT-024. Unspecified:
- Write concern. The shared SnapshotOptions set read concern only, and the Atlas SRV string sets no options, so commits take the server default (UNVERIFIED for the production server version). The notification stack's own transactions use majority, and inbox rows will ride inside canonical commits.
- Transient write conflicts on a mutable aggregate: re-execute from a fresh snapshot, never replay.
- Free retries when the Study moved only through the fold worker or reservation idle-token writes, so reviewers don't see spurious conflicts.
- Indeterminate commits.
- Flag capture once per request.
- Cache eviction.
- Pre-creating new collections.
- DuplicateKey on natural-key first writes.
Core Services/ProjectStatistics/Lifecycle/ProjectStatisticsTransaction.cs:21-22, 46-51, 66-91, 107-127; Common MongoContext.cs:76-83; STATS transaction-admission.md:35, 51-60, 135; Core Fold/ProjectAnnotationFoldSave.cs:160-184 (fold-only free retries); Mongo StudyRepository.cs:1966-1987 (idle token does $inc Audit.Version); Mongo Authorization/GuardedTransaction.cs:46-50, 147-185; NOTIF src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyConversationRepository.cs:13 Write a "canonical transaction admission" ADR at F1. It fixes:
- ReadConcern.Snapshot, ReadPreference.Primary, WriteConcern.WMajority with journal, and maxCommitTime.
- Whole-command re-execution from a fresh snapshot, within a deadline, with jittered backoff.
- Free retries when the reloaded Study moved only through fold, claim-token or idle-token writes.
- Typed outcomes: stale base, retryable conflict, locked, fenced, outcome unknown, refused.
- Cache eviction on indeterminate commits.
- Every canonical collection and index created at start-up.
- DuplicateKey on a natural key → reload and take the CAS path.
Pin a per-command budget test, like FEAT-024's command-budget tests.
DC-12 Major PKG contracts.md C1/C9 CAS rules; AC-ALL-03 l.51 The shared cache can serve stale canonical state. RepositoryCache hands one mutable instance to every caller in the process for two seconds, and production runs at least three API replicas. If canonical repositories inherit it, CAS bases, "current version" displays, gold pointers and admission reads can be up to two seconds stale on another replica. "Refused on the next request" after revocation isn't guaranteed either; any extra caching inside the authority evaluator is UNVERIFIED. Common RepositoryCache.cs:36-44; MAIN .claude/rules/repository-cache.md ("Isolation is not a consistency guarantee"); /home/chris/workspace/cluster-gitops/syrf/environments/production/api/values.yaml:46-49 (minReplicas 3) Canonical repositories never serve these reads from the shared cache: anything feeding a CAS, an admission decision, an authority check or a "current" display. Use BeginIsolatedReads or GetUncachedAsync. Commands return new versions in the receipt for read-your-writes. Restate AC-ALL-03 with an explicit bound, or require uncached authority reads in canonical commands. Add AC-DC-11.
DC-13 Major PKG contracts.md C11 l.444-456; E25 l.127; AC-R5a-01/02 l.312-313; integrated-plan R5a l.650-660 The as-of rule is under-specified.
- Nothing maps a date to a watermark.
- Nothing allows for host clock skew, or for the server enforcing transaction lifetime through a periodic sweep (interval UNVERIFIED).
- Nothing guarantees causal closure once the counter goes (DC-01): a cut could include a gold snapshot but not the session version it references.
- "Two exports at the same watermark are identical" is false for unversioned datasets: Study metadata rewritten by bulk update and corrections, display names, and alias maps derived from current membership. It's also false after account erasure (E32).
PKG contracts.md:444-456; MAIN src/libs/kernel/SyRF.SharedKernel/BaseClasses/Entity.cs:17 (settable DateTimeCreated); ADR-020:41-52 (bulk update rewrites Study fields) Stamp every canonical record with an HLC (the larger of the host clock and every stamp the command read, plus 1) and the per-aggregate version. as-of(T) = the records stamped ≤ T, offered only once T ≤ now − (transaction lifetime + sweep interval + skew bound). In the manifest, classify each dataset as versioned (reproducible), current-only (labelled "as at export time") or not observed. Store alias maps as versioned records. Scope AC-R5a-02 to versioned datasets and the same requester authority, and record erasure events in manifests. Add AC-DC-12.
DC-14 Major PKG contracts.md C3 "History capture" l.165; E26 l.128; integrated-plan R2a l.414-415 and R3a l.520-521; AC-R2a-17 l.170, AC-R3a-10 l.225; domain-model HistoryCapture l.144, PoolEntryEvent l.93, §5 l.176 Capture can be lost or orphaned, and pool-entry capture is incomplete. Capture must be complete and atomic with the write it records. But legacy screening writes in production are non-transactional single-document replaces. A separate capture record can be lost (crash after the Study write) or orphaned (written for a write that then lost its CAS). Pool-entry events are listed only for decision submits. Availability also changes on batch release, imports into an available stage, binding and lifecycle changes, personal grants and dedup reversal. Mongo StudyRepository.cs:187-200; Core Model/StudyAggregate/Study.cs:245 (the single AddScreening funnel); its callers Core Services/ParserImplementations/NewParser/ScreeningColumnHandler.cs:157, Services/ReviewEligibility/AdministrativeScreeningPolicy.cs:81, Services/StudyReferenceFileParser.cs:239, Services/BulkStudyUpdate/Atomic/BulkStudyUpdatePlanner.cs:177, Services/ReviewSubmissionService.cs:66; pending caps MAIN .claude/rules/materialized-stats.md:116-119 Capture inside the aggregate. Study.AddScreening (and its equivalents) appends a bounded capture entry to a new top-level Study field in the same document write: the FEAT-024 pending-entry pattern, with caps and an overflow marker that becomes a coverage gap. A leased worker moves entries idempotently to pmHistoryCapture. Enumerate every availability-changing writer for PoolEntryEvent, or derive pool entry from versioned inputs where amendment A allows, and test each one. Add AC-DC-13.
DC-15 Major PKG contracts.md C9 gold, assignment and query rows l.375-381; domain-model §5 gold l.178; AC-R4a-04 l.269, AC-R4a-07 l.272; E4 l.106, E6 l.108 Gold publication retries can conflict with RE2. On a lost pointer CAS, silently recomputing and republishing can publish answers the reconciler never saw (for example, a shared question another form's task has just taken). Candidate drift at Complete is detected only because candidate commits and gold both write the Study, and the plan doesn't say so. Two assignment races are write skew: (1) a released reconciler's Save racing the release, where RA4 forbids submission until the assignment is reacquired; (2) expiry racing the first start. Ledger PLN review-form-owner-decisions-2026-10-02.md:247-258 (RA1-RA4), 268-292 (RE2); PKG contracts.md:375-381 Complete carries the base snapshot ID and the task input-set etag the reconciler saw.
- Retry transparently only when the recomputed snapshot equals what was displayed. Otherwise return a typed "gold changed" or "inputs changed" conflict; Complete anyway stays available.
- Gold publication writes the Study, and the plan should state that this is load-bearing.
- Every reconciliation-form command CAS-writes the task's claim generation.
- The first draft or save CAS-sets assignment.started, and the expiry job CAS-checks "unstarted".
- Raising a query carries the answer version the raiser saw, never "current".
Move these from R4b to F4 and add AC-DC-14.
DC-16 Major PKG acceptance-criteria AC-M0-01/02 l.89-90, AC-R2a-07 l.160, AC-R2a-19 l.172; contracts.md C1 conformance l.127-129; integrated-plan §9 l.1002-1006 The engine's acceptance criteria miss failure injection and contention, and one latency target is unattainable.
- M0 and C1 conformance omit the research's failure-injection case A8 (an injected failure before commit commits nothing; a retry after an unknown commit returns the original result) and the claim and race cases A13 and A14.
- AC-M0-02 records contention but sets no gate.
- AC-R2a-19 (p95 ≤ 1.2 × today's single-document save) is unattainable by construction. A multi-document snapshot transaction measured roughly +60-70 ms of serial round trips in this codebase.
PLN screening-specialised-annotation-research.md:1253, 1258-1259; STATS screening-write-overhead-diagnosis.md:177-184, 253-257; STATS async-point-fold-design.md:71-74, 93 Add A8, A13 and A14 to AC-M0-01 and C1 conformance. Add AC-DC-01 (crash-point injection), AC-DC-02 (contention gate) and AC-DC-04 (indeterminate commit). Replace AC-R2a-19 with an absolute budget set after M0 (Q-DC1).
DC-17 Major PKG E31 l.133; migration §6 l.178-179; AC-R7-02 l.398 Restore is treated as a validation check, not a semantic event. A point-in-time restore erases every commit after the restore point. That invalidates as-of exports already issued at later watermarks, command receipts, inbox SourceIds and FEAT-024 receipts. MassTransit scheduled messages and Quartz state live in SQL Server and won't rewind with Mongo (lock leases, idle timeouts, expiry jobs). Current recovery practice is selective per-project recovery, which is unsafe for canonical data spread across many collections. MAIN docs/decisions/ADR-011-schema-v0-multi-option-conditional-parent-answers.md:174-177; ADR-020:85-89 (lock leases via MassTransit scheduled messages); MAIN .claude/rules/materialized-stats.md:297-303 Set a restore policy at F1:
- Canonical data gets whole-database point-in-time restores only.
- Each affected project gets a history-discontinuity record that manifests and as-of requests report.
- A post-restore consistency checker (Improvements §5) must pass before writes reopen.
- Scheduled messages and leases are reconciled from Mongo state.
- Selective per-project recovery of canonical collections is forbidden, or follows a rehearsed procedure (Q-DC5).
Add AC-DC-15.
DC-18 Minor PKG integrated-plan R0 item 1 l.280-282; contracts.md C16 l.550-552 "Tolerant class maps" points implementers at the lossy option. As this codebase uses the term, "tolerant class maps (or extra-element capture)" reads as SetIgnoreExtraElements. That drops unknown nested fields when an older binary replaces the whole Study; only [BsonExtraElements] round-trips them. AC-R0-01 would catch it, but the design text says otherwise. MAIN src/libs/kernel/SyRF.SharedKernel/BaseClasses/Entity.cs:21-22; Mongo Repositories/StudyPendingStatisticsClassMaps.cs:7-14; MAIN .claude/rules/materialized-stats.md:37-39 Require round-trip capture for any type an older binary may replace. Better still, add canonical fields only at Study or Project top level, where Entity [BsonExtraElements] preserves them, and never add fields inside existing embedded value objects.
DC-19 Minor PKG contracts.md C1 merge row l.109; E33 l.135; prisma-amendments D l.87-99 and L4 l.252-256; AC-P2-05 l.356, AC-P2-07 l.358 Merge and split conflict with the engine's invariants. Revisions are immutable and their context key includes studyId (C2), so "remap" can't rewrite them. Moving FormSessions collides with the unique (study, form, reviewer) key when one reviewer reviewed both duplicates. A merge must fence both studies against in-flight work. "Holds at all times" can't be tested without pinned reads. PKG contracts.md:104, 138-142; domain-model.md:219-222 Model a merge as an alias record (secondary → primary) plus per-reviewer resolution: choose the current session and supersede the other with provenance, never rewriting revisions. Run merges and splits as ADR-020-style operations that refuse busy studies and write both Study documents. Retroactive dedup honours ThrowIfBulkUpdateInProgressAsync. Restate AC-P2-07 as "holds in every pinned read and frozen report".
DC-20 Minor PKG contracts.md C3 exposure l.163; domain-model ExposureEvent l.94 Exposure binding is unspecified. Exposure reports arrive asynchronously from the client and may land after the Save they belong to. The plan doesn't say how an exposure binds to a version, or whether the independent/informed class is stored or derived. Ledger PLN review-form-owner-decisions-2026-10-02.md:46 (VS2) Carry the exposures seen since the base version in the Save/Complete payload, and record them in the commit transaction. Also accept late exposure events keyed by draft etag and base version. Derive the independent, informed or unknown class on read, so late events downgrade it. Evaluate "accepted snapshot available" in the commit's snapshot.
DC-21 Minor PKG contracts.md C1 l.118-121; integrated-plan R2a l.406-408 FEAT-024's transactional mode would add hot documents to every canonical commit. "Carries FEAT-024 pending entries under the fold protocol" doesn't cover an allowlisted project in transactional (non-fold) point mode. There, every canonical transaction would also write six per-project statistics documents and the Project version token. STATS screening-write-overhead-diagnosis.md:128-141; STATS async-point-fold-design.md:412-416 State that admitted projects run FEAT-024 in fold mode or with statistics writes off, never in transactional point mode, and have the admission service refuse that combination.
DC-22 Note PKG integrated-plan baseline l.18-24 main is now at 0f5c61073 (13:59 on 3 October). Since the plan's c59d9d0f1, #3956, #3959 and the slice-7 docs (#3962) have merged, all FEAT-024. Nothing on the canonical path changed. git -C /home/chris/workspace/syrf/main log --oneline c59d9d0f1..HEAD Refresh the baseline before F1.
DC-23 Note PKG domain-model §1 principle 4 l.40-43 vs §5 l.170-183 Principle 4 names three transactional operations, but §5 lists seven, and phase-2 batches and sweeps are transactions too. PKG domain-model.md:40-43, 170-183 Align the principle with §5 and with the conflict map in Improvements §3.

3. Improvements

§1 Concurrency-control rules to adopt at F1 (one ADR)

  • CR-1 Per-study serialisation. Every canonical command that changes study-scoped evidence or derived state writes that study's Study document in its transaction: version bump, projection, pending entry. This covers sessions, heads, decisions, outcomes, gold, task state and claims. It materialises the conflict for every intra-study invariant (see §3). An architecture test asserts that no canonical command for study S commits without writing S.
  • CR-2 No per-project or per-form document on the interactive path. Project-level read dependencies use three tools:
  • immutable pinned definition versions;
  • input-version vectors on derived records, checked by readers (DC-08);
  • predicate-driven, repeatable sweeps.
  • CR-3 Scoped fence-and-drain for boundaries that need quiescence: publication phase 1 (PS3), stage completion (LC1), settings changes (D4) and adoption cutover.
  • Sequence: write the fence, wait at least lifetime + sweep + margin, act in a pinned snapshot, release.
  • Every affected command reads the fence in its snapshot and is refused as retryable while it's up. Drafts are unaffected.
  • Release is guaranteed by a lease with expiry, plus an audited operator release.
  • CR-4 Long operations as ADR-020 operation records. Phase 2, projection rewrites, sweeps, adoption, retroactive dedup and merges each get an operation record with a lease, a generation, idempotent items, crash takeover and bulk-lock awareness.
  • CR-5 Commands carry what they observed: base versions, snapshot IDs, task input etags, target answer versions and draft etags. A retry never substitutes "current".
  • CR-6 Command ledger (DC-03): receipt before CAS, commandId stored on versions, typed "outcome unknown".
  • CR-7 Ordering by HLC plus per-aggregate versions (DC-13).
  • CR-8 Durable-effects classification (DC-10).
  • CR-9 No shared cache for reads that decide anything (DC-12).
  • CR-10 Transaction options: majority write concern with journal, snapshot read, primary, maxCommitTime (DC-11).
  • CR-11 First writes on natural keys: pre-create singleton documents; map DuplicateKey to reload and retry (DC-11).
  • CR-12 Legacy refusal by per-document markers checked in aggregate methods, plus write guards (DC-04).

§2 Consistency matrix

Read surface Consistency Staleness bound Mechanism What the user sees when stale or conflicting
Own session after Save/Complete (same tab) Strong (read-your-writes) 0 Receipt carries the new version IDs and HLC stamp —
The same session in another tab or stage tab Strong on load 0 (uncached primary read) CR-9; SignalR hint to open tabs (best effort) Banner "updated elsewhere"; Save → typed stale conflict, draft kept
Draft Strong per session 0 Lease and etag Non-holder sees "Editing in another tab — take over?"; a conflict copy is kept
Qualification, sufficiency, capacity (canonical admission) Strong 0 Derived in the commit snapshot from canonical records and recorded policies "This study now has enough reviewers"; drafts kept
Legacy readers via the Study projection (pool lists, StudyStats, legacy exports) Strong per study for commit-driven change; eventual after definition changes Until the rewrite operation finishes (target: minutes) Projection written in the commit transaction (DC-02); rewrite operation (CR-4) Lists may lag and are labelled "updating"; admission rechecks
Pool list and "next study" Eventual list, strong claim List: seconds; claim: 0 Claim CAS on the Study "Just taken" → next study offered
Collective screening outcome Strong for decisions; detects staleness after profile or threshold changes Until the sweep finishes; admission fails closed meanwhile Recomputed in the decision transaction; input-version vector "Re-evaluating under profile v2"; dependent steps wait
Dependent-step availability (DP6/DP7) Strong 0 Derived on read in one snapshot Lock reason, never "Excluded"
Outdated annotations (SF5); Needs updating Strong 0 Derived on read (pinned revision vs head; recorded policies) Flag or Needs updating in the form; Fix
Gold pointer; query pending flag Strong 0 Uncached pointer read; flag read in the same snapshot —
Reconciliation "inputs changed" Strong at read; checked at Complete 0 Task input etag vs current qualifying set Re-check banner; typed conflict, with Complete anyway
Feature queues (concerns, approvals, assignments, requested reviews) Strong 0 (plus the authority-cache bound, if any) Primary query over the aggregates —
Inbox Rows strong; badge and email eventual Badge: seconds; email: per preference Capture in the transaction; change-stream hint Delayed badge
Statistics, FEAT-024 fold mode Effectively current "Never behind": row plus pending overlay in a pinned snapshot FEAT-024 Existing Stale and fallback states
Statistics, FEAT-024 off Strong 0 Canonical-aware authoritative calculation (E20) —
Stage readiness and lifecycle Strong 0 Authoritative records; Completing fence Brief "Completing…" pause
Admission (is this project canonical?) Strong at command time 0 on the server; the web copy is a hint Marker plus registry Read-only containment notice
Revocation Bounded Proposal: 0 for canonical commands, ≤2 s for page reads CR-9 Refused on the next command
Publication impact preview Snapshot at preview; exact at phase 1 0 at the boundary Fence, drain, digest comparison "Impact changed — review again"
Current export Consistent cut 0 at stamp W HLC watermark W taken at start; immutable records ≤ W Manifest shows W
As-of export Exact for versioned datasets Available only for T ≤ now − (lifetime + sweep + skew) HLC cut Coverage and basis labels
PRISMA report Frozen; the current view is pinned 0 Manifest; pinned snapshot —

§3 Conflict map: which document materialises each invariant's conflict

Invariant Writers that must conflict Conflict document
One contribution per reviewer, form and study; capacity ≤ target across bound stages Save/Complete/Fix/Withdraw on any session of the study; claims Study document (CR-1)
Collective outcome reflects every current decision Decision submit and correct; adjudication Study document and the ScreeningOutcome document
Gold matches what the reconciler saw; shared question gold has one owner Candidate commits; gold Complete; query resolution Study document plus the StudyGold pointer CAS
A released reconciler can't submit; a started assignment never expires Release and reacquire; reconciliation-form commands; expiry job Task claim generation; CAS on assignment.started
Shared answers across forms Saves in two sessions of one reviewer AnnotationHead current pointer
Drafts have a single writer Autosaves from two tabs; consumption by Save SessionDraft etag and lease
Bulk-locked study is untouched Everything vs the bulk executor Existing Study write guard plus version bump
Legacy writers never write canonical scopes Legacy aggregate methods vs marker sweep or cutover Study/Project marker plus version CAS
Exact publication boundary (PS3); no admission into a Completed stage; exact settings change (D4) Commits vs phase 1, completion, settings change Scoped fence plus drain (CR-3)
Dedup merge is consistent Commits on either study vs the merge Both Study documents plus the operation record

§4 Failure modes and required handling

Failure Handling User sees Test
Transient write conflict Re-execute from a fresh snapshot within a deadline; free retries when the Study moved only through fold, claim-token or idle-token writes Nothing; if retries run out, "Busy — your draft is safe" AC-DC-02
Stale business base Typed 409 naming what changed Conflict view; draft kept AC-R2a-07
Indeterminate commit Bounded commit retry → typed "outcome unknown" → client retries the same commandId → the ledger resolves it "Checking whether your save landed…" AC-DC-04
Crash before commit Nothing persisted Retry; draft kept AC-DC-01
Crash after commit, before side effects Leased dispatcher handles the durable intents Nothing AC-DC-10
Bulk lock; fence; ownership refusal Typed 409 (with Retry-After) or typed retryable error Specific message; draft kept AC-DC-07, AC-DC-09
Worker crash Lease expiry → takeover with a generation bump Progress resumes AC-DC-10
Primary failover Majority-acknowledged commits survive; retryable writes for drafts Possibly a retry AC-DC-04
Change-stream gap Only hints are lost; clients refetch on focus or reconnect Delayed badge —
Replay after receipt expiry The base CAS refuses it Stale conflict AC-DC-03
Restore Discontinuity record, checker, reconciliation of scheduled state Notice on affected projects and exports AC-DC-15

§5 Consistency checker

Run it nightly for each admitted project, after every restore and before every adoption. It is read-only, and every result it reports is typed. It checks that:

  • every session pointer resolves to an existing version, and every revision that version references exists;
  • every gold pointer and snapshot reference resolves;
  • the Study projection equals a recomputation from canonical records;
  • every derived record is either current or flagged as pending a sweep;
  • every version has a ledger entry, or the manifest ID of the migration that wrote it;
  • no legacy review data was written after its marker;
  • inbox SourceIds resolve, or are labelled;
  • stamps are monotonic per aggregate;
  • drafts reference existing base versions;
  • outstanding intents and operations are within their age bounds.

§6 Acceptance criteria to add

ID Criterion Method
AC-DC-01 Fault injection at every write step of the Save, Complete, decision, gold and publication commits leaves no partial state readable, and a retry returns the original result. C, I
AC-DC-02 Zero engine-caused exhausted submissions at 1, 2, 5 and 10 reviewers, on the same study and on different studies, with the fold worker, claims and one background sweep running. p95 meets the Q-DC1 budget. B
AC-DC-03 A duplicate commandId returns the original result. A delayed Save retry after a later Complete doesn't change qualification. A replay after retention expiry fails the CAS. I
AC-DC-04 Forced UnknownTransactionCommitResult and a primary failover in a replica-set Testcontainer produce no duplicate versions or notices. I
AC-DC-05 A whole-Study replace by the R0 minimum image leaves the canonical tallies, screening aggregates and every canonical field intact. I, R
AC-DC-06 Concurrent admissions through two bound stages never exceed the form target. I
AC-DC-07 Each legacy writer, transactional or not, racing a marker sweep or cutover never lands after the marker. I
AC-DC-08 Drafts: lease, conflict copy, Save consuming the draft, a late autosave rejected, idempotent retries, the first-draft race. I, E
AC-DC-09 Randomised interleavings (≥1,000) of commits against stage completion, publication phase 1 and settings changes never violate LC1, PS3 or D4. I
AC-DC-10 A crash between commit and dispatch still delivers; operation takeover; duplicate delivery is a no-op. I
AC-DC-11 With two API instances, canonical CAS bases and "current" reads are never stale, and the revocation bound holds. I
AC-DC-12 With injected clock skew and a long transaction committing near the watermark, exports at the same watermark are identical and causally closed. I
AC-DC-13 Every legacy screening writer in an admitted project produces exactly one capture entry per committed change, and overflow is recorded as a coverage gap. Every pool-entry writer is tested. I
AC-DC-14 Reconciliation races are covered: published vs displayed answers, release vs Save, expiry vs start, query target retention. I
AC-DC-15 A point-in-time restore rehearsal passes the checker, records the discontinuity and reconciles scheduled state. R

§7 Existing mechanisms to reuse

These mechanisms already exist on main; the F1 ADRs should cite and reuse them rather than invent new ones:

  • GuardedTransaction: a serialisation document incremented first, documents pre-created, majority commits, honest outcomes (Mongo Authorization/GuardedTransaction.cs:11-50, 147-185).
  • ADR-020's operation record, generation fencing, lock batches and busy refusal.
  • The claim-revocation outbox and its leased dispatcher.
  • FEAT-024's fold-only free-retry budget, and its consumption-time context digests.
  • StudyWriteLockArchitectureTests.
  • FEAT-024's command-budget tests.
  • BeginIsolatedReads.

4. Questions for Chris

ID Question Recommendation
Q-DC1 What write-path budget should canonical Save and Complete meet? A multi-document transaction is unavoidable, so "≤1.2 × today's single-document save" can't be met. Mirror your FEAT-024 gate (b): zero engine-caused exhausted submissions at 1, 2, 5 and 10 concurrent reviewers. Set absolute p95 budgets after M0 measures them. As a starting proposal: Save ≤150 ms, Complete ≤300 ms on a 200-question form.
Q-DC2 Are short, scoped pauses acceptable for publication phase 1, stage completion and adoption cutover? Up to about 90 s for the affected form or stage only, with drafts kept and automatic retry. This is instead of serialising every save in a project through one document. PS2 already allows a brief pause for publication; the other two are new. Yes, with an in-form "being published / completing" message and an admin progress indicator.
Q-DC3 When an admin changes stage settings, may submissions already in flight commit within that short drain window? Or must every save serialise against settings, as the current eligibility design does via the Project token, which measured 40-70% exhausted saves at 5-10 reviewers? Accept the bounded window. Settings changes take effect after the drain (fence-and-drain), and the per-save Project write goes.
Q-DC4 When a reviewer has the same session open in two tabs, should the second be read-only with a "take over editing" control, or should both be editable with conflicts resolved at Save? Read-only with "take over". The other tab's unsaved edits are kept as a recoverable conflict copy.
Q-DC5 For canonical projects, should support keep offering "restore this project to yesterday"? No selective per-project restores of canonical data. Allow only whole-database point-in-time restores with a recorded history discontinuity, and handle project-level mistakes with forward correction tools.
Q-DC6 If an account is erased (E32), may as-of exports at earlier watermarks then differ in identity fields? Yes: "identical" means identical except for erased identities, and the manifest records the erasure event.

5. Coverage gaps

  • UNVERIFIED (no database or Atlas reads were made): the production MongoDB server version and its implicit default write concern; transactionLifetimeLimitSeconds and the expired-transaction sweep interval; the Atlas backup mode.
  • UNVERIFIED: real per-project write rates and concurrency in production. FEAT-024's benchmark is a synthetic stress test.
  • Sampled, not traced end to end: Study write shapes. I checked the fold UpdateOne, the capacity replace, MongoExtensions.SaveAsync, the idle-token update and the admission token. The PDF, risk-of-bias, import and deletion writers weren't traced.
  • UNVERIFIED on the deployed server: whether concurrent unique-key inserts inside transactions raise WriteConflict or DuplicateKey. This is inferred from the comment at Mongo Authorization/GuardedTransaction.cs:159-165.
  • Not read: #3965's conversation changes, and #3944's write paths beyond inbox capture.
  • Not analysed:
  • the eligibility programme's typed-claim internals, beyond the outbox and the Project token;
  • the consistency of AL1's cross-study proportional quota;
  • client-side AF2 retry and draft behaviour, beyond the src/services/web/CLAUDE.md rules;
  • consistency for classification (C1/C2) and outcomes (O1/O2), which only the general rules cover.

Critical Files for Implementation

  • /home/chris/workspace/syrf/pr/pr3617.research-screening-as-specialised-annotation-gxgahs/docs/planning/integrated-review-plan-2026-10/contracts.md
  • /home/chris/workspace/syrf/pr/pr3617.research-screening-as-specialised-annotation-gxgahs/docs/planning/integrated-review-plan-2026-10/domain-model.md
  • /home/chris/workspace/syrf/main/src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/ExtractionInfo.cs
  • /home/chris/workspace/syrf/main/src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs
  • /home/chris/workspace/syrf/main/docs/features/materialized-project-statistics/async-point-fold-design.md