Study Management — Phased Implementation Plan¶
Delivery rule¶
No implementation work begins from this plan branch. After product, permission, and Material 3 gates are accepted, delivery proceeds through thin PRs with non-overlapping file ledgers. A PR must leave a real usable outcome and preserve current routes while later waves remain unmerged.
The first release does not carry the future generic job architecture. The current four domain models remain authoritative and are adapted only for presentation.
Current-main technical inventory¶
Routes and navigation¶
app.routes.tsmounts a project at/projects/:projectId.project.routes.tsowns child routes includingstudies; the proposedsearchesandprocessingchildren produce/projects/:projectId/searchesand/projects/:projectId/processing.project-admin.routes.tsownsadmin/systematic-searches.- the project navigation's Studies group currently exposes Systematic Searches (
viewSearches) and All Studies (viewStudies). - All Studies currently launches Bulk Study Update from a table overflow menu through
project-overview/update-studies.
APIs and mutation capabilities¶
| Area | Current API | Authorisation/capability |
|---|---|---|
| Project detail/job projection | project details endpoint and SignalR project notification | Project projection rules; family audit required |
| Search Import | Search create/upload signature; delete import job | Search import/view policies; no current cancel/retry contract |
| Bulk Study Update | POST /api/projects/{projectId}/studies/getSignatureForBulkStudyUpdate |
ProjectBulkUpdateStudiesPolicy; creates job before S3 upload |
| Risk of Bias | per-search calculate endpoint | no endpoint policy today — SearchController.CalculateRob relies on the global authenticated-user fallback only (issue #3058 closes this with ProjectCalculateRobPolicy + grant + role-matrix tests, sequenced with the contract waves); no current generic retry/cancel endpoint |
| Bulk PDF | initiate/session/parts/complete/abandon/cancel/retry/history/report paths | ProjectBulkPdfUploadPolicy; flag and capacity also apply |
Store and live updates¶
- Project details normalisation populates entity maps for searches and all four job families.
- selectors relate each family to the current project; Bulk PDF also applies permission and monotonic revision reconciliation.
- ProjectDetails reload and project SignalR messages update the same global entity state.
- the Processing page will use pure derived selectors; no second mutable cache is permitted.
- before cross-type live sorting, each entity update path must reject stale/out-of-order payloads
using an authoritative monotonic revision/update sequence. Adapters remain pure and provide a
stable key (
kind:id).
Models requiring explicit mapping¶
| DTO | Fields usable now | Contract work |
|---|---|---|
SearchImportJobDto |
status, total/parsed/saved, errors/warnings, last modified, search ID | add or explicitly omit created/completed/initiator; document progress units |
BulkStudyUpdateJobDto |
status, counts, errors, created, initiator ID, parse result | map domain LastModified; fix validated deconstruction; define truthful modified count and owner projection |
RiskOfBiasJobDto |
rich counts, status, created/updated/completed, errors, search/attempt | define initiator availability and permission projection |
BulkPdfUploadJobDto |
status, counts, errors/report, created/updated (#3812)/completed/creator, revision/retry/capacity | retain current contract and contextual commands; consume #2976/#2979 |
Frontend presentation contract¶
Create a feature-local discriminated union after the backend truth PR. It is a view model, not a domain model:
type StudyJobKind =
| 'search-import'
| 'bulk-study-update'
| 'risk-of-bias'
| 'bulk-pdf-upload';
type StudyJobGroup =
| 'queued'
| 'running'
| 'succeeded'
| 'needs-attention'
| 'stopped'
| 'unknown';
interface StudyJobRow {
key: `${StudyJobKind}:${string}`;
id: string;
kind: StudyJobKind;
rawStatus: string;
group: StudyJobGroup;
name: string;
context: { label: string; route: readonly string[] } | null;
progress: {
completed: number;
total: number;
unit: string;
} | null;
createdAt: string | null;
startedAt: string | null;
updatedAt: string | null;
completedAt: string | null;
initiatedBy: { id: string; label: string } | null;
errors: readonly { code: string | null; message: string }[];
warnings: readonly { code: string | null; message: string }[];
capabilities: readonly ('open-context' | 'open-report')[];
}
Constraints:
- adapter switches over every known enum member and has an explicit unknown branch;
- no adapter invents a timestamp, percentage, person, terminal state, or command;
- warnings (e.g. Search Import
parseWarnings) are a separately typed collection, never folded intoerrors: an import completed with warnings keeps its success semantics and tone while its diagnostics stay disclosed (Wave 2 tests cover completed-with-warnings adaptation); - invalid/year-one dates become
nulland trigger a contract test failure at the server boundary; - progress preserves the family-specific unit; percentages are display derivatives only;
capabilitiescontains only non-mutating Processing links in the MVP; contextual components continue to own authorised mutation commands; and- display components accept rows as inputs and do not import DTO enums or the store.
Dependency waves and PR boundaries¶
Scope change 2026-09-02 (product owner): the Risk of Bias feature is disabled and its development is on ice. Every Risk of Bias item in Waves 2–5 below is superseded and omitted (no RoB adapter, reducer changes, rows, chips, or Searches summary). Wave 1B's server-side RoB revision field stays as dormant backend code. Tracking: acceptance-criteria.md.
Wave 0A — plan and contract gate (this PR)¶
Outcome: approved information architecture, data/capability matrix, ownership ledger, and proof matrix.
Owned files: the Study Management feature pack and design handoff, plus the documentation
registration this PR performs: docs/features/catalogue.md, the generated docs/features/index.md
and docs/planning/index.md, the docs/features/job-progress.md supersession pointer, and
mkdocs.yml navigation. CLAUDE.md, Material 3, About, Admin, and implementation files remain
externally owned.
Exit: product decisions are recorded; PR #2994's merged contract is available; backend owners approve the permission/timestamp changes. No implementation feature flag is created here.
Wave 0B — Claude Design iteration¶
Outcome: Claude Design produces and iterates the interaction system for Library, Searches, and Processing before component work begins.
Claude Code must invoke Claude Design specifically for this phase, using
docs/planning/study-management-design-handoff.md. A general-purpose coding agent or an
implementation-first prototype is not a substitute.
The iteration has four gates:
- authenticated current-state capture and component inventory;
- low-fidelity alternatives for contextual summaries, Processing list/detail, and adaptive navigation;
- user selection followed by annotated desktop, tablet, mobile, 200% and 400% designs; and
- accessibility/technical feasibility review with a decision record and implementation handoff.
No production source files are edited in this wave. Approved design artefacts and the decision record become required inputs to Waves 3–5.
Status: complete. The product owner approved the Iteration 2 design on 2026-09-01. The binding record is design-decision.md (selected direction, UX mandate, corrections, O1–O5 dispositions), with normative companions design-decision-log.md, design-source-truth.md and design-token-mapping.md.
Wave 1A — Bulk Study Update contract truth¶
Outcome: the existing project projection reports valid times, validated/parsed/modified counts,
and a safe initiator identity for permitted users. The job contract also records a truthful upload
failure: today GetBulkStudyUpdateS3RequestSignature persists the job in UploadingToS3 before
the browser's S3 PUT, and no endpoint ever transitions it to the existing ErrorUploadingToS3
state when that PUT fails — after a refresh the locally reported failure reads as a permanently
uploading job. This wave adds both: an authorised client failure-report endpoint
(idempotent; refused once the server has observed the upload) and a derived signature-expiry
projection on the job contract (UploadWindowExpired computed from creation time + the S3
signature validity window), so a browser that crashes mid-PUT and can never send the report still
reads truthfully as expired everywhere — including already-stuck legacy jobs, with no migration.
A durable background sweep that also rewrites the stored status is a named follow-up issue (it
needs its own scheduled-processor/lease infrastructure), not a silent omission.
Candidate owner: one backend agent/worktree only.
Expected files:
src/libs/project-management/**/BulkStudyUpdateJob*.csand focused tests;src/services/api/SyRF.API.Endpoint/Controllers/StudyController.cs(upload-failure transition endpoint) and its endpoint tests;src/services/api/SyRF.API.Endpoint/Models/**/BulkStudyUpdateJobDto.cs;- AutoMapper/profile or explicit mapping tests for the ProjectDetails projection; and
- the regenerated OpenAPI spec, TypeScript client and checksums
(
swagger.json,src/app/core/services/api-client.generated.ts,.generated-checksums.json) through the repository's supported NSwag command — the DTO changes above alter the transport contract, so regeneration is part of this ledger, plus any minimal web mock/literal fixes required for the app to compile against the new contract.
Must not touch: PR #2858 search-upload files, PRs #2887/#2900 recovery files, or Bulk PDF.
Tests: domain count semantics, DTO created/last-modified mapping, rejection of year-one values,
no-op versus modified update result, ordinary-member initiator projection, policy-filtered
payload, and the upload-failure transition (authorised client report, expiry path, and idempotent
rejection once the job has left UploadingToS3).
Wave 1B — cross-family permission, timestamp, and revision contract¶
Outcome: each ProjectDetails job collection is server-filtered by a documented view policy and exposes only truthful timestamps/owner data. Search Import, Bulk Study Update, and Risk of Bias also expose an authoritative monotonic revision/update sequence equivalent to the existing Bulk PDF revision, so clients can reject stale transport events before entity state is overwritten.
This may run in parallel with 1A only if the file ledgers do not overlap. Otherwise merge 1A first.
Expected files: project DTO projection/mapping, job persistence/mapping needed for monotonic versions, endpoint authorisation tests, and the regenerated OpenAPI spec/TypeScript client/checksums via the supported NSwag command (the revision fields change the transport contract; Wave 2's revision-aware reducers consume the generated interfaces, so regeneration belongs to this ledger, plus any minimal web mock/literal fixes needed to compile). Search Import metadata changes owned by PR #2858 and Bulk PDF files owned by PRs #2976/#2979 are consumed after merge or handled in follow-up PRs with their owners. If a shared monotonic contract cannot fit this PR without overlapping those owners, it becomes a named contract PR that blocks Wave 2; timestamp comparison is not a substitute.
Tests: a matrix of project administrator, authorised member, member lacking each family permission, non-member, and removed member. Tests assert absence from the response, not merely hidden HTML. Serialisation and persistence tests prove each version is monotonic across updates.
Wave 2 — feature-local presentation adapters and entity reconciliation¶
Outcome: pure, tested rows for all four job families, with no visible UI change.
Owner/files: new models, pure adapters, selectors, date/progress validation, and specs under
src/services/web/src/app/project/study-management/jobs/, the shared status presenter
(generalising the Bulk PDF StatusView contract per the approved design) under
src/services/web/src/app/project/study-management/shared/status/**, plus the existing
reducers/specs under:
src/services/web/src/app/core/state/entities/search-import-job/**;src/services/web/src/app/core/state/entities/bulk-study-update-job/**;src/services/web/src/app/core/state/entities/risk-of-bias-job/**; andsrc/services/web/src/app/core/state/project-details/project-details.reducer{.ts,.spec.ts}— it independently overwrites the per-family job ID membership arrays on every normalised project payload, so membership reconciliation must land there as well as in the three job reducers (the shipped Bulk PDF precedent does both).
Wave 2 also creates the default-off generated studyManagementProcessing runtime flag —
src/charts/syrf-common/env-mapping.yaml, both generators' outputs and checksums, and the
RuntimeFeatureFlagCatalog entry + tests — so Waves 3 and 4 can gate their contextual
Processing links on the typed flag selector, and Wave 5 consumes the flag it no longer mints.
Wave 2 remains invisible: the flag defaults false everywhere and no UI reads it yet.
Reducers reject lower revisions before entity state changes. Historical rows without a revision
may seed an initially empty entity, but once a versioned value is stored, an unversioned or lower
revision cannot overwrite it. Per-entity revisions cannot order an omission: a snapshot that
merely lacks a job presents no lower revision to reject, yet removals must still happen on
permission revocation or deletion. Each of the three reducers therefore reconciles snapshot
membership at the snapshot level using the enclosing project's audit.version (the shipped Bulk
PDF precedent): an older snapshot may never remove a job a newer snapshot created, and a newer
snapshot's omissions are authoritative. Tests include the omitted-job race (HTTP response started
before a job existed arriving after the newer SignalR snapshot must not delete the new row).
Do not place generic code in global theme/shared folders.
The three status-container tokens the approved design needs — --syrf-warning-container,
--syrf-warning-accent, --syrf-info-container — are added by one separate serial
theme-contract PR (PR #3059, sole owner of the global theme file per the Material 3 programme
rule) before Waves 3–5 consume them; UI waves never define them locally and never redefine
existing --syrf-* names.
Tests: every enum value, unknown future numeric/string value, invalid/missing timestamp, zero/missing total, no-op Bulk Update, error truncation input, missing context/person, stable key, and chronological sort. Reducer/effect tests deliver a delayed HTTP payload after a newer SignalR payload and prove the newer entity survives; adapter tests remain transport-order independent.
Wave 3 — Library vertical slice¶
Outcome: an authorised user can discover, launch, leave, refresh, and revisit Bulk Study Update
through a truthful current/latest summary and a link to canonical Processing history. Because the
processing route only exists once Wave 5 merges and its flag is enabled, the View in
Processing link is gated on route availability (Wave 5 merged and studyManagementProcessing
on); intermediate releases omit the link rather than shipping a dead one.
Owner/files:
src/services/web/src/app/studies/study-table/**;src/services/web/src/app/project/project-overview/update-studies/**;- new contextual summary components under
project/study-management/bulk-study-update/; and - focused Library E2E fixtures/specs.
No Searches, project navigation, shared theme, or Processing route files are included.
Acceptance/tests: action visibility by permission, upload success/failure, refresh/re-entry, exact status/progress/result, year-one regression, initiator availability, error disclosure, empty/summary states, Processing deep link when the route is available plus link absence when it is not, keyboard dialog focus, live announcement throttling, 320px/card layout, 200–400% zoom, and no page-level overflow. Per the approved design and UX mandate: Bulk study update becomes a visible page action beside Add systematic search (out of the table overflow menu), the contextual summary is the shared status strip with "View in Processing" in its standard position, and touch targets reach ≥44px below 600px.
After this slice is accepted, PR #2990 can be closed as superseded with a link to the replacement.
Wave 4 — Searches consistency¶
Outcome: search upload/import, Risk of Bias, and Bulk PDF use the same status/time/error/state language, show current/latest contextual summaries, link to filtered Processing history (gated on route availability exactly as in Wave 3), and keep their context-specific commands.
Owner/files: project-admin/systematic-searches/** plus the sibling directories implementing
its job children — project-admin/bulk-pdf-upload/** and project-admin/risk-of-bias-job-table/**,
both imported by systematic-searches.component.ts. Do not touch Library, global navigation,
Admin monitoring, or M3/theme sources.
Wave 4 can run in parallel with Wave 3 after adapters merge because the file ledgers are disjoint. It must sequence behind applicable #2858 and #2976/#2979 changes.
Tests: create/import state transitions, warning/error disclosure, per-search navigation, Bulk PDF flag/permission/capacity/cancel/retry/report regression, RoB status exhaustiveness, missing row fields, aggregate reconnect, keyboard/card/table semantics, zoom, and focus-preserving live updates. This wave also reconciles the unknown-status fallback (design disposition O5): Bulk PDF's "Status unavailable" aligns to the shared "Unknown status (n)" rule with that surface's owner.
Wave 5 — read-only Processing destination¶
Outcome: authorised users can scan the canonical permitted operation history and return to the owning Library or Searches context.
Owner/files:
- new
project/study-management/processing-page/**components/specs; - one
processingchild inproject.routes.tsplus the canonicalsearcheschild/legacy redirect; - the Study management group entries in the project navigation model/component;
- the app-shell skip link required by design decision D17:
app.component.{html,scss,spec.ts}limited strictly to a skip link preceding the global navigation and targeting the routed main content (Wave 5 is the sole route/navigation writer, so this shell touch belongs here); and - focused routing/navigation/permission and generated-configuration specs.
The studyManagementProcessing flag itself (env-mapping, generated outputs, and the
RuntimeFeatureFlagCatalog entry with its tests) is created in Wave 2 so Waves ¾ can gate
their contextual links on the typed selector; Wave 5 consumes the existing flag for route
visibility and access, and never re-declares it.
This is the only wave that edits project routes/navigation. It consumes PR #2994's merged adaptive
navigation and supported tokens rather than modifying theme files, and consumes the Wave 2
generated flag. Route visibility and the guard
are decided by explicit PermissionReport capabilities, never by whether the job collections are
empty: an unauthorised family and an authorised-but-idle family both project as [], so emptiness
cannot distinguish "hide the route" from "show the empty state". The flag itself is created in
Wave 2 (env-mapping + pnpm run generate:env-blocks in src/charts/syrf-common, pnpm run
generate:flags in src/services/web, all generated files/checksums, catalog entry); its default
stays false in preview, staging, and production until the release gate changes it.
For the MVP, filters and chronological sorting operate on the current ProjectDetails aggregate, then render 25 rows at a time through a focus-preserving Show more control. This bounds DOM/render cost only. Record payload size/retention with a 250-row fixture; create a dedicated paged history API follow-up if the measured aggregate is not acceptable.
Tests: direct URL/guard, Back and native link behaviour, route visibility for every permission combination, flag-off direct URL and navigation absence, generated default/env parsing, filter/reset/result count, contextual deep links that reveal a completed referenced operation despite the Active + Needs attention default, 250-row filter/sort/Show more behaviour, raw/unknown statuses, row-level missing fields, whole-aggregate load/reconnect failure, stable focus during SignalR updates, empty/loading/reconnecting/completed states, mobile/zoom reflow, screen-reader names/result-count announcement, and colour-independent meaning. Per the approved design: grouped list (Needs attention → Active → Completed and other outcomes), deferred resort while a row is expanded with its status line, deep-link reveal notice with Clear and focus on the revealed row, bounded error lists (first 10 of N with Show all), raw enum + value + copyable job ID in row detail, forced-colors block, and skip link.
Wave 6 — capability-specific command follow-ups (optional)¶
One backend+UI PR per job family may add cancel/retry only after idempotency, partial-write cleanup, permissions, audit trail, terminal-state races, and recovery behaviour are approved. PR #2612 may supply Search Import cancellation semantics. There is no generic command PR.
Wave 7 — final consistency and release audit¶
Outcome: the three Studies destinations behave as one programme without a big-bang merge.
Audit all job statuses, progress units, errors, names, timestamps, permissions, entry points, routes, responsive breakpoints, tokens, announcements, telemetry, support identifiers, and user guide copy. Admin monitoring remains a separate successor to #2528/#2562 and is tested for shared language only after PR #3010.
Parallel ownership matrix¶
| Slice | Exclusive source ownership | May run with |
|---|---|---|
| 1A contract | Bulk Update domain/API mapping | 1B only with confirmed disjoint files |
| 1B permission | Project projection/policy tests | 1A with disjoint files |
| 2 adapters | new Study Management adapter folder | backend slices |
| 3 Library | study table, update dialog, contextual Bulk Update summary | Wave 4 after Wave 2 |
| 4 Searches | systematic-searches plus its sibling job-child directories (bulk-pdf-upload, risk-of-bias-job-table) | Wave 3 after Wave 2 |
| 5 Processing route | new page, project route/navigation | no other route/navigation writer |
| 6 commands | one job family end-to-end | other family only with no common projection files |
Before every mutation, the owner records worktree, branch, base SHA, exact changed paths, and active conflicting PRs. If another task owns a path, sequence behind it; do not create a duplicate writer or “resolve later” merge conflict.
Active PR ownership at the plan date¶
| PR | Active ownership relevant to this programme | Coordination decision |
|---|---|---|
| #2990 | project-admin/bulk-study-update-job-history/** and systematic-searches.component.{ts,html} |
Preserve untouched as prototype evidence; no second writer |
| #2528 | historical wide overlap across Study Management, Systematic Searches, navigation/routes, selectors, and duplicated project/admin job components | Do not build on or push its worktree; replace only through approved slices |
| #2858 | Search Import metadata, saga/notifier, and systematic-search-upload-flow.md |
Sequence any overlapping Search Import contract work |
| #2887 / #2900 | Bulk Update recovery tooling/ADR; #2900 also owns CLAUDE.md |
Consume operational decisions; do not edit recovery or agent-context files |
| #2976 / #2979 | Bulk PDF backend/agent/runtime/hosting plans; #2979 also owns CLAUDE.md |
Consume after merge; no competing Bulk PDF backend writer |
| #2994 | merged M3 plan, adaptive-navigation, theme contract/scripts/tests | Consume the merged contract; do not redesign its shared primitives |
| #2992 | About and shared Page/SideNav work | No Study Management edits; consume merged adaptive primitive if applicable |
| #3010 | Admin Console source and E2E | Admin monitoring stays separate and sequences after it |
The exact changed-file list must be refreshed from GitHub immediately before each wave because an open PR can change after this plan is approved.
Design-review slices (November 2026)¶
A design review of the whole study-management experience (2 November 2026, against main @ d2fd9d440f)
found the gaps below. Chris decided its questions on 3 November (see the design decision log, R1–R13) and
approved slices 1–8 and 11. He then chose the Studies structure (R3, C+B′). Slices 9 (study detail and PDF fix)
and 13 (Studies destination) are now planned as the workspace slices W1–W6 in
Studies workspace (C+B′) below. Slice 12 (m3e adoption) is planned as
S12 in wave 7 below. Slice 10 (reviewer PDF states) still
waits for its owner and isn't planned here.
Rollout rule (R10)¶
- New behaviour is built and tested behind a feature flag that's off in production until its fixes and validation are complete. The flag gates the server behaviour as well as the UI, not only the page.
- Pure corrections of behaviour that's already shipped need no flag. Each PR still states its flag decision.
- Production behaviour is preserved, and no unrelated feature is switched off.
searchImportWizardstays off in production until slice 1 has merged; slice 2 is considered alongside.studyManagementProcessingstays off in production until slices 3–5 and the C7.2 rendered walkthrough are complete. In staging it is already on through a staging flag setting that predates these gates (runtime snapshot revision 83, 3 November 2026).- Turning a production flag on, or promoting to production, is a separate decision for Chris.
Today both searchImportWizard and studyManagementProcessing are read only by the web app. Any slice
that adds server behaviour under one of them also adds a server gate (FeatureGated or an equivalent
check).
Slices¶
Each slice is one PR. The finding IDs (SM-n) refer to the November 2026 design review report (https://claude.ai/artifact/MKnyMXa1iDvdfMoNf12sA4, private to the owner). Each slice's heading names its findings, and its acceptance criteria restate what they require.
Slice 1 · Wizard PDF failure truth (SM-1, SM-10)¶
Flag: inside searchImportWizard. Web only. The standalone bulk PDF dialog keeps its behaviour.
- A server-side
Failed,Infected,ExpiredorAbandonedjob shows the same headline, reason, access summary and "N of M files had been uploaded" as the standalone result panel. That presentation moves intobulk-pdf-upload/coreand both surfaces use it. - "No PDFs reached final storage" appears only when that's true, i.e. when nothing reached the server.
- "Retry PDFs" is offered only when the server's
canRetryis true. Otherwise the wizard says what to do next and starts no new session. - An
Infectedjob lists the infected files and offers "Choose another folder". - A match in the
readystate reads "Matched, waiting for you". Onlyconfirmedreads "Matched, confirmed by you". - Verified by: unit specs for each server status, with
canRetrytrue and false; an E2E for a server-side failure (today only a pre-session failure is covered).
Slice 2 · One PDF vocabulary (SM-4, SM-21)¶
Flag: none (correction).
- One stage and count table in
bulk-pdf-upload/coregives every surface the same words for the same server stage and the same counts: the wizard, the Searches row, the operations card and Processing. - "Uploaded", "attached", "skipped" and "missing a PDF" each mean one thing everywhere. A Skip-existing re-upload shows the same uploaded count in the wizard and on the row.
- A parity spec covers every
BulkPdfUploadJobStatusvalue. - The preview explains shared files. There's one cancel pattern,
Expiredshows itsfailureReason, andQueuedisn't shown beforeUploaded.
Slice 3 · Library usable in context (SM-7, part of SM-12)¶
Flag: none (correction of the approved Library).
- A visible filter bar: search, PDF state, inclusion, and "your status" in stage scope.
- The search filter is kept in the URL, so "View studies" from a search row opens the Library filtered to that search.
- The page title is "Library". A failed load shows an in-page error state with Retry, not only a snackbar.
- Links are absolute, so they also work on the stage route. The import call to action is gated on
importSearch, notdesign.
Slice 4 · Searches row and notes (SM-5, SM-6, SM-20; R6)¶
Flag: none (correction).
- One Status column: the import state first, then one short PDF line (for example "2 attached · verified"). Fuller detail stays in the row expander. The wizard adds only its call to action.
- The import-notes badge is a button that opens the same notes panel the wizard shows, reachable by keyboard, touch and screen reader.
- Headers in sentence case that don't wrap mid-word.
- A delete that cancels an import shows a neutral "Cancelled for deletion" state, not a red error, and the row shows a deleting state.
- Rebased on the Codex #4589 truncation wording.
Slice 5 · Processing defaults (SM-11; R7)¶
Flag: inside studyManagementProcessing. Web only.
- When nothing is active, the default view shows the latest outcomes, not "No operations match these filters".
- A failure leaves Needs attention when a later attempt for the same search completes, or when no action remains (for example, retry authority has lapsed). It stays in history, still shown as failed.
- Retry hints appear only where the reader can act. The initiator column is omitted for families that never record one. Headers are in sentence case.
Slice 6 · Gate alignment (SM-12)¶
Flag: none (correction). No permission is widened.
- Every UI gate matches its route's
canMatchand the server policy: Searches delete (removeSearch), the stage Review row (stages[id].review), the membership guard (editMembershiptypo), data export, admin options, and the Study management group with no visible children. - A refused project route says the page needs other permissions and keeps the project around it.
- Specs for an admin, an import-only member and an ordinary member, with the flags on and off.
Slice 7 · Search read security (SM-13; R4)¶
Flag: none (follow-on R12), after the security review. Detail stays out of public issues until confirmed. 7b's new group-granted search-overview permission is the rollout control for the new behaviour: no group holds it by default, so nothing new is visible until an administrator grants it.
- 7a: an HTTP-level test reproduces or rules out the read of a project's searches by a non-member, under both legacy and enforced authority.
- 7b, if 7a confirms it: the full search read requires the search-management permissions. Holders of the new group-granted search overview (off by default) get only names and study counts. Non-members of private projects are refused, and project details are filtered the same way.
- A separate security review on 7b before merge.
Slice 8 · Per-study PDF state (SM-2; R1)¶
Flag: a new runtime flag, studyPdfStatus, default off everywhere.
Outcome. SyRF knows whether each study's PDF is really available. It keeps that answer for each study (in
pmStudyPdfAvailability, keyed by study), and every reviewer sees the same answer, without the browser deciding it. A stored path alone never counts as a PDF.
Decided by Chris (R1), restated.
- The server tracks each study's PDF status.
- Retrieving a study for review returns at once. If the status is unconfirmed, the server checks the authoritative storage in the background, saves the result on the study, and pushes it to subscribed clients over SignalR.
- The browser sends no check request and never sets the shared status.
- A confirmed result is reused.
- "Not yet confirmed" or "Checking" is distinct from available and unavailable. Unchecked never means "not uploaded".
- A backfill is optional.
Design decisions. These are implementation considerations, settled here.
- Flag.
studyPdfStatusis added withpnpm feature:new(hostsapi), default off everywhere. With it off, nothing below runs or is written, and every response is as today. It gates: - the trigger;
- the check worker;
- reads and writes of the new collection;
- the new DTO field (null when off);
- the hub subscription method (refused when off);
- the web UI.
- Stored state, outside the study document. A new collection,
pmStudyPdfAvailability, holds one document per study (_id= study id, plusProjectId): Status:Available|Unavailable|Unverified|External;CheckedAt;CheckedLinkKey: a fingerprint of the link inputs (PdfRelativePath,BulkPdfDeliveredPathand the base they resolve against);CheckStartedAt, the claim lease.
Why a separate collection: study saves are whole-document ReplaceOnes under the Audit.Version and
StudyWriteGuard rules. A field on pmStudy would either be written back stale by a concurrent review save or,
if its update bumped Audit.Version, make those saves conflict. Keeping the result outside the study means:
- availability writes never touch pmStudy, never bump Audit.Version and never take the study write lock;
- the claim and the result are conditional updates on the availability document alone.
A study with no availability document, or whose CheckedLinkKey no longer matches, reads as Not yet
confirmed. There's no migration.
- Modelling and index: the availability document is an aggregate root (IAggregateRoot<Guid>, id = study
id), so the v1 path can reuse MongoContext.GetCachedCollectionChangeStream<TAggregateRoot, TId>. One index on
ProjectId (with Status) serves the Library's PDF filter and project-scoped reads.
- Code with the flag off never reads or writes the collection. The collection and its index stay until the flag
is retired, then go in the clean-up PR (.claude/rules/feature-flags.md).
- Deleted studies: reads join on the live study (by ProjectId, through StudyIdLoad.UnlessDeleted, because a
moved study keeps its _id), so an availability document for a deleted, moved or erased study is ignored on read.
Nothing removes it (decided for 8a): a study restored after a search deletion keeps its link, so its status still
counts. Documents left behind are removed when the flag is retired.
- Locked studies (ADR-020): a check is still allowed, because it records a fact about storage and changes
nothing on the study. W2's set and clear actions on a locked or deleted study are refused, as Approve is today
(409 or 410).
3. States as the client sees them (proposed labels):
- No PDF (no link inputs);
- Not yet confirmed;
- Checking (a fresh lease, under 2 minutes old);
- Available;
- Unavailable;
- Couldn't check (Unverified);
- Hosted elsewhere (External).
4. What is checked. Only links built from SyRF's configured PDF bases (PdfSettings.BaseUrl, and
BulkPdfStorage.PublicBaseUrl through BulkPdfLinkService).
- An external http… PdfRelativePath is recorded as External and never fetched by the server, so it can't
be used to make the server fetch arbitrary URLs.
- The check reuses the pdf-agent's proven method (PdfAvailabilityVerifier): an HTTP GET with
Range: bytes=0-4, a 10 s timeout, no redirects and no cookies. A 200 or 206 with a %PDF- signature means
Available. Another status, a wrong range or no signature means Unavailable. A timeout or transport error means
Unverified.
- The verifier's core moves into a shared library, so the API and the agent use one implementation.
- No PDF is parsed, so the isolated-checker work in #4683 and #3947 isn't needed for this.
5. Trigger. These start a check when the study's status is Not yet confirmed or due for a recheck:
- GetNextStudyForReview, GetStudyForReview, GetNextStudyForReconciliation and
GetStudyForReconciliation in ReviewController;
- the submit endpoints that return the next study.
The response returns immediately and carries the current status. The Library list and single-study reads only
report the stored status. They never trigger a check (MVP).
6. Dedup and rate.
- Each API pod has an in-process queue, following GravatarImportQueue: TryAdd dedup per study, a bounded
channel of 256 (when it's full, the offer is dropped and the next retrieval tries again), and a worker running
4 checks at a time.
- Across pods, a worker first claims the study with a conditional update (CheckStartedAt absent or older than
the 2-minute lease). If the claim fails, it skips the check.
7. Recheck rules.
- Available: reused until the link key changes. It's never rechecked on a timer, which is Chris's central
requirement.
- Unavailable: rechecked on retrieval once it's more than 24 hours old, in case the file is added later.
- Unverified: rechecked once it's more than 15 minutes old.
- External: never rechecked unless the link key changes.
8. Push. A new hub method, SubscribeToStudyPdfStatus(projectId, stageId, studyId), under
ProjectViewStudiesSignalRPolicy and the same GetReviewContextAsync checks as presence. It sends
StudyPdfStatusUpdated { studyId, status, checkedAt }. It follows the presence pattern on both realtime paths,
because realtimePipelineV2 isn't on in any environment:
- Every API pod must see a result that another pod wrote, so the push comes from the new collection's own change
stream, not from the worker's memory.
- v1: an Rx projection of the availability document's entity stream (the same MongoContext.WatchAsync
mechanism the study stream uses), with DistinctUntilChanged on the status and
TakeUntil(ProjectAccessRevoked), under current authority when enforced.
- v2: a ChangeFeedReader for pmStudyPdfAvailability, registered with the other feeds, and a new
RealtimeTopic kind, StudyPdfStatus. The dispatcher flushes with the same per-subscriber DecideAsync
authority check.
It isn't gated on active reviewer tracking. The payload carries no URL and no file content. 9. Web. With the flag on: - The review PDF panel takes its state from the server status and its pushed updates. It never uses the HEAD probe's result as the status. The viewer still loads the file. If that load fails, it shows the load error without changing the shared status. - The Library PDF cell shows the stored status, with an accessible label for each state.
With the flag off, everything is as today, including the HEAD probe. 10. No backfill in this slice. Older studies resolve on demand.
Delivery: two PRs.
- 8a (server). The flag, the
pmStudyPdfAvailabilitycollection and its index, the shared verifier, the trigger, the queue and worker, the claim and the DTO field. - 8b (push and UI). The hub method and both realtime paths (the v1 entity stream and the v2
ChangeFeedReaderforpmStudyPdfAvailability), the web subscription, the review panel and the Library cell.
Workspace slice W2 adds two things to this design: a project-scoped status subscription, with the same authority checks, for a workspace with no stage (it shipped with 8b); and a check queued after a set, replace or accept (W2, #4747).
Acceptance criteria.
- With
studyPdfStatusoff, no check runs and nothing is written topmStudyPdfAvailability. Responses match today's except for a null status field, and a hub subscription is refused. Tested on, off, on, including data written while the flag was on. - Retrieving a study for review with an unconfirmed status returns without waiting for the check. Measured in a test with a slow verifier stub: the response arrives before the stub completes.
- Concurrent retrievals of one study cause one check. Tested in-pod through the queue, and across pods through the claim.
- An Available result is reused: no second check while the link key is unchanged. Changing the path makes it Not yet confirmed, and the next retrieval checks again.
- Unavailable rechecks after 24 hours, Unverified after 15 minutes. Tested with a clock.
- An external link is recorded as
Externaland is never fetched. Asserted with an HTTP stub that fails the test if it's called. - Redirects aren't followed, and only the first 5 bytes are requested.
- A subscribed client receives
StudyPdfStatusUpdatedwhen the check completes, on both the v1 and the v2 paths. A client whose project access is revoked stops receiving updates. - The review panel shows Checking, then Available or Unavailable, from the push alone. A browser fetch failure never changes the stored status.
- The Library shows the stored states with accessible labels. Listing the Library never triggers a check.
- A check on a locked study (ADR-020, including a bulk lock) succeeds and writes nothing to
pmStudy. Tested by asserting the study document and itsAudit.Versionare unchanged. - An availability document for a deletion-marked, moved or erased study is ignored on read. A moved study's document counts again after a restore. Documents left behind are removed when the flag is retired.
- Docs: the feature doc is updated, and
pmStudyPdfAvailabilityis added todocs/architecture/mongodb-reference.md, in the same PRs.
Slice 11 · Language, accessibility and docs (SM-14, SM-16, SM-17, SM-19; R8, R9)¶
Flag: none (corrections).
- "Import a search" names the action whether
searchImportWizardis on or off. The legacy dialog gets no other work. - Overview's study count is the Library total and links to the Library. Imported-record counts stay per search on Searches.
- The ordinal date pipe calls
getDate(), so "2nd November". - The aria and focus fixes listed under SM-14, help links to the right user-guide sections, and the glossary in the docs and user guide.
Studies workspace (C+B′)¶
Chris chose C+B′ on 3 November 2026 (R3): "Studies is one page with a light Searches pane for choosing a search
and seeing its status. Importing, adding PDFs, history and deleting live on a Searches page reached from the pane,
with a back arrow. One Activity sheet covers both." The design is drawn as option C+B′ on the owner's prototype
board, Study-Management-Trial.dc.html (3 November). The board's words are quoted below as the proposed copy.
Flag. A new runtime flag, studiesWorkspace, default off everywhere. It gates the workspace UI, the new routes
and any server behaviour these slices add. With it off, today's Library, Searches and (flagged) Processing pages
behave exactly as now. Each slice is built and tested on, off, on.
studiesWorkspace doesn't depend on studyManagementProcessing. With the workspace on, /processing redirects to
Studies with the Activity sheet open whatever the Processing flag says, and the sheet is gated by studiesWorkspace
alone. studyManagementProcessing is retired once studiesWorkspace settles.
Routes with the flag on.
/projects/:id/studiesis the workspace./projects/:id/searchesis the Searches page for managing. It's for managers only, matched by today's route rule (studyManagementSearchesCanMatch)./projects/:id/processingredirects to/studies, with the Activity sheet open.- The stage scope,
/studies/stage/:stageId, keeps working.
Who's who. "Managers" are readers who hold today's search-management permissions (the Searches route rule). "Search-name holders" are members of a group granted slice 7b's search overview. Everyone else, including a default reviewer, sees no searches at all.
W1 · Workspace shell¶
Outcome: with the flag on, Studies is one page. The study list sits in the centre, a light Searches pane beside it chooses a search and shows its status, and a study's detail opens beside the list.
/studiesshows the workspace, titled "Studies". Its lede for managers is "Every study in this project, with the searches they came from. Choose a search to filter the list; Manage searches has their imports and PDFs." For everyone else it's "Every study in this project. Open one to see its details and PDF." In a stage scope, the lede names the stage.- The Searches pane, by role:
- Managers: titled "Searches", with "Import a search" as the board draws it (it opens the same import as the Searches page), then "All studies" with the project total, then one row per search. Each row shows the name and "N studies" with one status: "Importing…", "PDF upload running", "PDF upload stopped" or "N without a confirmed PDF". "Manage searches" at the foot opens the Searches page.
- Search-name holders: titled "Searches (names and counts)", with names and "N studies" only. No status, no import and no Manage searches. The data comes from slice 7b's server subset, never from the full search read.
- Everyone else: no pane, no Search column, no Search filter, and no Search field in the study detail.
- Choosing a search filters the list and keeps the search in the URL (slice 3's filter), so a link opens the same view. Choosing a search clears the PDF filter. "All studies" clears the search.
- The pane sits inline from 840 px. "Hide searches" and "Show searches" toggle it. Below 1,440 px it steps aside while a study is open, unless the reader shows it again. While it's hidden, the filter bar gains a "Search" chip with the same choices.
- The list's columns are Study (the title, then authors · year · journal), Search and PDF. Search shows only to
managers and search-name holders, from 1,240 px and while no study is open. In a stage scope, a "Your
" column shows Start or Resume, or Completed, Finished, Not started or "—". Any further columns for reviewers follow R14, decided below with R15 (asked as D2 and D3 on the progress page). - The filter bar has "Search titles and authors", a PDF filter, and Stage (managers) or My studies (everyone else). The PDF filter offers Any PDF status, Without a confirmed PDF, Available, Not yet confirmed, Unavailable and No PDF, plus Proposed PDF waiting for holders of R2's Set. Managers in a stage scope also get "Only mine". In a My studies scope, To do, In progress and Completed show with their counts. A count line follows the list, for example "No studies match these filters."
- Until slice 8b is on (shipped behind
studyPdfStatus), the PDF column shows today's Library PDF cell. WithstudyPdfStatuson, it shows the stored state. - Header actions: in a stage scope, "Review next study". For managers, "Bulk study update", "Activity" (W3), and "Import a search" while the pane is hidden.
- Opening a study shows its detail beside the list from 1,240 px, with a Close button. Narrower, the detail replaces the list, with "Back to the list". Focus moves to the detail and returns to the study's row on close.
- Phones, below 600 px: the table becomes a list of rows (title, authors, PDF state). Below 840 px a "Searches" button opens the pane as its own screen, with "Back to studies". More than two header actions fold into "More actions". Targets are 44 px.
- With the flag off,
/studiesis today's Library, unchanged. Route and component specs cover on, off, on.
W1's scope as built (decided 3 November 2026, PR #4718). W1 ships ahead of slices 4, 7b and 8, so:
- No PDF filter and no "N without a confirmed PDF". A stored path isn't proof of a file (SM-2), so criterion 6's
PDF filter and criterion 2's PDF count wait for slice 8. The pane rows show "N studies" plus the import or
upload state, and the PDF column stays as today. The code leaves a seam for both, pointing at slice 8.
Done by #4719 (PR #4764), with
studiesWorkspaceandstudyPdfStatusboth on: - One state mapping. The filter and the counts classify each study with
StudyPdfStatusRules.ReportonBulkPdfLinkService.ResolveCheckTarget, the mapping the PDF column and the study detail use (StudyPdfStatusClassifier, beside slice 8's reporter). Options match throughStudyPdfStatusFilterRules: Without a confirmed PDF is every state but Available (No PDF, Couldn't check and Hosted elsewhere included); Not yet confirmed includes Checking; Available, Unavailable and No PDF match only themselves. Couldn't check and Hosted elsewhere have no option of their own. Deletion-marked studies match none. The reporter now treats an availability document claimed under another project as absent, so its by-id read agrees with the classifier's by-project read. - The list.
StudyFilters.PdfStatusFilter(StudyPdfStatusFilter, append-only so slice 9 can add Proposed PDF waiting) narrowsPOST api/projects/{id}/studies. The page keeps the sort, text search and other filters; it is the same query limited to the matching ids (or to every id but the rest, whichever list is shorter), andtotalis unchanged. Available, Unavailable and Without a confirmed PDF read that status's availability documents first (the 0034 index) and then only those candidate studies' link inputs; Not yet confirmed and No PDF read every listed study's link inputs. Measured on a synthetic 20,000-study project in the replica-set test (StudyControllerPdfFilterTests): unfiltered 68 ms; Unavailable 95 ms; Available 402 ms; No PDF 352 ms; Not yet confirmed 521 ms; Without a confirmed PDF 584 ms (half the studies Available, the worst case for the first read). - The gate. The server honours the field only while
studiesWorkspacedecides on and the PDF status admission is open (studyPdfStatusand its 0034 index). Otherwise the list answers409 pdf-status-filter-unavailableand reads no availability. The web shows the filter only in the workspace with both flags on, keeps it in the URL as;pdfStatus=<name>, sends it only then, and drops it on the canonical rewrite otherwise. On a 409 it resets the filter to Any, hides it until the next page load and announces "PDF status isn't available, so the PDF filter is off." politely. - The pane.
GET api/projects/{id}/studies/pdf-status/search-counts(FeatureGated(StudiesWorkspace), the search-read policy, so managers only) returns each search'swithoutConfirmedPdfas ids and numbers, orpdfStatusOn: falsewith no availability read. A manager's settled row reads "812 studies · 85 without a confirmed PDF", 0 included. The counts are fetched when the workspace opens and again when a search import or bulk PDF upload finishes, after a W2 set, clear or accept (studyPdfLinkChanged) and after a search is deleted. Other list refreshes ("Try again", a reject, a proposal) don't refetch them, since each fetch reads every study's link inputs, and later triggers are coalesced over 500 ms. They aren't live per check. - Choosing. Choosing a search (the pane, "All studies" or the Search filter) resets the PDF filter to Any in the same state change; a link naming both keeps both. Choosing a PDF option closes the open study.
- With a My studies tab (#4720). A list request may carry
myStudiesTabandpdfStatusFiltertogether. The PDF filter's reads (the link inputs, the restricted page and count) take the applied tab (MyStudiesTabSelection) and AND itsMyStageWorkFiltersfilter with the id restriction, so the page and its count are the intersection (StudyControllerPdfFilterMyStudiesTests, every tab with every option). Refusals keep their own codes and run in this order: the tab's 400 (my-studies-tab-refused) and 404 (my-studies-stage-not-found) first, because they are about the request itself, then the PDF filter's 409. WithstudiesWorkspaceoff the tab is ignored, so only the 409 applies. - Not in #4719: Proposed PDF waiting (slice 9), the Searches page's and Overview's "N without a confirmed PDF"
(built by #4770, reusing the counts endpoint and the
pdfStatusparameter; see its subsection under #4719), and live counts. The table's CSV download has no server route on main (/datatables/getstudiescsvis served nowhere and nothing calls the client method), so there was no export to apply the filter to. - The pane's row status comes from main's job selectors (
selectStudyJobRowsForCurrentProject, with R7's cleared failures) andpresentStudyJobStatus, which also words the status for screen readers. It moves to slice 4's presenter (#4714) once that merges. - The stage scope is today's. It keeps the stage route, slice 3's "Your status" filter and today's review cell.
The board's To do, In progress and Completed tabs with counts, "assigned means to do" and "Only mine" become a
later slice with a server part. Built by #4720 (server #4765, web #4775): a reader who may review the stage gets
the tabs, the counts, the "Your
" column and the board's lede; managers get "Only mine"; "Your status" stays for managers with Only mine off and for readers who may not review the stage. See #4720 "Web as built" below. - The compact columns are the default. Study, Search and PDF show first. "Select columns…", sorting (the Study column sorts by title) and pagination stay in the table menu, so no Library column is lost.
Also settled: one pane row is current only when exactly one search is chosen (slice 3's filter is multi-select);
the filter bar keeps "Inclusion status"; and the layout is Angular Material plus the workspace's own CSS at the
board's widths, read from the window, because m3e-angular has no list-detail component. While the pane is out of
sight, the Search chip is slice 3's Search filter. Below 1,240 px the list uses the Library's existing card layout,
which is what stands in for the board's phone rows. W4 (PR #4724) adds the search-name grant: searchPaneAccess
gives holders of viewSearchOverview the read-only "Searches (names and counts)" pane, managers keep the full pane,
and everyone else has none. The server enforces it while studiesWorkspace is on; with the flag off, or decided at
its safe value, every member gets names and counts as on main, and only managers see the pane. The Library's bulk study update summary stays above the list until W3's Activity sheet
lands, so an update's progress stays in view. A study opens from its title, a real button, rather than from the
whole row, so the row's PDF, web page and review links do only their own job and the keyboard has the same path.
Depends on slices 3 and 6. W1 adds the flag. The search-name holders' pane shipped with W4.
W2 · Study detail¶
Outcome: the study detail shows the study's facts, its server-tracked PDF state, and only the PDF actions the reader's R2 capabilities allow.
- Fields: Authors, Published, Search (only for managers and search-name holders), In project since, and Study ID.
- With
studyPdfStatusoff, the PDF section shows today's link-based state (a PDF link when the study has a path, otherwise "This study has no PDF"), and the R2 actions are keyed on the path. With it on, the section shows slice 8's state and one line about it: - Available: where it came from (for example "Added by PDF upload, 13 May."), then "SyRF has checked the stored file.";
- Checking: "SyRF is checking the stored file. This updates by itself.";
- Unavailable: "The stored file can't be found. Reviewers see “PDF unavailable” on this study.";
- No PDF: "This study has no PDF." (or the record of who removed it);
- Not yet confirmed: "Added before SyRF checked PDFs. SyRF checks it when the study is next opened for review." The board's "when the study is opened" is changed, because under slice 8 decision 5 the Library never starts a check.
- If the state changes while the detail is open, the detail updates from slice 8b's push, with "Updated live when the server finished its check." 8b's hub method takes a stage and the workspace may have none, so W2 adds a project-scoped PDF-status subscription with the same authority checks.
- Actions for holders of R2's Set:
- Available: Open PDF, Replace, Remove link;
- Unavailable: Upload a PDF, Remove link;
- No PDF: Upload a PDF;
- with a proposal waiting: Use proposed PDF and Reject, first.
- Actions for holders of R2's Upload only (reviewers, through
SubmitPdfCorrections): Open PDF and "Propose a different PDF" on Available, and "Propose a PDF" on Unavailable or No PDF. After proposing they see "You proposed( ). An administrator will check it." Others see "A PDF has been proposed and is waiting to be checked." - Dialogs, in the board's words:
- "Remove the PDF from this study?": "The link to
is removed, and the study shows No PDF. The stored file isn't deleted. You can add a PDF again later." Button: "Remove link". - "Use the proposed PDF?": "It becomes this study's PDF. SyRF then checks the stored file." Button: "Use this PDF".
- "Upload a PDF" or "Replace the PDF": "It becomes this study's PDF straight away, then SyRF checks the stored file."
- "Propose a PDF": "An administrator checks it before it becomes the study's PDF. Until then the study keeps its current PDF status."
- Server: new set and clear endpoints, under
studiesWorkspace, follow R2's mapping in the design review handover. Set covers accepting a proposal, attaching, replacing and clearing, including a bulk-delivered PDF. Upload never changes the study's PDF. Clearing never deletes the stored file. The endpoints refuse a locked or deleted study, as Approve does today. Today's default grants are unchanged. With the flag off the endpoints refuse. - A set, replace or accept changes the link key, so the study reads Not yet confirmed. With
studyPdfStatuson, the set endpoint queues a slice 8 check for it, so the reader sees Checking and then the result. This is a trigger added to slice 8 decision 5's list. - "Your work on this study" (reviewers) shows their status in each stage: Not started, In progress, Completed, or Finished by other reviewers. Managers see "Screening", with each stage's result and a "You" line. Start and Resume buttons name their stage ("Resume title/abstract screening"); in a stage scope, only that stage's show. What else a reviewer sees follows R14.
Depends on W1, slice 8 (8a for the state, 8b for the push), and the new set and clear endpoints. R14 must be settled first.
W2 as built (PR #4747). R14 is decided (A). The detail is shown only while studiesWorkspace is on.
- Fields are criterion 1's five; W1's Reference type is gone. "In project since" is the study's
Audit.Created. - The PDF section (
study-detail/study-detail-pdf.ts) shows slice 8's state and the criterion's line withstudyPdfStatuson, and today's link-based state with it off. Two states the board doesn't draw get their own line and Available's actions: Couldn't check ("SyRF couldn't reach the stored file…") and Hosted elsewhere ("This PDF is on another website. SyRF doesn't check links to other websites."). "Added by PDF upload,." comes from the Library list's new PdfDeliveredByBulkUploadAt(null while the workspace is off). There's no "who removed it" record: that needs a field onpmStudy, which older pods can't read (no extra-elements convention), so it's a follow-up. - Live updates use 8b's project-scoped subscription (
SubscribeToProjectStudyPdfStatus, already on main), so W2 adds no hub method. "Updated live…" shows once a push changes the state. - Set (
ApprovePdfCorrections, the Administrator group):PUT …/studies/{id}/pdf(Add a PDF, Replace) andDELETE …/studies/{id}/pdf(Remove link),FeatureGated(StudiesWorkspace); Use proposed PDF is Approve, which with the workspace on now makes the proposal the study's PDF (on main its domain event changes nothing on the study). One conditional update onPdfRelativePaththroughStudyWriteGuardthat movesAudit.Version; locked409, deleted410; storage is never touched. After a set or accept the study reads Not yet confirmed and is offered to slice 8's queue (the added trigger). - Upload (
SubmitPdfCorrections, every member): Propose a PDF stores a proposal (StudyPdfCorrection) and never changes the study's PDF. With the workspace on it must be anhttp(s)web address. The correction DTO gainsStatusandSubmittedByInvestigatorId, so the detail shows only waiting proposals and "You proposed …" to the proposer; others see only that one is waiting. - By link, not by file (D5 = A). The board's dialogs choose a file from the computer. SyRF serves PDFs from
the PDF web server, which only the bulk pipeline writes to (virus scan, validation, copy); a file signed by
UploadSignaturelands in S3, where nothing serves or scans it. So Add, Replace and Propose take a web address (Set also takes a path on SyRF's PDF server, with no%,.., backslash or leading slash), the actions read "Add a PDF" rather than "Upload a PDF", and an uploaded-file proposal can only be rejected. Chris decided (D5 = A, 4 November 2026) that choosing a file becomes its own later slice: a single-file pipeline that scans and then copies, reusing the bulk stages. UploadSignaturenow takes the project and study from the route, the search from the study and the investigator from the signed-in user; body ids are ignored. Its policy staysImportSearch: nothing calls it, and moving it to the all-members Upload capability only makes sense with the single-file pipeline.- First delivery wins is relaxed for Set only (D4 = A, 4 November 2026). Replace and Remove link work on a
bulk-delivered PDF: the same compare-and-set write clears
BulkPdfDelivered*with the new path, and refuses (409) if the path or marker changed since it was read. While a bulk PDF upload for the study's search is unfinished every set or clear is still refused (409), because finalize fails the whole upload when any bound PDF path changes. A later bulk upload may still deliver to a study whose path was set by hand, as to any undelivered study. See Bulk PDF Upload. Approve writes the link before saving the decision, so a refused set never marks the proposal used; if the decision then doesn't stick (a crash, or a concurrent Reject), the re-read logs a warning with both ids. - "Your work on this study" (
study-detail/study-detail-work.ts) is the reader's own status per stage from the Library's fields, with Start or Resume named by the stage (only that stage in a stage scope, only for active stages they may review). Managers (W1's search managers) see "Screening": the study's result, each annotation stage's progress and a "You" line. Nobody else sees others' decisions (R14 = A). - W5's "N proposed PDFs to check" and the "Proposed PDF waiting" filter are built by slice 9 (PR #4785), over a proposals query with its own index; see Slice 9.
W3 · Activity sheet¶
Outcome: one Activity side sheet lists imports, PDF uploads and bulk study updates. With the flag on, it replaces the Processing page.
- "Activity" opens from the Studies header and the Searches page header. Its badge is the number of items that need attention, and its accessible name says so ("Activity, 2 need attention"). On phones it sits in More actions as "Activity (2 need attention)".
/processingredirects to/studieswith the sheet open, whateverstudyManagementProcessingsays. The sheet is gated bystudiesWorkspacealone.- The sheet is titled "Activity", with "Imports, PDF uploads and bulk study updates in this project, newest first."
It has three bands, Needs attention, Running and History, built from today's Processing presenters
(
processing-view.ts,study-job-rows.ts,study-job-status.ts) over the same store. There's no second cache. - Each item reads "
· · " (Completed, Stopped or Running), then its outcome line, then when and who started it. Its actions are only those the reader can use: Open upload (to the search's row on the Searches page), Open search, Retry (while the server's canRetryis true), Start a new upload, Report, and View studies. -
4705's rules apply. A failure leaves Needs attention when a later attempt for the same search completes, or when¶
no action remains. It stays in History as stopped. With nothing running or needing attention, the sheet shows the latest outcome ("Nothing running. Latest: …"), not "No operations match these filters". With nothing visible at all, it says "Nothing has run that you can see yet." - Who sees what follows the chosen C+B′ board: the button and the sheet are for readers holding a job-family
capability beyond plain
viewStudies(viewSearches,importSearch,removeSearch,bulkPdfUploadorbulkUpdateStudies). A default reviewer, withviewStudiesonly, gets no button, and;activity=opendoes nothing for them. Within the sheet the server still filters each family, as on Processing. - It's a modal side sheet. Focus moves to Close on open; Close and Escape return focus to the button that opened it. Changes are announced in a polite live region (D16).
- With
studiesWorkspaceoff,/processingbehaves as today understudyManagementProcessing.
W3 as built (PR #4723).
- The URL. The sheet is open while the URL says
/projects/:id/studies;activity=open(a matrix parameter; it also works in the stage scope). It's the contract the Searches page (W4) links to:['/projects', id, 'studies', { activity: 'open' }]. The list keeps it when it rewrites its own URL, and opening or closing the sheet doesn't fetch the studies again./processingreaches it through a route that matches only with the flag on and whose guard returns the URL tree (processingToActivityRedirect), keeping the query. It isn't aredirectTo: the router runs redirects before guards, so Angular refusesredirectTowithcanMatchin development (NG04014), and a production build would redirect whatever the flag said. A real-navigation spec (project.processing-redirect.spec.ts) covers both flag states. - "View in Processing" links (
?reveal=<kind>:<id>) survive the redirect and the list's URL rewrite. The sheet shows that job, paging History to it if needed, outlined and marked current, with a note; a job that has gone gets a note instead. - The sheet is an Angular Material sidenav in modal
overmode at the end of the page, full screen below 600 px, withrole="dialog". It reads Processing's rows and view (buildProcessingView,bandOfRow); the row actions' gates moved intoprocessing-row-actions.ts, which Processing and the sheet share. - Actions are Processing's, which is why criterion 4 differs. The sheet offers Open upload, Open search, View studies and Report, but not Retry or Start a new upload. Locked decision D2 keeps commands on the page that owns them, so Open upload takes the reader to the search's row on Searches, and a retryable failure says "Retry from Searches", as on Processing (decided 3 November 2026).
- Who sees it follows criterion 6 as revised on 3 November 2026 to match the board: a job-family capability
beyond
viewStudies.
Depends on W1 and slice 5 (#4705).
W4 · Searches page for managing¶
Outcome: managers import, add PDFs, see history and delete on a Searches page reached from the pane, and get back to Studies with one arrow.
- With the flag on,
/searchesis reached from "Manage searches" in the pane. It's titled "Searches", with a back arrow labelled "Back to Studies", and the lede "The searches imported into this project, with their studies and PDFs." - Header actions: "Import a search", "Who can see search names", and "Activity" with its attention count (W3).
- Each search keeps slice 4's row:
- the name;
- "Imported
· N studies", plus "same file as “ ”" when the R5 warning matched; - a progress bar while it imports or uploads;
- one PDF line, for example "PDFs: 640 attached · 631 available · 9 unavailable", "PDFs: upload stopped yesterday at 16:40 · 30 attached so far" or "PDFs: none added";
- the links "View N studies" and "N without a confirmed PDF", which open the workspace filtered;
- "Add PDFs", while the reader holds Upload and Set, nothing is running and some studies lack a confirmed PDF (in the menu on phones);
- a history expander ("Show history for
"); - a More menu: View studies, Add PDFs, Show history, Import notes (n), Delete search.
- The history in a row uses W3's items and rules.
- Open upload, from Activity or Overview, lands on the search's row, expanded and highlighted, with focus on its expander.
- "Who can see search names" opens a dialog: "Lets the groups you choose see this project's search names and how many studies each one has. They can't import, change or delete searches, or see search files or import notes. Off unless you choose a group." Administrators are listed as "Always: they manage searches". Each other group has a checkbox. Saving says who can now see them, or "Only administrators can see searches."
- The grant is R4's new group-granted permission, off by default and enforced on the server. This completes slice 7's second step: 7b's read rule plus a way to grant it. No group holds it by default, and default grants are unchanged. All members is offered, with a warning (R15 = B).
- "Delete “
”?": "Its N studies are removed from this project, with their screening and annotations." Button: "Delete search". Afterwards the workspace drops the search from its pane and clears it as a filter. - With the flag off,
/searchesis today's page, unchanged.
Depends on W1, W3, and slices 4, 6 and 7b. R15 is settled (B).
W4 as built (PR #4724).
- The grant is a new project activity,
ViewSearchOverview, appended toProjectActivitywith an all-emptyResourceSecurity.jsondefault: no group, no all-members grant, no owner and no application users. A project grants it through the existing per-activity permission save. - The server rule sits in
ProjectJobFamilyPermissionFilterand readsstudiesWorkspacethroughIFeatureDecisions(the flag gains a reviewed API consumer contract). With the flag on, a member without a search activity gets search names, the project and study counts only (no audit or living-search link) while holdingViewSearchOverview, and no searches at all otherwise. TheSearchDeletednotification names the search only to the same readers. The restriction applies only while the flag is on: off, or decided at its safe value, project details are exactly slice 7's and every member sees names and counts as on main.GET …/searchesstill needs a search activity, whatever the grant. - The dialog is its own component (
study-management/searches/search-names-visibility-dialog), using the shared dialog's save action and selectors. Groups holding any search-management activity show as "Always: they manage searches"; it is offered to managers who may also change permissions (assignPermissions). - "Activity" links to
/studies;activity=open, W3's URL, without its attention count until W3 lands. "Import a search" is the import button's label from slice 11 (#4711). "Open upload" and the delete dialog are unchanged. - The Overview's study count is the Library total from slice 11 (#4711), so a member who gets no searches still
sees it. With the flag on, the project setup checklist's "Upload your systematic search" step also counts as done
once the Library total is above 0, for a
design-only member who gets no searches. - A failed save in either permissions dialog now reaches it:
updateProjectPermissions$keeps the snackbar's Retry, and emitserrorUpdatingProjectPermissionsonce the snackbar is dismissed without one.
W5 · Links in: review page and Overview¶
Outcome: a reviewer gets from the review page to all their studies in the workspace, and Overview shows what needs attention.
- The review page's My studies panel ends with "See them all in Studies" (the board says "Library"; it's aligned with W1's page title and the glossary). It opens the workspace in that stage's My studies scope, with To do chosen and no other filter. The panel grows into the list (a container transform), which becomes a fade under reduced motion.
- Overview shows a Needs attention card for each stopped PDF upload the reader can see: "PDF upload stopped:
", its outcome line, then "You can retry until - For holders of R2's Set, Overview also shows "N proposed PDFs to check" with "Check proposals", which opens the workspace filtered to Proposed PDF waiting.
- Overview's Studies card (slice 11) links to the workspace.
- With the flag off, the review page and Overview are as today.
Depends on W1, W2 (proposals), W3 (the attention rule) and W4 (the Open upload target).
W5 notes (as built, studiesWorkspace on; with it off both pages are as today):
- My studies link.
stage/stage-review/my-studies-navigatorends with "See them all in Studies", a real link to/projects/:id/studies/stage/:stageId;myStudies=todo: the stage's To do tab (#4720,myStudiesTodoLinkinstudies-workspace-links.ts, throughmyStudiesTabLinkincore/state/ui/my-studies-tabs.ts). No other filter is set: no "Your status", search, title search or PDF filter. (Before #4720 it set "Your status" to the To do states.) The container transform is left out: the app has no view transitions to reuse. - Needs attention card.
project-overview/project-needs-attention-cardlists the jobs in W3'sneeds-attentionband (bandOfRow), for readers who pass W3'scanSeeActivityrule, so a card leaves when its failure does. A stopped upload reads "PDF upload stopped:", its outcome line, "You can retry until - "N proposed PDFs to check" (criterion 3) is built by slice 9 (PR #4785): the card's first item, for R2's Set
holders only (also without
canSeeActivity), with Check proposals. - Studies card (criterion 4). Unchanged: Overview's Studies count already links to
/studies, which is the workspace with the flag on.
W6 · Navigation¶
Outcome: with the flag on, the current project navigation shows Studies as one item, where today it shows the Study management group.
- One "Studies" item, shown with
viewStudies, opens/studies. It's the current item on/studies, the stage scope and/searches. Searches and Processing have no items of their own. - With the flag off, the Study management group is as today, including its
studyManagementProcessingvariant. - The rail drawn on the board (Overview, Studies, Design, Data, People, Settings) isn't part of this slice. Its adoption stays with the navigation stream.
- Specs for an administrator, an import-only member and an ordinary member, with the flags on and off (slice 6's pattern).
Depends on W1–W5.
Decisions for the workspace¶
R14 and R15 are both decided, and so are W2's two questions (4 November 2026, see the W2 notes): D4 = A, Set may replace or clear a bulk-delivered PDF, clearing its delivery marker, but never while a bulk upload for the search is unfinished; D5 = A, choosing a PDF file from the computer becomes its own later slice (a single-file pipeline that scans and then copies, reusing the bulk stages).
- R14 (decided 3 November 2026: A) · What a reviewer sees in the study detail before "What reviewers see" is designed: exactly what the Library shows them today: their own work, the citation and the PDF. Others' decisions stay hidden. The stage setting (R13) comes later. Built in W2.
- R15 (decided 3 November 2026: B) · "Who can see search names" offers All members, with a warning. See the design decision log.
What carries over from merged slices¶
- Slice 3's filter state and URL search filter feed the study list.
- Slice 5's view logic feeds the Activity sheet.
- Slice 6's gates decide who sees each pane, page and action.
- Slice 7's server rules decide what search data each reader gets.
Not in these slices¶
- "What reviewers see", the stage setting for blinding (R13). Its screens aren't designed yet.
- "Reviewers choose studies", a trial setting on the board. Chris hasn't decided it.
- The rail and the wider navigation.
Order¶
- Wave 1: slices 1, 3, 6 and 7a in parallel. Their files are separate except
systematic-searches.component.html, where slice 3 changes only the "View studies" link and slice 6 only the delete button. Slice 3 owns the Library's import call to action; slice 6 doesn't touch it. - Wave 2: slices 2 (after slice 1, which shares
bulk-pdf-upload/core), 4 and 11, and slice 7's fixes (7b). Slice 5 is open as #4705. - Wave 3: slice 8, 8a then 8b. The spec is above.
- Wave 4: W1, then W2, W3, W4 and W5 in parallel where their files allow, then W6.
- Wave 7: slice 12 merges first. Then #4719 (PDF filter and per-search counts), #4720 (My studies tabs) and "Bulk study update in Studies" add their controls in slice 12's pattern, so it doesn't restyle work that's still moving.
Before each wave, re-read the open PRs and the Codex #4589 follow-ups for overlapping files.
Studies wave 7: the remaining slices on the chosen board¶
These five slices finish the Studies workspace (C+B′) work that wave 4 left open. They were specified on 4 November
2026 against main @ 6240880235. Each is one slice with its own flag decision, acceptance criteria and PR split. The
specs' open questions were settled by their recommendations (see the
decision log).
Common rules for all five:
- Flag. All five ride on the existing
studiesWorkspace(Global, default off, not production-eligible). None adds a flag. Slice 9 and #4719 also needstudyPdfStatusor an index, and refuse until it exists. - Server gate. Every new route, field and filter value is gated on
studiesWorkspacethroughIFeatureDecisions, as well as in the UI (R10). Off, the server answers as it does on main. - Web boundaries (#4735). New web code imports only
core/,shared/, generated code and its own area. No slice adds a pair toeslint-boundary-exceptions.json. BSU-b prunes one. - Docs. Each slice's docs ride in its own PR (listed per slice).
- Run web specs with
pnpm exec ng test, never rawvitest.
S12 · m3e on the Studies workspace and the Searches page¶
Flag: studiesWorkspace, off in production. Web only. One PR.
Outcome. With studiesWorkspace on, the Studies workspace and the Searches page use Material 3 Expressive
buttons, icon buttons and progress indicators, as the board draws them. They are 40 px high (44 px below 600 px),
show a visible focus ring, and read correctly in light and dark. With the flag off, nothing changes except four
missing accessible names, which are fixed for everyone.
Design decisions (full inventory of controls in the S12 spec):
- One button pattern. As the notifications Inbox:
matButton="text|tonal|outlined|filled"withmatExpButton, andmatIconButtonwithmatExpIconButton. Each component's SCSS includesm3x.buttonsandm3x.buttons-pressed. Nocolor=attribute on any S12 button: M2 colour variants win over the mixin. matExpButtoncannot be switched off at run time. In markup that renders only with the flag on (workspace, pane, detail, Activity sheet, the Searches flag-on header and back link, the workspace review link), use the directive. In markup shared with flag-off screens (study table, Searches page), bind[matButton]and put a host class on the component (study-table--expressive,searches--expressive). The SCSS appliesm3x.buttonsonly under:host(.…--expressive). This sets the pattern for later slices and goes in the webCLAUDE.md.- Focus. A new
m3x.focus-ringmixin:outline: 2px solid var(--syrf-focus-ring); outline-offset: 2pxon:focus-visible. Never--syrf-accent, which is 1.88:1 on the dark surface. - Targets.
@include m.touch-target(and$square: truefor icon buttons): 40 px from 600 px, 44 px below. It replaces the detail's blanketmin-height: 44px. - Progress. Only the Searches pane row bar and the study table's workspace loading bar become
<mat-exp-progress-indicator mode="indeterminate">. Underprefers-reduced-motion: reducethe library draws a fixed 0–25% segment that reads as progress.global-styles/m3e-angular.scssgains one override (hide the active path, paint a static stripe on the track), as_indeterminate-bar.scssdoes. File an m3e-angular issue and remove the override when a release fixes it. app-study-job-status-chipstays as it is. It has six consumers, several flag-off. Moving it is a follow-up.- Detail emphasis.
StudyPdfAction.emphasisgains'confirm', used only by accept.confirm→ filled,primary→ tonal,secondary→ outlined,danger→ text in--mat-sys-error. The workspace Activity button becomes text. - Searches header, flag on. Import a search (tonal,
add), Who can see search names (text,visibility), Activity (outlined,history). Flag off: today's markup. - Names. Four icon buttons get an
aria-labeland anaria-hiddenicon, in both states: table options, column drawer close, search delete, search More. These are corrections and need no flag. - No new dependency, no upgrade.
package.jsonandpnpm-lock.yamldon't change (m3e stays 0.2.0-alpha.6). Dialog buttons stay with #4599. Nothing imports fromproject/intostudies/.
Flag decision. Flagged behind studiesWorkspace, including the Searches page (new design, not a correction;
restyling one production page early is the drift FEAT-023 rules out). Unflagged: the four names. No server change.
Tested on, off, on.
Acceptance criteria.
- Flag off, nothing moves. The Library and the Searches page render no
.mat-exp-button,.mat-exp-icon-buttonormat-exp-progress-indicator, no--expressivehost class, the Library'smat-progress-bar mode="query", and the Searches header in today's order and appearances. Only the four names differ. Verify: on, off, on cases instudy-table.workspace-rows.spec.ts,systematic-searches.component.spec.tsandsearch-operations-summary.component.spec.ts. - Workspace header. Each action carries
.mat-exp-button[data-size='s']: review and import tonal, searches and bulk outlined, Activity text with its badge. Names,aria-expanded, the badge's hidden count and the phone fold into More actions are unchanged. Verify:studies-workspace.component.spec.ts. - Workspace icon buttons. More actions, Hide searches, Back to the list, Close study details and Close activity
carry
.mat-exp-icon-buttonand keep theiraria-label. Verify: the workspace, pane, detail and sheet specs. - Pane. One
mat-exp-progress-indicator[data-mode='indeterminate']per busy row (replacing the pane spec's twomat-progress-bar),aria-hidden="true"; none when no row is busy. Import a search is tonal and Manage searches is text, bothmatExpButton. Verify:studies-workspace-searches-pane.component.spec.ts. - Detail. For each PDF state, with and without a waiting proposal, for Set and Upload holders: accept filled,
Remove link text with the danger class, Open PDF, Add a PDF and Propose tonal, the rest outlined. Open web page is
text. Work buttons are first tonal, then outlined. Verify:
study-detail-pdf.spec.ts(emphasis per state) andstudies-workspace-detail.component.spec.ts(rendered classes). - Activity sheet. Close is an m3e icon button, "Show N more" a text
matExpButton, item actions outlinedmatExpButton. Items still renderapp-study-job-status-chip. Verify:activity-sheet.component.spec.ts,activity-sheet-item.component.spec.ts. - Study table in the workspace. The loading bar is a
mat-exp-progress-indicatornamed "Loading studies". The review link is an outlinedmatExpButtonwith today's wording. Try again and Clear filters render with thestudy-table--expressivehost class. Verify:study-table.workspace-rows.spec.ts. - Searches page, flag on. Header: Import a search (tonal), Who can see search names (text), Activity
(outlined), each
matExpButtonwith its icon; the back link is a textmatExpButton. The host hassearches--expressive, the operations summary getsexpressivetrue, and no template element has acolorattribute. Every existingdata-testidis present (E2E uses only test ids). Verify:systematic-searches.component.spec.ts,search-operations-summary.component.spec.ts. - Names, both states. The Library's table options and column drawer close, and the Searches delete and More
buttons, have an
aria-labeland anaria-hiddenicon, flag on and off. Verify:study-table.component.spec.ts,systematic-searches.component.spec.ts. - Size and targets. Every touched component's compiled CSS gives its buttons the 40 px M3 height and a 44 px
min-height(square for icon buttons) only below599.98px. The detail no longer forces 44 px at every width. Verify: new compiled-CSS specsrc/global-styles/study-management-m3e.spec.ts; a rendered check at 1,440, 768 and 360 px in the PR. - Visible focus. Every touched component's compiled CSS has
outline: 2px solid var(--syrf-focus-ring)with a 2 px offset on:focus-visible. A keyboard pass shows the ring on every control in both themes. Verify:study-management-m3e.spec.ts; screenshots. - Contrast.
token-contrast-contract.mjsgains--mat-sys-primaryinFOREGROUNDS(4.5:1) and threePAIRINGS: filled (on-primary/primary, 4.5), tonal (on-secondary-container/secondary-container, 4.5), progress (primaryonsecondary-container, 3). They pass in both modes. Verify:syrf-theme.spec.ts,pnpm run check:contrast. - Reduced motion. The compiled
m3e-angular.scsshides the active path of.mat-exp-progress-indicator[data-mode='indeterminate']underprefers-reduced-motion: reduceand paints a static stripe, with no fixed fill left. Determinate bars are unaffected. Verify: newsrc/global-styles/m3e-progress-indicator.spec.ts(built likem3e-dialog-surface.spec.ts); a rendered check with reduced motion emulated. - Dark mode. No touched SCSS uses a literal colour or
--syrf-accent, and every token it references resolves in both layers. Verify:study-management-m3e.spec.ts, the existing "defines every theme token the app references" case insyrf-theme.spec.ts; dark screenshots. - Forced colours. Buttons keep a visible boundary and the indicator uses system colours. Verify: a rendered check with forced colours emulated (Chromium).
- Guards.
pnpm run lintpasses with no new suppression and no new pair ineslint-boundary-exceptions.json;pnpm run check:theme-migration,syrf-theme.spec.ts,hot-hook-zoneless-discipline.spec.ts,no-hardcoded-help-urls.spec.tsandpnpm run buildpass. - Dependencies and test hygiene.
package.jsonandpnpm-lock.yamlunchanged. No dialog spec added or edited. New specs never wait on an animation (#4738's lesson). Verify: the PR diff. - Docs. This plan section gains an "as built" note;
design-token-mapping.mdgets the board-to-production token table, progress roles and focus ring; the webCLAUDE.mdm3e line lists buttons, icon buttons and the progress indicator with decision 2's rule; them3e-angular.scssheader comment lists consumers. No user-guide change.
PR split. One PR, web only. Order: shared pieces with specs (focus-ring mixin, reduced-motion rule, contract
additions); workspace components and the confirm emphasis; the study table's workspace branch; the Searches page and
the operations summary input; docs.
Settled by recommendation.
- Shared status chip: keep it on Material's bar. Follow-up issue to move all six consumers after the library fixes its reduced-motion defect.
- Reduced-motion defect: override in SyRF now (decision 5), file an m3e-angular issue, remove the override on fix.
- Directive on shared markup: use decision 2's flag-class pattern and record it in the web
CLAUDE.md. - "Show searches" stays a labelled text button above the list (the filter bar belongs to #4719 and #4720).
- Order: S12 merges first; the other wave 7 slices add controls in its pattern.
- New names: "Table options", "Close columns", "Delete
"; the search More button is "More for ".
S12 as built (#4763)¶
- The flag class is bound in templates, not on component hosts. A quoted host key such as
'[class.study-table--expressive]'breaks thenaming-conventionlint baseline. So the class sits onapp-page-shellfor the Searches page (searches--expressive), on the study table's error state and listdivin workspace mode (study-table--expressive), and on the operations summary's<section>through itsexpressiveinput. "Back to Studies" renders only with the flag on, so it has its own rule. The rendering is the same. The rule is recorded insrc/services/web/CLAUDE.md. - One shared button mixin.
m3x.small-buttons($icon-buttons, $selector)emits the whole treatment (tokens, focus ring, 44 px phone target, press morph, square icon targets) for the workspace, pane, detail, Activity sheet and item, study table and Searches page.m3x.focus-ringis declarations only, placed in the caller's&:focus-visible, so no mixin has a top-level&(Sonarcss:S8776). - Flag-on-only markup uses the directive. The empty state's "Import a search" is an
@if (workspaceOn())branch withmatExpButton. - Flag-off Searches header. "Who can see search names" and "Activity" already needed the flag, so with it off the header keeps only today's raised "Import a search".
- Every button under a flag class gets the focus ring and the small-screen target, including the icon buttons the spec marked unchanged.
- Forced colours. The reduced-motion stripe sets
forced-color-adjust: noneand paints inHighlight, because Chromium otherwise drops it. - Tests. The study table's name tests are in
study-table.workspace-rows.spec.ts, which renders the template.global-styles/study-management-m3e.spec.tspins the compiled heights, focus ring and absence of literal colours across the touched stylesheets.m3e-progress-indicator.spec.tspins the reduced-motion override, and fails once a library release fixes the defect.syrf-theme.spec.tschecks the--mat-sys-primarypairings in both modes.
#4719 · PDF filter and per-search PDF counts¶
Flag: studiesWorkspace and studyPdfStatus, both off in production. Server and web. Read-only: no migration, no
collection, no index, no write.
Outcome. With both flags on, the workspace filter bar gains the board's PDF filter, and managers' Searches pane rows gain "N without a confirmed PDF". Both use slice 8's server-tracked state. Neither trusts a stored path, and neither can disagree with the PDF column or the study detail.
Design decisions.
- One server mapping decides the state. Every reader uses
StudyPdfStatusRules.Report(BulkPdfLinkService.ResolveCheckTarget(study), availability, now), the function behind the PDF column and the detail.BulkPdfDeliveredPathis read only insideResolveCheckTarget.StudyPdfStatusFilterRules.Matches(StudyPdfStatusFilter, StudyPdfStatus)(in…Core/Model/StudyPdfAvailabilityAggregate/) holds the classes: Without a confirmed PDF = any state exceptAvailable; Available; Not yet confirmed =NotYetConfirmedorChecking; Unavailable; No PDF. Couldn't check and Hosted elsewhere match only Any and Without a confirmed PDF. It is C#, not a Mongo predicate, because the state needs the link key and the configured bulk base. - Filter type. New enum
StudyPdfStatusFilter { WithoutConfirmedPdf, Available, NotYetConfirmed, Unavailable, NoPdf }inSyRF.SharedKernel/Enums/, serialised as strings.StudyFiltersgains nullablePdfStatusFilter(null = Any). Append-only, so slice 9 addsProposedPdfWaiting. - List query.
StudyController.GetTableData, with the filter set and the gate open: read the link inputs of studies matching the other filters (projection of_id,ProjectId,SystematicSearchId,PdfRelativePath,BulkPdfDeliveredPath,Deletion); read the project's availability documents; classify; page the base query with_id ∈ ids(or∉, whichever is shorter).filterCountis the matched set's size;totalis unchanged. Deletion-marked studies match no option. With the field null the endpoint runs as today. - Counts endpoint.
GET api/projects/{projectId}/studies/pdf-status/search-counts,[FeatureGated(StudiesWorkspace)]and[Authorize(ProjectSearchReadAuthorization.PolicyName)]. Returns{ pdfStatusOn, searches: [{ searchId, withoutConfirmedPdf }] }: ids and numbers only, never a name. AViewStudies-only member or aViewSearchOverviewholder gets 403. Admission closed →pdfStatusOn: false, no availability read. OneStudyPdfStatusClassifier(SyRF.API.Endpoint/PdfStatus/) serves both decision 3 and this. - Index use. New
IStudyPdfAvailabilityRepository.GetForProjectAsync(projectId, statuses?, ct)filters onProjectIdand optionallyStatus ∈ statuses, usingProjectId_1_Status_1(migration 0034). Its filter comes from one static method that the explain test also uses. Available/Without:Availableonly; Unavailable:Unavailableonly; No PDF: no read; Not yet confirmed: every document for the project. Every read is project-scoped on both collections. - Server gate. The filter is honoured only while
studiesWorkspacedecides on andIStudyPdfAvailabilityAdmission.IsOpenAsync(flag on and index present). Otherwise409 pdf-status-filter-unavailable. Production runs no ADR-023 migrations yet, so 0034 is absent there and the admission stays closed. - Web gate. The filter shows only in workspace mode with
selectStudiesWorkspaceandselectStudyPdfStatusboth true. The pane asks for counts only for managers (searchPaneAccess === 'manage'). On a 409 the client resets the filter to Any, hides it until the next page load and refetches. Counts that fail or answerpdfStatusOn: falseleave W1's lines. - URL. Matrix parameter
;pdfStatus=<enum name>, besidesystematicSearchFilter. A new control in the study filter form (core/state/ui/study);clearFiltersresets it. Sent only while both flags are on, else dropped on rewrite. Choosing a search, "All studies" or the Search chip resets it to Any (W1 criterion 3); a URL naming both keeps both. Choosing any option closes the open study. - Proposed PDF waiting is left out: no option, no disabled entry, no stub. Slice 9 appends
ProposedPdfWaitingtoStudyPdfStatusFilterand adds the option for Set holders. The web keeps its options in one ordered array (PDF_STATUS_FILTER_OPTIONS). - Pane line.
buildSearchPaneRowstakes an optional map, search id → count. A manager's settled row reads "N studies · M without a confirmed PDF"; "Importing…", "PDF upload running/stopped" and "Import stopped" win. Names-only rows and "All studies" never show it. M usesformatCount; M = 0 shows "0 without a confirmed PDF". - Freshness. Counts load when the workspace opens with both flags on, after a search import or bulk PDF upload job reaches a final state, and after a W2 set, clear or accept succeeds. They are not live per check.
- Parity guard. The reporter treats an availability document whose
ProjectIddiffers from the study's as absent (Not yet confirmed), so reporter and classifier agree. - Web placement. Control and URL param in
core/state/ui; request incore/services/study-table; select instudies/study-table/library-filter-bar; counts loader and pane line instudies/studies-workspace. Noproject/…import.
Flag behaviour.
studiesWorkspace |
studyPdfStatus (and 0034) |
Web | Server |
|---|---|---|---|
| off | off | Today's Library. | List as today; a request with PdfStatusFilter gets 409; counts 404. |
| off | on | Today's Library with 8b's PDF column. | As above. |
| on | off (or 0034 missing) | W1 as built: no filter, no pane count. | Filter 409; counts pdfStatusOn: false, no availability read. |
| on | on | The PDF filter and managers' count. | Decisions 3–5. |
Round trip (on, off, on): nothing is written. A ;pdfStatus= link opened while either flag is off shows the
unfiltered page and loses the parameter. Availability documents are left alone while off. The new reads join the
studiesWorkspace consumer contract (new-read-operation) in current-code-matrix.md.
Acceptance criteria.
- Options. With both flags on, the filter bar has a single-choice "PDF" filter: Any PDF status, Without a confirmed PDF, Available, Not yet confirmed, Unavailable, No PDF. Any is the default. No Proposed PDF waiting option for anyone. Verify: filter-bar spec, flags on, for a manager, a reviewer and a Set holder.
- Matching. For a seeded project with a study in each of the seven states plus a deletion-marked study: each
option returns exactly what decision 1 says; Without a confirmed PDF returns all but Available; Not yet confirmed
returns Not yet confirmed and Checking; the deleted study is in none. Verify:
StudyPdfStatusFilterRulesTests(every option × every state) and a controller test on a real replica-set database. - Parity with the column and the detail. For every seeded study, the matching option agrees with the list's
PdfStatus(ReportManyAsync) and the single-study read (ReportAsync). The seed has: no document; a staleCheckedLinkKey; a fresh and an expired lease;External,Unsafe(Unverified) andAvailable; a bulk-delivered path with and without a proven bulk base; a document whoseProjectIddiffers from the study's. Verify: a classifier test asserting equality study by study. - Paging, sorting and other filters. With an option chosen, page, sort, text search, search, inclusion and stage
filters behave as without it;
filterCountequals the matches;totalis unchanged. Verify: controller test with 30 studies, page size 10, sorted by title, with a search filter. - Index use. The availability read uses
ProjectId_1_Status_1with noCOLLSCAN, for a status-equality read and a project-only read. Verify: explain test inStudyPdfAvailabilityRepositoryTests, built with the repository's own filter method (asStudyConversationRepositoryTests.TheListReadUsesTheConversationIndexdoes). - Project scope. Studies and availability documents in another project never affect a list or a count, even with shared search ids or states. Verify: controller and classifier tests with two projects.
- Server gate. A list request with
PdfStatusFiltergets409 pdf-status-filter-unavailable, and a repository spy records no read ofpmStudyPdfAvailability, withstudiesWorkspaceoff,studyPdfStatusoff, or the 0034 index missing. With the field null every response is identical to today's. Verify:StudyControllerPdfStatusTests. - Counts endpoint access. 404 with
studiesWorkspaceoff. 403 for a member holding onlyViewStudiesand for aViewSearchOverviewholder without a search activity. 200 forViewSearches,ImportSearchorRemoveSearchholders. No search names in the body. Admission closed →pdfStatusOn: false, no availability read. Verify: HTTP tests in the style ofProjectSearchReadAuthorizationTests, in every authority mode. - Count value. A search's
withoutConfirmedPdfequals the list'sfilterCountfor that search with Without a confirmed PDF and no other filter. It counts No PDF studies and leaves out deletion-marked ones. Verify: controller test comparing both endpoints. - Pane line. A manager's settled row reads "812 studies · 85 without a confirmed PDF". "Importing…", "N studies ·
PDF upload running", "N studies · PDF upload stopped" and "N studies · Import stopped" win. A names-only row reads
"812 studies". "All studies" has no PDF count. With counts unavailable, rows read as W1. Verify:
studies-workspace-searches.spec.tsand the pane component spec. - Choosing clears. Choosing a search, "All studies" or changing the Search filter resets the PDF filter to Any.
Choosing a PDF option closes an open study. A URL with both
systematicSearchFilterandpdfStatusopens with both. Verify: workspace component spec. - URL. Choosing an option writes
;pdfStatus=<name>; Any removes it; reload restores it; an unknown value is Any; with either flag off it is not sent and is dropped on rewrite. Verify:study-ui.reducer.spec.ts,study-table.service.spec.ts/ effects spec, a component spec. - Web refusal handling. On
409 pdf-status-filter-unavailablethe client resets the filter to Any, hides it, refetches once and announces the change politely through the CDKLiveAnnouncer(D16). Verify: filter-bar or workspace spec with a stubbed 409. - Flag combinations and round trip. Each row of the flag table holds. An on, off, on run for each flag, with
availability documents and a
pdfStatusURL created in the first "on", gives the same results in the second. Verify: controller tests (server), workspace or route specs (web). - Accessibility. The PDF filter is a labelled single-choice control; the selected option is exposed; the pane line is part of the row button's text; targets stay 44 px below 600 px. Verify: component specs; a screen-reader check by hand on a preview.
- Boundaries and lint.
pnpm run test:lintandpnpm run lintpass;eslint-boundary-exceptions.jsonandeslint-suppressions.jsonare unchanged. Verify: CI's web lane. - Docs ride in the same PR: this plan's W1 "as built" decision 1 marked done by #4719; the README status line;
user-guide/studies/view-studies.md(the filter and the pane count) andglossary.md("Without a confirmed PDF"); the two new reads incurrent-code-matrix.md; the seam comments (LIBRARY_FILTERS,searchStatus).
PR split. One PR. The server half may ship first if review prefers, since it is inert until a client sends the
field. Path: enum, field and rules; repository read and explain test; classifier, list change and counts endpoint;
regenerate api-client.generated.ts; web control, URL, select, pane line and loader; docs.
Settled by recommendation.
- Couldn't check and Hosted elsewhere get no options of their own; they match Any and Without a confirmed PDF.
- A fully confirmed search shows "0 without a confirmed PDF".
- The Searches page row and Overview counts are a follow-up issue reusing
…/search-countsandpdfStatus. - Refusal copy: "PDF status isn't available, so the PDF filter is off." (polite announcement). Wording still wants the owner's eye in review.
- The CSV export applies the PDF filter through the same classifier, so the file matches the screen.
- Scale: before merge, time the list with a PDF option on a preview seeded to the largest real project; optimise (Available and Unavailable candidates from the index first) only if clearly slower than today's list.
Searches page and Overview counts (#4770)¶
Settled-by-recommendation 3, specified on 5 November 2026 against main @ 67ce1ddb50. Web only: no new route, field,
flag, migration or write. It reuses GET …/studies/pdf-status/search-counts (decision 4) and ;pdfStatus= (decision 8).
Outcome. With studiesWorkspace and studyPdfStatus on and the endpoint answering pdfStatusOn: true, the board's
"N without a confirmed PDF" appears in two more places for the readers the endpoint admits (search managers,
hasSearchesCapability): on each settled row of the Searches page, as a link that opens Studies filtered to that search
and to Without a confirmed PDF (the board's goLib({ fS: s.id, fP: 'noconf' })), and on Overview's Studies line
("1,540 studies · 85 without a confirmed PDF", the board's ovModel).
Design decisions.
- One loader, shared. #4719's counts store moves from
studies/studies-workspace/toshared/search-pdf-counts/(SearchPdfCountsStore), withcountFinishedJobsand asearchPdfCountsRequesthelper that builds the request the workspace already builds (project, admitted, a generation from finished jobs and the store's own change events). The workspace, the Searches page and Overview each provide their own instance, so each page view fetches once. Noproject/file importsstudies/, and no boundary exception is added. - Same refetch events as the pane. A search import or bulk PDF upload reaching a final state, a W2 set, clear or
accept (
studyPdfLinkChanged), and a search deleted (searchDeletedNotificationReceived); later triggers coalesce over 500 ms. Nothing else refetches. - Searches page row. In the Studies column, under the count: a text link "M without a confirmed PDF"
(
formatCount), with the accessible name "M without a confirmed PDF in", to /projects/:id/studies;systematicSearchFilter=<searchId>;pdfStatus=WithoutConfirmedPdf(withoutConfirmedPdfLinkincore/state/ui/studies-workspace-links.ts). As the board: only when M > 0 and the row is settled, so not while its import is unfinished (notComplete), while a PDF upload for it is active, or while it is being deleted. A manager withoutviewStudies(which/studiesneeds) gets the same words as text, not a link. - Overview. The Studies line (
overview-study-count) adds " · M without a confirmed PDF" after the "N studies" link, M being the sum of the searches' counts, 0 included (the board showsT.ncunconditionally). It's text, as on the board, and shows only while the Library total is above 0. The board's "from K searches" is left out: the card already shows the search count on the line above. M includes searches that are importing or have a PDF upload running, which the Searches page rows hide, on purpose: it sits beside the Library total, which counts every study in the project whatever its search is doing, so leaving those searches out would make the two numbers disagree. (The board leaves importing searches out of both its numbers; production's total doesn't, so M follows the total.) - S12's styling rule. The row link renders only with the flag on, so it uses
matButton="text"withmatExpButton(S12 decision 2), inside the page shell'ssearches--expressiveclass, with nocolorattribute.
Flag decision. No new flag. It rides on studiesWorkspace and studyPdfStatus (both off in production) because it
is the rest of #4719's flagged feature. The server is already gated: the endpoint is FeatureGated(StudiesWorkspace),
admits only ProjectSearchReadPolicy, answers pdfStatusOn: false while the PDF status admission is closed, and
carries ids and numbers only. The web asks only with both flags on and for an admitted reader. Off, both pages are
today's.
Acceptance criteria.
- Row link. Flags on, counts
{ pm: 85 }: the PubMed row shows "85 without a confirmed PDF", amatExpButtontext link to/projects/p/studies;systematicSearchFilter=pm;pdfStatus=WithoutConfirmedPdf, named "85 without a confirmed PDF in PubMed". Verify:systematic-searches.component.spec.ts. - Row hidden. No link for M = 0, an unfinished import, an active PDF upload, a row being deleted, or counts that
fail or say
pdfStatusOn: false. WithoutviewStudiesthe words are text. Verify: same spec. - Overview line. Flags on, an admitted reader, total 1,540, counts 85 and 0: "1,540 studies · 85 without a
confirmed PDF"; counts all 0 show "· 0 without a confirmed PDF"; no PDF text while the total is 0 or counts are
unavailable. Verify:
project-overview.component.zoneless.spec.ts. - Audience. A
viewStudies-only member and aviewSearchOverviewholder trigger no counts request on either page and see nothing new. Verify: both specs (getPdfStatusSearchCountsnot called). - Fetches. One request per page view; one more (coalesced) after a search import or bulk PDF upload finishes, a
studyPdfLinkChangedor a search deletion; none for other actions. Verify:search-pdf-counts.store.spec.ts(moved), and a call count in each page spec. - Flags off. With either flag off, no request and no new element on either page; on, off, on gives the same result as the first on. Verify: both specs.
- Workspace unchanged. The pane's lines, refetches and coalescing are as #4719 built them. Verify: the existing
studies-workspace*.spec.tspass with only their imports changed. - Boundaries and lint.
pnpm run lintpasses;eslint-boundary-exceptions.jsonandeslint-suppressions.jsonunchanged. Verify: lint run. - Docs. The user guide's Searches page section (
upload-search.md),view-studies.md, Overview (projects/overview.md) and the glossary; the README status line; this subsection's as-built note.
As built (PR #4809). As specified, web only, with these details:
- Shared loader.
shared/search-pdf-counts/search-pdf-counts.store.tsholdsSearchPdfCountsStore(the #4719 store, renamed),PDF_COUNTS_REFRESH_COALESCE_MSandsearchPdfCountsRequest, which reads both flags itself so every page gates the same way.finished-jobs.tsholdscountFinishedJobswith a structural row type, soshared/needs nothing fromproject/. The workspace, the Searches page and Overview each list the store in theirproviders. - Searches page. The link sits in the Studies cell under the count, with a
filter_listicon (the board's). An unfinished import means a job status other thanComplete; a search with no import job counts as settled. "Active upload" ishasActiveBulkPdfUpload, the rule that also disables Delete. - Overview. The project id is
selectLoadedProjectId, the one the Studies total uses. The count is plain text after the "N studies" link, inside the same paragraph. The id and the search-manager gate can't name different projects: the permission map isprojectDetails[loadedProjectId], read from the same store state. - Project switches. The app's route reuse keeps Overview's component when only
:projectIdchanges, so the store records which project its counts belong to. A request for another project clears them at once and fetches without the coalescing wait; an answer for a project it has moved off is dropped. - Tests.
systematic-searches.component.spec.ts("N without a confirmed PDF" on the rows), the Overview zoneless spec ("N without a confirmed PDF" on the Studies line),search-pdf-counts.store.spec.ts(the request helper: flags, audience, round trip, generation) andstudies-workspace-links.spec.ts(the link and its canonical URL with the PDF filter available and not).
#4720 · My studies tabs with counts in the stage-scoped workspace¶
Flag: studiesWorkspace, off in production. Server and web. No migration, no new index, no stored data.
Outcome. A reviewer opens a stage in Studies and sees only their own work in it, split into To do, In progress and Completed, each with a count. Managers see the whole stage as today and can switch to their own work with "Only mine". The review page's "See them all in Studies" opens To do. The server decides every tab and count, and nobody learns anything new about other reviewers' work.
Design decisions.
- Flag. The existing
studiesWorkspace. It gates the count endpoint (404 off), the list's tab filter (ignored off), the per-rowMyStageWork(null off), theMyStudiesTabAppliedecho (false off), and the web tabs, Only mine, column, lede, count line and review link target. The flag's consumer-contract comment inenv-mapping.yamlgains these readers; runpnpm generateandpnpm run validate:generated. - Stage kind. Screening-kind when
ReviewMode = Screening, orScreeningAndAnnotationwithStudySelectionMode = Screening; every other stage is annotation-kind. A C# twinStageWorkKind.For(stage)is pinned by a test to the same cases asyour-status-filter.ts. MyStageWorkper study for reader R, stage S:
| Value | Annotation-kind | Screening-kind |
|---|---|---|
InProgress |
own non-reconciliation session in S, Incomplete (with hide-excluded on, also not excluded) |
never |
Completed |
own non-reconciliation session in S, Completed (with hide-excluded on, also not excluded; Settled 9) |
own screening, Included or Excluded |
OpenToStart |
no own session, not ExcludedStudies, not SufficientlyAllocated, and where allocation applies WorkloadShareBucket in R's buckets |
no own screening, InsufficientlyScreenedStudies |
NotStartedNotOpen |
as OpenToStart but outside R's buckets |
never |
Finished |
no own session, not excluded, SufficientlyAllocated |
no own screening, SufficientlyScreenedStudies |
NotInStage |
no own session, ExcludedStudies; with hide-excluded on, every excluded study (Settled 9) |
never |
"Where allocation applies" = proportionalStudyAllocation on and StageWorkloadShareEligibility.NewAnnotationWorkAdmission
returning buckets; an empty set means nothing is open to R, and a stale or invalid configuration fails closed.
The annotation rules reuse GetInvestigatorAnnotationStats's predicates so tab counts match the review page; they
deliberately do not reuse the Library's legacy UserSessionStatusFilter.NotStarted. Reconciliation sessions never
count.
4. Tabs. To do = InProgress ∪ OpenToStart; In progress = InProgress; Completed = Completed. In a
screening-kind stage In progress is always 0 but still shows.
5. List. StudyFilters gains MyStudiesTab (None, ToDo, InProgress, Completed). It needs StageId (400
without, flag on) and is ANDed with every other filter. The reviewer is always CurrentUserId. The paged response
gains MyStudiesTabApplied; a browser that asked for a tab and got false (flag off, or an older pod) shows "Your
studies couldn't be loaded. Try again." and no rows, never an unfiltered list. StudyDetailsDto gains nullable
MyStageWork for every row when the flag is on and a StageId is sent, computed in memory by
MyStageWorkEvaluator (Core), the twin of the filters.
6. Counts. GET api/projects/{projectId}/stages/{stageId}/my-studies/counts on ReviewController, under
StageAuthorization.StageReviewPolicy and [FeatureGated(StudiesWorkspace)]. Returns { inProgress, notStarted,
completed, toDo }, toDo = inProgress + notStarted. Three parallel CountDocumentsAsync calls over
Filters.InProject(projectId); stage-wide, reading no other filter. 404 for a missing project or stage.
7. Privacy (R14 = A). The reviewer comes from the authenticated user only; no parameter names another. Each count
is the reader's own. Finished and NotInStage say only what the Library already shows every member, with no
decision, reviewer or review count. The response has no investigator id. The Library DTO's existing other-reviewer
session statuses are not widened and not narrowed here (belongs with R13).
8. No new index. Every count reuses a predicate the review page's progress meter runs today. The annotation counts
use the long ProjectId_1_ScreeningInfo.AgreementMeasure…_ExtractionInfo.Sessions.InvestigatorId_1_…Status_1_SystematicSearchId_1
index (checked on syrf_staging, 4 November 2026: IXSCAN, 14 keys and 4 documents for 4 matches); screening
Completed uses ProjectId_1_ScreeningInfo.Screenings.ScreenerId_1; open counts use the tally and agreement-measure
indexes, or ProjectId_1_WorkloadShareBucket_1 under allocation. Negated predicates scan the project's index range,
as the meter does. Production runs no ADR-023 migrations yet, so the design must not need one. If the PR's explain
evidence (AC 12) shows a COLLSCAN or a per-document fetch, the PR stops and adds a fallback migration with an
admission gate (see Merge order for the number).
9. Web state and URL. Matrix parameter myStudies=todo|inProgress|completed, kept by preservedStudiesUrlParams.
For a member with tabs a missing or unknown value is todo; for a manager the parameter's presence is "Only mine".
Constants, parser and myStudiesTabLink live in a new core/state/ui/my-studies-tabs.ts, so stage/ and
studies/ both import from core. Counts come through a core/services/study-table/ wrapper held in workspace
signals (no global-store slice), refetched on stage change, Only mine on, and after a post-review list refetch. No
polling.
10. Who sees what. Tabs show in a stage scope with the flag on when the reader may review the stage
(stages[id].review): non-managers always, managers with Only mine on. "Only mine" shows for managers who may
review the stage. A reader who may not review gets today's stage list, lede and filters, and no counts call. While
tabs show, slice 3's "Your status" filter is hidden and cleared; managers with Only mine off keep it.
11. "Your MyStageWork): OpenToStart → Start, InProgress → Resume (stage
active and reader may review), else "Not started"; Completed → "Completed"; Finished → "Finished";
NotStartedNotOpen → "Not started"; NotInStage → "—". Links go to /projects/:id/stage/:stageId/review/:studyId.
The Library's review cell is unchanged. W2's "Your work on this study" keeps its own mapping.
12. Review page. myStudiesTodoLink in core/state/ui/studies-workspace-links.ts returns
/projects/:id/studies/stage/:stageId;myStudies=todo with no "Your status", search, title search or PDF filter.
Flag behaviour. Off: today's Library; the count endpoint is unroutable; the list ignores myStudiesTab and returns
MyStageWork: null, MyStudiesTabApplied: false; no review-page link. On: all of the above. Off → on → off: nothing
is stored, so a bookmarked myStudies URL opens today's Library while off (the unknown parameter is ignored and
dropped). A browser with a stale on-snapshot (up to 30 s) gets MyStudiesTabApplied: false and the retry message.
Reviews made in either state appear in the next state's counts.
Acceptance criteria.
- Flag off: no change. The count endpoint answers 404 for a member who holds
StageReviewPolicyand one who doesn't. The list withmyStudiesTabset returns the same rows and total as without it;MyStageWorkis null andMyStudiesTabAppliedfalse. Verify:FeatureGateTestsfor the new action; aStudyControllertest comparing the DTO with and without the field. - Off → on → off round trip. With data made in each state (a session started off, completed on, another started
off), each state answers as defined; the feature writes nothing in any state. Verify: an API test toggling the
decision three times over one seeded project, asserting responses and that no
pmStudydocument changed except by the review saves. - Definitions, annotation-kind. For a seeded stage (target 2) each row of the table resolves as specified:
in progress, completed, open, open-but-outside-share, finished by others, excluded, excluded in progress with
hide-excluded on and off, and a reconciliation session (ignored). Verify: Mongo.Data integration tests of
MyStageWorkFilters, one fixture per row. - Definitions, screening-kind. Screened by me is Completed; not screened by me and insufficiently screened is To
do; sufficiently screened without me is Finished; In progress is 0. Verify: same test class, including a
ScreeningAndAnnotationstage with each selection mode (StageWorkKindparity withyour-status-filter.ts). - Filter and evaluator agree. For every fixture in 3 and 4,
MyStageWorkEvaluatoron the loaded study returns the value the filters place it under. Verify: a parity test. - Allocation. With
proportionalStudyAllocationon and an enabled 50/50 configuration, To do's not-started studies are exactly the reader's buckets; a study outside them isNotStartedNotOpen; an invalid configuration opens none; with the flag off buckets are ignored. Verify:StageWorkloadShareEligibilityfixtures. - Counts match tabs. For each tab,
countsequals the list'stotalfor that tab with no other filter;toDo = inProgress + notStarted. Verify: an API test over the seeded project. - Counts match the review page. In an annotation-kind stage with allocation off,
inProgressandcompletedequalReviewerAnnotationStats' unexcluded plus excluded in-progress (minus excluded when hide-excluded is on) and completed figures;notStartedequals its "available" figure. Verify: a Mongo.Data test computing both. - Own data only. Counts and the tab filter use the signed-in member; two reviewers on one project get different counts; no query, route or body parameter names a reviewer; the response contains no investigator id. Verify: an API test with two members; a contract test on the DTO's properties.
- Policy. The count endpoint is
StageReviewPolicy. A member without review rights on the stage gets 403, a non-member 403, asStageReviewPolicyanswers today (ReviewInProgressAuthorizationTests). Verify:StudyEndpointAuthorizationTests(or the review authorization tests) gains the action; the policy-attribute reflection test lists it. - Older pod fails closed. A list response without
MyStudiesTabApplied: trueto a tab request shows "Your studies couldn't be loaded. Try again." and no rows. Verify: web spec on the workspace with a stubbed response. - Cheap counts. Each of the six count queries (three per stage kind) is explained on
syrf_stagingagainst its largest project. The PR records the winning index, keys examined and documents examined. None is aCOLLSCAN; In progress and Completed fetch no more documents than they count. Verify: explain output pasted in the PR. A failure triggers the fallback migration and admission gate (decision 8). - Tabs and copy. With the flag on, a reviewer in a stage scope sees
To do N,In progress N,Completed Nwith To do chosen, the lede "Your studies in: the ones you’ve started or been given.", and the count line " in progress · not started · completed". An empty list says "Nothing left for you in this stage." Verify: studies-workspace.component.spec.ts. - Tab choice. Choosing a tab writes
;myStudies=<tab>, refetches withmyStudiesTab, keeps the other filters and;activity=open, and closes an open study. Reloading restores the tab. Verify: workspace spec andstudy-table-guard.service.spec.ts. - Managers and Only mine. A manager who may review the stage sees the whole stage with today's lede and no tabs; "Only mine" turns tabs on at To do; turning it off returns to the whole stage. A manager who may not review the stage gets no Only mine and no counts call. Verify: workspace spec, three readers.
- Readers who may not review. A non-manager without
stages[id].reviewsees today's stage list, lede and "Your status" filter, and no counts call is made. Verify: workspace spec. - "Your status" while tabs show. Hidden and cleared from the request; kept for managers with Only mine off.
Verify:
library-filter-bar.component.spec.ts. - Column. "Your
" shows Start, Resume, Completed, Finished, Not started and "—" from MyStageWork, with Start and Resume only in an active stage the reader may review. Aria labels name the work and the title. With the flag off the Library's review cell is unchanged. Verify:study-table.workspace-rows.spec.ts,study-table.component.spec.ts. - Review page link. "See them all in Studies" links to
/projects/:id/studies/stage/:stageId;myStudies=todowith no other filter; flag off, no link. Verify:studies-workspace-links.spec.ts,my-studies-navigator.component.spec.ts. - Boundaries.
pnpm run lintpasses with no new entry ineslint-boundary-exceptions.jsonand no new suppression. Verify: CI's web lint step. - Docs. This plan's W1 item 3 and W5 notes say what was built;
user-guide/studies/view-studies.mddescribes the tabs and Only mine; the flag decision is in both PR descriptions. Verify: diff review; CI docs validation.
Web as built (#4775).
- URL and request.
core/state/ui/my-studies-tabs.tsholds the tabs,parseMyStudiesTabandmyStudiesView(who sees what). The workspace's routes usestudiesWorkspaceTableGuard, the table guard plus the reader's tab: the guard and the page read the sameworkspaceMyStudiesView(permission report, stage,;myStudies), so the list it fetches is the one the page labels.fetchStudyListcarriesmyStudiesTab; the store keeps it, andgetSendableStudyTableParamsDtosends it with the stage. While a tab is chosen "Your status" is cleared, not only hidden: the guard's canonical URL sets it to All (so the form holds All and a manager's Only mine off can't bring the old value back), and the request also sends it at All. The Library's guard sends no tab and its URL rewrites drop;myStudies(preservedStudiesUrlParams(…, { workspace })). - Older pod. A tab request answered with
myStudiesTabAppliednot true holds no rows (myStudiesTabRefused), and the table shows "Your studies couldn't be loaded." with Try again, which refetches. - Tabs and Only mine. The tabs are a
mat-button-toggle-groupwithm3x.connected-button-group(a radiogroup named "Which of your studies"), projected by the workspace between the filters and the list. "Only mine" is amatExpButtonprojected into the filter bar, itsaria-pressedfrom the button'stoggle. Choosing a tab or toggling Only mine keeps the other matrix parameters (;activity=openincluded), goes to the first page and closes an open study. - Counts.
MyStudiesCountsService(core/services/study-table/) in anrxResourcekeyed on project, stage and a count of in-place list refetches (refreshStudyList), requested only while the tabs show. A tab change doesn't refetch. The count line also hides over an empty list, and treats every filter except "Your status" as narrowing (selectStudyListNarrowed): the inclusion filter too, not only Q7's search, title search and PDF filter, because the line would not describe the list either. - Column. In a workspace stage scope the Review column is "Your
" ( yourStageCell) for every reader, with or without the tabs (managers with Only mine off and readers who may not review included): each row is the reader's own work, so blinding is unaffected. "—" isNotInStage. The column picker and sort menu use the same name. Start and Resume are named "Start screening:" or "Resume annotation: " by stage kind. Without Start or Resume, an InProgressstudy reads "In progress", not "Not started". A row withoutMyStageWorkkeeps today's review cell. - Empty To do. "Nothing left for you in this stage." with no Clear filters, only while nothing else narrows the list; otherwise "No studies match these filters."
- Not built here: the stage chooser chip (Q5, its own follow-up: built by #4776, below). W2's "Your work on this study" keeps its mapping.
PR split. Two PRs. A (server): StudyFilters.MyStudiesTab; StageWorkKind, MyStageWorkFilters (Mongo.Data),
MyStageWorkEvaluator (Core); the list changes; the count endpoint and DTO; the env-mapping.yaml comment; OpenAPI
regeneration (pnpm run nswag:all after a Release API build); the feature doc's API notes. B (web): tabs, Only
mine, column, lede, count line, URL state, counts wrapper, review-page link; user guide
(view-studies.md, stages/screening.md, annotating.md); the plan's W1 item 3 and W5 notes flipped to built.
Settled by recommendation.
- Q1: To do = in progress plus every study the reader could start now, narrowed to their share where allocation applies (the board's rule with "Reviewers choose studies" on, which is how SyRF works today). Keep the board's lede.
- Q2: keep the In progress tab in screening-kind stages and accept 0.
- Q3: show "Only mine" only to managers who may review the stage.
- Q4: hide "Your status" while tabs show; keep it for managers with Only mine off.
- Q5: the stage chooser chip is its own small follow-up after this slice.
- Q6: if AC 12 fails, add a compound index on
ProjectId,ExtractionInfo.Sessions.StageId,ExtractionInfo.Sessions.InvestigatorIdby pre-deploy migration, with an admission check that keeps the tabs refusing until it exists. The number is assigned at merge time. - Q7: show the stage-wide count line only when no search, title search or PDF filter is set.
- Q8: "Nothing left for you in this stage." for an empty To do tab; "No studies match these filters." for the other two.
- Team-excluded studies (owner, 4 November, from #4765's review): the tabs and counts follow the review page's My
studies panel, which "See them all in Studies" opens, so a reviewer sees what they see today (D2). With
hide-excluded on, an excluded study is
NotInStage(no tab, no count) whether the reader completed it or has it in progress, as the panel hides every excluded saved study; with it off, excluded work keeps the reader's In progress or Completed status, as the panel's rows list it. This replaces AC 8's raw-sum reading for excluded studies; the counts equal the panel's summary wherever the panel agrees with its own rows.
Stage chooser chip (#4776)¶
Flag: studiesWorkspace, off in production. Web only: no server change, no migration, no new flag. Flag decision:
the chip renders only inside the Studies workspace, which mounts only while studiesWorkspace is on, so it needs no
flag of its own; with the flag off /studies is today's Library and has no chip. Its lists and links read only the
permission report and the URL contract #4720 already gates (;myStudies, the count endpoint, the tab filter).
Outcome. From anywhere in the Studies workspace a reviewer reaches their own work in a stage without leaving the page: the filter bar's "My studies" chip lists the stages they may review, and choosing one opens that stage on its To do tab. A manager's chip reads "Stage" and opens the whole stage, as the board does. Choosing "All studies" (or "All stages") returns to the project's list.
Design (the board, Studies-tc, chosen 3 November).
- Who sees it, and which stages it lists. It lists, in the project's stage order, every stage whose permission
report entry says
stages[id].review(Q5: "the stages the reader may review"), whether or not the stage is active, asworkspaceMyStudiesViewreads it (an inactive stage's own work still shows). Managers (W1's: the Searches pane'smanageaccess) get the same list. The chip shows only while it lists at least one stage; a reader who may review none sees no chip and no hint. - Labels. Members: "My studies", and "My studies:
" in a stage scope. Managers: "Stage", and " " in a stage scope. In a stage scope the chip is tonal with a leading check; otherwise outlined. The trailing arrow_drop_downmarks it as a menu. A stage scope the chip doesn't list (reached from a stage page's link) shows just its name on the chip, for members too, because the reader may not review it and sees the whole stage without tabs; no stage is checked in the menu. - Menu. A
mat-menulabelled "My studies" (members) or "Stage" (managers). First "All studies" (members) or "All stages" (managers), then one item per listed stage. Items aremenuitemradiowitharia-checked, and a check on the current one. Opening the menu focuses the checked item (the board'sopenMenu), else the first. - Choosing. A member's stage opens
/projects/:id/studies/stage/:stageId;myStudies=todo: the stage scope on its To do tab. A manager's stage opens the whole stage (Only mine off), as the board'sscopechange leavesmineoff; moving from one stage to another keeps a manager's Only mine (the board keepsmine), at To do. "All studies"/"All stages" opens/projects/:id/studies. Each choice keeps the search filter, the title search, the PDF filter and the page size (the board keepsfS,qandfP), goes to the first page and closes an open study; it drops the sort, "Your status", the session filter and;activity(the sheet is modal, so it can't be open). Choosing the current entry changes nothing. - Hint. For a member with the chip outside a stage scope, under the filters: "Choose My studies to see your work
in a stage." (the board's
hint, shown when!scopeand not a manager). - Focus. The menu returns focus to the chip as it closes. Between
/studiesand a stage scope the router builds a new workspace, so the chip asks a rootStudiesStageChipFocusto focus the new page's chip after the navigation; the guard's canonical rewrite can't drop it, as navigation state would be. The request lapses when the router's navigation is refused, fails, is skipped or ends outside the chosen scope; not when the firstnavigateresolvesfalse, because the guard's rewrite supersedes every chip link's first navigation. - Phones. The chip is
matButton matExpButtonwith the workspace'sm3x.small-buttons(S12): 44 px target below 600 px, focus ring. The workspace only mounts with the flag on, so the static directive is safe here.
Acceptance criteria.
- Flag off: no change.
/studiesand a stage scope render the Library, which has no chip and no hint; off → on → off stores nothing, so nothing carries over. Verify:studies.routes.spec.ts("keeps today's Library with studiesWorkspace off", "follows the flag on, off and on again"); the chip is declared only instudies-workspace.component.html. - Only stages the reader may review. With stages A (review), B (no review) and C (review, inactive), a member's
menu lists "All studies", A and C, in project order; B never appears. A manager with the same report gets "All
stages", A and C. A reader who may review no stage sees no chip and no hint. Verify:
studies-stage-chip.spec.ts(pure list) andstudies-workspace.component.spec.ts(rendered menu, three readers). - Labels. "My studies" / "My studies: A" for a member; "Stage" / "A" for a manager; a stage scope the chip doesn't
list (B) shows just "B", for members too, since B has no tabs for them. Verify:
studies-stage-chip.spec.tsand the workspace spec. - Choosing a stage opens its To do tab. A member choosing A navigates to
…/studies/stage/A;myStudies=todokeeping search, title search, PDF filter and page size, at the first page, with the open study closed. Verify: workspace spec (router spy). - Managers. Choosing A opens
…/studies/stage/Awith nomyStudies; from stage A with Only mine on, choosing C opens…/studies/stage/C;myStudies=todo. Verify: workspace spec. - All studies. "All studies"/"All stages" from a stage opens
…/studieswith the kept filters and nomyStudies. Choosing the current entry doesn't navigate. Verify: workspace spec. - Menu semantics. The trigger has
aria-haspopup="menu"andaria-expanded; the panel is labelled "My studies" or "Stage"; each item ismenuitemradiowitharia-checkedtrue only on the current scope; opening focuses the checked item. Verify: workspace spec. - Focus return. After a choice that rebuilds the page, the new page's chip has focus. A request survives the
guard's canonical rewrite and lapses when the navigation is refused, fails or ends outside the chosen scope, so a
later page never takes focus for it. Verify:
studies-stage-chip-focus.service.spec.tsand the workspace spec (a pending request focuses the chip on render). - Hint. A member with the chip on
/studiessees "Choose My studies to see your work in a stage."; not in a stage scope, not for a manager. Verify: workspace spec. - Touch target. The chip carries
matExpButtonand sits under the workspace'sm3x.small-buttons. Verify: workspace spec asserts the directive class; the SCSS includes the mixin for.ws-stage-chip. - Docs and checks.
user-guide/studies/view-studies.mddescribes the chip; studies, stage andcore/state/uispecs,pnpm run lint(no new suppression or boundary exception) andpnpm run buildpass. Verify: CI.
As built (#4808). studies/studies-workspace/studies-stage-chip.ts holds the pure list, view and link
(stageChipStages, stageChipView, stageChipLink); StudiesStageChipFocus (root) carries the focus request, keyed on
the chosen scope so the page being left can't take it. The chip is projected into the filter bar's [filterBarChips]
before Only mine, the hint into [studyListTabs] above the tabs. The menu is a mat-menu (.ws-stage-menu) whose
items are mat-menu-item with role="menuitemradio"; on open the checked item takes focus through MatMenuItem.focus
with the origin the chip was opened by, so the menu's key manager follows it. The chip doesn't carry aria-pressed: a
menu button's state is its label, and the board's aria-pressed on a menu trigger isn't valid ARIA.
Bulk study update in Studies (BSU)¶
Flag: studiesWorkspace, off in production. Server (small), web, and an optional second server PR. No new flag.
Outcome. With studiesWorkspace on, a manager starts a bulk study update from the Studies header, in the board's
three-step dialog. SyRF reads the whole file in the browser first and shows exactly what it will change; nothing
reaches the server until the manager ticks the box and presses "Update N studies". While it runs, Studies shows a
strip; a locked study says so in its detail. When it ends, the strip gives the outcome and the Activity sheet lists
the rows that were not applied. With the flag off, today's Library dialog and action are unchanged.
Design decisions.
- Flag.
studiesWorkspaceonly. The Library keepsUpdateStudiesComponent,LibraryBulkUpdateActionComponentandBulkStudyUpdateSummaryComponentuntouched. The server parts (BSU-a, BSU-c) read the flag throughIFeatureDecisions; off, each projects null and writes nothing. - Show each job as what it is. BSU-a adds
allOrNothing: bool?toBulkStudyUpdateJobDto(ExecutionVersion == 2, set only while the flag is on). Lock wording appears only for an all-or-nothing job (ADR-020: locks exist only for execution version 2,bulkStudyUpdateAtomicApply). Before a job exists, the dialog readsProjectDetailsDto.newBulkStudyUpdatesAllOrNothing(BSU-a): the API's own deployedbulkStudyUpdateAtomicApply, the value that stamps new jobs, so it can't disagree with the stamping. It is sent only to callers holdingbulkUpdateStudies, and is null while the flag is off. There is no web flag: each service gets its ownfeatureFlagsvalue, so a browser half could differ from the API's. A v1 job takes no lock, so it never says "can't be reviewed". - The check on this device. A pure module
src/app/shared/bulk-study-update/bulk-study-update-file.tsparses the whole file with@shared/utils/papaparse-loader, keeping only counts and the set of study IDs, and mirrors the server: headings case-insensitive;studyIdrequired plus at least one other column; duplicate (ignoring case) and empty headings refused; every other heading is a screening column; each row needs a GUIDstudyId; each decision cell is blank or in the server's set (1/0, include(d)/exclude(d), yes/no, true/false); 256 MiB or smaller. It sends nothing. (Today's dialog checks only the header row, andStudyIdcase-sensitively.) - Summary truth. N = distinct study IDs. "replaced" counts non-blank custom ID and PDF cells; "removed (blank cells)" counts blank cells in rows with no decision (a blank cell in a row with a decision is kept, so it is in neither). Decisions per person are non-blank cells, split include/exclude by the server's set.
- The dialog stays open until the file is uploaded. The job exists only once the signature is issued and the PUT
to S3 is made. "Update N studies" stays busy until the PUT succeeds, then the dialog closes, focus returns to the
header action and the start announcement is made. A failed PUT keeps the dialog on step 3 with an error and a
retry (
reportUploadFailure). ReuseprojectDetailActions.startBulkStudyUpdateJob; take the payload type from the action creator, notproject/. - Guesses. A column's person is the project member whose first or full name appears in the column name as a whole word, ignoring case; none or several leaves "Choose a person". The stage is the one whose name appears in a screening column name, else the first.
- "Update N studies" stays focusable.
aria-disableduntil the box is ticked; activated unticked it shows "Tick the box to confirm." and focuses the box. - Running strip. For holders of
bulkUpdateStudies, the newest running bulk update in the project (anyone's), usingselectCurrentOrLatestJobRowrestricted to the running group. That function moves fromproject/study-managementtoshared/bulk-study-update/(the Library imports it from there). N istotalNumberOfStudies; if unknown the strip omits it. - Finished strip. Shows when a bulk update this tab saw running reaches a terminal state, until dismissed. Kept in
memory per tab: survives leaving Studies, resets on reload. "Details" opens the Activity sheet
(
;activity=open,?reveal=bulk-study-update:<id>) with that item's errors shown; "Dismiss" focuses the page heading. - The lock in the detail. BSU-a adds
bulkUpdateLockedAt: DateTime?toStudyDetailsDtoon the Library list, fromStudy.BulkUpdateLock.LockedAtUtc, set only while the flag is on, to everyviewStudiesreader (it says only what the 409 already says). The workspace reloads the list page when any bulk update job enters or leaves the running group. Review buttons stay; the server still refuses the save. - Rows not applied. One pure presenter
shared/bulk-study-update/rows-not-applied.tsbuilds the list from what the server recorded: v1 parse errors (errors[]withrecordIndex); withreviewEligibilityPolicyon, the refused-row sample; v2 problem rows (code read fromRow n: Codeby a strict pattern, else the raw message); from BSU-c, unmatched rows with study IDs. Row numbers are the file's line counting the header:recordIndex + 2. Unmatched rows without detail become one count line.StudyJobErrorgains an optionalrecordIndex, filled only bybulk-study-update-job.adapter.ts. - Where code lives. Dialog and strip in
studies/studies-workspace/bulk-study-update/; file check and presenter inshared/bulk-study-update/; job data from@core/state/entities/bulk-study-update-job/*. The workspace no longer importsUpdateStudiesComponent, so that pair is pruned fromeslint-boundary-exceptions.json(the fixture suite requires the list to be exact). The workspace branch of the study table stops renderingsyrf-bulk-study-update-summary; the Library branch keeps it. No pair is added. - Components (ADR-027):
SyrfDialogService.open(…, { mode: 'adaptive', title: 'Bulk study update' })with<syrf-dialog-layout>, neverMatDialogdirectly; m3eMatExpStepper(linear, each step label keeps a hidden "Step i of n: "); Angular Material for form fields, selects and the checkbox;InformationBoxComponentfor the warning; polite live region on the workspace, throttled at 2 s (D16).
Binding copy is the board's, with copy for undrawn states settled in "Settled by recommendation". Key strings:
header action "Bulk study update" (icon edit_document, outlined); steps "Choose the file", "Match screening columns"
(only with screening columns), "Check and update"; confirm "I understand this can’t be undone" / "Tick the box to
confirm." / "Update lock) and "Bulk study update finished:
Flag decision. Flagged behind studiesWorkspace. Off: the Library runs as today; BSU-a's fields are null; BSU-c
writes nothing new. On: the new dialog, strips, lock note and Activity errors. Off, on, off: every job and lock is the
same server data in both states; newBulkStudyUpdatesAllOrNothing only exposes the API's deployed
bulkStudyUpdateAtomicApply, which no flag state changes.
Acceptance criteria.
- Flag off.
/studiesis today's Library. "Bulk study update" opensUpdateStudiesComponentwith steps and copy unchanged; the Library strip is unchanged;allOrNothingandbulkUpdateLockedAtare null. Verify: existingupdate-studies.spec.ts,library-bulk-update-action.component.spec.tsandstudy-table.component.spec.tspass unchanged; a new controller test asserts both fields null while off. - On, off, on. A job started from the new dialog shows in the Library strip while off; a job started from the old dialog shows in the workspace strip and Activity while on; locks taken while off show in the note once on. Verify: one workspace spec and one controller test, each toggling on, off, on with data made in each state.
- Permission. The header action and both strips appear only with
bulkUpdateStudies. The signature still goes toProjectBulkUpdateStudiesPolicy; no other policy is touched. Verify: workspace spec for a reviewer, an editor and a manager; existingStudyControllerBulkStudyUpdateTestspolicy tests stay green. - Steps. "Match screening columns" appears only when the file has screening columns. Back from step 3 goes to step 2 or 1 accordingly. Each step is labelled "Step i of n". Focus moves to the current step on Next and Back. Verify: dialog spec with and without screening columns.
- Name. The default is "Bulk study update #
". The two errors show at the drawn moments, and focus goes to the field. Verify: dialog spec. - File. Choosing a file shows "
rows · columns: …". Remove clears it and returns focus to "Choose a CSV file". Next with no file shows "Choose the file to use." Verify: dialog spec. - The check runs on the device and sends nothing. Choosing a file, every step, Back and Cancel make no HTTP
request; the signature request is sent only by "Update N studies". Verify: dialog spec with
HttpTestingControllerexpecting none until confirm, then exactly one signature request. - The check matches the server. The file module accepts and refuses the same cases as the server: headings in any
case; a missing
studyId;studyIdalone; duplicate headings ignoring case; an empty heading; a blank or non-GUID study ID; each accepted decision value and an unknown one; blank cells; a byte over 256 MiB. Each case has the expected row number. Verify:bulk-study-update-file.spec.ts, cases taken one for one fromStudyUpdateRecordProcessorandScreeningDecisionParserTests, each commenting the server test it mirrors. - Row problems block the upload. A file with any row problem can't go past step 1; the problems are listed with row numbers and focus moves to the list. Verify: dialog spec.
- Guesses. People and the stage are pre-filled by decision 6. An ambiguous column is left unset. "Choose whose
decisions these are." and "
is already matched to ." block Next and focus the first bad select. Verify: dialog spec. - Summary truth. N counts distinct study IDs; "removed (blank cells)" counts only rows with no decision; decision
counts split by the server's value set. Verify: file-module spec; the E2E (AC 21) checks the server's
resultSummaryagrees with the summary. - Confirm. "Update N studies" is
aria-disableduntil the box is ticked; activated unticked it shows "Tick the box to confirm." and focuses the box. Verify: dialog spec. - Upload. While signing and uploading the button is busy and the dialog stays open. On success it closes, focus
returns to the header action (or "More actions" on phones) and the start announcement is made. On a failed PUT the
dialog stays on step 3 with the error and allows a retry, and the job reads as failed. Verify: dialog and
workspace specs; the existing
project-detail.effects.spec.tsforreportBulkStudyUpdateUploadFailure. - Lock wording only when true. For a v1 job, no strip, announcement or Activity line says the studies "can’t be
reviewed", and no lock icon or detail note appears. For a v2 job all of them do. Verify: strip and Activity specs
with
allOrNothingtrue, false and null (null = false). - Running strip. Shows the newest running update with its N and offers "Activity", which opens the sheet with
focus on Close. Not shown to a reader without
bulkUpdateStudies. Verify: strip spec with two running jobs and with stale-revision payloads. - Finished strip. Shows when a job this tab saw running ends. "Details" opens the sheet with that job revealed
and its errors shown; "Dismiss" removes the strip and focuses the heading; a reload doesn't bring it back.
Verify: strip spec and a workspace spec over the URL contract (
;activity=open,reveal). - Lock note. A study whose list row has
bulkUpdateLockedAtshows the drawn note in its detail. The list reloads when a bulk update enters or leaves running, so the note appears and clears without a manual refresh, for a reviewer too. Verify: detail spec, workspace spec for the reload, controller test for the projection on, off, on. - Rows not applied. An ended bulk update with known problems offers "Show errors (n)". The list is labelled "Rows
not applied"; row numbers count the header row; each source in decision 11 is worded; unknown codes fall back to
the raw message; unmatched rows without detail show as one count line. Verify:
rows-not-applied.spec.tsandactivity-sheet-item.component.spec.ts. - Boundaries.
pnpm run lintpasses. Thestudies-workspace.component.ts → update-studies.component.tspair is pruned fromeslint-boundary-exceptions.json, and no pair is added. Verify:pnpm run test:lint(tools/eslint/config.test.cjs) andpnpm run lintin CI. - Accessibility. Dialog full screen below 600 px (adaptive), every control labelled, errors linked with
aria-describedby, 44 px targets on coarse pointers. One polite, throttled live region. Show errors hasaria-expandedandaria-controls. Verify: dialog and strip specs, plus a 320 px pass in the E2E. - End to end. In a local E2E stack with
studiesWorkspaceon, a manager runs the flow with a file that has one unknown study and a screening column. v1: the strip runs and finishes with "… studies updated · 1 row not matched", and Activity lists the row (as a count until BSU-c; with BSU-c, by row and study ID). WithbulkStudyUpdateAtomicApplyon as well, the same file is refused, nothing changes, and the problem row is listed; a clean file locks its studies and a reviewer sees the note. Verify: newe2e/tests/studies-workspace-bulk-update.spec.tsone2e/pages/bulk-study-update.page.ts, underrun:e2e-full. - Docs.
user-guide/studies/manage-studies.mdgains the workspace flow and keeps the Library text for flag-off projects; this plan section folds in the spec; FEAT-016's doc records the blank-cell rule the summary uses. All in BSU-b.
PR split.
- BSU-a (server, API only, small):
BulkStudyUpdateJobDto.AllOrNothing,StudyDetailsDto.BulkUpdateLockedAtandProjectDetailsDto.NewBulkStudyUpdatesAllOrNothing(each null while off; the last only forbulkUpdateStudiesholders, from the API's deployedbulkStudyUpdateAtomicApply; no web flag half); regeneratedswagger.json,api-client.generated.ts, checksums and REST goldens. No project-management change, no stored field. - BSU-b (web): decisions 3–13, user guide and feature docs.
- BSU-c (server, after BSU-a and BSU-b): per-row unmatched detail. The API stamps
RecordsUnmatchedRows = trueon a newBulkStudyUpdateJobwhile the flag is on (decided once at creation, asExecutionVersionis; BSONSetIgnoreIfDefaultplusSetIgnoreExtraElements(true), theBulkPdfUploadJobprecedent). For a stamped v1 job each 400-row batch reads the matching IDs first (project-scoped, deletion-marked excluded,_idonly) and adds the unmatched rows toStudyUpdateParseResult.UnmatchedRowSample(RowNumber,StudyId, bounded at 100). The DTO carries the sample only while the flag is on. For v2 theStudyNotFoundproblem also carries the study ID. Rollback rule: don't roll API or project-management back below the release that adds the tolerant job map while any stamped job exists; ship the tolerant map one release ahead if production can't hold that. Tests:StudyReferenceFileParserTests.cs(exact unmatched rows up to the cap, both paths; unstamped writes nothing new; counts unchanged), a repository round-trip, mapping and controller tests on, off, on, and an E2E row.
Settled by recommendation.
- Q1: show each job as what it is (decision 2). The drawn partial outcome appears for v1 jobs; v2 jobs show a refusal. ADR-020 is unchanged.
- Q2: copy for undrawn states is the spec's recommended set: v1 strip and announcement drop the lock sentence
("Bulk study update running." / "…started."); N unknown reads "Its studies can’t be reviewed until it finishes.";
the step 3 warning drops the lock sentence for v1, and adds the
reviewEligibilityPolicysentence ("A decision for someone who has already decided on a study isn’t applied.") and the all-or-nothing sentence ("If anyone has already decided on a study, nothing is changed."); v2 refused or stopped: "Bulk study update stopped: nothing was changed."; upload failure: "The file didn’t reach SyRF, so nothing has changed. Try again."; row problems, rows-not-applied reasons and the repeated-ID studies line (", from rows of "). Client row problems: "Row : the study ID is missing."; "Row : isn’t a study ID."; "Row , column : isn’t a decision. Use 1 or 0, include or exclude, yes or no, true or false."; "The column appears twice."; "A column has no name."; "CSV files must be 256 MiB or smaller." Rows not applied: "Row : no study with this ID in this project."; "Row : isn’t an active member of the project."; "Row : someone in this row has already decided on this study."; "Row : the stage can’t take screening decisions."; " rows had no matching study in this project."; " more rows aren’t listed." - Q3: ship BSU-a and BSU-b with a count line, then BSU-c; v2 also carries the study ID.
- Q4: omit the file name from the Activity meta (not stored; no server change).
- Q5: add "in a row with no decisions" to the two help lines (customId, pdfRelativePath) now. Making blanks consistent on the server is the FEAT-016 follow-up.
- Q6: the finished strip lasts per tab, in memory, until dismissed or reload. Activity keeps the outcome.
BSU-b as built (#4779)¶
- Where it lives.
shared/bulk-study-update/:bulk-study-update-file.ts(the on-device check, comma-separated as the server reads it, blank rows skipped and unnumbered, the first problem per row in the server's order: screening cells, then a blank, then a non-GUID study ID;Guid.TryParse's D, N, B and P forms),rows-not-applied.ts,bulk-study-update-job-state.ts(phase, all-or-nothing and outcome from the core entity; the adapter spec pins its agreement with Processing's groups) andcurrent-or-latest-job.ts(the movedselectCurrentOrLatestJobRow, over a structural row soshared/imports nothing fromproject/).throttledAnnouncementmoved toshared/utils/with a re-export at its old path, so the workspace uses it without a new boundary pair. The dialog, strip, guesses and the tab's memory (WorkspaceBulkUpdateMemory, a root service) are instudies/studies-workspace/bulk-study-update/. - Undrawn copy beyond Q2. "The first row needs a studyId column.", "Add at least one column besides studyId.",
"Row
has fewer columns than the first row." (the server can't index a short row), "The file is empty.", "The file has no rows of studies.", "Choose a stage." and " more problems aren't listed." A stopped job says "Bulk study update stopped: nothing was changed." only when that is known (it never reached parsing, or an all-or-nothing job's own message says so); otherwise "Bulk study update stopped." A v2 rollback held for an engineer is not claimed as unchanged. "Row : isn't an active member…" names "someone in this row", because the server records the reason, not whose decision it was. - Review round 2. While the file is signed and sent the dialog sets
disableCloseand disables Cancel, so Escape, the backdrop, m3e's close button and Cancel can't close it while the job carries on; a failed upload gives both back. A check that rejects (the CSV reader's chunk fails to load) leaves "SyRF couldn't read this file." rather than a step stuck checking. The server reads bulk updates as comma-separated only (CsvLibrary), so the picker offers.csvand a tab-separated header says "The file must be comma-separated (CSV). This one uses tabs." The finished strip is announced once a tab (WorkspaceBulkUpdateMemory), and the running set behind the list reload is seeded only once the project's jobs have loaded. The Activity sheet now returns focus in the render that closes it, not on the drawer's late(closed)timer, which lost focus to the page when the drawer had recorded Close as the element to restore. - Guesses. A full-name match is tried before a first-name one, so "Bob Singh" is Bob Singh in a project with two Bobs; ambiguity still leaves "Choose a person".
- Focus. Next and Back focus the shown step's heading; Next with a problem focuses the name, "Choose a CSV file",
the problem list or the first bad select. "Dismiss" focuses the page heading:
app-page-shell'sh1gainedtabindex="-1"(programmatically focusable, not in the tab order). - Forms. Typed reactive forms, not signal forms:
mat-form-field's error state,mat-errorlinking througharia-describedbyandmat-selectneed anNgControl, and no in-repo control pairs signal forms with them. - Tests. The dialog spec renders the content component directly (no dialog open,
motion: 'none'on the stepper, so nothing waits on an animation) and runs the realProjectDetailEffectssignature and S3 effects overHttpTestingController: no request until "Update N studies", then exactly one signature request, the S3 PUT, and on a failed PUT thereportUploadFailurecall. - Full screen below 600 px in both dialog modes (AC 20, decided 2026-11-04). With
expressiveDialogson,SyrfDialogServiceopens m3e's adaptive dialog, which is full screen on phones. With it off it opens Angular Material's dialog, so the workspace passespanelClass: SYRF_DIALOG_PHONE_FULL_SCREEN_CLASSandglobal-styles/_dialog-phone.scssmakes that native pane 100vw by 100dvh below 600 px, with square corners and safe-area padding. The rule skips the m3e panel (:not(.mat-exp-dialog-panel)), andexpressiveDialogsis not required.global-styles/dialog-phone.spec.tspins the compiled rule; the 320 px E2E runs with the flag on and off. - E2E (AC 21).
e2e/tests/studies-workspace-bulk-update.spec.tspasses on a local stack. v1, the 320 px pass (both dialog modes) and the v2 half are separate tests; the v1 and v2 halves read the project'snewBulkStudyUpdatesAllOrNothingoncestudiesWorkspaceis on and skip in the other mode. Two gaps, accepted: - The all-or-nothing half runs only with the deployment flag.
bulkStudyUpdateAtomicApplyis deployment-only and no CI lane deploys it, so underrun:e2e-fullthe v2 half skips. Locally it runs withFeatureFlags__BulkStudyUpdateAtomicApply=trueexported beforee2e/run-local.sh. - The reviewer's lock note is covered by unit specs, not E2E. Seeing it depends on catching the lock while the job runs, so the E2E check is bounded and recorded as an annotation, not asserted. The detail spec covers the note and the workspace spec covers the list reload that shows and clears it, for a reviewer too.
- Found while testing (W3), fixed in #4806 (#4791). In the
local stack the app toolbar (sticky,
z-index: 100) covered the Activity sheet's Close, so a pointer couldn't reach it; Escape and the scrim still closed it. The cause was Material's sidenav stacking context: the project shell'smat-sidenav-containerandmat-sidenav-contentareposition: relative; z-index: 1, so the sheet's fixed layer (z-index: 1000) ranked 1 against the toolbar whatever its own z-index. The sheet now renders in the CDK overlay container under<body>, above the toolbar. Every other overlay (snackbar, tour step, dialog, menu) stays above it, whether it opened before the sheet or after: in a browser they are popovers in the top layer, and without popover support the sheet's overlay moves to the front of the container on each open. The v1 E2E closes the sheet with Close after checking thatelementFromPointat its centre is the button.
BSU-c as built (#4810)¶
- Stamp.
BulkStudyUpdateJob.RecordsUnmatchedRows, set byStudyController's signature from the per-requeststudiesWorkspacedecision at creation (Project.CreateBulkStudyUpdateJob(…, recordsUnmatchedRows)), never changed and never read from a flag by project-management. Stored only when true (SetIgnoreIfDefault); the job map gainsSetIgnoreExtraElements(true). - Rollback check. Main's job map before BSU-c had no
SetIgnoreExtraElements, butBulkStudyUpdateJobinheritsEntity.ExtraElements([BsonExtraElements], since 2025-11), which takes precedence: an older binary reads a stamped job, runs it unstamped and writes the stamp back.StudyUpdateParseResult's map has ignored extra elements since abff1badd4 (PM 11.137, API 9.141).BsonClassMapTestspins both against a copy of the pre-BSU-c map. So the tolerant maps are already a release ahead on main; the rule binds only a rollback below PM 11.137 / API 9.141 while a stamped job exists, and no stamped job exists anywherestudiesWorkspaceis off (production). - Version 1. For a stamped job, each 400-row batch first calls
IStudyRepository.GetStudyIdsInProjectAsync(_idin the batch,ProjectId= the job's project,StudyWriteGuard.Unmarked,_idprojection; a locked study still exists) and adds the rows naming none toStudyUpdateParseResult.UnmatchedRowSample(RowNumber= record index,StudyId), in file order, at most 100 (UnmatchedRowSampleLimit). Once the sample is full no further batch reads IDs. UnderreviewEligibilityPolicya screening row naming no study is already a refusedStudyNotFoundrow, so it isn't listed twice and no IDs are read for it. Tallies, and soresultSummary, are unchanged. An unstamped job reads nothing new and writesUnmatchedRowSampleas null (not stored). - Version 2.
BulkStudyUpdatePlanner.PlanAsync(…, recordStudyIds)puts the row's study ID on eachStudyNotFoundproblem when the job is stamped (BulkStudyUpdateExecutorreads the job's own stamp). The operation store writesStudyId(CSUUID) on that problem only when present; its reader reads named elements only. The job error becomesRow n: StudyNotFound <id>: the ID rides in the message becauseParseError's stored map is strict, so a new element would break an older reader. - Projection. The AutoMapper map alone is now the flag-off shape:
parseResult.unmatchedRowSamplenull and eachRow n: StudyNotFound <id>message without its ID (exact string handling, no regex: ASCII digits, then the ID inGuid"D" form and nothing else).BulkStudyUpdateJobDto.ApplyStudiesWorkspace, called byProjectJobFamilyPermissionFilter(project details over REST and SignalR) andStudyController.JobDto(the upload-failure report), restores both whilestudiesWorkspaceis on, alongsideallOrNothing. A job made while on keeps its stamp off, on and off. - Web.
rowsNotAppliedtakesunmatchedRowSample. With a sample each row reads "Row: no study with ID in this project." (the board's binding line, now true) and the count line is dropped. Unmatched rows the sample doesn't name go into the tail " more rows aren't listed." only when the sample has reached the server's cap (100); below it the server looked at every row, so the rest matched a study without being counted as matched and read " other rows weren't applied." Two ways that happens: with reviewEligibilityPolicyoff, two screening rows naming one study match it once; and a row with nothing to write (a file ofstudyIdand screening columns only, and a row whose decisions are all blank, parses with neither a custom ID nor a PDF path, soApplySimpleUpdatesnever sends it). Without a sample (an unstamped job) the count line stays. A v2StudyNotFoundmessage carrying an ID reads the same; without one it keeps Q2's "Row: no study with this ID in this project." Every row line starts "Row : " with the file's row number, including a v2 code without words, which keeps what follows the server's own prefix. - Sonar (review round 2).
ParseBulkStudyUpdateAsyncwas split into helpers over aBulkStudyUpdateRunaccumulator (ReadBatchAsync,RecordParseErrors,RecordUnmatchedRowsAsync,ApplyAdministrativeScreeningAsync,ApplyScreeningUpdatesAsync,ApplySimpleUpdatesAsync,ToParseResult), andPlanAsyncgainedStageProblemsandStudyNotFoundProblems, to bring each method under the cognitive-complexity limit. The code moved as it was: the same awaits withConfigureAwait(false), the same cancellation checks in the same order. - E2E.
studies-workspace-bulk-update.spec.ts: v1 checks the stored stamp and sample and the listed row; the v2 half (deployment flag only) checks the refused row names its ID.
Slice 9 · PDFs to check (Check proposals, Use proposed PDF)¶
Flag: studiesWorkspace, off in production, plus an index admission. Server (9a) and web (9b).
As built (PR #4785, 9a and 9b in one PR). Everything below, with these differences from the text:
- Migration
0035(0035_pmStudyPdfCorrection_ProjectId_Status_StudyId, constantStudyPdfCorrectionMigrationVersion = 35), the next free number on main at the time. If #3945 (L8) merges first and takes 0035, this PR renumbers its constant and lock entry. - No new
onlyStudyIdsparameter. #4719'sStudyIdRestrictiononFilterSortAsyncandFilterSortCountAsyncalready narrows the table query to a set of ids (live studies only) and ANDs a My studies tab, so Proposed PDF waiting passes the waiting study ids through it. - The summary has a fifth field,
AllOnNoPdfOrUnavailable, for settled 3: true only when every waiting proposal is on a study whose PDF state is No PDF or Unavailable (No PDF from the link; Unavailable only whilestudyPdfStatusis open). Overview picks the line from it. - Reads project the documents.
StudyPdfCorrectionhas noCheckedProposalIdmember, so the slice 9 reads andGetProposalAsyncreadBsonDocumentprojections (StudyPdfProposal), which see and exclude it. - Use's retries. A miss (link or decision) aborts and is classified once from a re-read. A write conflict (a concurrent Use or Reject's transaction still open) aborts and tries again (up to 6 attempts, 25 ms doubling), always against the link state first read, so a retry can't overwrite a link someone changed meanwhile.
- Web refusals. A list request that sent Proposed PDF waiting and gets
409 study-pdf-proposals-unavailable,409 study-pdf-proposals-too-manyor403resets the filter to Any and hides the option until the page reloads; the 409s are announced ("Proposed PDFs can't be listed here yet.", "There are too many proposed PDFs to list at once."). The guard keeps the value for a Set holder, or while the permission report is still loading (the server's 403 then resets it). - The PDF cell's suffix shows the
pendingicon with "N proposed" beside it; its accessible name is "PDF: No PDF · N proposed".
Outcome. An administrator (holder of Set, ApprovePdfCorrections) sees how many proposed PDFs are waiting, opens
them in one list in the Studies workspace, and uses or rejects each one. Each proposal carries its source, so checked
uploads from L9 (#3947) can join the same list later. Two administrators deciding the same proposal at once never
leave a study and its proposal disagreeing.
Decided (D6 = A, D7 = A, restated). Slice 9 is built now over the proposals the study detail stores
(StudyPdfCorrection, collection pmStudyPdfCorrection), behind studiesWorkspace. L8 (#3945) and L9 (#3947, with
4683's isolation plan) keep their own order and owner; slice 9 builds none of them. Single uploaded PDFs (#4751, and¶
L9's proposals) go through #4683's checking service later; slice 9 adds no upload and no scanning. D4 (Set may replace a bulk-delivered PDF, clearing its marker, never while a bulk upload for the search is unfinished) and D5 (Propose takes a web address) stand. Proposer names and the proposed-PDF count belong here (#4749); removal attribution does not.
Design decisions.
- Flag.
studiesWorkspace, no new flag. New routes areFeatureGated(StudiesWorkspace)(404 off). The new filter value is refused off (409). Approve's and Reject's new behaviour runs only while on; off, both are exactly as on main. Reads of the index need the admission: flag on and index present. - One index, by pre-deploy migration (ADR-023).
CreateIndexMigrationonpmStudyPdfCorrection, nameProjectId_1_Status_1_StudyId_1, keys{ ProjectId: 1, Status: 1, StudyId: 1 }(not unique, partial or TTL). The collection has no index on main:StudyCorrectionRepository.InitialiseIndexesAsyncbuilds a key definition and never creates it. Number: the next free one at merge time, through one constant (StudyPdfCorrectionMigrationVersion); see Merge order. AddpmStudyPdfCorrectiontoPmMigrations.MigrationOnlyCollectionsand remove the dead key definition.StudyPdfProposalsAdmission(Mongo.Data) copiesStudyPdfAvailabilityAdmission: decides the flag throughIFeatureDecisionson every call, then checks the index withNotificationAdmissionChecks.IndexesReadyAsync; a positive answer is kept for the process. Production can't run migrations yet, so there the admission stays closed and the list and count refuse safely. No new field, no new collection. Neithersyrftestnorsyrf_staginghas the collection today. - What "waiting" means.
ProjectIdis the route project,StatusisPending, noCheckedProposalIdelement, and the study is live in the project (pmStudyhas that_idwith the sameProjectId; a deletion-marked study drops out and returns on restore). - Source. Every proposal the new reads return carries
Source(enumStudyPdfProposalSource, string):WebAddress(UploadedToSyrffalse; Use and Reject) andUploadedFile(UploadedToSyrftrue; Reject only, D5). L9 adds its own value. The web treats an unknown source as "waiting, decided elsewhere": listed and counted, line "A PDF has been proposed and is waiting to be checked.", no Use or Reject. - Server reads, all
ApprovePdfCorrectionsonly, underapi/projects/{projectId:guid}/studies: GET pdf-proposals/summary→{ Available, WaitingCount, StudyCount, AtLimit }. Admission closed →200 { Available: false }and no read. Distinct studies bounded at 10,000.GET pdf-proposals?studyId=…(1 to 50 ids) →{ Available, Items }, waiting only, newest first; each item hasId,StudyId,Source,Address(null for a file),ProposedAt(Audit.Created),ProposedByName.- The list is the study list. The PDF filter value
ProposedPdfWaiting:403withoutApprovePdfCorrections;409 study-pdf-proposals-unavailablewhile the admission is closed; otherwise the distinct waiting study ids from the index (above 10,000:409 study-pdf-proposals-too-many) go toFilterSortAsyncandFilterSortCountAsyncas a new optionalonlyStudyIds. No ids means no rows and nopmStudyquery. The total stays the project total. - Field, reconciled with #4719. The spec declared its own
StudyFilters.Pdffield andStudyPdfFilterenum. #4719 merges first and ownsStudyFilters.PdfStatusFilterandStudyPdfStatusFilter(its decision 2). Slice 9 appendsProposedPdfWaitingto that enum and declares no second field. Nothing else in the slice changes. - Proposer names: Set holders only.
ProposedByNameis the proposer's full name frompmInvestigator(GetManyAsync), only in the two reads above.StudyCorrectionDtogains no name, so the Library list and review pages send no name to anyone (R14, R13). A missing record gives null and the web leaves out "by". - Use proposed PDF: one transaction, idempotent (flag on). Load the correction uncached; refuse a file (
409 study-pdf-proposal-file), a linkStudyPdfLinkRules.NormalizeForSetrejects (400) and a correction withCheckedProposalId(409 study-pdf-proposal-checked). A deleted study answers410first, on the first call and on a repeat, before the same-decision check, and sends nothing. If already used and the study's link is that link with no bulk marker, answer200and write nothing; used otherwise or rejected →409 study-pdf-proposal-resolved. Refuse a locked study (409) and a running bulk PDF upload for the search (409 study-pdf-bulk-upload-running). Then, in one MongoDB transaction (IPmUnitOfWork.StartSession(), bounded unknown-commit-result retry): W2's compare-and-setSetPdfLinkAsync(gaining an optionalISessionHandle), then a conditional decision writeIStudyCorrectionRepository.TryDecideAsync(filter_id,ProjectId,StudyId,Status: Pending, noCheckedProposalId; setStatus,ResolvedByInvestigatorId, bumpAudit.Version,LastModified,LastModifiedBy,LastAppVersion). If either misses, abort and write nothing. After an abort, re-read: the same decision already made →200; else409(study-pdf-link-changedif the link moved, otherwisestudy-pdf-proposal-resolved). After commit: dispatchStudyPdfCorrectionApprovedEvent, invalidate both caches, queue slice 8's check. W2's re-read-and-warn goes. BumpingAudit.Versionstops a stale whole-document save from an older pod overwriting the decision. - Reject: one conditional write, idempotent (flag on). Keep the
404and410checks, thenTryDecideAsynctoOriginalPdfKept. A miss: already rejected200; already used409 study-pdf-proposal-resolved("Someone has already used this proposal."); checked409 study-pdf-proposal-checked. No transaction, no lock check. - Compatibility with L8 and L9. #3947 adds
CheckedProposalIdand refuses the legacy Approve and Reject for a bound correction. Slice 9's filter and step 1 already exclude any document with that element, so a checked proposal can't be decided through slice 9 whichever lands first. #3947 keeps that field name (or updates slice 9's literal in the same PR) and owns adding its source value and actions to the reads. Slice 9 changes no file that #3945 or #4683 changes exceptPmMigrations.cs,migrations.lock.json,PmMigrationsTests.csandmongodb-reference.md. It sharesStudyController.csandStudyPdfCorrection.cswith #3947: it adds the session overload and decision write and leavesApprove/Rejectthemselves alone. - Web (all with
studiesWorkspaceon; off, nothing changes).core/services/study-pdf-proposals/study-pdf-proposals.service.tswraps the two reads;checkProposalsLink(projectId)joinscore/state/ui/studies-workspace-links.ts(all searches, no search text, no stage scope, no open study, PDF filter on Proposed PDF waiting). Overview (project/) and the workspace (studies/) import only core files.- Overview Needs attention card. For
approvePdfCorrectionsholders withAvailableand a count above 0, the card's first item is "1 proposed PDF to check" / "N proposed PDFs to check", the line "Reviewers proposed PDFs for studies with none or an unavailable one.", the action "Check proposals", thependingicon, not styled as an error. It shows whether or not the reader passescanSeeActivity. Fetched on load; no live update. - Filter. "Proposed PDF waiting" is offered only to
approvePdfCorrectionsholders while the summary saysAvailable; the chip reads "Proposed PDF waiting"; choosing a search clears it. A reader who can't use it and opens the URL gets Any PDF status and a rewritten URL. Until #4719 lands the menu has two items; after it, the option is appended after No PDF. - PDF cell. With
studyPdfStatuson, a study with a waiting proposal whose state is No PDF or Unavailable shows "No PDF · N proposed" or "Unavailable · N proposed" with thependingicon. N comes from the proposals already in the list'sstudyCorrections; no new data. - Detail, Set holders. The line becomes "Proposed: by
, ." and the dialog's first paragraph ", proposed by on ." Actions follow the source. With the read unavailable, W2's lines and actions stand. The detail still acts on the newest waiting proposal. Everyone else: unchanged, no name. - After Use or Reject,
refreshStudyListruns; a409shows the server'sdetail.
- Off, on, off. Off: the summary and proposals routes 404;
ProposedPdfWaiting409; Approve and Reject as on main; no web item, option, suffix or name. On with the index missing (production today): Use and Reject work with decisions 7 and 8 (they need no index); the summary and proposals answerAvailable: false; the card item, option and names stay hidden; the detail shows W2's lines. On with the index: everything above. No new field is written, so a decision made on reads the same off; a proposal made on is an ordinary waiting correction off; a proposal made off shows when on, and Use refuses an address W2's rules don't accept. Rollback: an older build ignores the index.
Acceptance criteria.
- With
studiesWorkspaceoff the summary and proposals routes answer404,ProposedPdfWaitinganswers409, and Approve and Reject behave exactly as on main (pinned by the existing W2 off tests). Tested on, off, on, with proposals and decisions made in each state, including a proposal made on and decided off, and the reverse. - With the flag on and the index missing, the summary and proposals answer
Available: falseand read nothing frompmStudyPdfCorrection; the filter value answers409; Use and Reject still work. Once the index exists, the next call opens without a restart. LikeStudyPdfAvailabilityAdmission, the admission keeps a positive index check for the life of the process: if the migration's Down drops the index, it keeps reading without the index until the next restart. The rollback runbook restarts the API after a Down. - Migration: the declared name, keys and options; version = the previous constant + 1; Up, Down and adoption; the
lock entry;
pmStudyPdfCorrectioninMigrationOnlyCollections; nothing builds it at start-up. - The count, the filter and the proposals read count or list only waiting proposals in the route project on live
studies: not used or rejected ones, not another project's, not a deletion-marked study's (which counts again after
a restore), not one carrying
CheckedProposalId.explainshows the index used, with no collection scan. - Every route and filter value in this slice refuses a caller without
ApprovePdfCorrections(403), including a reviewer who holdsSubmitPdfCorrections. No default grant changes. ProposedByNameappears only in the two Set-only reads. The Library list,StudyCorrectionDtoand the review DTOs carry no proposer name for any caller (serialisation goldens).- Each returned proposal carries
Source:WebAddressorUploadedFile. The web lists a proposal with an unknown source without Use or Reject. - Use proposed PDF writes the link and the decision together or not at all. A refusal from either write (locked,
deleted, a running bulk upload for the search, a changed link, a decided proposal) leaves the study, its marker, its
Audit.Versionand the proposal unchanged. - Two concurrent Uses of one proposal produce one link write and one decision; both callers get
200. A Use racing a Reject ends with exactly one decision and a study that agrees with it; the loser gets409. Neither path answers500. Tested on a replica set with an interleaving hook between the two writes. - Reject repeated is
200and writes nothing the second time; Reject after Use is409; Use after Reject is409. - Use on a bulk-delivered PDF clears both marker fields with the new path (D4). It is refused while any unfinished
bulk PDF upload exists for the study's search. After a commit, slice 8's check is queued (with
studyPdfStatuson). - Overview shows "N proposed PDFs to check" (singular for 1) with "Check proposals" only to Set holders, first in the card, and also to a Set holder who can't see Activity. "Check proposals" opens the workspace filtered to Proposed PDF waiting with every other filter cleared.
- "Proposed PDF waiting" is offered only to Set holders. A non-holder opening that URL gets Any PDF status and a rewritten URL. Choosing a search clears it.
- The PDF cell reads "No PDF · N proposed" or "Unavailable · N proposed" for a study with N waiting proposals, with
studyPdfStatuson, and is unchanged with it off. - A Set holder's detail reads "Proposed: by
, ." and the dialog ", proposed by on ."; with no name both leave out "by "; a reviewer's detail is unchanged from W2. pnpm run lintpasses with no new entry ineslint-boundary-exceptions.json.- Docs: this plan's slice 9 section, W2 item 10 and W5's left-out line updated;
mongodb-reference.mdcorrected topmStudyPdfCorrectionwith its index;study-endpoint-authorization.mdrows for the new routes and filter;user-guide/studies/manage-pdfs.mdcovers checking proposals; the flag's consumer-manifest entries updated.
PR split. Two PRs. 9a (server): the migration and its lock entry, the admission, TryDecideAsync, the session
overload of SetPdfLinkAsync, the two reads and DTOs, ProposedPdfWaiting on #4719's filter enum, the transactional
Use, the conditional Reject, the consumer-manifest entries and the generated client. 9b (web): the core service
and link, the Overview item, the filter option, the PDF cell suffix, and the detail's names and source handling.
Settled by recommendation.
- Provenance line after Use ("Proposed by
, accepted by just now"): build it for Set holders only, in 9b; everyone else keeps W2's line. - The Overview count is
WaitingCount(waiting proposals); the filter lists the studies they belong to. - The card's line: the board's line when every proposal is on a No PDF or Unavailable study, otherwise "Reviewers proposed PDFs for these studies."
- Keep the 10,000-study bound, with the refusal "There are too many proposed PDFs to list at once." and no paging work until someone hits it.
Follow-ups, not in this slice: index the resolver's per-row pmStudyPdfCorrection read by adding ProjectId (its own
flag decision); send SubmittedByInvestigatorId only to the proposer and Set holders; the flag-off duplicate-key
500 on racing decisions (main's behaviour, kept); removal attribution (#4749, first item).
Merge order¶
- S12 first. The others add their controls in its pattern, and it doesn't restyle work that is still moving.
- #4719 next, then slice 9. Slice 9 extends #4719's
StudyFilters.PdfStatusFilter(it appendsProposedPdfWaiting), so it follows #4719. - #4720 (server PR, then web PR) and Bulk study update in Studies (BSU-a server, BSU-b web; BSU-c, the
per-row unmatched detail, after them) run in parallel with those. Merge
mainin after whichever of #4719 and #4720 merges first: both edit the filter bar and workspace files. - Migration numbers. Main ends at
0034_pmStudyPdfAvailability_ProjectId_Status. Slice 9's index and #4720's fallback index (only if itsexplainevidence fails) each take the next free number at merge time (0035 or later), because #3945 (L8) also claims one andMigrationCatalogrefuses gaps. Whichever merges second renumbers its constant and lock entry. Slice 9 (#4785) declares0035. - Migration 0034 is declared pre-deploy. It runs only where ADR-023 migrations are enabled, and production runs none yet (ADR-023 P2, not run). #4719 and slice 9 therefore refuse safely in production through their admissions.
Still blocked¶
- "What reviewers see" (R13). The setting's screens in Stage setup are not drawn.
- Slice 10. Needs the AF2 owners.
- S13. There is no navigation rail on main.
- #4751 (single uploaded PDFs). D7 = A: it waits for #4683's questions and its isolated checking service.
Migration, rollout, and rollback¶
Data migration¶
The MVP adds no universal job collection and no destructive data migration. Mapping fixes read existing aggregate fields. If a timestamp/owner is historically absent, the UI displays Not available. A later backfill requires a separate ADR and dry-run counts; it must never infer creation time from object IDs or browser observation without product approval.
Feature exposure¶
- ship contract and contextual fixes independently because they correct existing behaviour;
- keep
/studiesstable and redirect the legacyadmin/systematic-searchesURL to/searches; - expose the new Processing child/navigation item behind one generated, documented runtime flag until
staging proof passes. Wave 2 creates
studyManagementProcessingthrough the repository's env-mapping generators (default false everywhere; Waves 3–5 consume the typed selector); do not restore the historicalnewStudyManagementroute-replacement flag; - flags gate route visibility and route access consistently, not only the navigation link; and
- record production defaults in the deployment configuration PR, outside UI source ownership.
Rollback¶
- Wave 1 mapping changes are independently revertible and do not rewrite stored documents.
- Waves 3 and 4 preserve or redirect existing routes and can revert their presentation without losing operations.
- Wave 5 can be disabled through its runtime flag while contextual current/latest summaries remain usable.
- a rollback must not cancel or mutate active server jobs; it only changes presentation/routing.
- retain support access to job IDs and owning screens throughout rollback.
Validation strategy¶
Focused component/domain tests¶
- C#: mapping/profile tests, domain count semantics, policy projections, serialisation of unknown statuses, and historical missing-field fixtures.
- TypeScript: pure adapter matrices, selectors, progress/date validation, component states, accessibility semantics, and stable live-update ordering.
- E2E: vertical user journeys rather than screenshot-only assertions.
Controlled staging proof¶
For each UI wave, capture:
- environment URL, version, SHA, feature flags, viewport/zoom, user role and permissions;
- a newly uploaded systematic search through import completion or a controlled error;
- a Bulk Study Update with at least one modified and one unmatched/no-op row;
- Bulk PDF upload plus supported cancel/retry/report paths when enabled;
- Risk of Bias launch and terminal/error state when enabled;
- navigation away, hard refresh, reconnect, Back, and a second authorised browser session;
- keyboard-only and screen-reader pass, 320px, 200% and 400% zoom, long errors/names; and
- timestamps checked against API payload and server logs, including proof that no year-one date is rendered.
Use disposable project/search/study data and document cleanup. Never run destructive recovery against production. PR previews are useful evidence but cannot substitute for a staging environment whose capabilities match the release target.
CI and merge gates¶
- focused affected .NET and web tests pass;
- generated API client is current when a DTO changes;
- lint/build and documentation validation pass;
- all review threads are resolved or explicitly deferred to a named dependency;
- preview/staging proof: for flag-gated slices the controlled staging proof is attached before the flag is enabled anywhere (see acceptance-criteria.md C7.2); unflagged corrections attach preview or test evidence to their own PR;
- no in-progress or failed required check remains; and
- final user review controls merge and phased deployment.
Follow-up issue set after plan approval¶
Create one issue per wave with the file ledger above, plus separate issues for:
- Search Import created/completed/initiator contract if not included in 1B;
- Risk of Bias initiator and command semantics;
- history retention/pagination evidence and any dedicated API;
- admin cross-project monitoring successor to #2528/#2562 after #3010; and
- post-#2994
CLAUDE.mdpointer update once PRs #2900/#2979 release that file (the catalogue, generated indexes, and MkDocs navigation are already updated in Wave 0A).
Do not create these issues or implementation PRs until the plan review confirms the boundaries.