Skip to content

Studies workspace and PDF status: staging walkthrough plan

This is a plan. Nothing in it has been switched on. It describes how studiesWorkspace and studyPdfStatus would be switched on in staging, what a person then checks there, and how to switch them back. Chris decides whether and when it happens; the cluster-gitops change in section 3 is written out here and has not been opened as a PR.

The walkthrough is the controlled staging proof (C7.2) that the acceptance criteria tracker and the implementation plan's Controlled staging proof ask for before these flags are enabled anywhere else. It covers all eight of that proof's items; 4.1 maps each one to its steps. The rendered accessibility and zoom evidence that acceptance rows A4, B16, C4.4 and C5.7 wait on comes from section L. Production is out of scope (section 7).

Every prerequisite below was checked on 2026-11-05 between 13:00 and 13:30 UTC, read-only: the cluster-gitops main branch (c5f7de9d) through the GitHub API, the anonymous staging runtime-flag snapshot, the staging /health/live probe, and the syrf_staging database through the preview MongoDB MCP (find, count, aggregate and index listings only). Nothing was written to any database or cluster, and production (syrftest) was not touched.

Update, 10 November 2026 (#4873). This snapshot predates the stored PDF classification's arm PR, which registers migrations 0036 and 0037. Once it merges and staging deploys, staging's PreSync applies both; 0037's index arms the writers there and the PM backfill worker starts five minutes later. The walkthrough below doesn't cover them: studyPdfStatusStored gates only the stored read and stays off, so the PDF filter still answers from the C# classifier. Production runs no ADR-023 migrations and doesn't arm.

Summary for the decision

Item State Detail
Migrations 0034 and 0035 in syrf_staging Met Both applied, both indexes present, checksums match the lock file (P1, P2)
Staging builds contain wave 7 Met API and PM at 6fb604a, web bundle 97b5acf
Staging builds contain wave 8 Not met: blocks part of the walkthrough #4805, #4806, #4808, #4809 and #4810 merged after the last staging promotion. The promotion that carried them was closed during the 5 November promotion freeze, and the newest main CI run failed (P7)
Flag values in staging Both off false static, no override, revision 83 (P8)
Runtime-flag rehearsal in staging In progress: needs sequencing Restarted at 13:36Z with a promotion freeze and the API at zero replicas (cluster-gitops #1905, #1906, #1907 open). A legacy flag edit in the meantime "needs a new preview (G1)", so switching these flags on has to be agreed with that rehearsal (P9)
#4771 timing Met for staging; not met for large projects Slow at 215,000 studies (1.75–3.5 s per filtered page, 2.1–2.6 s per counts call, against 0.65–0.75 s for the list), but staging's largest project has 1,481 (P10)

What blocks the walkthrough:

  1. Wave 8 isn't in staging. Until a staging promotion carries main at or after 86a63dc6ca (#4805), the steps marked [wave 8] below can't be run: the stage chooser (#4808), "N without a confirmed PDF" on Searches and Overview (#4809), the unmatched-row detail in Activity (#4810), the Activity sheet's clickable Close (#4806), and the deleted-study 410 on a repeated Use (#4805). Everything else can be walked on today's builds.
  2. The staging runtime-flag rehearsal is running. At 13:36Z on 5 November the promotion freeze was restarted for the Shared-mode rehearsal (cluster-gitops #1905), the API was taken to zero replicas at 13:40Z (#1906), and the lineage apply retry (#1907) was open at 13:45Z. Staging can't be walked while the API is at zero, and any change to a staging flag, through GitOps or a runtime override, changes what the lineage import captures. Agree the order with whoever runs that rehearsal first; after its consumer enrolment, flags change only through a reviewed policy revision.

1. What switching on changes

Both flags are runtime flags, default off, activationRequirement: immediate, runtime-manageable outside production, and read by the API and the web app only:

  • studiesWorkspace: env-mapping.yaml lines 2164–2206. Its consumer contract is Global grain, safe value false, maximumAgeSeconds: 30, productionEligible: false, host api.
  • studyPdfStatus: the same file, lines 2207–2223. It has no consumer contract and no catalog dependency.
  • The featureFlags group is services: [api] (line 1375), and the generated config keys give both flags to api and web only (src/charts/syrf-common/config-keys.generated.json). Project-management never reads either flag.

The staging snapshot confirms "dependencies": [] for both, so the catalog lets either be on without the other. The code then decides what each combination shows (1.3).

The server reads both through IFeatureDecisions on every request. FeatureGated makes a route unroutable (404 before authorization) while its flag is off (FeatureGatedAttribute.cs lines 79–90). A failed decision counts as off.

1.1 studiesWorkspace

Screens (web). With the flag on:

  • /projects/:id/studies and /studies/stage/:stageId become the Studies workspace; with it off they are today's Library (studies.routes.ts lines 30–66);
  • /processing redirects to /studies;activity=open, the Activity sheet, whatever studyManagementProcessing says (project.routes.ts lines 82–100);
  • /searches becomes the managers' Searches page with "Back to Studies" (W4);
  • the project navigation shows one "Studies" item (W6);
  • Overview gains the Needs attention card and, for administrators, "N proposed PDFs to check" (W5, slice 9);
  • the review page's My studies panel gains "See them all in Studies" (W5).

Endpoints that exist only while it is on (FeatureGated(StudiesWorkspace), so 404 while off):

Route Policy Code
GET api/projects/{id}/studies/pdf-status/search-counts search read (managers) StudyController.cs 291–306
GET api/projects/{id}/studies/pdf-proposals/summary ApprovePdfCorrections StudyController.cs 354–372
GET api/projects/{id}/studies/pdf-proposals?studyId=… ApprovePdfCorrections StudyController.cs 380–407
PUT api/projects/{id}/studies/{studyId}/pdf (Add a PDF, Replace) ApprovePdfCorrections StudyController.cs 814–828
DELETE api/projects/{id}/studies/{studyId}/pdf (Remove link) ApprovePdfCorrections StudyController.cs 834–845
GET api/projects/{id}/stages/{stageId}/my-studies/counts StageReviewPolicy ReviewController.cs 1322–1344

All paths are relative to src/services/api/SyRF.API.Endpoint/Controllers/.

Behaviour that changes on routes that always exist:

  • Library list (POST …/studies, StudyController.cs 114–268). It applies myStudiesTab (lines 134–155), refuses the PDF filter unless the workspace and the PDF status admission are both open (lines 157–167, 409 pdf-status-filter-unavailable), and gates "Proposed PDF waiting" (lines 323–333). It also fills pdfDeliveredByBulkUploadAt and bulkUpdateLockedAt (lines 241–251).
  • Use proposed PDF and Reject (PUT …/Pdf/Correction/{id}/Approve and /Reject, lines 529–569). Use runs slice 9's single transaction and Reject one conditional write (lines 537 and 558). Off, both are as before.
  • Propose a PDF (POST …/Pdf/Correction) must be an http(s) web address while on (line 512).
  • Bulk study update signature (line 1087). A job created while on is stamped RecordsUnmatchedRows (BSU-c), decided once, at creation.
  • Project details (ProjectJobFamilyPermissionFilter, ProjectDetailsDto.cs lines 131–252). A member without a search activity gets search names and counts only with ViewSearchOverview, and no searches otherwise (lines 237–252). Off, slice 7's trim applies and every member keeps names and counts. Bulk study update jobs gain allOrNothing and the unmatched-row detail (lines 191–194).
  • SearchDeleted notification names the search only to the same readers (SignalR/Deletion/SearchDeletedConsumer.cs line 63).
  • Slice 9's waiting-proposal reads open only with the flag on and the 0035 index present (StudyPdfProposalsAdmission.cs lines 18–46).

1.2 studyPdfStatus

With the flag on and migration 0034's index present (StudyPdfAvailabilityAdmission.cs lines 22–44):

  • Responses carry pdfStatus. The Studies list (StudyController.GetTableData), single-study reads and the review and reconciliation retrievals (ReviewController.cs 1656–1671) carry it. The review retrievals start a background check for their study, and the Studies list (#4887) starts one for each study on the page it returns, in both cases only when the status is Not yet confirmed or due for a recheck, and without waiting for it. The list then reads the page again by itself up to three times (about 5, 10 and 20 s apart) while a row is Not yet confirmed or Checking. Single-study reads only report. With studyPdfBackgroundChecks also on (#4890, off by default and in Diff A), a background sweep in the API walks every study and offers those with a link and no current result, or a stale Couldn't check, to the same queue (B8). Passes are 6 hours apart by default (StudyPdfCheckSweep:IdleBetweenPasses).
  • The check. StudyPdfChecker reloads the study, claims its pmStudyPdfAvailability document and records the result. Only an internal link is fetched: one GET with Range: bytes=0-4, a 10 s timeout and no redirects. An http… link is recorded as Hosted elsewhere and never fetched (implementation plan, slice 8, decision 4). In staging the internal bases are pdfSettings.baseUrl (https://ecrf1.clinicaltrials.ed.ac.uk/camarades/, cluster-gitops syrf/environments/staging/api/values.yaml line 130) and the bulk PDF delivery base.
  • The push. The hub methods SubscribeToStudyPdfStatus and SubscribeToProjectStudyPdfStatus (NotificationHub.cs lines 1182–1219) push StudyPdfStatusUpdated. While the feature is closed they throw "Study PDF status updates are not available."
  • Web. The review PDF panel takes its state from the server (the browser's HEAD probe is no longer the status). The Library PDF cell, in the old Library and in the workspace, shows the stored state, and so does the study detail's PDF section.

Nothing in project-management reads it.

1.3 The two flags together

studiesWorkspace studyPdfStatus What a person sees
off off Today's Library, Searches, Processing and review page (staging today)
off on Today's Library and review page with server-tracked PDF states in the PDF cell and the review panel; no workspace
on off The workspace with today's link-based PDF cell. No PDF filter: the server refuses it with 409. The pane counts answer pdfStatusOn: false. "Proposed PDF waiting" works, since it needs only this flag and 0035
on on Everything: the PDF filter, "N without a confirmed PDF" in the pane, on Searches and on Overview, live PDF states in the study detail, and "No PDF · N proposed" in the PDF cell

The PDF filter requires both flags (StudyController.cs 309–311). The search counts route requires the workspace and answers pdfStatusOn: false without PDF status (lines 296–298).

1.4 bulkStudyUpdateAtomicApply (deployment-only)

  • Where it is defined. env-mapping.yaml lines 914–925: services: [api, project-management], default false, not runtime-overridable. It changes only by a values change, which restarts the pods.
  • What it does. The API reads it once, when it creates a job at the signature request (StudyController.cs line 1087). On, a new job gets execution version 2 and runs all-or-nothing (ADR-020): plan without writing, lock, apply with before-images, then commit or roll back. Project-management runs any version 2 job it receives, whatever its own value (BulkStudyUpdateAtomicRegistry.cs lines 13–18). Switching it off stops new version 2 jobs and never strands one already admitted.
  • How the browser learns it. ProjectDetailsDto.NewBulkStudyUpdatesAllOrNothing (ProjectDetailsDto.cs lines 196–201) is the API's own deployed value. It is sent only to callers who may start a bulk study update, and only while studiesWorkspace is on; it is null otherwise. The workspace dialog reads it (workspace-bulk-update-dialog.component.ts line 193) to word the confirm step, and each job's own allOrNothing words its strip, lock note and Activity line. There is no web half of the flag, so the browser can't disagree with the value that stamped the job.
  • Staging today. It isn't set in any cluster-gitops file (searched on 2026-11-05), so it is the chart default, false, and staging creates version 1 jobs. Its env-mapping comment asks for previews first, then staging. No cluster-gitops preview values file sets it. A preview can also take flags from its PR's #preview-config block, which this plan didn't search. The recorded version 2 runs are the local E2E's (BSU-b as built, E2E gap 1). Switching it on is a separate choice (Diff B).

2. Prerequisites (checked now, read-only)

# Prerequisite State Evidence
P1 Migration 0034 (pmStudyPdfAvailability, ProjectId_1_Status_1) applied in syrf_staging Met syrf_migrations _id: "0034_pmStudyPdfAvailability_ProjectId_Status": status: applied, appliedAt 2026-11-04T22:23:36Z by 11.291.0-cac600c. Checksum sha256:afb73aa1…8d99 equals migrations.lock.json line 171. collection-indexes lists ProjectId_1_Status_1 {ProjectId: 1, Status: 1}
P2 Migration 0035 (pmStudyPdfCorrection, ProjectId_1_Status_1_StudyId_1) applied Met _id: "0035_pmStudyPdfCorrection_ProjectId_Status_StudyId": status: applied, 2026-11-05T04:59:11Z by 11.292.0-97b5acf. Checksum sha256:793fd129…3566 equals lock line 176. The index is listed with keys {ProjectId: 1, Status: 1, StudyId: 1}
P3 Migration 0002 (pmStudy, ProjectId_1_BulkUpdateLock.OperationId_1), which version 2 locking uses Met status: applied, outcome adopted, 2026-11-03T18:54:45Z. Checksum equals lock line 11. The index is listed on pmStudy
P4 Nothing the admissions check is missing Met The two admissions check only 0034 (PmMigrations.StudyPdfAvailabilityIndexes) and 0035 (StudyPdfCorrectionIndexes), PmMigrations.cs lines 590–629. Main's catalogue ends at 0035. syrf_migrations holds 35 documents and none has a status other than applied
P5 Staging runs migrations before deploys Met migrations.enabled: true with the staging binding in api/values.yaml lines 348–354 and in project-management/values.yaml
P6 Both admissions open once the flags are on Unknown until switched on The admission compares each index's name, keys and options (NotificationAdmissionChecks.cs lines 35–56); the MCP lists names and keys only. Walkthrough step A1 confirms it: no "is on but index … is missing" warning in the API log, and pdfStatusOn: true
P7 Staging runs builds with the features Wave 7 met; wave 8 not met api/config.yaml: 9.302.2-sha.6fb604a; live /health/live returns 9.302.2-6fb604a. project-management/config.yaml: 11.292.2-sha.6fb604a. web/config.yaml: 7.319.1-sha.67ce1dd; the served bundle says buildHash 97b5acf01, a retag, with no src/services/web change between the two. These contain #4717, #4737, #4747, #4764–#4766, #4775, #4779, #4785 and #4807 (git merge-base --is-ancestor). They don't contain #4805, #4806, #4808, #4809 or #4810. cluster-gitops #1901, which promoted 86a63dc6ca, was closed unmerged: it was opened during the freeze (#1895 to #1904). The newest main run (bbddca15df, run 42020179536) failed "Test Web Service", so no newer promotion exists
P8 Current flag values in staging Both off GET https://api.staging.syrf.org.uk/api/runtime-feature-flags, read 2026-11-05T13:24:57Z, revision 83. studiesWorkspace and studyPdfStatus: staticValue false, overrideValue null, effectiveValue false, runtimeManageable true, dependencies []. Neither key is set in any staging values file. bulkStudyUpdateAtomicApply is unset, so false. For context: studyManagementProcessing is true and reviewEligibilityPolicy is false (it changes the bulk update's wording)
P9 No other staging change depends on flag values staying put Not met: needs sequencing api/values.yaml lines 357–366 on main: the runtime-policy lineage apply (G4) was refused and is parked. Its retry needs the API at zero again, and "a legacy flag edit in the meantime needs a new preview (G1)". After consumer enrolment (rehearsal step S10), flags change only through a reviewed policy revision (runbook 7), not by a values edit or a legacy override (rehearsal plan)
P10 #4771: the PDF filter and counts are fast enough at real project size Met for staging; not met for large projects See the box below
P11 Synthetic test data available, nothing in flight Met pmProject has 6 documents: the 5 seed projects (…0100–…0104, SeedDataConstants.cs lines 82–103) and one other. Per-project study counts are 1,481, 731, 60, 30, 15 and 10 (2,327 in all). pmStudyPdfAvailability and pmStudyPdfCorrection hold 0 documents, no study has a BulkUpdateLock, and 210 studies have a bulk-delivered path. The walkthrough uses a new project of its own (4.0)
P12 BSU-c's rollback floor (PM 11.137, API 9.141) Met Staging runs PM 11.292 and API 9.302 (BSU-c as built, "Rollback check")
P13 Bulk PDF upload delivers in staging (the only way the walkthrough can make an Available PDF) Unknown bulkPdfUpload: true in staging.values.yaml line 15, and 210 staging studies have a bulk-delivered path, so it has delivered before. Not run for this plan; step 0.3 confirms it

#4771 timing outcome (measured in PR #4831; the method and the full tables are in Read performance at real project sizes).

  • Result. Measured locally (a replica set in Docker, everything cached), not on a preview. At 215,000 studies in 58 searches (runs A and B in performance.md's table), every PDF option but Unavailable takes 1.75–3.5 s per filtered page, and the Searches PDF counts take 2.1–2.6 s per call, against 0.65–0.75 s for the unfiltered list: 2.7–4.7 times as long. Every option but Unavailable grows linearly with project size. At 50,000 studies they take 0.5–0.8 s.
  • Decision: it doesn't block staging. Staging's largest project has 1,481 studies (P11), well below where the reads were measured as slow.
  • It does block switching on anywhere with large projects, production included. studiesWorkspace decides globally, so it can't be switched on for small projects only. The fix recommended in performance.md is to store the PDF classification on each study (its link kind and current check result, with a configuration fingerprint). It is decided (the owner's #4771 decision, performance.md alternative C): a follow-up slice behind its own flag, with migrations and a backfill, tracked in #4771 and being built in #4838.

3. The change itself

Not opened, not applied. Both diffs are against cluster-gitops main at c5f7de9d. Check them against main again before opening a PR, because P9's rehearsal edits these files.

Diff A: switch on studiesWorkspace and studyPdfStatus

Both flags are read by the API and the web app (config-keys.generated.json). The staging rule of 2026-11-04 says a flag that more than one service reads is set once in syrf/environments/staging/staging.values.yaml, "so the services can never disagree", and never repeated in a service's values.yaml (lines 8–9 of that file). It layers into every service (argocd/applicationsets/syrf.yaml lines 102–108). So the change is one file:

--- a/syrf/environments/staging/staging.values.yaml
+++ b/syrf/environments/staging/staging.values.yaml
@@ -17,6 +17,13 @@ featureFlags:
   # Project Management refuses to start with bulkPdfUpload on and this off ("BulkPdfUpload requires
   # DeletionLifecycle"), so the two must move together. Before #1873 only the API had it on, and #1870's
   # bulkPdfUpload change left PM crash-looping on start-up.
   deletionLifecycle: true
+  # Studies workspace (syrf FEAT-018, C+B′) and server-tracked PDF status (slice 8): the staging
+  # walkthrough (syrf docs/features/study-management/staging-walkthrough.md). Read by the API and
+  # web only, and both runtime-manageable. Migrations 0034 and 0035 are applied in syrf_staging
+  # (checked 2026-11-05). Off in production. Rollback: remove these lines (default false); the
+  # data they leave is kept and ignored while off (walkthrough, "Switching back").
+  studiesWorkspace: true
+  studyPdfStatus: true

 # Environment-specific infrastructure endpoints

Effect: ArgoCD re-renders the API and web Deployments with SYRF__FeatureFlags__StudiesWorkspace=true and SYRF__FeatureFlags__StudyPdfStatus=true and rolls their pods. Project-management doesn't consume either key, so its pods don't change. The snapshot's staticValue for both becomes true; with no override, the effective value is true, and browsers pick it up on their next snapshot fetch.

Alternative for the round trips in section 5. While staging stays in Legacy mode, an administrator can override either flag from the Feature flags admin page (audited, non-production only, mutationAllowed: true in the snapshot), effective within 30 s and with no deploy. That is quicker for off, on, off than three values PRs. It is still a legacy flag edit, so P9 applies. After the rehearsal's step S10, use a reviewed policy revision (runbook 7) instead.

Diff B (optional, separate decision): all-or-nothing bulk study updates

Only if Chris wants the version 2 half of the bulk study update walked in staging. It is read by the API and project-management, so it goes in the same shared block. It is deployment-only: this restarts the API and project-management (project-management uses Recreate, so it is briefly down; the rehearsal plan, step S2).

--- a/syrf/environments/staging/staging.values.yaml
+++ b/syrf/environments/staging/staging.values.yaml
@@ -20,3 +20,7 @@ featureFlags:
   deletionLifecycle: true
+  # ADR-020 all-or-nothing Bulk Study Update (execution version 2), deployment-only, API and PM.
+  # New jobs only: the API stamps the version at creation, and PM runs any version 2 job it receives,
+  # so turning it off never strands an admitted job. Rollback: remove this line (default false).
+  bulkStudyUpdateAtomicApply: true

 # Environment-specific infrastructure endpoints

The env-mapping comment orders previews first. If Chris wants that order kept, run Diff B on a preview before staging.

Diffs A and B touch the same hunk (both add lines after deletionLifecycle: true, before the blank line). If both are taken, whichever lands second has to be rebased onto the first. Diff B's hunk header and context then change: its lines go after Diff A's studyPdfStatus: true.

How the fail-closed admissions behave if a migration hasn't run

Both indexes exist in staging (P1, P2), so this is what would happen elsewhere, not what staging will do:

Admission Index With the flag on but the index missing
StudyPdfAvailabilityAdmission (studyPdfStatus) 0034 Every read, write and check is closed. pdfStatus is null, review retrievals start no check, the hub subscription throws, the search counts answer pdfStatusOn: false, and the PDF filter answers 409 pdf-status-filter-unavailable. The web then resets the filter to Any, hides it until reload and says "PDF status isn't available, so the PDF filter is off."
StudyPdfProposalsAdmission (studiesWorkspace) 0035 The summary and proposals reads answer Available: false and read nothing. "Proposed PDF waiting" answers 409 study-pdf-proposals-unavailable, and the web hides the option and the Overview item. Use and Reject still work, because they need no index

Shared behaviour (NotificationAdmissionChecks.cs lines 35–56 and the two admission classes):

  • Each closed call logs "{Feature} is on but index {Collection}.{IndexName} ({MigrationId}) is missing; it stays closed until the migration runs".
  • A negative answer is asked again on every call, so the feature opens on the first call after the migration runs, without a restart.
  • A positive answer is kept for the life of the process, so after a migration Down the API must be restarted (the rollback runbook's step).
  • A schema check that throws, or a flag decision that fails, counts as closed.

4. The walkthrough script

Roles: Admin is a staging test account that creates the project, so it is in the project's Administrator group, which holds ApprovePdfCorrections, BulkUpdateStudies, ImportSearch and ViewSearches by default (ResourceSecurity.json lines 85, 180, 190; group E432B8B3-…). Reviewer is a second test account, joined as an ordinary member: it has ViewStudies and SubmitPdfCorrections (all members, lines 75 and 124), may review the stage, and holds nothing else. An optional Name holder is a third member in a group granted "Who can see search names" (ViewSearchOverview, no default grant, line 230).

Use only the synthetic project made in 4.0 (or the seed projects, read-only). Never use real study content. Don't run a bulk PDF upload against the seed "Bulk PDF Upload Test Set": a delivery retires that fixture (Seed PDF fixtures).

Devtools checks are same-origin GETs from the staging app's console, for example await fetch('/api/projects/<projectId>/studies/pdf-proposals/summary').then(r => r.status). The API answers /api on staging.syrf.org.uk (api/values.yaml lines 26–35), and the signed-in session applies. Never send a write from the console, with one exception: section 5's phases 0 and 2 send a PDF proposal and its approval while the flags are off. With studiesWorkspace off no screen proposes or approves a PDF: only the workspace calls those routes (study-pdf-actions.service.ts lines 55, 64 and 84). The routes themselves are ungated, so phases 0 and 2 call them directly. A same-origin fetch passes the BFF's origin check (Auth/ValidateSameOriginAttribute.cs).

Steps marked [wave 8] need a staging build that includes it (P7). Steps marked [Diff B] need bulkStudyUpdateAtomicApply on.

4.0 Set up and record

# Role Do Expected Failure
0.1 Admin Before the switch, record the evidence header: URLs, the /health/live version, the web version.json buildHash, the snapshot revision and both flag values, browser, viewport, zoom and the accounts' roles All captured, as the plan's controlled staging proof item 1 asks Missing evidence; the versions don't match P7 or a newer promotion
0.2 Admin Create a project "Studies walkthrough 2026-11 (test)". Import search S1 from a copy of e2e/fixtures/data/search-10-references.csv (synthetic). In the copy, fill PdfRelativePath on rows 1–3 with walkthrough-ref1.pdf, walkthrough-ref2.pdf and walkthrough-ref3.pdf, and leave rows 4–10 blank. Import S2, a second copy with the titles changed and only row 10's PdfRelativePath filled (walkthrough-s2-ref10.pdf, for section 5's phase 0) Two searches, 20 studies, both imports Complete An import fails or the counts differ
0.2b Admin Controlled error: start a third import from a copy whose header row has no Title column, or from a renamed non-CSV file The import is refused, or ends in a named, readable error. No studies are added, and the attempt shows in Searches history (and Processing or Activity) with its reason It hangs, reports success, adds studies, or shows "Unknown status" with no reason
0.3 Admin Bulk-upload PDFs to S1. Make a fresh local folder holding three synthetic PDFs from https://staging.syrf.org.uk/assets/seed-pdf-fixtures/syrf-seed-pdfs-hosted.zip, renamed walkthrough-ref1.pdf to walkthrough-ref3.pdf, and select that folder (matching keys on the path inside the folder you pick, not the folder's own name) The match preview reports 3 matched and 0 unmatched; the upload completes and delivers 3 The upload stops or delivers a different number. If bulk upload doesn't work in staging, B2–B6 can't produce Available (P13)
0.4 Admin Add a screening stage over both searches, invite Reviewer (and Name holder) as members, and activate the stage Reviewer sees the project and the stage Reviewer can't open the stage
0.5 Admin Note the project ID and two study IDs (one from S1 rows 1–3, one from S1 rows 4–10) for the read-only checks Recorded n/a

A. Studies library: list, navigation and filters

# Role Do Expected Failure
A1 Engineer After Diff A syncs: read the snapshot and the API log Both flags effectiveValue: true. No "is on but index … is missing" warning for "Per-study PDF status" or "Proposed PDFs to check" A flag still false after sync plus 30 s; a missing-index warning (P6)
A2 Admin Open the project One "Studies" navigation item, with no separate Searches or Processing items The old Study management group still shows
A3 Admin Open Studies Title "Studies", the manager's lede, the Searches pane with "Import a search", "All studies 20" and rows S1 and S2 with "10 studies", and "Manage searches" at the foot The old Library; no pane; wrong counts
A4 Admin Choose S1 in the pane, then "All studies" The list filters to S1's 10 studies and the URL keeps the search. "All studies" clears it The filter isn't applied, or a reload loses it
A5 Admin Open the PDF filter The options are Any PDF status, Without a confirmed PDF, Available, Not yet confirmed, Unavailable, No PDF and Proposed PDF waiting The filter is missing, or Proposed PDF waiting is missing for Admin
A6 Admin Choose each option in turn (after B3, when some states exist) Each list matches the PDF cells, and the URL carries ;pdfStatus=<name>. Choosing a search resets the filter to Any A row whose PDF cell contradicts the option; a 409 while both flags are on
A7 Admin Narrow the window below 1,240 px, below 840 px and to 320 px Below 1,240 px the list switches to cards and the detail replaces the list. Below 840 px a "Searches" button opens the pane. At 320 px nothing is cut off and the targets are 44 px Overlap, horizontal scroll, unreachable controls
A8 Reviewer Open Studies The member's lede, no Searches pane, no Search column, no Search filter, and no "Import a search", "Bulk study update" or "Activity" Any admin-only control or the pane
A9 Name holder (optional) Admin grants the group on Searches with "Who can see search names"; Name holder opens Studies "Searches (names and counts)" with names and "N studies" only: no status, no import, no Manage searches Status, import or manage controls show

B. PDF status (slice 8)

# Role Do Expected Failure
B1 Admin Look at the PDF cells, staying on the page for a minute Rows 4–10 of S1 and all of S2 read No PDF. S1 rows 1–3 read Not yet confirmed or Checking when a page first lists them (A3 already did), then Available without a reload, usually within about 35 s: listing the page offers them for a check and the list reads the page again by itself. The list shows no loading bar for those re-reads. With a PDF filter chosen (any option but Any PDF status) the list does not update by itself: rows change only when you load the page A stored path shown as Available before any check (B7 has no document for it); rows still Not yet confirmed after a minute on the page; a loading bar or a lost selection during the re-reads
B2 Reviewer Open S1 row 1 for screening The PDF panel shows Available at once (B1's listing checked it) and the page never waits for a check. Had the row still been Checking, the panel moves to Available without a reload (the push) It stays Checking past 2 minutes; the page waits for the check; the panel's own load error changes the status
B3 Admin Return to Studies Rows 1–3 read Available for everyone The list differs between Admin and Reviewer
B4 Admin Open row 1's detail "SyRF has checked the stored file.", plus "Added by PDF upload, ." The wrong line; no provenance
B5 Admin Keep a study's detail open while its check runs: do this with C2's study, right after "Add a PDF" The detail updates live, with "Updated live when the server finished its check." The detail needs a reload
B6 Reviewer Open row 1 for review again No new check: the status stays Available Engineer: a read-only find on pmStudyPdfAvailability for row 1's id shows a later CheckedAt (Available must never be rechecked while its link is unchanged)
B7 Engineer count on pmStudyPdfAvailability with ProjectId: CSUUID("<projectId>") With studyPdfBackgroundChecks off (its default, and Diff A leaves it off): only studies with a PDF link that a Studies page listed or someone opened for review, plus any set in C, have documents: S1 rows 1–3 here. With it on (B8), every study with a link gets one within a pass. No No PDF study has one A document for a No PDF study, or for another project
B8 Engineer Optional, owner-approved only (the flag is off in staging until the owner switches it on): switch studyPdfBackgroundChecks on with a non-production runtime override (it asks to confirm its studyPdfStatus dependency, already on). Wait up to 2 minutes, then read pmStudyPdfCheckSweep (_id: "pmStudy") read-only a few times; then switch it off and read again after a minute On: LeaseOwner names one API pod (machine:guid) and LeaseExpiresAtUtc moves forward; Scanned grows by 500 a batch and Offered counts the studies with a link and no result (never one already No PDF, Available, Unavailable or Hosted elsewhere; an external link with no result yet is offered once and recorded Hosted elsewhere without a fetch); Cursor moves forward until PassCompletedAtUtc is set and Cursor goes; the API log line says the next pass starts in 6 hours (the default StudyPdfCheckSweep:IdleBetweenPasses), so a PDF added by an import or bulk upload after the pass is picked up by the next one, up to about 6 hours later. A Not yet confirmed study nobody listed reads its result on the next Studies load. Off: Scanned stops within one batch and Cursor stays, so on again resumes there. The API log has one "PDF check sweep pass completed" line per pass and no per-study lines Two pods hold the lease at once; Cursor moves backwards within a pass; offers for Unavailable studies; per-study log lines; a fetch for a Hosted elsewhere link; the sweep still advancing a minute after the flag went off

C. Study detail PDF actions (W2)

# Role Do Expected Failure
C1 Admin S1 row 4 (No PDF): "Add a PDF" with https://staging.syrf.org.uk/assets/seed-pdf-fixtures/seed/01-mcao-transient-neuroprotection.pdf Hosted elsewhere ("This PDF is on another website…"), with no server fetch Available, or a check recorded against the URL
C2 Admin S1 row 5: "Add a PDF" with the PDF-server path walkthrough/does-not-exist.pdf Not yet confirmed, then Checking, then Unavailable (or Couldn't check if the PDF host times out) Available; a 500
C3 Admin Try a path with %2e%2e, .., a backslash or a leading / Refused: "Give the PDF's web address, or its path on SyRF's PDF server." Accepted
C4 Admin Row 1 (Available, bulk-delivered): Replace with C1's URL, then "Remove link" Replace clears "Added by PDF upload". Remove link: "The stored file isn't deleted" and the row reads No PDF The marker stays; a stored file is deleted
C5 Admin Start a bulk PDF upload on S1 and, while it runs, try Replace on an S1 study 409: refused while the upload is unfinished The write is accepted
C6 Reviewer Open a No PDF study's detail Only Open PDF (where one exists) and "Propose a PDF"; no Add, Replace or Remove Any Set action

D. PDF proposals review (slice 9)

# Role Do Expected Failure
D1 Reviewer S2 row 1 (No PDF): "Propose a PDF" with C1's URL. Repeat on S2 row 2 and on C2's study (Unavailable) "You proposed (). An administrator will check it." Accepted without http(s); the study's PDF changes
D2 Admin Open Overview Needs attention shows "N proposed PDFs to check" first (N = 3 from D1, plus the phase 0 proposals still waiting: 6 if section 5's phase 0 ran), with the pending icon, the line "Reviewers proposed PDFs for studies with none or an unavailable one." and "Check proposals" Missing; the wrong count; styled as an error
D3 Admin Check proposals Studies opens filtered to Proposed PDF waiting with every other filter cleared, listing the studies with a waiting proposal (D1's 3, plus phase 0's) Other studies listed; another filter kept
D4 Admin Look at the PDF cells "No PDF · 1 proposed" and "Unavailable · 1 proposed" No suffix
D5 Admin Open S2 row 1's detail "Proposed:
by , ." with Use proposed PDF and Reject first
No name for Admin; no actions
D6 Reviewer Open the same detail "You proposed …", and no proposer names on other studies Names leak to Reviewer
D7 Admin Use proposed PDF on S2 row 1 "Use this PDF". The study's PDF becomes the address and reads Hosted elsewhere, and the list and count refresh to 2 The proposal stays waiting, or the link and the decision disagree
D8 Admin Reject S2 row 2 twice (second time in a stale tab) First 200; second 200 with nothing written 409 or 500 on the repeat
D9 Admin Two Admin tabs on C2's study: Use in one, Reject in the other, as close together as possible One wins. The other gets 409 study-pdf-proposal-resolved (study-pdf-link-changed if the link moved), shown as the server's detail. The study and its proposal agree Both succeed; a 500; a study whose link disagrees with the decision
D10 Engineer aggregate on pmStudyPdfCorrection for the project, $match on D1's three study IDs, then $group by Status Exactly one of two results, following D7–D9. D9's Use won: used 2, rejected 1. D9's Reject won: used 1, rejected 2. Used is PdfReplacedWithSuggestion and rejected is OriginalPdfKept. Either way, none of the three is Pending (phase 0's proposals are counted in section 5, not here) Any other split; a Pending proposal among the three; more than three documents for those studies
D11 Reviewer Devtools: GET …/studies/pdf-proposals/summary and …/pdf-proposals?studyId=<id> 403 both 200
D12 Admin [wave 8] Do this last, after section 5. Delete S2's search, then repeat Use on D7's proposal from a tab opened before the deletion 410 (#4805) 500 or 200

E. Bulk study update (BSU-a, BSU-b, BSU-c)

File: a CSV with studyId,customId holding 3 real study IDs from the project, one well-formed GUID that matches no study, and new custom IDs. For E7, add a screening column named after Reviewer.

# Role Do Expected Failure
E1 Admin Studies header, "Bulk study update" The three-step dialog: "Choose the file", then "Match screening columns" (only with a screening column), then "Check and update". The default name is "Bulk study update #" The old Library dialog
E2 Admin Choose a file with a duplicate heading, a non-GUID studyId and a bad decision cell Row problems listed with row numbers (the header is row 1), focus on the list, and no way past step 1 It uploads; the row numbers are off by one
E3 Admin Devtools Network while choosing files and moving between steps No request until "Update N studies" Any request before confirm
E4 Admin The valid file, step 3 "Update 4 studies" stays aria-disabled until "I understand this can't be undone" is ticked. Activated unticked: "Tick the box to confirm." It submits unticked
E5 Admin Confirm The dialog stays busy until the S3 upload succeeds, then closes and announces the start. The strip reads "Bulk study update running." (v1: no lock sentence) The dialog closes before the upload; lock wording on a v1 job
E6 Admin When it ends "Bulk study update finished: 3 studies updated · 1 row not matched.", with Details and Dismiss. Details opens Activity with the job revealed The wrong outcome; Details doesn't reveal it
E7 Admin [wave 8] Activity, "Show errors (1)" "Rows not applied": "Row : no study with ID in this project." (BSU-c, the stamped job) Only the count line " rows had no matching study in this project." on a job created with the flag on
E8 Reviewer Studies No "Bulk study update" action and no strips Either shows
E9 Admin [Diff B] Devtools: project details newBulkStudyUpdatesAllOrNothing true for Admin; null for Reviewer true for Reviewer; null for Admin with Diff B
E10 Admin [Diff B] Re-run E5 with the same file "Bulk study update stopped: nothing was changed." The unmatched row is listed, with its ID on wave 8. The custom IDs are unchanged Partial writes
E11 Admin, Reviewer [Diff B] Run a clean file of all 10 S2 studies, and while it runs, have Reviewer open one in Studies The strip says "Its 10 studies can't be reviewed until it finishes." (lock icon). Reviewer's detail: "This study is being changed by a bulk study update…", which clears when it finishes No note while it's locked; the note stays after it finishes

F. Stage chooser and My studies tabs

# Role Do Expected Failure
F1 Reviewer [wave 8] Studies, filter bar A "My studies" chip and the hint "Choose My studies to see your work in a stage." No chip; the hint shown to a manager
F2 Reviewer [wave 8] Open the chip A menu with "All studies" and the screening stage, as menuitemradio items; choosing the stage opens …/studies/stage/<id>;myStudies=todo The menu lists a stage Reviewer can't review
F3 Reviewer In the stage scope To do, In progress and Completed with counts; the "Your " column with Start or Resume Counts that don't match the rows
F4 Reviewer Screen one study and return To do −1 and Completed +1 Stale counts after a reload
F5 Admin [wave 8] Chip It reads "Stage", and choosing the stage opens the whole stage with Only mine off Admin lands on To do
F6 Reviewer Review page, My studies panel, "See them all in Studies" The stage's To do tab with no other filter Another filter set

G. PDF counts on the pane, Searches and Overview

# Role Do Expected Failure
G1 Admin Studies pane Settled rows read "10 studies · N without a confirmed PDF", 0 included The count disagrees with the "Without a confirmed PDF" filter for that search
G2 Admin [wave 8] Manage searches "Back to Studies". Each settled row has "N without a confirmed PDF" (only when N > 0), which opens Studies filtered to that search and to Without a confirmed PDF The link shows while an import or upload runs; it lands on the wrong filter
G3 Admin [wave 8] Overview "20 studies · N without a confirmed PDF", where N is the sum of G1 A mismatch with the pane
G4 Admin Change a PDF (C1) and return The counts refresh (coalesced) Stale until a reload
G5 Reviewer Overview, and devtools GET …/studies/pdf-status/search-counts No PDF count text; 403 Counts shown; 200

H. Activity sheet (W3)

# Role Do Expected Failure
H1 Admin "Activity" in the Studies header A modal sheet titled "Activity" with Needs attention, Running and History; focus on Close Opens as a page; focus lost
H2 Admin Items The imports, the bulk PDF upload and the bulk study updates from this run, newest first, each " · · " with when and who Missing items
H3 Admin [wave 8] Close with the pointer, then Escape Both close it and return focus to the button (#4806: the toolbar no longer covers Close) Close can't be clicked
H4 Admin Open /projects/<id>/processing Redirects to /studies;activity=open The Processing page shows
H5 Admin A "View in Processing" link (?reveal=…) The sheet opens on that job, outlined, with a note Not revealed
H6 Reviewer Studies, then /studies;activity=open No Activity button, and the URL does nothing The sheet opens

I. Reviewer access boundaries (summary check)

# Role Do Expected Failure
I1 Reviewer Visit /projects/<id>/searches The route doesn't match (studyManagementSearchesCanMatch needs a search capability), so the project's ** route shows the in-project page "This page isn't available", with "It may not exist, or you may not have permission to open it. Ask a project administrator if you think you should have access." and "Back to project overview" (project.routes.ts lines 116–124, project-access-denied.component.ts lines 20–24) The Searches page shows; the app-wide not-found page; a blank page
I2 Reviewer Devtools GETs: pdf-status/search-counts, pdf-proposals/summary and stages/<stageId>/my-studies/counts 403, 403, 200 Any other code
I3 Reviewer Scan Studies, the detail, Overview and the review page No Import a search, Manage searches, Bulk study update, Activity, Add/Replace/Remove PDF, Use/Reject, Proposed PDF waiting, PDF counts or other people's names Any of them

J. Bulk PDF upload: cancel, retry and report (proof item 4)

Bulk PDF upload is on in staging (bulkPdfUpload: true, staging.values.yaml line 15). Use S1 and copies of the three synthetic files from 0.3. Choose Replace always so they are sent again. To leave time to cancel, throttle the network in devtools (for example "Slow 3G"). Labels are quoted from bulk-pdf-upload/upload-detail and result-panel, and the outcomes from the user guide's What each outcome means.

# Role Do Expected Failure
J1 Admin Start an upload on S1 with Replace always. While it reads Uploading or "Received, waiting to be processed", choose Cancel upload and confirm Upload cancelled: no PDFs reached final storage and no study links changed, so the three studies keep their links and PDF states. No report is offered. The action reads Upload again — starts over The links or states change; a report is offered; Cancel upload is missing while it runs
J2 Admin Upload again — starts over, choose the same folder and let it finish A new attempt ends Upload complete, with 3 uploaded and 3 attached (replacements counted). The search's history lists both attempts with who and when. If J1's attempt was listed under Needs attention in Activity, it leaves once this one completes (#4705's rule) It resumes the old attempt; J1's attempt stays in Needs attention after this one completes
J3 Admin Controlled failure: an upload with Replace always of one file named walkthrough-ref1.pdf that isn't a PDF (a renamed text file) Upload failed — No matching PDFs were uploaded, or the file is refused on the computer before anything is sent (record which). Either way, row 1's link and state are unchanged. Retry — starts over shows while SyRF still accepts a retry (canRetry) The invalid file is attached; the study's state changes; a retry is offered after canRetry is false
J4 Admin Start another throttled upload and close the tab during Uploading. Reopen the project later, and record how long it took to settle The attempt reads Never finished, with a reason where SyRF recorded one. Overview's Needs attention card: "PDF upload stopped: S1", its outcome line, then "You can retry until It stays Uploading indefinitely; no card; Open upload lands elsewhere
J5 Admin Retry J4's attempt to completion A new attempt completes and J4's card leaves Overview The card stays
J6 Admin On a completed attempt (J2 or J5), Report (CSV) (or Download report (CSV) in the result panel; also Report on the Activity item) A CSV with one row per submitted file: the three file names, each uploaded with its study. Files skipped on the computer aren't in it The download fails; wrong or missing rows; a report offered for J1's cancelled attempt

Not walkable in staging: Upload stopped (the virus check found a problem) and Upload failed with canRetry false. Neither can be produced without a test hook (the programme handover, "Operational prerequisites"), so they stay covered by unit and E2E tests only. Record them as not run.

K. Navigation, refresh, reconnect, Back and a second session (proof item 6)

# Role Do Expected Failure
K1 Admin During a throttled bulk PDF upload, choose Watch in background, go to Overview, then back to Searches and Open upload The upload kept running (the tab stayed open) and shows current progress. S1's row is expanded The upload stopped or lost its progress
K2 Admin On Studies with a search chosen and a PDF option set (the URL carries ;systematicSearchFilter=…;pdfStatus=…), open a study, then hard refresh The same list, search and PDF option, from the URL. The study detail is closed, because it isn't in the URL. A bulk study update still running shows its running strip again (from the server). The finished strip doesn't come back after a reload (BSU-b decision 9) The filters are lost; an error page; a stale strip
K3 Admin Open Activity (;activity=open), then the stage scope, then press Back twice Each Back restores the previous URL's view: out of the stage scope, then Activity closed. Filters are kept. Opening or closing Activity doesn't refetch the list (runStudiesGuardsUnlessOnlyActivityChanged) Back leaves the project; the filters reset; the list reloads on closing Activity
K4 Admin, Reviewer Reconnect. Set devtools Network to Offline for about 60 s, then Online: (a) Reviewer on the review page; (b) Admin on Studies with a study detail open while Admin, in a second browser, sets a PDF-server path on that study (as in C2), so its check runs; © with the flags off (before Diff A or in section 5's phase 2), Admin on the Processing page (a) The review page shows "Reconnecting…", then resumes. (b) After going online, the detail shows the check's result without a reload. © "Reconnecting — these operations are no longer updating live and may be out of date." and the rows are kept and marked stale, then they update after reconnecting (C5.6) No reconnect; rows dropped; a stale state that needs a manual reload (record which surface)
K5 Admin ×2 Second authorised session: the same Admin in a private window (or a second Administrator account). Session 1 starts a bulk study update Session 2's Studies shows the running strip for it (the newest running update in the project, anyone's), and the lock note with Diff B Session 2 shows nothing until a reload
K6 Admin ×2 Both sessions open one study's detail. Session 2 replaces its PDF (C1's address). Session 1 then chooses Remove link from its stale view Session 1 gets 409: "This study's PDF has just changed": "Someone else changed this study's PDF. Look at it again before you change it." Nothing is written (StudyController.cs lines 797–799) Session 1's write overwrites session 2's
K7 Admin ×2 Both sessions on one waiting proposal: session 2 Rejects, then session 1 Uses Session 1 gets 409 study-pdf-proposal-resolved, and the study keeps its link Both decisions stick; a 500

L. Keyboard, screen reader, zoom and long content (proof item 7)

Run on Studies (with a study open), the study detail dialogs, the bulk study update dialog, Activity, Searches, Overview and the review page's PDF panel, with the flags on. Then run the Processing page with the flags off (before Diff A or in section 5's phase 2), since /processing redirects while on. Use a desktop screen reader (NVDA with Firefox or Chrome, or VoiceOver with Safari) and record which one.

# Role Do Expected Failure
L1 Admin Keyboard only, from the address bar: Tab to "Skip to main content", then through the pane, filters, list, a study title, the detail and Close Every control is reachable and visibly focused, in reading order. A study opens from its title button. Close returns focus to that study's row (W1 criterion 9) A focus trap; an invisible focus; a control reachable only by pointer
L2 Admin Keyboard only: the PDF filter menu, the stage chooser ([wave 8]), the bulk study update dialog (Next, Back, the confirm box), Activity, and the Remove link and Use proposed PDF dialogs Menus open with Enter or Space, arrows move, and Escape closes and returns focus to the trigger. Dialog steps move focus to the step heading. Escape and Close on Activity return focus to the Activity button Focus lost to the page; Escape does nothing
L3 Admin Screen reader on Studies and Activity Names: the Activity button reads "Activity, N need attention" (or "1 needs attention"); a PDF cell reads, for example, "PDF: No PDF · 1 proposed"; the chip says it is a menu, with aria-expanded. One polite live region announces list changes, a started or finished update and refusals such as "PDF status isn't available, so the PDF filter is off.", throttled rather than one per row. Show errors has aria-expanded Unlabelled controls; several competing announcements; silence on a refusal
L4 Admin Screen reader on Searches and Processing (flags off) aria-expanded on expandable rows, the progress bar's aria-valuetext during an upload, role="status" notices, labelled focusable scroll regions, and the result count announced on Processing (B16, C4.4, C5.7) Any of them missing
L5 Admin Browser window 1280 px wide at 200% and at 400% zoom (400% is 320 CSS px), on every page in L1 to L4; then a real 320 px viewport Content reflows into one column with no horizontal scrolling of the page. The detail replaces the list, the pane is reached by the "Searches" button, dialogs go full screen below 600 px, and targets are at least 44 px. Nothing is cut off Horizontal scrolling; overlap; hidden actions
L6 Admin Long content: rename S2 to a 200-character synthetic name; run a bulk study update with more than 10 row problems; Add a PDF with a 2,000-character synthetic web address The name wraps or truncates with its full text available (title or accessible name). The problem list is bounded, ending " more problems aren't listed.". The address wraps in the detail Layout breaks; unbounded lists; text lost
L7 Admin Devtools rendering: emulate forced-colors: active, then prefers-reduced-motion: reduce Under forced colours, states stay distinguishable without colour (icons and text, not colour alone). Under reduced motion, progress shows the static indeterminate track Meaning carried only by colour; animation under reduced motion
L8 Reviewer L1 and L3 as Reviewer, on Studies, the detail and the review page Only the reviewer's controls, all reachable and named As L1/L3

M. Timestamps (proof item 8)

# Role Do Expected Failure
M1 Admin For each shown time, note it and its devtools payload field: the Activity items' start (bulk study update, bulk PDF upload, import); the bulk PDF upload's "Attempt started … by …" and "Updated"; the detail's "In project since" and "Added by PDF upload, ." (pdfDeliveredByBulkUploadAt); and a proposal's date (proposedAt) Each shown time is the payload's UTC instant in local time. Upload times carry a labelled UTC offset (for example UTC+00:00). Dates match the payload's date in local time A shifted hour; a time shown as UTC without saying so; the wrong date
M2 Engineer For J2's attempt and E5's job, compare the shown start and end with the API and project-management log lines for the same IDs (Cloud Logging, read-only) They agree to the minute A drift beyond rounding
M3 Admin Devtools: search every response from M1's pages for 0001-01-01, and the rendered pages for a year-one date (for example "1 Jan 1" or "0001") None. A time the server didn't supply shows Not available (study-job-dates.ts, D9) Any year-one date in a payload or on screen
M4 Admin Look at a legacy item with no update time (an older seed or upload record, if any) "Updated: Not available", not a date A year-one or epoch date

4.1 Coverage of the controlled staging proof (C7.2)

The implementation plan's Controlled staging proof lists eight items for each UI wave. This walkthrough covers the waves behind studiesWorkspace and studyPdfStatus:

Proof item What it asks for Steps
1 Environment URL, version, SHA, flags, viewport and zoom, role and permissions 0.1; each section 5 phase records the revision and both flags; L5 records the viewport and zoom
2 A newly uploaded search through import completion or a controlled error 0.2 (completion, two searches), 0.2b (controlled error)
3 A bulk study update with at least one modified and one unmatched row E1–E7 (3 modified, 1 unmatched), E10–E11 with Diff B; section 5 phases 0 and 2 (old Library)
4 Bulk PDF upload with its cancel, retry and report paths 0.3, J1–J6 (the virus-stopped and non-retryable failures can't be produced in staging; recorded as not run)
5 Risk of Bias launch and terminal or error state, when enabled Not applicable: robToolEnabled is false in staging (snapshot revision 83, P8) and the AI RoB tool is suspended (env-mapping.yaml, batchRiskOfBiasAtomicFlags). Record as not applicable
6 Navigation away, hard refresh, reconnect, Back and a second authorised session K1–K7
7 Keyboard-only and screen-reader passes, 320 px, 200% and 400% zoom, long errors and names L1–L8, with A7
8 Timestamps against the API payload and server logs, and no year-one date M1–M4

Acceptance rows waiting on this evidence:

  • A4 and B16: L1–L8.
  • C4.4 (Searches): L4, L5 and K4.
  • C5.7 (Processing): L4 and L5 with the flags off.
  • A1: E5–E6 with K2–K3.

Record each result against its row in the acceptance criteria tracker when the walkthrough runs.

5. Flag-safety checks

Phase 0 runs before Diff A, alongside 4.0; phases 1 to 3 run after section 4. Switch with Diff A's revert and re-apply, or with the runtime override if P9 allows it. Each phase records the snapshot revision and both flag values.

Phase State Make data Observe (pass)
0 Both off (before Diff A) (a) Admin runs a bulk study update from the old Library dialog (one unmatched row): an unstamped version 1 job. (b) Admin bulk-uploads walkthrough-s2-ref10.pdf to S2 (row 10). © Reviewer makes three proposals while off, from the console (the exception above), with the propose call under Console calls for phases 0 and 2: on S2 row 3 HTTPS://STAGING.SYRF.ORG.UK/assets/seed-pdf-fixtures/seed/02-mcao-permanent-infarct-volume.pdf (P0-a, valid but not normalised); on S2 row 4 ftp://example.invalid/walkthrough.pdf (P0-b, which W2's rules refuse); on S2 row 5 https://staging.syrf.org.uk/assets/seed-pdf-fixtures/seed/03-mcao-hypothermia-outcome.pdf (P0-c, to stay Pending into phase 2) The Library behaves as today. Each POST answers 200, and the values are stored verbatim: with the flag off, StudyController.cs line 512 skips the http(s) normalisation (read-only find on pmStudyPdfCorrection by the three study IDs). The study PDFs are unchanged
1 Both on Sections 4.A–4.M: availability documents, W2 sets, proposals and decisions, a stamped job (E5), a ViewSearchOverview grant (A9). Then resolve phase 0's proposals: Use P0-a, Use P0-b, and leave P0-c waiting Phase 0's job shows in the workspace strip and Activity with the count line (unstamped, so no per-row detail). Phase 0's delivered PDF reads Not yet confirmed until a Studies page lists it or it is opened for review, then the check's result. All three phase 0 proposals are listed under Proposed PDF waiting and counted on Overview. Use P0-a answers 200, and the study's link is stored normalised (https://staging.syrf.org.uk/…, lower-case scheme and host) and reads Hosted elsewhere. Use P0-b is refused with 400 ("The proposed link isn't a web address or a path SyRF can use.", StudyController.cs lines 596–597): the study and the proposal are unchanged, and it stays waiting until Admin Rejects it (200)
2 Both off Admin runs another old-Library bulk study update. Admin approves P0-c while off: no off-state screen approves a proposal, so send the approve call under Console calls for phases 0 and 2 for S2 row 5 and P0-c (the correction id from a read-only find, or the Library list response's studyCorrections) The approval answers 200 and, as on main, records the decision only (StudyController.cs lines 535–549): P0-c's Status becomes PdfReplacedWithSuggestion (StudyPdfCorrection.cs lines 32–37) while S2 row 5's PDF link stays unchanged: off, the approval's domain event changes nothing on the study. /studies is the old Library, /processing is the Processing page, and the navigation has the Study management group. Devtools: the gated routes answer 404; pdfStatus, bulkUpdateLockedAt and pdfDeliveredByBulkUploadAt are null on list rows; allOrNothing, unmatchedRowSample and newBulkStudyUpdatesAllOrNothing are null in project details. PDF cells are link-based (C1's study shows a link, Remove-linked studies show none). Phase 1's jobs are listed in the Library strip. Name holder sees names and counts as on main, and Reviewer does too (slice 7's trim): the grant is ignored, not lost. No 500s and no API errors in the log
3 Both on again n/a P0-c is no longer waiting: it isn't under Proposed PDF waiting or in the Overview count, the detail shows no proposal for S2 row 5, and S2 row 5 still reads No PDF (the off approval set no link). Phase 1's stamped job lists its unmatched row by ID again ([wave 8]). Phase 2's job shows with the count line. Studies Available in phase 1, with links unchanged since, are still Available with the same CheckedAt (read-only find), so no recheck. D7's link still reads Hosted elsewhere. The A9 grant applies again. The counts in G1 match the filter
4 Combinations (optional, runtime override) n/a Workspace on with PDF status off: no PDF filter, the counts say pdfStatusOn: false, and Proposed PDF waiting still works. Workspace off with PDF status on: the old Library shows stored PDF states, and the review panel shows Checking and the result

A pass means:

  • every phase serves without a 500 or an unhandled error in the browser console;
  • data made in one state is shown correctly, or correctly ignored, in the other;
  • nothing made while on is lost while off;
  • the read-only counts (B7, D10) are the same after phase 3 as after phase 1, apart from what the phases added.

Console calls for phases 0 and 2

Paste into the devtools console on a staging.syrf.org.uk page, signed in as the role named. The shapes match StudyController.cs:

  • SubmitStudyCorrection (POST …/studies/{studyId}/Pdf/Correction, lines 502–527) takes a StudyCorrectionDto body, and reads only pdfIssue and correction (replacementUrl, uploadedToSyrf). The server takes the project, the study and the proposer from the route and the session, as the app's propose does (study-pdf-actions.service.ts lines 70–84).
  • pdfIssue is the numeric enum (0 is PdfLinkNotWorking), as the generated client sends it.
  • ApproveCorrection (PUT …/Pdf/Correction/{correctionId}/Approve, lines 529–549) takes no body: the app sends none.
  • Without Content-Type: application/json the POST answers 415, which isn't the result being tested.
// Phase 0 (c), signed in as Reviewer, with both flags off. Expect 200 for each.
const projectId = '<projectId>';
async function propose(studyId, replacementUrl) {
  const response = await fetch(`/api/projects/${projectId}/studies/${studyId}/Pdf/Correction`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      pdfIssue: 0, // PdfLinkNotWorking
      correction: { replacementUrl, uploadedToSyrf: false },
    }),
  });
  console.log('propose', studyId, response.status, response.status === 200 ? 'ok' : await response.text());
  return response.status;
}
await propose('<S2 row 3 id>',
  'HTTPS://STAGING.SYRF.ORG.UK/assets/seed-pdf-fixtures/seed/02-mcao-permanent-infarct-volume.pdf'); // P0-a
await propose('<S2 row 4 id>', 'ftp://example.invalid/walkthrough.pdf'); // P0-b
await propose('<S2 row 5 id>',
  'https://staging.syrf.org.uk/assets/seed-pdf-fixtures/seed/03-mcao-hypothermia-outcome.pdf'); // P0-c
// Phase 2, signed in as Admin, with both flags off again. Expect 200 (the response is the study's details).
const projectId = '<projectId>';
async function approve(studyId, correctionId) {
  const response = await fetch(
    `/api/projects/${projectId}/studies/${studyId}/Pdf/Correction/${correctionId}/Approve`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      // No body: the route takes none.
    },
  );
  console.log('approve', correctionId, response.status, response.status === 200 ? 'ok' : await response.text());
  return response.status;
}
await approve('<S2 row 5 id>', '<P0-c correction id>');

A 403 means the signed-in account lacks the policy (Reviewer for the POST needs only membership; the PUT needs ApprovePdfCorrections). A 404 means a wrong id, or a study outside the project.

6. Switching back

The revert. Revert Diff A's commit in cluster-gitops by a PR (git revert <sha>), or remove the two lines from staging.values.yaml; both flags then fall back to the chart default, false. For Diff B, remove bulkStudyUpdateAtomicApply: true the same way. If an override was used, clear it on the Feature flags admin page (or by a reviewed policy revision after the rehearsal's S10). Then confirm with the snapshot that both read effectiveValue: false.

What remains, and why that is safe:

What remains Why it is safe
pmStudyPdfAvailability documents and the 0034 index Read and written only while studyPdfStatus decides on and the index exists, so off nothing reads them. Back on, they are reused: an Available result whose link key is unchanged isn't rechecked, and a changed link reads Not yet confirmed. Migrations are never reversed by a flag; the collection and index go in the flag's clean-up PR (PmMigrations.cs lines 70–75)
pmStudyPdfCorrection decisions and the 0035 index Ordinary proposals every build reads and writes. A used or rejected proposal reads the same off. The index is expand-only, and older builds ignore it
Study PDF links changed by Add, Replace, Remove link or Use Real edits to PdfRelativePath (and the cleared bulk marker), made by an administrator through StudyWriteGuard. Off, the Library shows them link-based. They aren't flag data, and reverting the flag must not undo them
Jobs stamped RecordsUnmatchedRows with an unmatchedRowSample Stored on the job, ignored by the off projection, which sends null. Every staging build has the tolerant maps (P12)
Version 2 jobs (Diff B) Project-management finishes any admitted version 2 job and releases its locks whatever the flag says. Revert only when no job is running, to keep the evidence simple
ViewSearchOverview grants Kept, and ignored while off
Browser state The finished-update strip is in memory and resets on reload. ;pdfStatus=, ;myStudies= and ;activity=open are dropped or ignored off: the canonical rewrite drops the PDF filter, the server ignores the tab, and /processing is the Processing page
The project made in 4.0 Synthetic, in staging only. Delete it afterwards through the app (reversible deletion is on in staging), or keep it for the next walkthrough

7. What this does not cover: production

  • Production runs no ADR-023 migrations yet. Step P2 of the production prerequisites is NOT RUN, so neither 0034 nor 0035 exists there. Both admissions would stay closed: no PDF status, no PDF filter, no proposals list or count. studiesWorkspace's consumer contract has productionEligible: false (production is deployment-controlled).
  • Production has no pmStudyPdfAvailability collection. The team lead checked syrftest read-only on 2026-11-05 and it doesn't exist, so slice 8's status has nothing stored there. Migration 0034 would create its index, and the collection fills only as checks run.
  • #4771: the PDF filter and counts are too slow on large projects (P10). At 215,000 studies a filtered page takes 1.75–3.5 s and a counts call 2.1–2.6 s. studiesWorkspace decides globally, so it can't be switched on for small projects only. The fix is decided (the owner's #4771 decision, performance.md alternative C): store the PDF classification on each study (its link kind and current check result, with a configuration fingerprint), in a follow-up slice behind its own flag, with migrations and a backfill. Production waits for that slice, which is being built in #4838.
  • Switching either flag on in production, promoting, or enabling production migrations needs Chris's explicit approval, each as its own decision. This plan grants none of them.
  • This plan's author didn't read or write syrftest (production). The one production fact above is the team lead's read-only check.