Calculate Risk of Bias authorization¶
POST /api/projects/{projectId}/searches/{searchId}/rob/calculate (SearchController.CalculateRob) starts a
batch Risk of Bias job: it adds a job to the project aggregate, saves the project and submits
IStartRobProcessingJobCommand. It had no resource policy, so any signed-in user could start a job against
any project (#3058). The policy constant
ProjectCalculateRobPolicy existed, but it had no ProjectActivity member and no ResourceSecurity.json default,
so nothing used it.
This is the separate batch RoB workload the application-authority plan keeps and protects (M3b). It is not the mothballed MapsGroup AI prototype (#3762).
The rule now¶
- The action requires
ProjectCalculateRobPolicy. ProjectActivity.CalculateRobis appended (ordinal 24) so the policy resolves.- Its deployed default grants the project Administrator group, the same audience as
ImportSearch,ViewSearchesandRemoveSearchthat manage searches on the same page. No owner flag, all-members or application-group grant. - A refused request loads nothing, creates no job and submits nothing.
- The Systematic Searches page offers Calculate Risk of Bias only when the RoB tool flag is on and the
project permission report grants
calculateRob. Job status still shows wherever the flag shows it.
| Caller | Before | After |
|---|---|---|
| Signed-in non-member, any project | starts a job | 403, no side effect |
| Member outside the Administrator group | starts a job | 403, no side effect |
| Project owner / Administrator group | starts a job | starts a job (unchanged) |
Validation and rollout¶
CalculateRobAuthorizationTests: attribute, shipped grant, audience parity with the search-management activities, everyProjectActivityhas a shipped default, and the domain decision for owner, administrator, member, non-member, removed member and a non-member application administrator.CalculateRobEndpointAuthorizationTests: real routing, global authentication filter,Program.cspolicy loop, production handler and deployedResourceSecurity.json; refused and anonymous callers cause no project read, save or job submission; administrators reach the job submission.ProjectAuthorizationActivityParityTestsno longer needs an exclusion list.- Web:
canCalculateRobspec (flag × report).
Unflagged security fix; no data migration.
All-or-nothing save (application-authority M5b)¶
Chris decided P9 on 2026-10-02: a data-changing background job is atomic, and when it is stopped because its requester's permissions changed it rolls back, leaving no partial results (queued-work ledger). Before this, a batch RoB run wrote its results in concurrent batches of 400. A failed batch was logged and skipped, and its authority was checked only when the job started.
Flag: BatchRiskOfBiasAtomicApply (Helm featureFlags.batchRiskOfBiasAtomicApply) is project-management only,
deployment-only and default off everywhere. Off, results are saved exactly as before. The AI RoB tool is suspended,
so this is correctness on a dormant path; nothing is reactivated.
What a run does with the flag on. Only the save at the end of a run writes studies; the PDF conversion and the
model run before it write none. RobAtomicApplier runs the save, with MongoRobRunStore underneath:
- It creates the run's operation record,
pmRobRunOperation(_idis the RoB job ID). The record holds a 2-minute lease and a generation, and takes the search's slot: a unique partial index onActiveSearch. A second run in the same search waits up to 10 minutes for the slot, then fails with nothing saved. A redelivered job finds its record and is never applied twice. - Before every batch of 200 studies, and once more before the commit, it checks cancellation, renews the lease
and rechecks the requester's authority. The recheck uses the M5a admission (key = job ID) and
DelegatedWorkAuthorityCheck(DelegatedWorkBatchRiskOfBiasAuthority), but only whileDelegatedWorkAdmissionEnforcedis on. A denial is audited on the admission (Attempt = -1) and is terminal. - Each batch is one transaction. It writes the operation's guard at the expected generation, so a stale attempt
can never write. It then stores each study's prior
RiskOfBiasInfoV2exactly as stored (a value, an explicit null, or absent) inpmRobRunBeforeImage, keeping only the first image. Finally it sets the new result withAudit.Versionincremented. A study outside the run's project and search is skipped. A study locked by an ADR-020 bulk update fails the run. - The commit is one conditional write (
Applying→Completed). Before it, every stop rolls back; after it, nothing does. - Rollback happens on a denial, a cancellation or any failure, including unknown authority. It runs under
batch-risk-of-bias-rollback.v1: it only restores and never consults the requester. Each transaction restores up to 200 studies. A study that no longer holds this run's result is left alone and marked superseded: another run wrote it since, or it was deleted. A study an ADR-020 bulk update has locked pauses the rollback until the lock is released. - Recovery.
RobRunRecoveryBackgroundServiceruns every minute while the flag is on. It takes over a run whose lease expired (the generation increments, which fences the dead attempt) and rolls it back asinterrupted. It also finishes a paused rollback and shows any outcome the job status missed.
Job status uses the existing statuses, so the web client is unchanged:
| Outcome | Status |
|---|---|
| Completed | Completed |
| Cancelled | Cancelled |
| Denied, failed, interrupted or busy | GeneralError, with a message saying that no result from the run was kept |
Before-images expire 30 days after the run ends.
Why rollback is safe without study locks. RiskOfBiasInfoV2 has a single writer, the batch RoB run:
- No person and no other code path edits it. The API only reads it, as StudyDto.RiskOfBiasInfo.
- Whole-study saves never change it; they only write back the value they loaded.
- Every write and every restore touches only that field and increments Audit.Version. A whole-study save loaded
before the write therefore fails its version check and reloads, so neither side is lost.
- A restore applies only while the study still holds this run's result. Runs in one search are serialised by the
slot, and a study belongs to one search.
RoB results are therefore never merged with human edits, and another run's or another user's results are never touched.
Evidence:
- RobAtomicApplierTests (own replica-set collection, 13 tests) cover:
- commit;
- byte-exact rollback on a denial, cancellation or unknown authority, keeping an earlier run's results;
- a study locked by a bulk update;
- a dead attempt rolled back by recovery and then fenced;
- a rollback paused by a lock and finished by recovery;
- a busy search;
- superseded studies;
- the version-check race;
- no double apply.
- BatchRiskOfBiasAtomicTests cover:
- the recheck;
- the status mapping;
- with the flag off, the legacy save is unchanged;
- with the flag on, nothing goes through whole-study saves.
- Recovery and host-registration tests.