Review eligibility completion audit (S5, first pass)¶
Dated snapshot (25 September 2026)
This audit was written against main at 118424964 on 25 September 2026 and has not been re-baselined.
Since then S4-A (#3732) has merged, so the rows that treat it as open or in flight (D7.2, MG.4, MG.6, WF.2,
WF.4 and the "Concern to relay to S4-A" note) describe that date, not today. The eligibility programme is
paused; the current plan for its remaining slices (X-ELIG) is in
the integrated review plan.
This audit maps every decided requirement in the review eligibility policy and its truth table to the PRs that implement it and the tests that prove it.
Scope and method¶
- Baseline.
mainat118424964: #3736 merged, #3732 (S4-A) open. - Read-only. Nothing was run for this audit. A test counts as evidence only if it was found by name and its body or assertions were read. The policy doc's own claims were not trusted without that.
- Decisions as written. The audit records what the doc decides. Where it finds a gap it doesn't propose new policy; the questions that need a decision are listed separately.
- What is not re-audited. Items marked "proposal" in the doc are audited only where the lead accepted them as MVP criteria. Those are points 1, 2, 3 and 5 of "Proposed independent revocation and SignalR enforcement".
Classes¶
| Class | Meaning |
|---|---|
| Done | Implemented, and a test on main proves it. |
| Done, thin | Implemented, but the tests don't adequately prove it. |
| Partial | Some of the requirement is missing. |
| Not done | Not implemented. |
| S4-A / S4-B / S4-C | Owned by the in-flight S4 slices: #3732 (grouped configuration, writer floor), migration tooling, and the settings API and editor. |
| Deferred (#n) | Explicitly deferred by an open issue. No open issue defers any of the gaps below. |
Path prefixes¶
| Prefix | Path |
|---|---|
| Core | src/libs/project-management/SyRF.ProjectManagement.Core |
| CoreT | …Core.Tests |
| MongoT | …Mongo.Data.Tests |
| ApiT | src/services/api/SyRF.API.Endpoint.Tests |
| PmT | src/services/project-management/SyRF.ProjectManagement.Endpoint.Tests |
| Web | src/services/web/src/app |
Headline findings¶
- The server contract is largely built and tested. The reviewer browser does not consume it.
- The web app never reads
NextStudyResponseDto.eligibility. - The review page still applies #3551's universal screening-complete veto
(
Web/stage/stage-review/stage-review.component.tsscreeningComplete, viascreeningIsComplete). That veto ignoresAdditionalScreeningPolicy. - As a result, even with the flag on, Combined Allow (including existing unset stages under D1) is refused in the browser while the API accepts it.
- The
ReconciliationNotReadywarning, the reason copy, stale-study refetch and per-activity disabling are likewise absent. - Only two causes emit the claim-revoked event: D8 decision 2 and Apply anyway.
- Screening completion releases claims on the server but sends no event.
- Permission or membership loss refuses admission but keeps the claim.
- Project-level threshold and default-target changes plan and revoke nothing.
- No browser (e2e) or staging acceptance exists for
reviewEligibilityPolicy. Most of the concurrency matrix is also untested. - Flag off is behaviourally identical. It is not byte-identical, however: there are unsanctioned wire additions
(
null/0fields), inert new infrastructure (an index-init collection and a per-pod queue), a changed errordetail, and one unflagged persisted-data change (FO.4, a data-preserving fix). - The allocation interaction is correct. Flag off, #2991/#3603 behaviour is unchanged, and the D8(2) slot rule is tested at Core and Mongo level. The kept-claim continuation lacks endpoint-level tests.
1. Decisions D1–D8 and the truth table¶
| ID | Requirement (doc line) | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| D1.1 | Screening-only is always strict (L123, L1079) | #3579, #3646 | CoreT ReviewEligibilityPolicyTests::ScreeningOnlyIsImplicitlyStrictAfterSufficiency; StageReviewConfigurationTests::NewSinglePurposeInputCannotSupplyCombinedSettings |
Done | |
| D1.2 | An existing combined stage with no policy resolves to Allow (L124) | #3579 | StageReviewConfigurationTests::MissingExistingPolicyAllowsButNewCombinedStops, ::LegacyCombinedRoundTripDoesNotInventExplicitPolicy; ReviewEligibilityPolicyTests::ExistingCombinedStageWithMissingPolicyResolvesToAllow |
Done | Server only; the browser refuses it (UI.1). |
| D1.3 | Explicit combined choices are preserved (L124) | #3579 | StageReviewConfigurationTests::ExistingExplicitCombinedPolicyIsPreserved |
Done | |
| D1.4 | Annotation-only has no screening policy (L125) | #3579, #3646 | ReviewEligibilityPolicyTests::AnnotationOnlyHasNoScreeningPolicyAndOffersNoScreeningAction |
Done | |
| D2.1 | New combined stages default to Stop and persist it (L1080) | #3579 | ApiT ProjectControllerTests::AddStageUsesFlaggedConfigurationDefaults; StageReviewConfigurationTests::NewCombinedStopSurvivesBsonRoundTrip; Web create-stage.component.spec.ts |
Done | |
| D2.2 | New single-purpose stages accept no Allow/Stop input (L1080) | #3579 | ProjectControllerTests::AddStageRejectsInapplicablePolicyBeforeMutation |
Done | |
| P.1 | Strict refuses a new decision after screening is complete; Allow permits it (L127-129) | #3579, #3691 | ActivityReservationAdmissionTests::ScreeningSaveUsesCombinedPolicyAndRetainsOwnCorrection; ReviewEligibilityPolicyTests::CombinedStopRefuses…, ::CombinedAllowPermits… |
Done | Server only. |
| P.2 | A sufficient study never re-enters the new-screening pool (L128) | #3646, #3659 | ReviewEligibilityPolicyTests::ACompletedStudyNeverReturnsToTheNewScreeningPoolEvenUnderAllow; MongoT ReviewEligibilityPoolPredicateTests::PoolPredicatesMatchTheTruthTableRow |
Done | |
| P.3 | Claim set per configuration (L131-134) | #3579, #3719 | ActivityReservationPolicyTests::ClaimsFollowActivityAndStrictnessNotSelection |
Done | |
| P.4 / D3b | No person-level prerequisite (L137-141) | #3646 | ReviewEligibilityPolicyTests::AnnotationHasNoScreeningPrerequisite |
Done | |
| P.5 | Correcting one's own decision is separate from strictness (L143-147) | #3646, #3579 | ReviewEligibilityPolicyTests::CorrectionIsSeparateFromStrictnessAndScreeningCapacity |
Done | |
| D3a.1 | No closure concept; a reviewer's no-work state is not stage completion (L1027-1030) | n/a | absence confirmed by grep | Done | A negative requirement. |
| D3a.2 | Fix the fixed-two StudyStats/AnnotationTallyStateProfile discrepancy before any code claims configured completion (L1048-1058) |
none | none | Not done | Latent; no issue tracks it. |
| D4.1 | Reconcile grant plus route/body identity (L99-103) | #3565 | ApiT ReviewControllerReconciliationAuthorizationTests (3 facts) |
Done | Unflagged. |
| D4.2 | Row ownership on saves (L1083) | #3671, #3691 | CoreT ExtractionInfoSessionOwnershipTests (~25 facts) |
Done | Unflagged. |
| D4.3 | Refuse the newly unavailable activity after a disable or mode change; keep work (L1083) | #3579, #3691 | ApiT ReviewWriteRefusalEndpointTests::FlagOnRefusalIsTypedAndLeavesTheStudyAndProjectUnchanged; StageReviewSettingsControllerTests::StageDisableRefusesFreshReviewAndLeavesDraftUncommitted |
Done | |
| D4.4 | Selection mode alone is not an action prohibition | #3646 | ReviewEligibilityPolicyTests::SelectionModeIsNotAnActionProhibition |
Done | |
| D5.1 | Exclusion removes a study only from the new-annotation pool | #3646, #3659 | ReviewEligibilityPolicyTests::SufficientExclusionOnlyRemovesTheStudyFromTheNewAnnotationPool |
Done | |
| D5.2 | Excluded-work visibility and grouping settings are preserved (L1084) | #3579 | indirect only | Done, thin | No test round-trips ExcludedSessionStatsGrouping/HideExcludedStudiesFromReviewers through the guarded PUT. |
| D6.1 | Screening capacity uses the project rule, not the annotation target | #3579 | ActivityReservationPolicyTests (2); MongoT ActivityReviewAdmissionTests::ConcurrentCrossStageClaimsCannotTakeTheSameRemainingScreeningPlace |
Done | |
| D6.2 | A conflict on the first save writes nothing | #3579 | StageReviewSettingsControllerTests::ConflictIsReadOnlyAndForceReleasesOnlyExcessAnnotationClaims |
Done | |
| D6.3 | Apply anyway is for an authorized admin and overrides only the reservation conflict | #3579 | ApiT ::ForceCannotBypassCurrentDesignPermission, ::ForceDoesNotBypassConfigurationValidation, ::UnknownHistoryRequiresConversionConfirmationEvenWhenForceIsSet; CoreT ReviewSettingsConsentPolicyTests |
Done | |
| D6.4 | Recheck, then commit settings and revocations atomically against competing writes (L1085, L1449-1464) | #3579 | ::ClaimPinnedBeforeStageDisableCannotCommitAgainstTheOldSettings, ::ProjectSaveFailureRollsBackClaimReleases |
Partial | No race of a confirmed Apply anyway against a concurrent claim or submission. |
| D6.5 | The earliest valid reservations survive, with a stable tie-break | #3579 | ActivityReservationPolicyTests::ReducingScreeningCapacityRetainsEarliestEligibleClaimsWithoutTouchingAnnotation |
Done, thin | The equal-timestamp Page.Id tie-break is untested. |
| D6.6 | When completed work meets the target, remaining temporary claims are revoked (L1085, L815-830) | #3579 | screening case only | Partial | The annotation case is untested. It is skipped when the target isn't enforced, and "saved" counts incomplete sessions. Decision needed (Q1). |
| D6.7 | Saved work and independently eligible activity are preserved | #3579 | ::ConflictIsReadOnly…, ::ConfirmedConversionRetainsSavedReviewsAndTheirPositiveHistoricalEvidence |
Done | |
| D6.8 | Activity-specific SignalR plus API/reconnect recovery | #3736 | ApiT ActivityClaimRevokedConsumerTests, ActivityClaimRevocationDispatchServiceTests; Web study-presence.init.spec.ts |
Partial | Only two causes emit the event (RV.8), and there is no per-activity disabling (UI.1). |
| D7.1 | Pure legacy mapping (four mismatches, schema-0, missing policy becomes Allow) | #3579 | StageReviewConfigurationTests::AllAuditedMismatchKindsRetainModeAndDeriveSelection, ::SchemaZeroBothFalseRetainsProductionScreeningMeaning |
Done | Read-time only. |
| D7.2 | Migration execution | #3732 (open) | – | S4-A/S4-B | |
| D8.1 | Own annotation can be removed on a disabled or mode-changed stage | #3646, #3691 | ReviewEligibilityPolicyTests::RemovalNeedsOnlyTheReviewGrantAndOwnershipOnADisabledOrChangedStage; ApiT ::StageDisableStillAllowsOwnSessionRemovalOnTheCurrentSettingsRevision |
Done | |
| D8.2a | A leftover claim grants no exemption for new work | #3646, #3719 | ReviewEligibilityPolicyTests::ALeftoverClaimOutsideTheAllocation…; ActivityReservationClaimReleaseTests::SlotLeft_WithoutAClaimTheStudyIsStillNotNewWork |
Done | |
| D8.2b | Release only when no slot is left; a kept claim continues | #3719, #3736 | MongoT ActivityReviewAdmissionTests::ALeftoverClaimWithNoSlotLeftIsReleased…; ActivityReservationClaimReleaseTests (5 facts) |
Done | Endpoint-level continuation is untested (AL.2). |
| D8.2c | Under reallocation, claims are protected work; unaccommodated ones are released with a warning | none | – | Deferred (#3745) | Waits for allocation Phase 3 (lead, 25 September 2026). |
| D8.3 | A disabled stage cannot be opened (UI guard plus server) | #3646, #3659 | Web stage-disabled-guard.service.spec.ts; ApiT NotificationHubStudyPresenceTests::JoinStudyReview_ShouldDenyDisabledStageBeforePresenceMutation |
Partial | REST GetStudyForReview still returns 200 with the study, and the reconciliation GET has no refusal (RO.1). The guard's route wiring is untested. |
| D8.4 | Hidden excluded saved work is absent only from resumption | #3646, #3659 | ReviewEligibilityPolicyTests::HiddenExcludedSavedWorkIsOnlyAbsentFromResumption; MongoT rows |
Done | |
| D8.5 | The reconciliation pool respects readiness; the action only warns | #3646, #3659, #3663 | ReviewEligibilityPolicyTests::ReconciliationReadinessGatesThePoolAndOnlyWarnsOnTheAction and 2 more |
Partial | No UI shows the warning. |
| T.1 | Converting a reviewed stage to Combined warns and needs explicit choices | #3579, #3695 | ReviewSettingsConsentPolicyTests (7); ApiT ::FirstScreeningSaveRecordsHistoryAndRequiresConversionConfirmation |
Partial | NeverReviewed is never produced (MC.4), so every stage warns. |
| TT.1 | Every decision appears in the truth table | #3646 | ::TruthTableCoversEveryActionAndReasonAndHasUniqueRows, ::MarkdownExportIsCurrent |
Done | D2, D6, D7 and D3a are tested outside the table. |
| TT.2 | Every row is tested against the pure policy | #3646 | ReviewEligibilityPolicyTests::PolicyMatchesTheTruthTable |
Done | |
| TT.3 | Every row is tested against the Mongo pools | #3659 | MongoT ::PoolPredicatesMatchTheTruthTableRow |
Partial | Pool columns only (by design). Allocation is injected with applyAllocation:false, so the #3603 evaluator is not run per row. |
| TT.4 | The save path agrees with the table | #3691 | ApiT ReviewWriteRefusalEndpointTests (refusals) |
Partial | ActivityReviewWritePolicy is a separate implementation, and only its refusals are cross-checked, not its allowances. |
2. UI/API agreement and the engineering contract¶
| ID | Requirement (doc line) | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| AC.1 | The server returns allowed/refused plus a reason per action (L1104) | #3663 | ApiT ReviewControllerTests.Eligibility::EveryStudyResponseAttachesEligibilityOnlyWhenThePolicyFlagIsOn |
Done | |
| UI.1 (AC.2/AC.3/EC.14/EC.15) | The UI shows server-permitted actions and derives no different rule (L1105-1107, L1164-1165) | none | none | Not done | The web never reads eligibility. screeningComplete re-derives #3551's veto and ignores Allow. |
| AC.4 | Every save rechecks; page-open state never authorizes a save | #3579, #3691 | ReviewWriteRefusalEndpointTests::FlagOnRefusalIsTypedAndLeavesTheStudyAndProjectUnchanged; SubmitAnnotationSessionServiceReviewEligibilityTests::RetriesAFencedWriteThatLostItsRaceAndThenSaves |
Done | |
| AC.5 | A refusal keeps the draft | #3579, #3691 | Web review.effects.spec.ts "keeps the annotation draft…"; review-effects.decision-rollback.spec.ts |
Done | |
| AC.6 / EC.22 | Refusal, reconnect or configuration change refreshes eligibility and the study (L1111, L1179-1180) | #3691, #3719, #3736 | Web review-effects.decision-rollback.spec.ts "refreshes stage settings…" |
Partial | Refreshes the stage and reviewer stats only, never eligibility or the study. |
| AC.7 | Never auto-resubmit a refused decision | #3579, #3691 | Web review-effects.decision-rollback.spec.ts "…without offering a retry" |
Done | |
| EC.1 | A pure policy plus an orchestrator | #3646, #3659 | ReviewEligibilityServiceTests::EvaluatesThePolicyOverFreshlyReadFacts |
Done | |
| EC.2 | Reuse the presence/access result; no second lease model | #3579, #3719 | ActivityReservationAdmissionTests::AdmissionAcquiresIndependentClaimsAndRepeatOpenPreservesAge |
Done | |
| EC.3 | eligibility also on refresh and refusal responses (L1121-1122) |
#3663 | – | Partial | The typed refusal problem carries only code and detail. |
| EC.4 | Revisions and policy version | #3663 | ReviewControllerTests.Eligibility::EligibilityStudyRevisionMayBeNewer… |
Done | The project revision stands in for the configuration revision. |
| EC.5 / EC.7 | Separate per-action and per-selection decisions | #3646, #3663 | ReviewEligibilityPolicyTests (several) |
Done | |
| EC.6 | Stable reason codes and bounded details (L1129) | #3663 | – | Partial | No details field. Reasons serialize as integer ordinals, not names. |
| EC.8 | Reservation ownership, timer and capacity facts in eligibility (L1132-1133) |
– | – | Partial | Absent from the DTO. Only the access-result snapshot exists. |
| EC.9 | Reopen and remove, including deletion's effect on claims | #3646, #3691, #3719 | ReviewControllerTests.RemoveSessionClaim::RemoveSession_WithThePolicyOn_… |
Done | |
| EC.10 | ReservationExpired, ConfigurationChanged, EligibilityUnavailable reasons (L1154-1158) |
– | – | Partial | Not modelled (the doc admits this at L1226). |
| EC.11 / EC.12 | 403/404 for authorization, typed 409s; neutral screening-complete copy | #3691 | ReviewWriteAuthorizationResultHandlerTests; ReviewWriteRefusalEndpointTests |
Done | |
| EC.13 | Pools and policy share a table and fixtures | #3646, #3659 | as TT.2/TT.3 | Done | |
| EC.16 | Local validity, busy and connection states compose with server eligibility, each with a visible reason (L1165-1167) | pre-existing | not verified | Partial | Nothing is composed with eligibility. |
| EC.17 / EC.18 | Revalidate on every submission and retry; bind to versions at commit | #3579, #3691 | SubmitAnnotationSessionServiceReviewEligibilityTests (3) |
Done | |
| EC.19 | Tracking-mode admission, idempotency and authorization inside the fenced write | #3579 | ReviewControllerTests::SubmitScreening_FailsClosed_WhenTheDurableModeCannotBeVerified (not flag-on) |
Done, thin | No flag-on test of the tracking-mismatch or reviewer-mismatch paths. |
| EC.20 | Concurrent Stop screenings cannot both pass | #3579 | claims only | Done, thin | No two-writer screening-decision race. |
| EC.21 | Allow has no hidden completion veto on the server | #3646, #3579 | ReviewEligibilityPolicyTests::CombinedAllowPermits…WithoutACapacityVeto |
Done | Server only (UI.1). |
| EC.23 / EC.24 | Coalesced, cancellable, trailing-refresh eligibility pipeline; re-evaluate transient refusals | – | – | Not done | |
| EC.25 | Counters and statistics are not write authority | #3579, #3695 | ActivityReservationAdmissionTests::ScreeningSaveUsesCombinedPolicy… |
Done | |
| RO.1 | Direct access to a disabled stage or newly unavailable activity is refused (L452-456) | #3691, #3663 | hub and save tests | Partial | REST GetStudyForReview returns 200 with the study; the reconciliation GET has no refusal. |
| RO.2 | Route guard for a disabled stage | follow-on to #3659 | Web stage-disabled-guard.service.spec.ts (8 cases) |
Done | The wiring in stage.routes.ts is untested. |
| RO.3 | Record-and-stay makes no new-study request | pre-existing | ReviewControllerTests::SubmitScreeningWithoutAdvancing_DoesNotAllocateNextStudy |
Done | |
| WR.1 / WR.2 | Pool membership is not permission; correction and reconciliation are exempt | #3691, #3646 | ActivityReservationAdmissionTests::AnnotationContinuationSurvivesFullTarget…; ReviewControllerTests::SubmitReconciliation_BypassesScreeningCapacityGuard |
Done | |
| UI.2 | Reason copy for every eligibility reason | #3691, #3736 | Web review.effects.spec.ts |
Partial | Copy exists only for ReviewWriteRefusal codes and claim release. |
| UI.3 | Refetch the study when eligibility.studyRevision is newer |
– | – | Not done |
3. Minimum usable release, reservation lifecycle, revocation and the test plan¶
| ID | Requirement (doc line) | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| MR.1 | A single combined-only admin setting with D1/D2 (L1408-1411) | #3579 | StageReviewConfigurationTests (3); Web create-stage.component.spec.ts |
Done | The grouped editor is S4-C. |
| MR.2 | Strictness and Stop enforced on the API, including concurrent saves | #3646, #3659, #3691 | ReviewWriteRefusalEndpointTests |
Done, thin | No race of two Stop votes (TP.C1). |
| MR.3 | Matching UI and API behaviour (L1407-1409) | #3663 | server only | Partial | Same root cause as UI.1. |
| MR.4 | Correct screening capacity and atomic predicate | #3659, #3579, #3719 | see SP.* | Done, thin | |
| MR.5 | Independent claims, one timer, no single untyped slot | #3579, #3719 | ActiveReviewSessionTests::TypedFirstAnnotationSavePreservesScreeningClaimAndSharedTimer; PmT SessionTimeoutConsumerTests::TypedPage_… |
Partial | Legacy untyped pages are only refused; see MG.11 for their drain. |
| MR.6 | The impact check covers activity, strictness, targets and timing (L1417-1421) | #3579 | ::ClaimPlanningProjectionCarriesEveryFieldThePlannerAndCountsRead |
Partial | Plan ignores idle-timeout changes and project-level changes (MR.13). |
| MR.7 | A harmless edit is not blocked by presence | #3579 | – | Done, thin | No test with a live claim plus an unrelated edit. |
| MR.8–MR.10, MR.14, MR.15, MR.17 | Settings conflict, Apply anyway, recompute and atomic commit; cancel and invalid leave state alone; editor UI | #3579, #3695, #3719 | ApiT StageReviewSettingsControllerTests (conflict, force, rollback, retain); Web review-settings.component.spec.ts, guarded-review-settings.spec.ts |
Done | |
| MR.11 | Step 4: durable intent, SignalR, clients refetch and disable only the affected action (L1442-1447) | #3736 | see RV.P* | Partial | No activity-specific disabling. |
| MR.12 | A shared boundary between the settings commit and every claim or submit transition (L1449-1461) | #3579 | unconfirmed-save races only | Partial | No race of claims or submissions against a confirmed Apply anyway, nor of expiry, renew or convert. |
| MR.13 | Project-scope impact when project-level rules change (L1459-1460) | – | – | Not done | Threshold or default-target changes, and membership disable, plan and revoke nothing. |
| MR.16 / RV.7 | Completed-target annotation revocation; the saved-incomplete worked example | #3579 | screening only | Done, thin | "2 completed + 1 temporary, target 2" is untested. Q1. |
| RL.1–RL.8, RL.11, RL.13 | Open, idle, dirty, clean, expiry, first save, submit, correct, multi-tab, delete-restore | #3579, #3719 | PmT SessionTimeoutConsumerTests::TypedPage_*, CheckConnectionLivenessConsumerTests; CoreT ActiveReviewSessionTests; ApiT NotificationHubStudyPresenceTests::OnDisconnectedAsync_TypedPage_… |
Done | Some multi-tab and clean-restart tests use legacy pages. |
| RL.9 / TP.10 | Clearing one's own decision with a conditional restore | – | – | Deferred (out of MVP scope) | The doc labels it a proposal; the lead ruled it out of MVP scope on 25 September 2026. |
| RL.10 / RL.12 | Another change revokes only the ineligible claim; clients learn without waiting for a timer | #3579, #3719, #3736 | ::ConflictIsReadOnly… |
Partial | Only two causes push to clients. |
| RV.1, RV.4–RV.6 | Full capacity is not loss; a dirty draft doesn't veto; never delete saved work; earliest survivor | #3579, #3646 | ActivityReservationPolicyTests; ActivityReservationAdmissionTests::AnnotationContinuationSurvivesFullTarget… |
Done | The tie-break is untested (D6.5). |
| RV.2 | Screening completion removes screening claims and keeps annotation | #3579 | ActiveReviewSessionTests::ScreeningCompletionReleasesOtherStageScreeningClaimsButPreservesTheirAnnotationDrafts |
Partial | Server release only: no intent or event. |
| RV.3 | Loss of annotation eligibility removes the annotation claim | #3719, #3579 | ActivityReservationAdmissionTests::ExistingPageDoesNotBypassRevokedReviewPermission (asserts the page stays) |
Partial | On permission or membership loss the claim is kept and only admission refuses it. |
| RV.8 | Other revocation causes emit the event | – | the outbox has only two writers | Not done | Screening completion, permission or membership loss, and project threshold or target. |
| RV.P1 | Atomic recheck on claims and submissions | #3579, #3659, #3691 | MongoT ::ConcurrentCrossStageClaims…; ApiT ::ClaimPinnedBeforeStageDisable… |
Done | |
| RV.P2 | Commit before publish, with durable intent | #3736 | MongoT ActivityClaimRevocationOutboxTests (3); CoreT ActivityClaimRevocationDispatcherTests |
Done | Two causes only. |
| RV.P3 | Cross-pod fan-out with delivery-time authorization | #3736 | ApiT ActivityClaimRevokedConsumerTests (5) |
Done, thin | Authorization is tested. There is no two-pod fan-out test (statistics has PublishedInvalidationReachesTwoIndependentPodQueues). |
| RV.P4 | Client refetch, per-activity disable, revision ordering | #3719, #3736 | Web study-presence.init.spec.ts; review.effects.spec.ts |
Partial | A 30 s dedupe window only; no revision ordering and no per-activity disable. |
| RV.P5 | Reconnect gets current state; no blind resubmit | existing, #3719, #3736 | Web signal-r.service.wiring.spec.ts (rejoin, typed denial) |
Done | |
| SP.1, SP.2, SP.5, SP.8 | Project/study-scoped screening count; annotation excluded; one predicate for selection and claim; Allow bypass | #3659, #3579, #3646 | CoreT ActivityReservationAdmissionTests::OutstandingScreeningAcrossOtherStages…; MongoT ::ScreeningClaimInAnotherStageLeavesNoPlaceForTheSameReviewer |
Done | |
| SP.3 / TP.8 | The minimum-2 five-row table (L894-921) | #3659, #3579 | ActivityReservationPolicyTests::ScreeningCapacityUsesSubmittedCompletion… (places only) |
Done, thin | Rows with one outstanding claim are never run against the pool or admission. |
| SP.4 | Manual-completion disagreement is excluded | #3659 | – | Done, thin | No manual-completion case. |
| SP.6 | Two readers cannot take the one post-minimum place | #3579 | same-reviewer race only | Done, thin | No race between different reviewers. |
| SP.7 | Configuration admission protects threshold, membership and policy | #3579 | stage-disable race only | Partial | No threshold or membership race. |
| TP.1, TP.2, TP.7, TP.11, TP.15b, TP.R1, TP.R2, TP.R5 | Configuration variants; policy grid; claim policy; mode-change warning; stale action refused; rollout compatibility; statistics independence | #3579, #3646, #3663, #3695 | as cited in earlier sections | Done | |
| TP.1b, TP.R3, TP.R4 | Migration, mixed-version and opt-in/rollback tests | #3732 | – | S4-A/S4-B | |
| TP.3 / TP.4 / TP.5 / TP.6 | Target matrix ½/3; flag-on max-in-progress and saved fallback; "project count 2 / stage target 5"; enforcement × tracking grid | #3659, #3579 | partial | Partial | |
| TP.9 / TP.12 / TP.14 | Timer lifecycle, settings layer, revocation delivery | as above | as above | Partial | Rolls up RL.10, MR.12, MR.13 and RV.8. |
| TP.C1 / TP.C2 | Two final votes race, under Stop and under Allow | – | – | Done, thin | The fence exists but is untested. |
| TP.C3 / TP.C6 | Configuration, threshold or membership change during a save; a version refusal causes a reread | #3579 | mocks; annotation path only | Partial | |
| TP.C4 / TP.C5 | Slot expiry or capacity increase; source deletion races | – | – | Not done | |
| TP.13 | UI/API parity, including a direct API bypass | – | – | Not done | Same as UI.1. |
| TP.15 | Cross-pod recovery with two viewers; dropped events converge | – | – | Not done | |
| TP.0, TP.RB1–RB7 | Staging setup and real-browser acceptance for the flag | – | e2e only covers unflagged #3551 | Not done | RB5 (Allow in the browser) would currently fail. |
| TP.AX | The main acceptance example (L1536-1541) | #3646, #3691 | server only | Partial | The UI half fails under Allow. |
4. Migration, activation and the writer floor¶
| ID | Requirement (doc line) | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| AG.1 | The flag defaults off, and off keeps existing behaviour | #3579 | StageReviewSettingsControllerTests::FlagOffDoesNotReadOrWriteReviewSettings |
Done | See FO.* for byte identity. |
| AG.2 | Flag off must not erase a persisted explicit policy (L40-41) | #3579, #4336 | ApiT StageReviewSettingsControllerTests::StoredStopIsKeptButNotAppliedWhileTheFlagIsOffAndIsEnforcedAgainWhenItReturns, ProjectControllerTests::FlagOffUpdateStageKeepsAStoredStopUnlessTheStageLeavesCombined; CoreT StageReviewConfigurationTests::LegacyUpdateThatStaysCombinedKeepsTheStoredPolicyByteForByte |
Done | #4293: off keeps the stored Stop byte for byte and does not apply it (Chris's option B); leaving Combined clears it in both flag states. |
| AG.3 | Don't enable before the contract and migration/writer gates are met (L42-43) | – | – | Not done | A plain boolean with no interlock. The gate is a roll-up of the rows below. |
| AG.4 / AG.6 / WS.1 | Remaining activation work: settings reconciliation, restore, refusals, history proof, e2e, staging | several | see rows | Partial | |
| AG.5 | Untyped reservations are untouched; typed admission refuses them | #3579 | ActivityReservationAdmissionTests::LegacyReservationIsATypedMigrationRefusalNotAnException; ApiT ::ForceDoesNotInterpretAnUnmigratedReservationAsTypedClaims |
Done | |
| MG.1–MG.3, MG.5 | Legacy mapping at read time | #3579 | StageReviewConfigurationTests |
Done | |
| MG.4, MG.6 | Production-getter semantics; the grouped destination | #3732 | #3732 tests | S4-A | |
| MG.7–MG.10, EV.1, EV.3 | Dry run, versioned plan with before-image, idempotent apply, reconciliation, inventory | – | – | S4-B | |
| MG.11 | Verify tracking history and outstanding untyped claims; drain or transition if any exist (step 7, L747-749) | #3579 (refusal only) | – | S4-B | Assigned to S4-B (lead, 25 September 2026). |
| MG.12 | Seed HasEverBeenReviewed from positive tallies during migration (L1627-1631) |
– | – | S4-B | Assigned to S4-B (lead, 25 September 2026). Safe default: shows the warning. |
| MC.1–MC.3, MC.5 | Conversion consent, stage-scoped evidence, monotonic history, save-time recheck | #3579, #3695 | ReviewSettingsConsentPolicyTests, StageReviewStatisticsEvidenceTests, ApiT ::ConversionCannotCommitWithoutWarning… |
Done | |
| MC.4 | New or unused stages don't warn (L1607-1609) | – | pure test only | Partial | HasEverBeenReviewed=false is never recorded, even for stages created with the flag on, so they always warn. |
| MC.6 | Combined → single-purpose keeps prior combined choices for a later return (L1655-1660) | – | – | S4-C | Assigned to S4-C (lead, 25 September 2026). ApplyReviewConfiguration clears the policy today. |
| WF.1 | #3695 writer floor: no API/PM replica older than #3695 when enabling (L1681) | #3695 | StudyUpdateParseResultBsonTests (3) |
Not done (operator gate) | Minimums are api-v9.141.0 and project-management-v11.137.0. The checkbox is unchecked and not enforced. |
| WF.2 | Configuration-version plus per-service writer floor (L1680) | #3732 | ServiceVersionFloorTests, DatabaseMetadataWriterTests (#3732) |
S4-A/S4-B | Concern for S4-A: connectedAt is written only at startup, and any pod's startup prunes versioned instances older than 24 h. A long-running below-floor pod can drop out of the registry, and the floor then passes wrongly. |
| WF.3 | Client (web) floor: mixed clients never show Stop as enforced early (L1669-1670) | – | – | Not done | The web doesn't register in syrf_metadata. |
| WF.4, RB.1 | Readers of both versions; old writers fenced; version-aware rollback | #3732 | #3732 tests | S4-A/S4-B | Checked and refuted: an audit agent's concern that pre-#3732 binaries can't load migrated projects. Stage : Entity<Guid> has [BsonExtraElements] ExtraElements, so unknown elements round-trip. |
| WF.5 | No mixed writers that drop AdditionalScreeningPolicy (L99-100) |
#3579 | ::NewCombinedStopSurvivesBsonRoundTrip |
Partial | Met only if WF.1 is honoured. |
| RB.2 | Rollback after an explicit Stop keeps the restriction (L1671-1673, L1702-1704) | – | – | Not done | Turning the flag off makes Stop stages behave as Allow, which the doc forbids. No minimum rollback writer is documented. |
| DS.1 | Specify the four deployment states (L1685-1690) | – | – | Not done | |
| ST.1 | Staging reviewer journeys before activation | – | – | Not done | Same as TP.0/TP.RB. |
| EV.2 | Characterize production P against the implementation, and approve compatibility changes (L1785-1792) | – | – | Not done |
5. Allocation interaction (#2991, #3603, D8 decision 2)¶
| ID | Requirement | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| AL.1 | keepSlottedClaim=false is exactly the legacy rule |
#3719 | ActivityReservationClaimReleaseTests::TheSlottedClaimExemptionAppliesOnlyWhenTheCallerOptsIn(false) |
Done | |
| AL.2–AL.4, AL.7 | ReviewController SubmitSession, direct access, SubmitAnnotationSessionService and the flagged save pass the flag only | #3719 | flag-off legacy tests (ReviewControllerAllocationTests, SubmitAnnotationSessionAllocationTests) |
Done, thin | The legacy tests hold no typed claim, so they wouldn't catch a mis-wiring. No endpoint-level kept-claim continuation test. |
| AL.5 / AL.6 | The settings plan and typed admission pass true or applyAllocation, reachable only under the flag |
#3719 | ::FlagOffDoesNotReadOrWriteReviewSettings; ActivityReservationClaimReleaseTests |
Done, thin | No test that the flagged repository entry points throw when the flag is off (FO.13). |
| AL.8–AL.12 | Plan, ReviewersFor, AllocationClaimSlot, the release rule and continuation |
#3719 | AllocationClaimSlotTests (5); ActivityReservationClaimReleaseTests; ReviewEligibilityPolicyTests (keep and release rows); MongoT release test |
Done | |
| AL.13 | Regime publication is refused while typed or untyped claims exist | #3603 | MongoT StageAllocationRegimePersistenceTests::BothActivityChecks_AgreeForEveryKindOfReviewActivity |
Done | |
| AL.14 | Flag off: #2991/#3603 Next, assign and filtering are unchanged | #3579, #3659, #3719 | ProportionalStageReviewServiceTests (13); StageReviewServiceReviewEligibilityTests::FlagOff_UsesTheLegacyStatusAndCandidateQueriesOnly |
Done |
6. Flag-off identity (reviewEligibilityPolicy = false)¶
| ID | Changed path | PR(s) | Test evidence | Class | Gap / note |
|---|---|---|---|---|---|
| FO.1–FO.3 | Unflagged: the reconcile grant (#3565), row ownership (#3671), and #3635 payload ordering with the query-parameter removal | #3565, #3671, #3691 | ReviewControllerReconciliationAuthorizationTests; ExtractionInfoSessionOwnershipTests; SubmitSessionOwnershipTests::FlagOff_… |
Done | Sanctioned in the doc. |
| FO.4 | Unflagged persisted-data change: ExtractionInfo.AddOutcomeData removes only this stage's rows (b667aa001) |
#3579 | ActivityReservationAdmissionTests::ReplacingOutcomeDataInOneStagePreservesTheReviewersOtherStage |
Not done (unsanctioned) | A data-preserving fix: other-stage outcome rows are no longer deleted. It needs a sanction line in the doc, not a revert. |
| FO.5, FO.8–FO.10, FO.12, FO.14, FO.16, FO.19, FO.20, FO.22, FO.23 | Stage BSON null-omitted; hub flag-off branch verbatim; Refuse flag-off result; auth handler passthrough; the exception contract; guarded settings and AddStage; eligibility null (sanctioned); search import; Next legacy path; pure policy; idle dispatcher |
various | as cited by each PR (e.g. ReviewWriteAuthorizationResultHandlerTests::OtherwiseTheDefaultHandlerForbidsAsBefore, StageReviewServiceReviewEligibilityTests::FlagOff_*, ActivityClaimRevocationDispatchServiceTests::FlagOff…) |
Done | |
| FO.6 | A legacy SlotReservation BSON lacks the new elements |
#3579 | – | Done, thin | Identical by inspection only. |
| FO.7 | Legacy domain paths acting on typed pages after a flag on→off rollback | #3579 | – | Done, thin | Identical while no typed pages exist. Rollback behaviour ties into RB.2. |
| FO.11 | The detail of the unflagged AnnotationOwnershipMismatch 403 changed (#3691 after #3671) |
#3691 | – | Partial | The code and status are unchanged. |
| FO.13 | The flagged repository entry points throw when the flag is off | #3579, #3695 | admin batch only (AdministrativeScreeningBatchTests::TheFencedBatchRefusesToRunWithThePolicyOff) |
Done, thin | No guard test for TryAdmit…, TrySaveActivityReview… or TrySaveActivitySessionDeletion…. |
| FO.15 | Wire: StageDto.additionalScreeningPolicy serializes as null (Newtonsoft includes nulls) |
#3579 | – | Not done (unsanctioned) | |
| FO.17 | Wire: claimRelease: null on NextStudyResponseDto and StudyReviewAccessResult (REST and SignalR) |
#3719, #3736 | – | Not done (unsanctioned) | |
| FO.18 | Wire: bulk-update DTO gains refusedRows: 0, refusedScreeningRows: 0 and refusedScreeningRowSample: null |
#3695 | BSON is proven identical (StudyUpdateParseResultBsonTests::AFlagOffResultSerializesWithoutTheRefusalReport) |
Partial | JSON is not identical. |
| FO.21 | Shared additive members (GetUncachedOrDefaultAsync, ActivityReviewAdmission.Admitted, StageAllocationAdmission.Plan) |
#3659, #3719 | callers | Done, thin | |
| FO.24 | Infrastructure: the pmActivityClaimRevocationOutbox collection and indexes are created by PM index-init, and each API pod declares a temporary api-activity-claim-revocations endpoint |
#3736 | – | Not done (unsanctioned) | Inert while the flag is off. Sanction it in the doc, or gate it. |
| FO.25 | Web flag-off behaviour | several | Web specs | Done, thin | Only the sanctioned codes reach the UI when the flag is off. |
Slice plan (lead, 25 September 2026)¶
| Slice | Owner | Scope |
|---|---|---|
| S6a | s1b-server | G3 server-contract residue and G5 flag-off identity (sanction lines, the AG.2 test), plus the D6.6 interpretation note. Merged before S6b. |
| S6b | s1b-server, after S6a | G1: the reviewer browser consumes eligibility. A plan goes to the lead first. |
| G2 | new worker, in parallel | Revocation coverage for the remaining causes. |
| G4 | each slice adds the races in its own area | Leftovers go to a final hardening slice. |
| G6 | S4 worker, after S4-C | Activation readiness. |
| MG.11, MG.12 | S4-B | Untyped-reservation inventory and drain; history-marker seeding. |
| MC.6 | S4-C | Keep prior combined choices for a later return. |
Gaps not owned by S4 and not deferred¶
These are grouped as candidate slices. The IDs refer to the tables above.
G1. The reviewer browser consumes eligibility (largest; blocks D1/Allow in the UI)¶
UI.1 (AC.2/AC.3/EC.14/EC.15/MR.3/TP.13), D8.5 (ReconciliationNotReady warning), UI.2 (reason copy), UI.3 (stale study
refetch), AC.6/EC.22 (refetch eligibility and the study after a refusal, reconnect or change), EC.23/EC.24 (coalesced
refresh pipeline; re-evaluate transient refusals), RV.P4/MR.11 (per-activity disable, revision ordering), EC.16
(composing local states), RO.2 route-wiring test.
G2. Revocation coverage for the remaining causes¶
- RV.8/RV.2: screening-completion releases emit intents and events.
- RV.3: permission or membership loss revokes the claim, rather than only refusing admission.
- MR.13/SP.7: project threshold or default-target changes and membership disable plan and revoke affected claims.
- MR.6: idle-timeout changes enter the impact plan.
- RL.10/RL.12 roll up into these.
G3. Server-contract residue¶
- RO.1/D8.3: REST
GetStudyForReviewand the reconciliation GET refuse a disabled stage. - EC.3:
eligibilityon refusal responses. - EC.6: string reason codes and bounded details.
- EC.8: reservation, timer and capacity facts.
- EC.10: the
ReservationExpired/ConfigurationChanged/EligibilityUnavailablecodes.
G4. Concurrency and test-matrix hardening¶
- Races: D6.4/MR.12 (confirmed Apply anyway, expiry, renew and convert), TP.C1/TP.C2 (two final votes), TP.C3/TP.C6 (threshold or membership change mid-save; screening reread), TP.C4/TP.C5, SP.6, EC.20, TP.15 (two-viewer cross-pod).
- Capacity and revocation cases: SP.3/TP.8 (outstanding-claim rows through pool and admission), SP.4 (manual completion), D6.5 (tie-break), MR.16/RV.7 (completed-target annotation; saved-incomplete).
- Missing tests:
- MR.7 (harmless edit);
- D5.2 (grouping round-trip);
- EC.19 (flag-on tracking or reviewer mismatch);
- TP.3/TP.4/TP.5/TP.6 matrices;
- TT.3 (a real allocation evaluator per row);
- TT.4 (the save path's allowances vs the table);
- AL.2–AL.7 (endpoint-level kept-claim continuation);
- RV.P3 (two-pod fan-out);
- FO.6 (legacy SlotReservation BSON);
- FO.13 (flag-off guards on the repository entry points).
G5. Flag-off identity¶
FO.4, FO.11, FO.15, FO.17, FO.18 and FO.24. Each needs either a sanction line in the policy doc's flag decision, or a fix: null-omit the new DTO members, or gate the index-init and endpoint registration on the flag. AG.2's flag-off test that a stored Stop survives a legacy update landed in #4336 (#4293).
G6. Activation and rollout, not in S4's stated scope¶
- AG.3: an activation interlock, or at least a runbook gate.
- WF.1: verify the replica floor at enable time.
- WF.3: a web client floor.
- WF.5.
- RB.2: a rollback rule after an explicit Stop, plus the minimum rollback writer.
- DS.1: specify the four deployment states.
- ST.1/TP.0/TP.RB1–RB7: e2e and staging acceptance for the flag.
- EV.2: characterize production P and record approved compatibility changes.
- MC.4: record NeverReviewed for stages created under the flag.
Scope questions for the lead (possibly S4-B)¶
- MG.11: an untyped-reservation inventory and drain.
- MG.12: seeding the history marker.
- MC.6: keeping combined history for a later return.
Concern to relay to S4-A (#3732)¶
WF.2: the version floor can pass wrongly once any pod's startup prunes a long-running versioned instance, because
connectedAt is written only at startup.
Decision questions (answered 25 September 2026)¶
| # | Question | Decision | Rows |
|---|---|---|---|
| Q1 | D6 completed-target revocation: the code skips it when the annotation target is unenforced, and counts allocated incomplete sessions. Which is intended? | Keep the code. An unenforced target always leaves a place, so revoking would contradict the approved AnnotationPlaceAvailable semantics, and counting allocated incomplete sessions matches the capacity model. The interpretation note is recorded in the policy doc by S6a. |
D6.6, MR.16 |
| Q2 | D3a fixed-two summary discrepancy in the MVP? | Pending the lead. It is display only: StudyStats hardcodes minNumberSessions = 2 for the stage-progress annotation counters and their materialized mirror, so they miscount on stages whose target isn't 2. Selection, admission and saves use the real target. |
D3a.2 |
| Q3 | Defer D8(2) "under reallocation"? | Yes, to allocation Phase 3: #3745. | D8.2c |
| Q4 | Is "clear own decision" in scope? | No. Out of MVP scope, recorded as deferred. | RL.9, TP.10 |
| Q5 | Owner of MG.11, MG.12 and MC.6? | MG.11 and MG.12 go to S4-B; MC.6 goes to S4-C. | MG.11, MG.12, MC.6 |