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:
studyPdfStatusStoredgates 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:
- Wave 8 isn't in staging. Until a staging promotion carries
mainat or after86a63dc6ca(#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-study410on a repeated Use (#4805). Everything else can be walked on today's builds. - 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.yamllines 2164–2206. Its consumer contract is Global grain, safe valuefalse,maximumAgeSeconds: 30,productionEligible: false, hostapi.studyPdfStatus: the same file, lines 2207–2223. It has no consumer contract and no catalog dependency.- The
featureFlagsgroup isservices: [api](line 1375), and the generated config keys give both flags toapiandwebonly (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/studiesand/studies/stage/:stageIdbecome the Studies workspace; with it off they are today's Library (studies.routes.tslines 30–66);/processingredirects to/studies;activity=open, the Activity sheet, whateverstudyManagementProcessingsays (project.routes.tslines 82–100);/searchesbecomes 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.cs114–268). It appliesmyStudiesTab(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 fillspdfDeliveredByBulkUploadAtandbulkUpdateLockedAt(lines 241–251). - Use proposed PDF and Reject (
PUT …/Pdf/Correction/{id}/Approveand/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 anhttp(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.cslines 131–252). A member without a search activity gets search names and counts only withViewSearchOverview, and no searches otherwise (lines 237–252). Off, slice 7's trim applies and every member keeps names and counts. Bulk study update jobs gainallOrNothingand the unmatched-row detail (lines 191–194). SearchDeletednotification names the search only to the same readers (SignalR/Deletion/SearchDeletedConsumer.csline 63).- Slice 9's waiting-proposal reads open only with the flag on and the 0035 index present
(
StudyPdfProposalsAdmission.cslines 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.cs1656–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. WithstudyPdfBackgroundChecksalso 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.
StudyPdfCheckerreloads the study, claims itspmStudyPdfAvailabilitydocument and records the result. Only an internal link is fetched: oneGETwithRange: bytes=0-4, a 10 s timeout and no redirects. Anhttp…link is recorded as Hosted elsewhere and never fetched (implementation plan, slice 8, decision 4). In staging the internal bases arepdfSettings.baseUrl(https://ecrf1.clinicaltrials.ed.ac.uk/camarades/, cluster-gitopssyrf/environments/staging/api/values.yamlline 130) and the bulk PDF delivery base. - The push. The hub methods
SubscribeToStudyPdfStatusandSubscribeToProjectStudyPdfStatus(NotificationHub.cslines 1182–1219) pushStudyPdfStatusUpdated. 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.yamllines 914–925:services: [api, project-management], defaultfalse, 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.csline 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.cslines 13–18). Switching it off stops new version 2 jobs and never strands one already admitted. - How the browser learns it.
ProjectDetailsDto.NewBulkStudyUpdatesAllOrNothing(ProjectDetailsDto.cslines 196–201) is the API's own deployed value. It is sent only to callers who may start a bulk study update, and only whilestudiesWorkspaceis on; it is null otherwise. The workspace dialog reads it (workspace-bulk-update-dialog.component.tsline 193) to word the confirm step, and each job's ownallOrNothingwords 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-configblock, 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.
studiesWorkspacedecides globally, so it can't be switched on for small projects only. The fix recommended inperformance.mdis 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.mdalternative 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 |
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 |
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 |
Only the count line " |
| 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 |
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 " |
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 " |
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
500or 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 aStudyCorrectionDtobody, and reads onlypdfIssueandcorrection(replacementUrl,uploadedToSyrf). The server takes the project, the study and the proposer from the route and the session, as the app'sproposedoes (study-pdf-actions.service.tslines 70–84).pdfIssueis the numeric enum (0isPdfLinkNotWorking), 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/jsonthe POST answers415, 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 hasproductionEligible: false(production is deployment-controlled). - Production has no
pmStudyPdfAvailabilitycollection. The team lead checkedsyrftestread-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.
studiesWorkspacedecides globally, so it can't be switched on for small projects only. The fix is decided (the owner's #4771 decision,performance.mdalternative 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.
Related¶
- Implementation plan: W1–W6, slice 8, #4719, #4720 and the stage chooser, BSU, slice 9, and the controlled staging proof.
- Acceptance criteria tracker: C7.2 and C7.4.
- Manage feature flags: runtime overrides.
- Runtime flags staging rehearsal.
- User guide: View studies, Manage PDFs, Manage studies.