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. Single-study reads only report the stored status. They never trigger a check.
Amended 10 November 2026 (owner, PDF checks started from the Studies list). The MVP's "the Library list only
reports" left every linked PDF in an unreviewed project reading Not yet confirmed, so the Studies list now starts
checks too:
- the list read (POST /api/projects/{projectId}/studies, StudyController.GetTableData, every branch: the plain
list, a PDF option, Proposed PDF waiting, a My studies tab) offers each live study on the page it returns whose
status is due, by the same rules (decision 7), queue and dedup (decision 6), lease and worker;
- it returns without waiting and reports the status as read, so a study just offered still reads Not yet confirmed
until a worker claims it;
- with the flag off, or its index missing, nothing is offered.
PR 1 of 2 is #4887 (as built below). PR 2 (#4890) adds a background sweep behind its own flag (decision 11), which
also picks up links that project management adds or changes, on its next pass.
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.
11. Background sweep (added 10 November 2026, owner; #4890). "Checks can occur in the background as jobs": with
the new runtime flag studyPdfBackgroundChecks on (Operational, default off everywhere including staging) and
studyPdfStatus's admission open, one API pod at a time walks pmStudy and offers studies to that pod's check
queue. The details below are the orchestrator's calls, reported to the owner.
- Host and lease. The API, because the queue, checker and worker live there. One scanner at a time: a single
lease document, pmStudyPdfCheckSweep _id: "pmStudy", with a 2-minute lease renewed every batch. A pod that
loses the lease stops, and whoever takes it next resumes from the cursor.
- Walk. _id order from the cursor, batches of 500, projecting only the link inputs and the deletion marker;
availability documents by _id. No index or migration is needed.
- What it starts. StudyPdfStatusRules.NeedsBackgroundCheck: a link with no current result, or a Couldn't
check older than 15 minutes on a link SyRF fetches. Never Unavailable on a timer: that stays on a retrieval
after 24 hours, so a project with many missing files doesn't fetch them all daily. Always a subset of
NeedsCheck.
- Gentle. It offers only while fewer than 64 checks (a quarter of the queue) are pending on the pod, and
waits otherwise, so list and review offers always have room. The worker's 4 at a time is the rate limit on the
PDF host. There is also a 200 ms pause between batches.
- Passes. A completed pass rests StudyPdfCheckSweep:IdleBetweenPasses (6 hours by default, clamped to
15 minutes to 7 days), then the next starts from the beginning. That is how links that project management
writes (imports, study update files, bulk study updates, bulk PDF delivery) get checked, with no message from
it: they are picked up by the next pass, up to about 6 hours later by default. The Studies list and review
still start a check at once for what people open, and the study detail's set or replace and an accepted
proposal queue their own.
- Switching. The flag is decided before each pass, each batch, and every second while the sweep waits for
room, so off stops it within one batch. Off, it reads nothing but the flag. The cursor survives off and on.
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).
As built: PDF checks started from the Studies list (PR 1 of 2, #4887). No new flag: it is behind
studyPdfStatus, because it widens when an already-flagged check runs, adds no data, and survives on, off and on.
- Server.
StudyPdfStatusReporter.ReportManyAsyncgainsstartChecks(default false). With it, after the page's one availability read, each live study in the project whoseStudyPdfStatusRules.NeedsCheckis true is offered withStudyPdfCheckQueue.Offer. That means Not yet confirmed (including a document claimed under another project), Unavailable older than 24 hours, and Unverified older than 15 minutes. It never offers No PDF, Available, a current External, a fresh lease (Checking), or a deleted, moved or other-project study. OnlyGetTableDatapasses it; single-study reads and the correction responses still only report. - What doesn't change. A full queue drops the offer silently and the next read offers it again. A storage failure
returns no statuses and offers nothing. External and unsafe links are still never fetched (
ResultWithoutFetch). The recheck ages, the queue's 256 and the worker's 4 are unchanged. - Who can cause checks. Authorisation is unchanged: only a caller admitted by
ProjectViewStudiesPolicyreaches the offer. An impersonated request starts no checks:GetTableDatapassesstartChecks: falsewheneverICurrentUserService.IsImpersonating()is true (support impersonation, read-only or with an edit context, and the legacy path, asUserImpersonationMiddlewarereports them). The list stays[ImpersonationReadOnlySafe], readable under impersonation, and reports the stored statuses exactly as before #4887. This is the conservative reading of the attribute's "writes nothing, publishes nothing and starts no job"; the orchestrator decided it and is telling the owner. The GET review retrievals still offer their study's check under impersonation, as before this PR. - Web. With
studyPdfStatuson,followPdfChecksOnPage(studies/study-table/pdf-check-rereads.ts, called byStudyTableComponent) follows each load the reader or the app starts (fetchStudyList,refreshStudyList). While a row on the page is Not yet confirmed or Checking, it dispatchesrereadStudyListQuietlyafterPDF_CHECK_REREAD_DELAYS_MS(5, 10 and 20 s, each measured from the previous answer), three times at most. - It stops early once the page settles, and for the rest of that load once the query, page, project or flag changes (even if it changes back), or when the component goes.
- The quiet re-read shares the list effect's
switchMap, so a load the reader starts replaces it. It is dropped while any load or other re-read is in flight (the reducer stampsrereadTokenon the one it accepts, and the effect starts a request only for a stamp it has not seen), so it never replaces or restarts one. - It does nothing while a PDF status filter is chosen (any option but Any PDF status), so rows never vanish from under the reader and the page then changes only when they load it; clearing the filter lets the next load follow afresh. It also does nothing under support impersonation, when the server starts no checks.
- It never sets
loading. A failure is quiet (studyListRereadFailed: no error state, no filter reset). - The table tracks rows by id, so selection, focus, scroll and the open study stay.
- Off, nothing is scheduled and the list is as before.
- Tests.
StudyPdfStatusReporterTests(which studies are offered, none without the opt-in or while closed, a storage failure, a page larger than the queue's room, dedup, on/off/on);StudyControllerPdfStatusTests(offers its due rows, returns before a slow check finishes, on/off/on, an impersonated read offers nothing);StudyEndpointAuthorizationTests(a non-member causes no offers; an impersonated member offers nothing, the same member not impersonated offers). On the web:pdf-check-rereads.spec.ts, the reducer, effects and workspace-rows specs.
As built: the background sweep (PR 2 of 2, #4890). Flagged: studyPdfBackgroundChecks (feature
study-pdf-background-checks, Operational, Global, default off everywhere until the owner switches it on, with a
runtime-manageable dependency on studyPdfStatus). Reason: a new background load on the database and the PDF host,
which wants its own kill switch.
- Code.
SyRF.API.PdfStatus.StudyPdfCheckSweep(one run: lease, pass, walk) andStudyPdfCheckSweepWorker(hosted, a run every minute, registered afterStudyPdfCheckWorkerinAddStudyPdfStatus), overIStudyPdfCheckSweepStore. The rule isStudyPdfStatusRules.NeedsBackgroundCheck. The constants are onStudyPdfCheckSweep:BatchSize500,OfferHeadroom64,LeaseFor2 min,RenewWhileWaitingEvery30 s,RoomPoll1 s,PauseBetweenBatches200 ms,PollInterval1 min. One setting is bound from configuration:StudyPdfCheckSweepOptions.IdleBetweenPasses, keyStudyPdfCheckSweep:IdleBetweenPasses(a TimeSpan such as06:00:00). Its default is 6 hours. Any value is clamped to 15 minutes at least and 7 days at most, so a bad value can neither make the sweep reread pmStudy back to back nor stall it for ever. A value that doesn't parse falls back to the default rather than stopping the API. No Helm or GitOps value sets it in this PR, so the default applies everywhere. The pass log line says when the next pass starts. - The rule's one refinement. A stale Couldn't check on an unsafe link is not rechecked: it is always recorded Unverified without a fetch, so a recheck could never change it.
- State and progress.
pmStudyPdfCheckSweep, a raw-BSON document (MongoStudyPdfCheckSweepStore), which is how an operator follows progress: LeaseOwner(machine:guid) andLeaseExpiresAtUtc;Cursor, a CSUUID: the last study the current pass finished; absent at a pass's start and after it completes;PassStartedAtUtcandPassCompletedAtUtc;ScannedandOffered, 64-bit counters for the current or last pass.
Every progress write is conditional on holding the lease. A read-only find on the collection in a non-production
database shows how far a pass has got. One log line per completed pass gives scanned, offered and duration; one
per lease loss; one when the flag or admission pauses a pass. There are no per-study logs and no metrics: the PDF
checker has no metrics pattern to follow.
- Liveness. A deletion-marked (deleted or moved) study, or one with no project, is never offered. A document
claimed under another project counts as absent, as on every read. Offers are made in the study's own project.
- What doesn't change. No request path changes; the #4887 list and review behaviour are untouched. Recheck ages,
the queue's 256 and the worker's 4 are unchanged. External and unsafe links are still recorded without a fetch.
No index or migration.
- Shutdown. The worker returns from start-up at once (Task.Yield), honours the stopping token in every wait,
never throws out of ExecuteAsync (a failed run is logged by exception type and retried at the next poll) and never
releases its lease on shutdown: the lease expires within 2 minutes, so no pod can clear another's.
- Sizing (criterion 8). StudyPdfCheckSweepScaleTests, SYRF_PDF_SWEEP_SCALE_SIZE 20000 and 50000, Release, on a
Testcontainers MongoDB on the development host. Synthetic studies with empty abstracts: real documents are larger.
- Settled studies, no pause: 11,300 to 14,700 studies/s. This is the raw scan.
- Settled studies, the production 200 ms pause: about 2,130 studies/s (20,000 in 9.3 s, 50,000 in 23.4 s).
- Every study unconfirmed, the queue drained at once: 8,700 to 9,700 studies/s. This is the most the sweep can
offer.
A 200,000-study project: - A settled pass takes 200,000 / 2,130 ≈ 94 s: 400 batches × 0.2 s = 80 s of pauses, plus about 14 s of reads. - First checks are bound by the worker, not the sweep: 4 at a time, so 4 / t checks a second, where t is one check (one 5-byte ranged GET to the PDF host plus about five small database operations; not measured here). - t = 0.3 s gives about 13 a second: 200,000 first checks in about 4.2 hours. - t = 1 s gives 4 a second: about 14 hours. - If every fetch timed out at 10 s, 0.4 a second: about 5.8 days. - The sweep keeps at most 64 checks pending, so 192 of the queue's 256 places stay free for list and review offers.
Steady state. Once settled, a pass still reads every study's link inputs, once every 6 hours by default plus the pass time. PDFs added by imports and bulk uploads are picked up by the next pass, up to about 6 hours later by default.
Before production. Each pass reads every study's link inputs across all projects, and production's
pmStudyis about 10 GB (#4771). BeforestudyPdfBackgroundChecksis switched on in production, the owner decides the interval (StudyPdfCheckSweep:IdleBetweenPasses, 15 minutes to 7 days, default 6 hours). The preferred follow-up is an index-driven pass: read only the studies with a link and no current result through the stored classification index (migration 0037,ProjectId_1_PdfLinkKind_1_PdfCheckState_1_SystematicSearchId_1) once it is armed there, instead of walking every study. It is not built in this PR.If the PDF host is unreachable for a long time, every internal link records Couldn't check, and each pass offers them all again once that result is 15 minutes old. At the 10 s fetch timeout such a pass could take days. No back-off is built in this PR. The cost is bounded: four fetches at a time, list and review offers keep their queue headroom, and nothing else waits on the sweep.
studyPdfBackgroundChecksis the lever during an outage: switch it off and the sweep stops within one batch, keeping its cursor.Follow-ups, not built: an index-driven pass (above), and backing off after consecutive Couldn't check results. - Tests.
StudyPdfStatusRulesTests, theNeedsBackgroundCheckcases.StudyPdfCheckSweepTests: the walk, rule, headroom, lease, passes, restart, flag and admission on/off/on, two scanners and the worker's shutdown.StudyPdfCheckSweepStoreMongoTests, on Testcontainers MongoDB: the document shape, CSUUID cursor, conditional lease, projection and order, and a pass end to end.StudyPdfStatusRegistrationTests: the registration.StudyPdfCheckSweepScaleTests: the sizing harness, run only withSYRF_PDF_SWEEP_SCALE_SIZE.
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 (superseded on 10 November 2026: the Studies list now offers its page's due studies, see decision 5 and the as-built note for #4887).
- 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 will check it soon, and this updates by itself." It was "SyRF checks it when the study is next opened for review." until #4887. Listing the page now starts the check (slice 8 decision 5, as amended), so the line no longer names review. It promises no time, because a full queue drops the offer until the next listing.
- Couldn't check (since #4887): "SyRF couldn’t reach the stored file. It tries again after 15 minutes, when the study is next listed or opened for review." It previously named review only.
- 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.
W2 details card, sliding selection and motion as built (PR #4885)¶
Owner-approved revision of 10 November 2026 (the mockup 03r-study-detail-revised.png and the motion sketch, its
default settings: expressive springs, the card from the edge), under UI standard VIS-6. Web only; no server change.
- Flag decision. No new flag. The card, the selected row, the selection shape and all motion render only with
studiesWorkspaceon; the PDF icon change applies wherever the stored-state PDF cell renders, which needsstudyPdfStatuson (so withstudiesWorkspaceoff andstudyPdfStatuson, the Library's cell gets the new icon too). Both flags are off in production. With both off the Library is unchanged (study-table.workspace-rows.spec.tson/off/on). - One card.
studies-workspace-detail.componentis one outlined, raised card: surface, 1 pxoutline-variant, 16 px corners (--mat-sys-corner-large),--mat-sys-level1,overflow: hidden. A header band insecondary-containerholds a small "Selected study" label (data-testid="workspace-detail-label") above the title, with Close beside the list or Back in its place. The facts, the PDF section and the work section follow as parts of the card divided by 1 pxoutline-variantlines; the bulk-update lock note stays a tinted note between the facts and the PDF section. Test ids, heading ids, focus moves (heading on open, row on close) and the card's accessible name (the title) are unchanged. - Selected row. In workspace mode the selected row is a filled container with 16 px corners in the same
secondary-containertint, withon-secondary-containertext and no divider through or above it. No edge bar. The row keepsaria-current="true"on its title button. All new table rules sit under the workspace-only.study-table--expressiveclass. - One sliding shape.
study-table/selection-indicator/study-selection-indicator.component(aria-hidden) is the fill: an absolutely positioned element in the drawer content, before the table, which is lifted above it with a transparent background (the header keeps its surface). Rows carrydata-study-rowin workspace mode only. The shape takes the selected row's box in the drawer content's own coordinates (scroll included, so scrolling needs nothing) after each render that changes the selection (afterRenderEffect), and from observers: aResizeObserveron the drawer content, the table and the selected row (window resize, column changes, the detail opening, text reflow), and aMutationObserveron the table bodies (sort, filter, paging). Observer callbacks write its styles directly and ask Angular for nothing. Only a move to another row transitions (study-selection-indicator--sliding, position and size on the default spatial spring, removed ontransitionend); a layout change on the same row follows at once. On first selection it grows from the middle of the row (animate.enter), on close it fades (animate.leave). While the shape sits on a row the row carriesstudy-row--indicatedand drops its own tint; when the row isn't on the page or has no layout the shape hides and the row keeps a non-animated fallback tint, so selection is never invisible. Forced colours: the shape hides and the selected row gets aHighlightoutline. The selection already closes when the study leaves the page (W1), so in practice the shape hides only before layout. - Card motion. The card sits in a slot (
data-testid="workspace-detail-slot"). Beside the list (from 1,240 px) the slot grows from width 0 (and minus the list gap) to 400 px on one default spatial spring as the list narrows, and the 400 px card inside it fades in on the effects spring, so it enters from the right; close reverses the slot (animate.enter/animate.leave, Angular 22's built-in support, no@angular/animations). The list-and-card row clips sideways withoverflow-x: clip, which makes no scroll container, so the card stays sticky and nothing scrolls sideways; 4 px of padding either side, taken back by a negative margin, keep the card's shadow and edge focus rings inside the clip. Changing study keeps the slot and the card; the title and body cross-fade on the effects spring (ws-detail--swap-a/-b, alternating so each switch restarts it). Below 1,240 px the card replaces the list (W1), so it only fades in, and goes at once; the list is hidden while it is open, so there is no row for the shape to slide to there. - Motion tokens.
_m3-expressive.scssadds$default-spatial(damping 0.8, stiffness 380, sampled intolinear()over 411 ms) with a cubic-bezier fallback,$reduced-motion-fade(150 ms linear), and thespring-transition/spring-animationmixins, which put movement on DefaultSpatial and colour or opacity on the existing$default-effects. Underprefers-reduced-motion: reducenothing slides, grows or resizes over time: the shape and the card fade in and out, and the list takes its width at once. - PDF icon (owner revision, 10 November 2026). Every state with a PDF is the
picture_as_pdfMaterial Symbol with a small badge on its bottom-right corner that says what kind it is, so each state reads without colour; no question mark anywhere. Available: no badge, primary. Not yet confirmed: muted,schedule. Checking: muted,sync(static; there is no icon spinner convention, and nothing turns under reduced motion). Unavailable: muted, with theerrorbadge in the error colour (the file isn't usable, so it mustn't look like the available one). Couldn't check: muted,sync_problem. Hosted elsewhere: primary,open_in_new. No PDF staysremove, muted. Every glyph's colour comes from the state, never from the link around it; only Open PDF's icon takes its button's label colour. A waiting proposal (slice 9) stayspending: today's icon on No PDF, the badge on Unavailable. The mapping is a typedRecord<StudyPdfStatus, StudyPdfStatusIcon>(base icon, optional badge, tones) instudy-pdf-status-labels.ts, so a new server state is a compile error, andstudyPdfStatusIcon(status, proposalWaiting)applies the proposal rule. One presentational component,shared/study-pdf-status-icon/(syrf-study-pdf-status-icon,aria-hidden), draws it in the study list's PDF cell, before "PDF ·" in the details card's PDF heading, and as Open PDF's leading icon (the PDF icon with the open_in_newbadge,STUDY_PDF_NEW_TAB_ICON, in place of the bareopen_in_new). The icon is a fixed box 1.3 times the glyph (31 px for the 24 px glyph in the list and the card heading); the badge is 55% of the glyph (13 px), sits about 38% of its size beyond the glyph's bottom-right corner, and has a disc and 1.5 px ring in the colour behind it (--pdf-icon-ring: the surface, the selected row's tint, the hover colour or a tonal button's container), so it reads at 100% zoom and changes no layout size. The badge takes the icon's tone, not a surrounding link's colour, unless it is the error badge. The component carries themat-iconclass and, in the button, thematButtonIconattribute, so the button's icon slot sizes and spaces it like any button icon; inside, everything is sized inem. Labels, tooltips and accessible names are unchanged. - Browser pass (local E2E stack). The card title is 18 px medium on 24 px lines, as the mockup sets it (title-medium
face at that size). The card's buttons get their M3 colour roles from
m3x.button-colour-roles(the colour half ofm3x.buttons, now its own mixin): withsyrf-expressive-buttonson<html>, the M2 compatibility theme's root tokens still drew a tonal button white and text labels black, as on main. Measured: no horizontal overflow at 1,240, 1,440 or 1,920 px in any frame of opening or closing the card; the header, actions and Searches pane keep their boxes at 1,440 and 1,920 px (below 1,440 px the pane steps aside by W1's rule). The selection shape matched its row to the pixel after selection, scrolling, two window sizes, the column picker and sorting, and went when paging took the study off the page. The white tonal button was identical on main. With the card open the list's PDF column fell to 35 px and its header read "PD" (also on main); in workspace mode it now has an 80 px minimum, and no header is clipped at 1,240 or 1,440 px with the card open or closed. Glyph, badge and row-text contrast measured at least 5.02:1 in light and dark, on plain rows and on the selected tint. - Housekeeping. The study table's unused legacy
detailExpandtrigger is removed, so the component no longer runs the@angular/animationsrenderer besideanimate.leave. - Tests.
study-selection-indicator.component.spec.ts(placement, one shape, slide only between rows, resize, re-order, not on the page, no layout, close);study-table.workspace-rows.spec.ts(onearia-hiddenshape before the table, none without a selection, on/off/on);study-table.pdf-status.spec.ts(every state's icon and badge, nohelp_outline);study-pdf-status-labels.spec.ts(the mapping, distinct shapes, the proposal rule);study-pdf-status-icon.component.spec.ts;studies-workspace-detail.component.spec.ts(header band, label, sections, swap classes, the PDF heading's and Open PDF's badged icons);studies-workspace.component.spec.ts(the slot, the same card across a switch, focus); andstudies-workspace-motion.spec.ts, which compiles the four stylesheets and pins the card, the selected row, the spring tokens, the reduced-motion blocks, the clip and the icon styles, since jsdom applies no component styles. Screenshots and a recording through the local E2E stack are captured separately.
W2 details card rearranged (PR #4899)¶
The owner reviewed the card on staging on 11 November 2026 and approved a rearrangement (design reference section
s3, "Design review of the details card", capture cr-review-2.png). The card answers, top to bottom: is this the
study I meant; does it have a PDF I can use; where is it in the review and is there anything for me to do; and,
rarely, the record's details. The #4885 shell, motion, selection shape, badged icon and m3x.button-colour-roles are
unchanged. Web only; no server change.
- Flag decision. No new flag: all of it renders only with
studiesWorkspaceon (the card exists only on the workspace route;studies.routes.spec.tsfollows the flag on, off and on again). - Header. The title, then one muted citation line (
studyCitationLine: authors · year · journal, missing parts left out with no stray separator, every author). Authors and Published left the facts list. - PDF. A small muted heading "PDF", then a lead line: the badged icon (24 px) and the state in plain words at
title-medium (
StudyPdfSection.lead). WithstudyPdfStatusoff it is the link-based state the section already decides ("Available" or "No PDF"), without the icon. No empty actions row is rendered, and the empty polite live region is visually hidden while empty, so it leaves no gap. Copy unchanged. - Review (
study-detail/study-detail-work.ts,studyReview). Heading "Review" for a manager, "Your work" for a reviewer. The lead line comes first: a manager's result (studyResultLead: Included, Excluded, "Not yet decided" for Insufficiently Screened, otherwise the server's words), or "Work for you in N stages" / "Nothing for you to do". A manager with work also sees "Work for you in N stages." Then one item per stage in project order: the name, a muted line (manager: "All reviewers: - A reviewer sees "Your work in
", with that stage's status as the lead and its one button "Start " (tonal). "Show your work in N other stages" opens the other stages in place, with outlined buttons. - A manager (orchestrator's decision on the review's question, 11 November 2026) sees "Review in
", with the study's result still as the lead and the "Work for you in N stages." note. The scoped stage's item comes first, with its "All reviewers … · You: …" line and a full-labelled tonal button. "Show N other stages" / "Hide other stages" opens the rest; it is not "your work", because the items include all reviewers' progress. - The expander's words ("Show N finished stages", "Show your work in N other stages", "Show N other stages", and
their Hide forms, singular for 1) come from the presenter (
StudyReview.expander), so the pure spec covers them. - The statuses are one typed constant,
STUDY_WORK_STATUS, which every comparison and the words map use. - Record, last and quiet: Search (only with
showSearch) as a link-styled native button that filters Studies to that search through the workspace'schooseSearch; the card closes with the choice, so focus goes to the page heading. Added (the date the study came into the project). Study ID with a "Copy study ID" icon button. It is m3e's extra-small icon button with the outlined glyph, and still a 44 px target below 600 px. The clipboard write starts in the click handler. The Record section's own polite region, beside the button, says "Study ID copied" or "Couldn’t copy the study ID" (a refusal or a missing clipboard never throws). The region clears when another study opens, and each copy renders a new message node, so a repeat is read again. - The contradiction. On staging a manager saw a stage read "Not started" for all reviewers (no sessions) and
"Finished by other reviewers" for them. The fault is in the web view model, not the server:
annotationsCompleteinstage-study.viewmodel.tsissessionCountTarget <= sessions, which is true for a stage with no positive session target (0) and no sessions. The server treats a non-positive target as a configuration issue (NonPositiveSessionCountTarget), and the same view model'sallSlotsFilledalready treats 0 as "no limit".myStageStatusnow reads "finished by others" from the same session counts the progress line shows, against a positive target, so the two lines agree. The view model was left for a follow-up because the Library's expanded row readsannotationsCompletetoo; #4912 (PR #4914) has since fixed it to the same positive-target rule. - Kept. The bulk-update lock note sits under the header, before the PDF section. Every test id, heading id,
focus move and live region is kept;
data-factmoved with Search, Added (since) and Study ID into Record. - Tests.
study-detail-work.spec.ts(status words, result lead, reviewer and manager leads for 0, 1 and N stages, items in order, buttons and names, the one-tonal rule, folding, stage scope, the contradiction case);study-detail-pdf.spec.ts(the lead line for every status and flag off);studies-workspace-detail.component.spec.ts(header citation, section order, PDF lead, no empty actions row, Review variants, folding, stage scope, Record link, copy and its failure);studies-workspace.component.spec.ts(the Record link filters Studies and focuses the heading).
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). The sheet has one scroller, the drawer's inner container: the sheet's content is its own containing block, so the live region's visually hidden text can't stretch the drawer into a second scrollbar. The CDK's block scroll strategy stops the page scrolling behind it while it is open, and releases it on every way of closing and when the workspace goes.
- 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.
Searches page matches C+B′ (PR #4884). W4 item 3 asked for the board's row, but the page kept slice 4's table
and W4's as-built note didn't record the row as deferred (the 10 November conformance review against the board). With
studiesWorkspace on, the page is now the board's (Studies-tc.dc.html, option C+B′); with it off it is today's
table, unchanged. Web only, no new flag.
- No summary, no table. The page renders no "Current or latest operations" and no "View in Processing", whatever
studyManagementProcessingsays. Its one polite live region (D16) now also says what the summary did ("1 operation in progress"). The rows are arole="list"of row blocks, with no column headers or outer border. - Row. The name; line 1 "Imported 12th May 2026 · 812 studies" (the search's
audit.created, ordinal, no weekday), "Importing 240 records…" (the import's total, or "Importing…") with an m3e progress indicator, "Import failed" with a "See reason" text button (owner decision 2: words, no chip), or slice 4's "Deleting…" and "Cancelled for deletion". Line 2 is one "PDFs: …" line in the board's forms, while bulk PDF upload is available to the reader: "waiting for the import", the running upload's label in lower case, "upload stopped today at 09:41 · N attached so far", "N attached · N available · N unavailable" (plus "N not checked"), or "none added". Then the text links "View N studies" and #4770's "N without a confirmed PDF" (no icon). The presenter isstudy-management/searches/search-list-row.ts. - Trailing controls. "Add PDFs" (outlined) on the board's rule: bulk PDF upload available, the import finished,
nothing running and some studies without a confirmed PDF; hidden below 600 px, where the menu has it. Then the
expander ("Show history for
", expand_more/expand_less,aria-expanded) and More ("More for"). No delete, view or notes icon in the row. - Menu. View studies, Add PDFs, Show or Hide history, Import notes (n) when the import has notes; then Copy search ID and Risk of Bias (owner decision 3); then Delete search, a plain item as the board draws it. Each opens today's dialog or route. Add PDFs and Delete search are disabled while an upload is live, and say so in their label.
- Expander. The description first (owner decision 1: not in the row), then "History of
": the search's imports and PDF uploads, newest first, worded as Activity words them without the search name ("PDF upload · Stopped"), a live failure in the error container colours. Each item has the board's outcome line ("74 uploaded · 74 attached · 71 available · 3 unavailable", "812 studies added", "Stopped: ") and short time ("Yesterday, 16:40 · Started by ", plus "You can retry until " while Retry is offered). Items offer Retry (retryable live failure), Start a new upload (live failure without a retry, with studies still needing a PDF) and Report (a finished upload with one), not Open upload or Open search ( study-management/searches/search-history/). While an upload is live, today's upload detail follows the list, so progress and Cancel stay (owner decision 4). - Header. The back arrow is an icon button beside the title, in the page shell's new
[pageLeading]slot. Activity shows the attention badge and is named "Activity, N need attention"; below 600 px it folds into "More actions" as "Activity (N need attention)", placed beside Import a search so it is never left on a row of its own (the page lays its actions out in one wrapping row, where the shell would stack each one). The count isattentionCountOfover the rows the Activity sheet shows;jobStatusWord,needAttentionTextandattentionCountOfmoved intoprocessing-page/so both pages share them. The empty state reads "Import a search to bring studies into this project." - Open upload expands the row, highlights it (the primary role at 8%, standing in for the board's accent, which is navy in dark mode) and focuses its expander.
- Kept reachable: the wizard's call to action and its
search-wizard-status-<id>state sit in line 1; a legacy import's warning count ("· 3 import warnings", with its tooltip) too. Test ids keep their meaning:searches-upload-btn,searches-studies-link("View N studies"),search-actions-menu-<id>,delete-search-<id>andbulk-pdf-upload-action-<id>(menu items),import-notes-<id>(menu item),bulk-pdf-expander-<id>(the expander),search-status-<id>,search-pdf-line-<id>,searches-import-error-reason-<id>. - Button colours. The page's flag-on host sets the board's colour roles: tonal on the secondary container, text
in the primary role, outlined in on-surface-variant. The M2 compatibility theme (
mat.all-component-themes) emits M2 values for those--mat-button-*tokens at the root (tonal on surface, text on on-surface), and neither m3e-angular nor theexpressiveButtonsgate resets them for plain tonal and text buttons, so with the gate on "Import a search" was a white pill and the text buttons black. The same gap affects every tonal and text button under the gate; fixing it inm3e-angular.scssis a follow-up for the buttons work. - Review fixes (r1). The rows behind the PDF line and the history go through R7's
clearSupersededAttention(asselectStudyJobRowsForCurrentProjectdoes), so a failure a later upload replaced, or whose granted retry window has run out, reads as past: no "upload stopped" line, no live item, no Retry. A missing count is unknown, not 0: withstudyPdfStatusoff, or the counts off or failed on the server (the counts store'sunavailable), Add PDFs and Start a new upload are offered under the rest of the board's rule and no "N without a confirmed PDF" link shows; while the first answer loads, neither is offered (r2), so nothing appears and then goes. The page and the row history tick each minute (the conversation's and the inbox's clock), so a closing retry window takes Retry and "You can retry until …" with it. Each row's view is acomputedper search id (#4822's memoisation), dropped when its search leaves the list (r2). The colour roles come from #4885'sm3x.button-colour-roles. - Choices made here. "Attached", "available" and "unavailable" are the latest finished upload's counts (the
browser has no per-search total); "attached so far" for a stopped upload is the same number. One row is open at a
time, as today. The board's "same file as “
”" is not shown: the row has no duplicate-file data. - Evidence and what remains.
e2e/tests/searches-page-workspace.spec.tscovers the list (on, off) and a stopped upload's Retry from its history; the stopped job is synthetic andbulkPdfUploadis turned on in the page's view of the flag snapshot, because the API reports it off outside the Bulk PDF lane. Screenshots at 1440 and 390 px, light and dark, were compared with the board in the PR. Remaining differences: dates stay ordinal ("12th May 2026", slice 11) where the board shows "12 May 2026"; at 390 px line 1 may wrap after a separator, as the board's does; icon buttons are the library's size (48 px box) where the board draws 40; the title stays at the shell's size below 600 px (board: 22/28); an import's history item says "Updated" and has no source line; the Activity sheet keeps its long timestamps. - Tests.
search-list-row.spec.ts,search-history.view.spec.ts,search-history.component.spec.ts,page-shell.component.spec.ts(the leading slot), and insystematic-searches.component.spec.ts"the Searches list (C+B′, studiesWorkspace on)" (on, off, on), the W4 and S12 header cases, and insystematic-searches.bulk-pdf-upload.spec.ts"with studiesWorkspace on (C+B′ rows)". - "Updating…" while search statistics are fenced (#4912, PR #4915). With materialised project statistics on,
GET /api/projects/{id}/searchesanswers 503{"title":"Search statistics temporarily unavailable","status":503, "reason":"Fenced"}during an import, on purpose.SearchStatisticsStatealready keeps the last good rows in phaseunavailable; it now also recordsfenced(that 503 with reasonFenced, read from the generated client's parsed problem or an undeclared answer's raw body; any other reason or error leaves it false; reset with the phase). The flag-on page then shows a plain "Updating…" at the end of its lede and announces "Updating…" once through its one live region (D16; a still-fenced poll repeats the same message, which the throttled region never announces again); both go when the rows arrive. No banner, no error styling. The read, its timeout and its polling are unchanged, and so is the flag-off page.
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. Superseded (#4769, #4815): every button carries the directive and the library's styles apply only under the app-wideexpressiveButtonsflag (button migration). These screens keep their phone targets, SyRF's focus ring (decision 3, throughm3x.button-accessibility) and colour roles; the--expressiveclasses now scope only those.- 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. Measured (#4771, locally at 50k, 130k and 215k studies): every option but Unavailable, and the counts, run at 2.7–4.8 times today's list. A stored classification brings all of them below it. Under the owner's #4771 decision, the follow-up stores the PDF classification (alternative C) on each study (its link kind and current check result, with a configuration fingerprint), and while a check is in progress it corrects the page and counts from the in-progress checks. The follow-up slice is tracked in #4771. See performance.md.
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 sat in the Studies cell under the count, with a
filter_listicon. Since #4884 it is a plain text link in the row's link line, after "View N studies" (the board's place). 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).
Stored PDF classification (#4771)¶
Specified on 5 November 2026 against main @ d7a6d57863, from the measurements in PR
#4831 (performance.md once it merges). The owner chose the exact
design (D8 = A, D9 = A), split into four stacked PRs. Each is inert, or changes no behaviour, until the last one arms
it. Decision record:
ADR-028.
Outcome. With studiesWorkspace, studyPdfStatus and a new studyPdfStatusStored on, the Studies workspace's PDF
filter and the per-search "without a confirmed PDF" counts are answered from fields stored on pmStudy. They're
exactly what #4719's C# classifier gives, for every study, including during a check's lease. At the largest real
project size (215k studies) they're no slower than today's unfiltered list. Off, or before the data is ready, the C#
classifier answers as today.
Owner decisions. - D8 = A: store the classification on pmStudy, behind a new runtime flag (default off everywhere) that gates only the read. The read falls back to the C# classifier when the flag is off or its admission is closed. Writers don't read the flag. - D9 = A: while a study's check holds a fresh lease, the stored read agrees with the column's Checking. It corrects the page and the counts with the project's in-progress checks, through an availability index. A two-minute disagreement isn't accepted. - Shape: stacked PRs (below). Production is never touched: the ADR-023 production migrations aren't run, and this work doesn't change that.
Four findings that shaped the design.
- Every study save is a whole-document replace. Review saves, screening, reservations and the unit of work
ReplaceOneorFindOneAndReplacetheStudyaggregate (16 call sites). Checks finish in the background just after a review retrieval, so a check result stored on pmStudy would be erased, or written back stale, by the reviewer's next save. A check can't bumpAudit.Versioninstead: those saves would then conflict, reversing slice - So armed whole-document Study writes preserve the stored result (ADR-028).
- Unsetting the result when a path is written breaks exact parity. A same-value rewrite (the study-update spreadsheet) keeps the C# key, and A→B→A with no check in between makes the old result current again. So the result is never unset by a path writer. It records the link it was checked for, and one derived, indexed field says whether it's current. Every writer of either side recomputes that field in the same single-document update.
- A build that doesn't map a field writes it back stale. Every entity captures unknown elements in
ExtraElements([BsonExtraElements],Entity.cs:21), and a whole-document save writes them back exactly as loaded. (The spec first said the map was strict and an older pod would throw; PR 1's tests showed it reads them intoExtraElementsinstead.) An older pod would therefore write back a stale mirror, or a link kind and key that no longer match a path it has just changed. Meanwhile a PR 1 build, which maps the fields but doesn't serialise them, drops them on save. ADR-023 keeps compatibility for the rollout window. So nothing writes the new fields until every pod preserves and re-derives them. PR 1 maps the fields so they're read onto the aggregate, not intoExtraElements. Writers are armed by the pmStudy index migration existing, and that migration is registered only by PR 4, after PRs 1–3 are in staging. - Bulk Study Update hashes the whole stored document (
StudyContentHash, ADR-020). A result landing on a locked study would fail its apply and quarantine its rollback. So the four stored fields are excluded from the hash, and the bulk writer re-derives them after apply and after restore.
The stored model.
- One link-shape rule.
PdfLinkShape.Of(projectId, searchId, pdfRelativePath, bulkPdfDeliveredPath)in Core gives the link'sKind, its configuration-freePartandSuffix, andKey. KindisNone,Legacy,DeliveredorExternal.Keyis"v1:"plus the first 16 hex digits of SHA-256 overKindandPart.- The URL is the normalised base plus
Suffix: the legacy base forLegacy, the proven bulk base forDelivered(no link without one), andSuffixalone forExternal. PdfLinkBuilderandBulkPdfLinkServiceboth build their URLs from it, byte-identical to before.PdfLinkShape.BaseKey(base)is the configuration fingerprint: the same 16-hex SHA-256 of the normalised base, so a trailing slash doesn't change it.Externalhas the constant"-", because its URL doesn't depend on a base.- Fields on pmStudy:
PdfLinkKind(string, always present once written,"None"included);PdfLinkKey;PdfCheck { Status, LinkKey, BaseKey, CheckedAt, ProjectId }, a mirror of the last recorded check;PdfCheckState, derived:"{Kind}|{Status}|{BaseKey}"whenPdfCheck.LinkKey == PdfLinkKey, otherwise absent.- In the aggregate,
PdfLinkKindandPdfLinkKeyare computed from the paths, so a whole-document save always carries values consistent with them. PdfCheckandPdfCheckStateare read-only: the aggregate never writes them back.- Only current results are mirrored. The check writer mirrors what it just recorded. The backfill mirrors a
document only when its key and its
ProjectIdmatch the study's now (#4719 decision 12). - The derive stage. One pipeline stage recomputes
PdfCheckState. Every armed writer of either side ends its update with it, and values go in as$literal.
Writers (d7a6d57863).
| # | Writer | Armed change |
|---|---|---|
| W1–W3 | new Study / CreateWithId (import, seeding); StudyReferenceFileParser.ApplyScreeningUpdate (:337, saved :327); AdministrativeScreeningPolicy.Apply (:79, saved ActivityReviewWrites.cs:120) |
whole-document saves through the preserve-on-replace seam |
| W4–W5 | BulkStudyUpdatePlanner (:201, :227); MongoBulkStudyUpdateStudyWriter apply (:233–238) and rollback (:285–291) |
hash exclusions; re-derive after each write in the same transaction |
| W6 | StudyRepository.MarkBulkPdfDeliveredAsync (:532–533) |
pipeline with the shape's fields, then the derive stage |
| W7 | StudyRepository.SetPdfLinkAsync (:605–607; W2 set, clear and accept, and slice 9's Use proposed PDF) |
the same |
| W8 | StudyRepository.ApplySimpleUpdates (:1534) |
pipeline. A delivered study keeps its link ($type test on BulkPdfDeliveredPath) |
| W9 | Study.MarkBulkPdfDelivered (no production caller) |
listed in the guard |
| W10 | MongoSearchDeletionSweepStore.MoveAsync (ProjectId to the holding project) |
none: a marked study is outside every project-scoped read, and a restore returns its own ProjectId |
| W11 | raw E2E $sets and fixtures |
none: the E2E stack is never armed |
| W12 | StudyPdfChecker after RecordAsync |
mirror write, guarded by CheckedAt; no audit change and no version bump |
| W13 | StudyPdfChecker, not-due branch |
self-heal: rewrite the mirror if it doesn't match a current document |
| W14 | TryClaimAsync (the lease) |
none: leases stay on the availability document |
| W15 | the backfill | compare-and-set on the inputs it read |
The whole-document sites are MongoExtensions.SaveAsync, TrySaveExistingAsync and Save, MongoUnitOfWorkBase's
single and bulk models, ActivityReviewWrites (three), ActivityReservations, ReservationStatistics (two) and
StudyRepository's seven FindOneAndReplace calls. Armed, each becomes
[ {$replaceWith: {$mergeObjects: [{$literal: study}, {PdfCheck: "$PdfCheck"}]}}, derive ]. They go through one seam
registered per aggregate type, in the shape of AggregateWriteGuards. Unarmed they're today's ReplaceOne. The study
change stream drops updates that touch only the four fields.
Arming. StudyPdfStoredArming is true once pmStudy has the index from migration 0037, checked by a hosted
refresher: a positive answer is kept, a negative one is asked again every 60 s. It reads no flag. The host doesn't start
serving until the first definite answer, and a write asked before it fails closed (ADR-028 decision 5).
The read.
- Per request: the fingerprints
F_L(legacy) andF_B(proven bulk base, or none) and onenow, shared with the PDF column's reporter. - The current values:
Cur(X)is{Legacy|X|F_L, External|X|-}, plusDelivered|X|F_Bwhen the bulk base is proven. - The checking set:
Cis the project's in-progress checks: availability documents withCheckStartedAt > now − Lease. - The predicates:
- Without a confirmed PDF:
PdfCheckState ∉ Cur(Available)or_id ∈ C. - Available: a link kind,
PdfCheckState ∈ Cur(Available)and_id ∉ C. - Not yet confirmed: a link kind, and
PdfCheckStatenot current or_id ∈ C. - Unavailable: as Available, with
Cur(Unavailable). - No PDF:
PdfLinkKindisNone, orDeliveredwithout a proven bulk base. - Proposed PDF waiting isn't affected.
- The counts: one covered
$groupper search over the pmStudy index, plus the confirmed studies inC. - Indexes:
- 0037,
{ProjectId, PdfLinkKind, PdfCheckState, SystematicSearchId}on pmStudy: every option, and the counts with no document examined; - 0036,
{ProjectId, CheckStartedAt}onpmStudyPdfAvailability:C. - Hint: 0037 is hinted only if the explain tests show it's needed, and never with a search term.
Flag.
- Definition: studyPdfStatusStored (feature study-pdf-status-stored), Operational, API only, default off
everywhere, productionEligible: false, no web entry and no catalogue dependency.
- Where it's read: only in StudyPdfStoredAdmission. That opens when all of these hold:
1. the flag is on;
2. slice 8's admission is open;
3. 0036 and 0037 exist;
4. the backfill has completed for the current arming epoch;
5. the project has no study without PdfLinkKind.
- On, off and on is safe: the writers never read the flag, and nothing is rewritten by switching it.
Migrations and backfill.
- Migrations: 0036_pmStudyPdfAvailability_ProjectId_CheckStartedAt and
0037_pmStudy_ProjectId_PdfLinkKind_PdfCheckState_SystematicSearchId. Both are CreateIndexMigrations. Their
numbers are taken, and re-checked against main, in PR 4.
- The backfill isn't a catalogue migration. ADR-023's up also runs in the API and PM init containers, and
pmStudy is about 10 GB.
- It's a PM hosted worker.
- It starts five minutes after 0037's appliedAt, the arming epoch.
- It holds a lease and works in 500-study batches by _id with a pause.
- Each write is a compare-and-set on the inputs it read.
- It resumes from a cursor in pmStudyPdfStoredBackfill.
- It records completion for the epoch, and runs again after a new one.
Settled by recommendation (owner, 5 November 2026).
1. Preserve on replace, armed only, through one seam.
2. Arm the writers by 0037's index, not by the flag.
3. The migrations are registered in a final arm PR.
4. The backfill is a PM worker.
5. An availability document that's stale at backfill time and later becomes current is healed by the checker on the
study's next review retrieval.
6. 16-hex fingerprints.
7. Hint 0037 only if the explain tests need it.
8. No catalogue dependency.
9. One now per request.
10. An ADR (ADR-028).
11. Filter mirror-only updates in the change stream's $match.
12. Store "None" explicitly.
The four PRs.
| PR | Scope | Inert because |
|---|---|---|
| 1 (#4838) | This section and ADR-028. PdfLinkShape, and PdfLinkBuilder/BulkPdfLinkService built on it. The mapped Study read: the four fields are mapped, read onto the aggregate (not into ExtraElements) and never written |
nothing writes the fields; URLs are byte-identical |
| 2 | Arming, the stored fields and the derive stage, the replace seam at every whole-document site, W5–W8 and W12–W13, the hash exclusions, the change-stream filter, guard tests G1–G4. Migration objects defined, not registered | never armed (no 0037) |
| 3 | The backfill worker, the admission, the studyPdfStatusStored flag, the stored read with the D9 correction and one now, the explain and parity tests, the 215k harness run |
never armed; flag off |
| 4 (arm, #4873, done) | Register 0036 and 0037 in PmMigrations.All and migrations.lock.json, with their migration tests. Merges only after PRs 1–3 are deployed to staging |
production runs no ADR-023 migrations |
Acceptance criteria, with the PR that delivers each.
- (PR 1) One link-shape rule.
PdfLinkBuilder.Build,HasLinkablePdf,BulkPdfLinkService.BuildandResolveCheckTargetgive byte-identical results to before for every input in their existing tests and in a new odd-case table: HTTP://,httpfile.pdf, a relative path with and without a leading slash;- a blank delivered path, and a delivered path that normalises to nothing;
- an unslashed base, and
#,?,%or spaces in a delivered path.
PdfLinkShape's Kind, Part, Key and BaseKey follow the rules above. Verify: PdfLinkBuilderTests,
BulkPdfLinkServiceTests and StudyPdfCheckTargetTests unchanged and passing; new PdfLinkShapeTests.
2. (PR 1) Mapped read. A study document carrying PdfLinkKind, PdfLinkKey, PdfCheck and PdfCheckState
loads through every typed read (single, cached, many, find, find-and-update). The aggregate exposes them and none
lands in ExtraElements.
- Stored PdfLinkKind and PdfLinkKey values are ignored: the aggregate's values equal PdfLinkShape.Of of its own
fields.
- A saved Study serialises none of the four, so a replace of a document that has them drops them. That's
harmless, since nothing writes them before PR 2 arms.
- A mirror carrying an element from a later build still loads.
- An unmapped element, by contrast, is written back as loaded (the positive control).
Verify: StudyStoredPdfFieldsReadTests (Mongo fixture) and the existing StudyClassMapRegistrationTests.
3. (PR 2) Writers. Every armed writer in the table leaves PdfLinkKind, PdfLinkKey and PdfCheckState equal to
their recomputation. Unarmed, every stored document is byte-identical to today's. Verify: G3, and an unarmed
golden-document test per writer.
4. (PR 2) Writer guards. Adding a writer of a PDF link input, or a whole-document write of a study, outside the
allow-list fails a source-scan test (G1, G2), each with a positive control. Verify: the guard tests.
5. (PR 2) Preserve on replace. At every whole-document site, a mirror written between the load and the save
survives and is re-derived. Conflicts, upserts and retries answer as today. Verify: StudyStoredPdfReplaceTests.
6. (PR 2) Bulk Study Update. A mirror write on a locked study fails neither apply nor rollback. Verify: G4.
7. (PR 2) Change stream. A mirror-only update emits no study event. Verify: the change feed tests.
8. (PR 2) Production untouched. Without 0037, nothing writes the four fields, saves are plain ReplaceOne, and the
class map serialises no new element. Verify: the unarmed tests.
9. (PR 3) Exact match. Over a generated mix, every study matches each option exactly as
StudyPdfStatusFilterRules.Matches(option, StudyPdfStatusRules.Report(...)) says, and the counts are today's.
- The mix: at least 2,000 studies, a fixed seed, two projects sharing search ids.
- The states: every state of the timing mix, plus Unsafe; a document under another ProjectId; a stale key; an
expired lease; Delivered with and without a proven base; A→B→A; a same-value rewrite; a delivered path that
normalises to nothing.
- The states are produced through the real writers and the backfill.
- Repeated after a legacy base change, a bulk base change and the bulk base becoming unproven.
Verify: StudyPdfStoredParityTests.
10. (PR 3) D9 lease. A confirmed study with a fresh lease is in Not yet confirmed and Without, not in Available. Its
row says Checking in the same response, and its search's count includes it. Once the lease expires, it's
Available again. The same for Unavailable. Verify: a controller test with a fake clock.
11. (PR 3) Performance at 215k. The harness (StudyPdfFilterScaleTimingTests, #4831), seeded armed and
backfilled, five runs, 58 searches, the timing mix: every option's median and the counts' median, with the flag
on, are at or below the same run's unfiltered list. The backfill's duration and rate are recorded. Verify: the
harness output and a table in performance.md.
12. (PR 3) Explain plans.
- Each option uses 0037, with point bounds on ProjectId and PdfLinkKind, no COLLSCAN, and docs examined at
most the matches plus the sort window.
- The counts are covered (no document examined).
- C uses 0036.
*Verify:* `StudyPdfStoredIndexExplainTests`, using the repository's own filter builders.
- (PR 3) Admission and fallback. The stored path runs only when all five admission conditions hold. Otherwise
the list and the counts give today's answers by the C# path. Verify:
StudyPdfStoredAdmissionTestsand a controller spy. - (PR 3) Flag on, off and on, with data made in each state and a base change between states. Verify:
StudyControllerPdfStoredFilterTests. - (PR 3) Backfill. It doesn't start before arming plus the settle. It resumes after a killed worker. It skips and
counts a concurrently edited study. It records completion for the epoch, and runs again on a new one. The admission
opens only after completion. Verify:
StudyPdfStoredBackfillWorkerTests. - (PR 4) Migrations. 0036 and 0037 have the definitions above. The lock file updates. Up, down and up are
idempotent.
StartupIndexPolicytreats 0037 as migration-owned. Verify:PmMigrationsTestsand the lock-file test. - (each PR) Docs. This section gains an "as built" note per PR.
performance.mdgets PR 3's figures, and the README status line is updated. Nothing is user-visible, so the user guide doesn't change.
As built, PR 4 (#4873). The arm PR. It registers 0036 and 0037, so it is the one that arms anything.
- Registered.
PmMigrations.Allends withStudyPdfStoredLeaseIndex(0036) andStudyPdfStoredIndex(0037), andmigrations.lock.jsonand the runtime-compatibility golden (scripts/testdata/runtime-compatibility/generated-candidate.json) gain their entries. The definitions are unchanged from PR 2. Main's catalogue still ended at 0035, so the numbers stand. - Release rule. Merge only after PRs 1–3 are deployed to staging. Staging's PreSync Job then applies 0036 and 0037;
0037's index arms the writers there, and five minutes after its
appliedAtthe PM backfill worker starts. Production runs no ADR-023 migrations, so it never arms and stays on plainReplaceOne. - 0037 is not built at startup. It is the one index on a governed collection that no repository creates, so with
Migrations:PreDeployoff nothing arms either. The startup-governance tests exclude it from the "startup builds every migration-owned index" expectations, and a test pins that startup never creates it. - Tests.
PmMigrationsTestspins both definitions to the spec (keys, names, collections, no options, no binding, a Down), the catalogue's end at 0037, andStartupIndexPolicyowning both withPreDeployon and neither with it off. The up, down, up test now runs over 37 migrations. The test that asserted "not registered until PR 4" now asserts they are registered. - Flag decision. No new flag.
studyPdfStatusStoredstill gates only the read and stays off everywhere; arming is by schema, and no flag default or production configuration changes.
Flag decision. Flagged, as studyPdfStatusStored: it changes how a hot read works, and its writers touch hot study
paths. The writers are armed by schema, not by the flag. PRs 1 and 2 need no flag of their own: PR 1 changes no
behaviour, and PR 2's writers stay unarmed until PR 4 (#4873), which registers the migrations and adds no flag.
As built, PR 1 (#4838). Behaviour-neutral: no URL changes, and nothing writes the new fields.
- The shape.
PdfLinkShapeandStudyPdfStoredLinkKindare inSyRF.ProjectManagement.Core/Services/.PdfLinkBuilder.BuildandHasLinkablePdfare one-liners over it. The decision tree's reasoning comments moved with the code. - The service.
BulkPdfLinkServicegainedBuild(PdfLinkShape)andBaseKeyFor(kind): the fingerprint of the base each kind is served from,"-"for external, null when there's no link. ItsBuildandResolveCheckTargetpick the base by kind. An external link reads no base, as before. - The check target is unchanged.
StudyPdfCheckTargetdidn't gainStoredKind/BaseKey. PR 2, which writes the mirror, takes them fromPdfLinkShapeandBaseKeyForwhere it needs them. - Byte-identical, proved.
PdfLinkShapeTestscompares both classes with a frozen copy ofmain @ d7a6d57863's code over every pair of a 19 × 12 odd-case input table, against three legacy bases and a proven and an unproven bulk base. A case-insensitive"http"test was tried as a mutation: seven cases failed, so the comparison bites. - One known exception to "equal URLs ⇔ equal kind and key", recorded in a test: a bibliographic path rewritten to the absolute URL its legacy layout already served. The C# key calls an old result current, and the stored state doesn't until the next check.
- The mapped read.
Study.PdfLinkKindandPdfLinkKeyare computed from the study's own fields.Study.PdfCheck(StudyPdfCheckMirror) andPdfCheckStateare read-only. All four are mapped in the class map withShouldSerializefalse. - The mirror type ignores unknown elements: it's never serialised by the aggregate, and it has no
ExtraElementsof its own. StudyPdfAvailabilityneeded nothing. No field is added to it, and as anEntityit already captures unknown elements.- The map's configuration moved into
StudyRepository.MapStudy, soStudyDeletionMarkerHonouringTests's "map without the marker" variant starts from exactly the registered map. PdfLinkKind's string representation is set there rather than by attribute, so a bareAutoMap()never maps it.- Tests.
StudyStoredPdfFieldsReadTests(Mongo fixture), with theExtraElementsround-trip as the positive control.
As built, PR 2 (#4845). Inert until PR 4 registers migration 0037: no database is armed, so every study write is exactly today's.
- The seam.
AggregateReplaceStrategiesinSyRF.Mongo.Commonis a per-type registry, in the shape ofAggregateWriteGuards. It carriesReplaceOneAsync,ReplaceOne,ReplaceOneModelandFindOneAndReplaceAsync. - With no pipeline it makes the same driver call with the same filter and options.
- With one it makes
UpdateOneorFindOneAndUpdate, and maps the result back. - Routed through it:
MongoExtensions.SaveAsync,TrySaveExistingAsyncandSave; the unit of work'sSaveMany,SaveManyAsync,BatchedSaveManyandBatchedSaveManyAsync; and every direct study replace inStudyRepository(5ReplaceOneAsync, 8FindOneAndReplaceAsync). AggregateWriteGuards.DuplicateKeyIdsnow takes the replaced documents rather thanReplaceOneModels.- The Study strategy.
StudyStoredPdfReplace, registered byStudyRepository's constructor next to the write guard. Armed, it writes[ $replaceWith $mergeObjects [ $literal study + PdfLinkKind/Key, {PdfCheck: "$PdfCheck"} ], derive ]. - One shape per change of inputs (#4838 review). The BSON serialiser reads a mapped getter even when it doesn't
write the element, so every Study serialisation read
PdfLinkKindandPdfLinkKey.Studynow caches itsPdfLinkShapewith the four inputs it was built from and rebuilds only when one changes;GetPdfLinkShape()exposes it to the armed replace and the checker. A build counter pins it (StudyPdfLinkShapeCacheTests). - Arming.
StudyPdfStoredArmingis keyed by the database's servers and name, not by client instance (a host or a test may hold several clients). The key is cached perIMongoDatabaseinstance (ConditionalWeakTable), sinceIsArmedis on every Study save.StudyPdfStoredArmingRefresheris hosted by the API (AddStudyPdfStatus, before the check worker and MassTransit) and by PM (Program, its first hosted service). - No write before a definite answer (#4845 review). A pod that started after arming could serve plain replaces while its first check ran, or for 60 s if it failed. Those would drop the four fields from an armed, backfilled database and keep that project's stored read closed.
- The refresher
Expects its database when it's constructed. ItsStartAsyncreturns only with a definite answer, retrying a failed check from 1 s, doubling to 30 s. - Both hosts start hosted services in registration order, each
StartAsyncbefore the next, and start Kestrel after all of them. So no consumer, scheduler or request writes a study before the answer (StudyPdfStoredArmingHostOrderTests, and the order pins inStudyPdfStatusRegistrationTestsandStudyPdfStoredWriterInventoryTests). - A write asked while the answer is unknown throws the retryable
StudyPdfStoredArmingPendingExceptionand writes nothing. A database no host in the process expects (tests, tools) is unarmed, as before. - Init before the host (#4845 round 3). PM's
DatabaseSeederis anIRunAtInit: it writes studies fromExecuteInitAndStartupMethods()beforeapp.RunAsync()starts the refresher (withSYRF_SEED_DATA_ENABLED), and from seed-data mode, which never starts the host. Both paths now awaitStudyPdfStoredArming.AwaitFirstAnswerAsyncfirst, the same refresh-with-backoff the refresher's start uses, so seeding writes armed wherever 0037 exists (ProjectManagementAwaitsTheArmingAnswerBeforeItsSeederAndInitTasks). The API's init tasks write no study. - The API's order is checked on the real host's built registrations, not on
Program.cstext, so hosted services registered by helpers count too. The BFF test factory records everyIHostedServiceregistration in order before it swaps its stubs. The refresher is 11th of 21: after ten that write no study (telemetry, health publishing, APM, data protection, flag refresh, inbox invalidation, notification fan-out, email and digest, and Gravatar import), and before MassTransit's bus (16th) and Kestrel'sGenericWebHostService(last). - The API's test host (
BffTestWebApplicationFactory) stubs Mongo, so it doesn't host the refresher. - The fields.
StudyPdfStoredFieldsholds the derive stage, the link values and literals, the mirror's BSON, the mirror's write filter (MirrorMayBeReplacedBy), the classic-to-pipeline conversion the targeted writers use ($set,$unset,$inc) and theExpectedoracle. - The targeted writers. W6 (
MarkBulkPdfDeliveredAsync), W7 (SetPdfLinkAsync) and W8 (ApplySimpleUpdates) render their classic update and, armed, write the pipeline equivalent plus the link fields and the derive stage. - W7 and W8 compute the kind and key from the new path alone; neither depends on the study's ids.
- W8 keeps the stored link of a study with a delivered path (
$typetest). - The bulk writer. W5:
MongoBulkStudyUpdateStudyWriterre-derives after each apply and each restore, under the lock, in the same transaction. The four fields are inStudyContentHash.ExcludedTopLevelFields. - The check mirror. W12 is
IStudyRepository.MirrorPdfCheckAsync(armed only): - no
Auditchange and no write guard, which is onStudyWriteLockArchitectureTests' allow-list with its reason; - guarded by
CheckedAt: written when there's no mirror or an older one, or one with the sameCheckedAtthat differs in any other field (#4845 review).
StudyPdfChecker mirrors after a recorded result, never a dropped one, and logs a failure without failing the check.
W13 heals a not-due study whose current result, claimed under its project, differs from its mirror.
- The same-CheckedAt case is real. A rewrite that keeps the C# link key (a bibliographic path rewritten to the
absolute URL its legacy layout already served) keeps the document current but changes the stored link, so the
heal's mirror differs only in LinkKey and BaseKey.
- With an older-only filter, that heal never landed and ran a no-op update on every retrieval. Now it lands once,
and the stored state is current again, which closes PR 1's recorded exception once a retrieval heals it.
- It can't make a stale result current: the derive stage sets the state only when the mirror's LinkKey is the
study's current key.
- The change stream. ChangeFeedCollection gains SilentFields. The study stream's $match drops updates whose
every updated, removed or truncated path is under the four fields.
- An armed save arrives as an update, not a replace. The dispatcher treats them alike, and the event's
UpdateLookup reads the document once: one extra server-side read per armed save, accepted as a technical
trade-off (5 November 2026).
- Migrations. PmMigrations.StudyPdfStoredLeaseIndex (0036), StudyPdfStoredIndex (0037) and
StudyPdfStoredIndexes are defined but not in All or the lock file.
- Tests.
- The seam with its own aggregate: every mechanism armed and unarmed, upsert, version conflict, guard explanation
and bulk errors.
- Every whole-document Study site through its public method, armed and unarmed, a mirror written between load and
save (StudyStoredPdfSaveSitesTests). Unarmed is byte for byte. Also upsert, version conflicts, a transaction write
conflict and its retry, and A→B→A.
- The reservation fold path's harness runs on its fixture's shared database, and arming is kept per database for the
process lifetime. So its armed and unarmed cases each get their own replica set (two extra test containers), an
accepted trade-off.
- W5 with G4 (BulkStudyUpdateExecutorTests.StoredPdf).
- W6 to W8 and W12, armed against unarmed, plus the derive stage and arming (StudyPdfStoredWritersTests).
- W12 and W13 in StudyPdfCheckerTests.
- The change stream filter (ChangeFeedReaderMongoTests).
- The G1 and G2 source scans (StudyPdfStoredWriterInventoryTests).
- Mutation checks confirmed the site, writer, hash and G2 tests each fail without the code they cover.
As built, PR 3 (#4849). The read, the backfill and the flag. Inert until PR 4 registers 0036 and 0037: without 0037 no database is armed, the backfill worker finds no epoch and does nothing, and the admission stays closed, so the C# classifier answers as today.
- The flag.
studyPdfStatusStored(Global, default off, API host,productionEligible: false), added withpnpm feature:new; feature definitionstudy-pdf-status-stored(Operational). Its consumer-contract row is incurrent-code-matrix.md, and its manifest entry namesStudyPdfStoredAdmission. The generator also writes the flag's web block (feature-flags.generated.tsand the env templates); nothing in the web reads it. - The values.
StudyPdfStoredValues(Core) holds the two base keys fromBulkPdfLinkServiceand builds the state strings each option compares (Current(status),CurrentAll(),LinkKinds(),NoLinkKinds()); a delivered link counts as a link only while the bulk base is proven.StudyPdfStoredPredicateis an option, those values and the project's checking ids. - The read.
StudyIdRestrictiongainsStored(StudyIdRestriction.ByStoredPdf), so the predicate replaces the id list in the same table query, ANDed with every other filter and the My studies tab. It's a field on the existing restriction rather than a separateStudyListRestriction.Predicatetype: the repository's page and count already take the restriction.StudyPdfStoredQueries(Mongo.Data) builds the filters: - the page uses the option's predicate (
For); - the count uses
CountParts: disjoint signed parts (for example, Without = not a current Available result + a current Available result being checked now). The large part counts 0037's keys, and only the checking-id parts read documents. A single$oror$ninon_idisn't in 0037's index, so a single count fetched every match; at 20k that doubled the Without read; - the counts endpoint is one
$groupover 0037's keys, plus one small aggregation for the confirmed studies being checked now (D9). - The shared time.
StudyPdfStatusClassifier.UtcNow()is read once per request. The controller passes it to the filter and to the PDF column, so a lease can't expire between them. - The admission.
StudyPdfStoredAdmission(Mongo.Data, an API singleton) checks the five conditions in cost order: the flag (decided on every call; a failed decision is off), slice 8's admission, the 0036 and 0037 indexes (a positive answer is kept), the backfill complete for the current epoch with no blank delivered path (re-read at most every 60 s), and none of the project's studies lackingPdfLinkKind(one count on 0037's prefix). An incomplete project closes the call and is queued for repair, at most once per project per 60 s in each process (#4849 review), since the worker reads the queue only between passes. - The backfill.
StudyPdfStoredBackfillwith the hostedStudyPdfStoredBackfillWorkerin PM, polling every 5 minutes, or every 30 s while a pass is settling, so the walk starts about when the settle period ends (#4849 review). Its state is one document,pmStudyPdfStoredBackfill_id: "pmStudy": - the epoch is
applied:<appliedAt>from 0037'ssyrf_migrationsrecord, orunregisteredwhen the index exists without a record (tests and the harness); - it waits 5 minutes after a new epoch, so writers on every pod have refreshed their arming;
- a 2-minute lease, a resume cursor by
_idand batches of 500; - each study is written with a compare-and-set on its link inputs (a concurrently edited study is skipped and counted), mirrors only a current result claimed under the study's own project, and keeps a newer mirror;
- it counts blank delivered paths, which keep the admission closed, and keeps the first 1,000 such studies' ids;
- while any remain, each later pass recounts them (#4849 review). It re-reads the tracked studies, re-derives the stored fields of any whose path was fixed (even by a raw write), and sets the count to those still blank, so fixing the data reopens the admission within a pass and a minute, with no new epoch and no operator step;
- if more than 1,000 were found, the recount is one scan of pmStudy per pass instead (#4867). It lists up to 1,001
of the studies still blank, reading only their ids, and keeps the first 1,000; finding 1,001 means "more than
1,000". The scan can't see studies already fixed, so the state records that one ran. When the count then reaches
0, by either kind of recount, the walk restarts for the epoch in the same write that clears its completion, so
every study is re-derived and the admission stays closed until that walk completes. The walk, the recount and
the scan share one test of a blank path: only characters .NET counts as whitespace, Unicode's (such as a
no-break space) as well as ASCII's. The scan's regex lists that set from .NET itself, because PCRE's
\sis ASCII only, and a scan that missed paths the walk counted would restart the walk every pass. No environment has applied 0037 before this change (only the arm PR, #4873, registers it), so no state document can lack the scanned marker after a scan; - it then repairs the queued projects. The repair loop doesn't renew the lease, and it dequeues a project even after its 1,000-round bound: the admission re-queues any project still incomplete.
- Its pmStudy write (
WriteBatchAsync, through a BSON-document collection) is onStudyWriteLockArchitectureTests' allow-list: it writes locked and deletion-marked studies on purpose, as W12 does. The test now finds BSON-document writers of pmStudy, and a dedicated scan pins their exact set. - Migrations stay unregistered. 0036 and 0037 are still outside
PmMigrations.Alland the lock file (registered by PR 4, below). - Tests. The criteria's test names changed while building:
- 9, 10, 13 and 14 are
StudyControllerPdfStoredTests(API, replica set). The mix is 2,100 studies in two projects sharing search ids, with a fixed seed, made by raw inserts and the backfill, then by W6, W7, W8, W12 and whole-document saves. Every option's list, every option's count, every search count and every row's PDF column are identical on both paths. This holds under today's bases, with no proven bulk base, after a legacy base change and after a bulk base change. Also tested: the D9 lease, on, off and on with data made in each state, and four closed admissions, where a spy shows the stored path never ran. Dropping the checking exclusion from Available and Unavailable fails four of its tests. StudyControllerPdfFilterMyStudiesTestschecks that the stored path gives each tab and option's intersection, with leases in the tabs.- 12 is
StudyPdfStoredExplainTests. Each option's page reads 0037 with noCOLLSCAN, and its count reads no document but the checking ids'. The counts examine no document, and the checking read uses 0036. - 13 is also covered by
StudyPdfStoredAdmissionTests. 15 isStudyPdfStoredBackfillTests. - Performance (criterion 11): met for every option but Without a confirmed PDF. At 215k (two runs), every option and the counts are 2 to 9 times faster than today. All but Without are at 0.42–0.86 times the unfiltered list. Without is at 1.12–1.15 times: its page fetches and sorts 75% of the project, as the unfiltered page does for all of it, but in 0037's order. The backfill took about 100 s for 236,500 studies. Figures and the breakdown are in performance.md.
#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 choice named "Which of your studies" (owner, 10 November 2026): with
expressiveButtonson, m3e-angular's connectedmat-exp-button-group(single select, the chosen one with its check); with it off, amat-button-toggle-groupwithm3x.connected-button-group(a radiogroup), 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.
List controls and columns as built (PR #4900)¶
The owner reviewed the Studies list on staging (11 November 2026): the filters were two generations of control side
by side, the search and the ⋮ menu ("Select columns…", "Sort by…") were the old Library's, and the PDF cells showed an
icon without words. Approved and built, with studiesWorkspace on only (no new flag); the Library with it off is
unchanged.
- One filter row. A visible search field ("Search titles and authors", the board's
.tf: filled, rounded, a leading search icon and a clear button), then PDF, the Stage chip, Inclusion, Search (while the Searches pane is out of sight) and Your status (stage scope), then Only mine, Clear filters, and at the end Columns and, below the card breakpoint, Sort. The search field is the samesearchTermform control as the Library's search bar, with its 400 ms debounce. - One chip control.
shared/chip-menu/(syrf-chip-menu) is the Stage chip's (#4776) button and menu as a shared component, drawn as the board's M3 filter chip (.ch): 32 px high, the rectangular 8 px shape, label-large type, 12 px before the label and 8 px after the trailing dropdown arrow; a 1 px outline-variant outline while unset, and the primary-container fill with its label, tick and arrow in on-primary-container (7.19:1 in both themes) and a leading tick and "Name: value" when a value other than the default is chosen (owner decision, 10 November, UI-STANDARDS CTL-1/CTL-4). Single-choicemenuitemradioitems with the checked one focused on open. The Stage chip renders through it with its behaviour, menu, labels, focus return, test ids, panel class anddata-stage-itemitems unchanged; only its look changed. Only mine is the same chip as a toggle (no arrow,aria-pressed), and Columns the same chip, never filled. All of them share one Sass mixin (shared/chip-menu/_filter-chip.scss): a state layer (8 % hover, 10 % focus and press), SyRF's focus ring, and below 600 px an invisible 44 px tall hit area around the 32 px chip (the hit area grows, not the chip). Design-conformance decision (component): the chip is a native<button>. m3e-angular has no chip component, and Angular Material's chips can't be a menu trigger (amat-chip-setis a listbox or grid and its chips are options or grid cells), so neither fits; a native button keeps its own click, focus and keyboard behaviour and the menu trigger'saria-haspopup/aria-expanded. It follows the existing SyRF filter chip (syrfDetailsChip, #4712), which also fills with the primary container when chosen. Not a Material button, so the m3e button gates don't apply to it;filter-chip.styles.spec.tspins its compiled styles instead. Decision: Inclusion, Search and Your status were multi-select selects and stay multi-select, as tick boxes (menuitemcheckbox, the menu stays open while ticking); a single-choice radio would have dropped combinations such as "Included and Undetermined". Each tick writes exactly the value the select wrote ("none means all" included). PDF stays single choice. - No ⋮ menu, no drawer, no old search bar in workspace mode.
- Sort from the headings through the table's existing
matSort(ascending, descending, cleared; Material's arrow faint on hover and keyboard focus, full once sorted). The header cell statesaria-sortitself in workspace mode (Material puts it on the sort header's host, adivinside the cell, which stays as it is so the Library is unchanged). Below the card breakpoint (1,240 px, where the header row is hidden) one Sort icon button opens one flat menu: the sortable columns shown, as radio items, a divider, Ascending / Descending (one wording that also suits Year) and Clear sort while a sort is set. Both call the handler the old "Sort by…" menu called. - Columns. A chip (icon
view_column) opens the drawer's columns as tick boxes that stay open while ticked, Study always shown (disabled, "always shown"), a disabled filtered or stage column with its reason, and Reset to default. Columns the workspace hides from the reader (Search without access to search names) aren't listed. Column choices weren't persisted before this slice (the drawer kept them for the visit), so a choice lasts for the visit too. - Default columns follow the list's width until the reader chooses. The table container is observed with a
ResizeObserver, applied at most once per animation frame, and a hidden list (0 px) changes nothing. One typed table
(
WORKSPACE_AUTO_COLUMNSinstudy-table-workspace-columns.ts) gives each column's minimum width, and a column joins once the gutters (48 px), every column shown and it fit:
| Column | Minimum width | Joins at list width (all columns permitted) |
|---|---|---|
| Study | 320 px | always |
| PDF (icon and words) | 176 px | always (the pair needs 544 px) |
| Search | 160 px | 704 px |
| Year | 80 px | 784 px |
| Inclusion status | 136 px | 920 px |
| Journal | 160 px | 1,080 px |
A column the reader may not see is skipped and the next ones move up by its width. Before the first measurement
the list shows today's Study, Search and PDF. Stage and filtered columns keep their rule. A chosen set that doesn't
fit scrolls sideways inside the table's own container (--table-min, --col-min), never the page; the automatic
set carries no minimum widths (it fits by construction), and the table's drawer container is stretched to the
list's width (align-self: stretch, min-width: 0; it sits in a centred flex column), so the list follows its own box and its width observer sees it narrow when the details card
opens. With the card and the Searches pane both open at 1,440 px the list is about 396 px, under Study's 320 px plus
the icon-only PDF cell and gutters (424 px): there Study gives up the difference rather than the list scrolling. The workspace
now hides the Search column only from readers who may not see searches (it used to hide it below 1,240 px and
while a study was open; the list's width decides that now). The selection shape (#4885) follows its row through
its own observers, so columns changing doesn't need it measured again.
7. PDF words. After the badged icon, 4 px apart (the board's .pl): "Available", "Not yet confirmed", "No PDF ·
1 proposed" and so on (STUDY_PDF_STATUS_LABELS); the cell's accessible name is unchanged ("PDF: Not yet
confirmed"). The icon-only fallback applies only in the table layout (a viewport of 1,240 px or more): there, with
the list under 544 px (where Study and the words no longer fit, for example with the details card and the Searches
pane both open at 1,440 px), the cell is its icon with its tooltip (pdfWords). The cards (below 1,240 px) always
show the words, at any width, 390 px included. On the cards the PDF line shows for every state, linked or not: the
Library's card rule hides a PDF cell without a link (no dangling "PDF: "), which had also hidden the workspace's No
PDF and "No PDF · 1 proposed" there; the workspace now shows them. Design-conformance decision (words): small, label-medium (the board's
500 12/16 px), in the state's colour role, the board's per state wherever SyRF already has the role
(pdfCellTone): Available in --syrf-success, a waiting proposal (on No PDF or Unavailable) in
--syrf-warning-accent, Unavailable in --mat-sys-error, Hosted elsewhere in --mat-sys-primary (#4885's icon
colour), and every other state in on-surface-variant. The board's --syrf-accent for Checking isn't a SyRF text
role: it has no dark value and measures 1.1–2.0:1 on the dark surfaces, so Checking stays on-surface-variant. Where the role replaces the icon's own tone (success,
warning, error) the icon takes it too, so icon and words agree (the icon's colour input; the error badge stays
error). All three tokens are held to 4.5:1 on every surface in both themes by the shared contrast contract
(scripts/token-contrast-contract.mjs). This applies to the list cell only; the details card keeps #4885's icon
mapping (primary for Available).
8. Search column headed "Search" (with the other workspace headers in sentence case: Inclusion status, Reference
type, First author), in place of the Library's "Systematic Search".
Headings line up with their columns (owner, from staging): Material's header cells kept their 16 px inline
padding while the body cells had none (td.general-cell), so every heading started 16 px right of its titles,
icons and search names (measured in the browser at 1,440 and 1,920 px, every column, sorted or not, automatic or
chosen set). In workspace mode a column's header and body cells now share one inline padding
(--study-table-cell-inline, 0), so each heading starts at exactly its cells' x; the sort arrow sits after the
label. The spacer columns (left, right, 32 px as drawn) are left alone in both rows, so they stay equal: a first
attempt that also zeroed the header spacer moved every heading 8 px, which only the browser measurement showed. The
Library's header cells are unchanged.
9. Tests. chip-menu.component.spec.ts, filter-chip.styles.spec.ts (the chips' compiled styles),
studies-search-field.component.spec.ts, the filter bar's "the Studies workspace's filter row (#4900)",
study-table-workspace-columns.spec.ts ("Responsive default columns"), study-table.list-controls.spec.ts (the
row, Columns, responsive defaults with a mock ResizeObserver, heading sort, the phone Sort button, PDF words and
their colours), the icon's colour input in study-pdf-status-icon.component.spec.ts, and the study-management
m3e style spec (the search field).
Workspace follow-ups as built (#4912, PR #4914)¶
Behind studiesWorkspace, except the view-model fix, which is a correction to both modes.
annotationsCompleteneeds a positive target.selectCurrentStageStudyVmsinstage-study.viewmodel.tsnow reads a stage as full only whensessionCountTarget > 0and the sessions reach it, asallSlotsFilledand the details card'smyStageStatusalready read a target of 0. No server field depends on it. Its one reader is the Library expanded row'suserReviewAvailable(expanded-detail.component.ts), where a stage with a target of 0 now offers review instead of hiding it. That component is not rendered anywhere today, so nothing on screen changes.- The page button colour roles stay. The Searches page (
.searches--expressive) and the details card (.ws-detail) includem3x.button-colour-roles. WithexpressiveButtonson, the gate inm3e-angular.scssand m3e-angular's own button tokens declare the same values on each button, so they win. That includes the outlined label in on-surface-variant, which m3e-angular sets over the gate's primary. WithexpressiveButtonsoff andstudiesWorkspaceon, the includes are the only M3 colours those buttons get: the M2 root would draw a tonal button white and text buttons black. In the browser, every button on both surfaces drew the same colours with the flag on and off, in both themes, while the root tokens stayed M2. Removing the includes would change the flag-off look, so they stay, and the comments say why. - List type. The workspace's column headings are label-medium (500 12/16, 0.5 px tracking) in
on-surface-variant, as the board's
th. The PDF words drop label-medium's tracking, as the board's.plsets none. In the workspace Columns menu, a disabled column has a short reason: "Your annotations (choose a stage first)", or "(clear its filter first)". The Library drawer keeps "Disabled: Select/create a stage to enable stage columns". Header and cell alignment from #4900 was re-measured in the browser. Every heading starts at its first cell's content (0 px apart): 2 columns at 1,440 px with the card open and 4 at 1,920 px (light), and 6 at 1,920 px with it closed (light and dark). Computed type: headings 500 12/16 with 0.496 px tracking; PDF words 500 12/16 withnormal, which is 0. - Presenter.
studyReviewinstudy-detail-work.tsis split into small helpers (entry, line, stand-out, item, scoped section, reviewer lead). Its cognitive complexity is 11, from 21, by eslint-plugin-sonarjs. The lead has no nested ternary, and the joins filter withBoolean. There is no behaviour change:study-detail-work.spec.tspasses unchanged. - Tests.
stage-study.viewmodel.spec.ts(new: target 0 with and without sessions, a met and an unmet target);expanded-detail.component.spec.ts(review availability fromannotationsComplete);study-table.list-controls.spec.ts(the short Columns reason, with the drawer's wording kept); andstudies-workspace-motion.spec.ts(heading type and the PDF words' tracking in the compiled styles).
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 card, 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 tour's scrim (_tour.scss,z-index: 999), unlike its card, sits below the container, so it would not dim the sheet; that never shows, because a tour and the sheet can't be open together (#4825). The only walkthrough anchors to the membership page (project-members.component.html), the sheet lives only in the Studies workspace (studies-workspace.component.html) and is disposed with it, andTourServicecloses any tour onNavigationStart(theNavigationStartsubscription inTourService's constructor). If a walkthrough is ever anchored on a Studies page, give the scrim a z-index above the overlay container then. 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.