Skip to content

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.CalculateRob is appended (ordinal 24) so the policy resolves.
  • Its deployed default grants the project Administrator group, the same audience as ImportSearch, ViewSearches and RemoveSearch that 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, every ProjectActivity has 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.cs policy loop, production handler and deployed ResourceSecurity.json; refused and anonymous callers cause no project read, save or job submission; administrators reach the job submission.
  • ProjectAuthorizationActivityParityTests no longer needs an exclusion list.
  • Web: canCalculateRob spec (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:

  1. It creates the run's operation record, pmRobRunOperation (_id is the RoB job ID). The record holds a 2-minute lease and a generation, and takes the search's slot: a unique partial index on ActiveSearch. 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.
  2. 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 while DelegatedWorkAdmissionEnforced is on. A denial is audited on the admission (Attempt = -1) and is terminal.
  3. 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 RiskOfBiasInfoV2 exactly as stored (a value, an explicit null, or absent) in pmRobRunBeforeImage, keeping only the first image. Finally it sets the new result with Audit.Version incremented. A study outside the run's project and search is skipped. A study locked by an ADR-020 bulk update fails the run.
  4. The commit is one conditional write (Applying → Completed). Before it, every stop rolls back; after it, nothing does.
  5. 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.
  6. Recovery. RobRunRecoveryBackgroundService runs 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 as interrupted. 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.