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/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.
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.