Skip to content

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.ts mounts a project at /projects/:projectId.
  • project.routes.ts owns child routes including studies; the proposed searches and processing children produce /projects/:projectId/searches and /projects/:projectId/processing.
  • project-admin.routes.ts owns admin/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 todaySearchController.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 into errors: 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 null and trigger a contract test failure at the server boundary;
  • progress preserves the family-specific unit; percentages are display derivatives only;
  • capabilities contains 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:

  1. authenticated current-state capture and component inventory;
  2. low-fidelity alternatives for contextual summaries, Processing list/detail, and adaptive navigation;
  3. user selection followed by annotated desktop, tablet, mobile, 200% and 400% designs; and
  4. 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*.cs and 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/**; and
  • src/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 processing child in project.routes.ts plus the canonical searches child/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 /studies stable and redirect the legacy admin/systematic-searches URL to /searches;
  • expose the new Processing child/navigation item behind one generated, documented runtime flag until staging proof passes. Wave 2 creates studyManagementProcessing through the repository's env-mapping generators (default false everywhere; Waves 3–5 consume the typed selector); do not restore the historical newStudyManagement route-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:

  1. environment URL, version, SHA, feature flags, viewport/zoom, user role and permissions;
  2. a newly uploaded systematic search through import completion or a controlled error;
  3. a Bulk Study Update with at least one modified and one unmatched/no-op row;
  4. Bulk PDF upload plus supported cancel/retry/report paths when enabled;
  5. Risk of Bias launch and terminal/error state when enabled;
  6. navigation away, hard refresh, reconnect, Back, and a second authorised browser session;
  7. keyboard-only and screen-reader pass, 320px, 200% and 400% zoom, long errors/names; and
  8. 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.md pointer 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.