Stage Overview annotation pie cutover¶
The Phase 5.2 first slice is one stage-annotation endpoint and one independently reversible Stage Overview consumer: the current annotation pie, and nothing else on that page. The area chart, the annotation leaderboard, the allocation progress panel and every screening surface continue to use their existing contracts.
This is the sibling of the screening-only Project Overview cutover and follows it deliberately closely. Where the two consumers' rules are the same, they are the same because the second one reuses the first one's seams rather than restating them.
Complete current-stage snapshot — PR #3580¶
The subsequent default-off materializedProjectStatisticsStageOverviewBundle consumer replaces
Stage Overview's current pies and leaderboards together. It also requires the Pages, writes,
serving and four contributing family gates plus project admission. History and allocation retain
their separate endpoints. The original annotation-pie consumer below remains the rollback path.
Route-owned current statistics state — PR #3776¶
The project route provides one ProjectStatisticsStore for the Stage Overview current-statistics
bundle. Entering Stage Overview acquires its current-statistics demand; leaving the page releases
it. The store subscribes to the selected project, stage, viewer and authorization tier only while
demanded. Multiple consumers on that route read the same accepted response, so they do not start
duplicate HTTP reads or SignalR invalidation subscriptions. The existing default-off bundle flag
and explicit server enabled: false response still restore legacy FullStats loading.
An authorization-tier change clears the accepted and last-good responses before a new HTTP read. The identity includes screening graph, screening decisions, membership visibility and stage graph grants; retaining graph permission alone cannot preserve a previously disclosed peer row or decision count. A same-scope transient failure retains the last authorized coherent response, while 401, 403 and 404 clear it and stop refreshes. Polling, reconnects and SignalR messages still trigger an authorized HTTP read; notification payloads carry no display values. History charts, allocation progress, reviewer dialogs and direct statistics payloads over SignalR remain separate follow-up slices.
GET api/projects/{projectId}/stages/{stageId}/overview-statistics returns the permitted project
screening and current-stage annotation sections and the two leaderboards from one pinned snapshot.
It includes snapshot screening definitions, source/projection checkpoint provenance and
decimal-string invalidation revisions. Graph-hidden sections are null. The roster preserves legacy
administrator/all versus restricted/own visibility, with catalogue permissions (ViewMemberships
for any peer) as an additional boundary that only ever withholds (#3642). Signed availability is
preserved.
Every permission is evaluated against the caller's effective application groups (claim groups plus
the application roles on their investigator record), the same union the authorization handlers and
the bundle reader's reauthorization use. membershipScreening leaves through
MembershipScreeningVisibilityShaper, so it follows the #3083 tiers exactly as the legacy
full-stats routes do: without ViewScreeningProgressGraph there is no screening pie and no rows;
with it alone every row is anonymised ("Reviewer A", no investigator id, a surrogate membership id
for peers, the caller's own row flagged isSelf); with ViewScreeningProgressGraphDecisions as well
the rows are named. membershipAnnotation is a separate list keyed by membership (stage
ViewAnnotationProgressGraph; own row while an active member, peers with ViewMemberships), so an
anonymised screening row cannot be joined to an identified annotation row. An anonymised-tier
caller whose envelope includes peers is always answered from the guarded source snapshot: the
materialized peer-identity rule (RequiredPeerIdentityActivity) refuses identified peer rows to a
caller without the decisions permission, and only the source rows can be anonymised.
A Fresh complete bundle executes no authoritative study aggregation. A missing, stale, malformed or incompatible required row falls back for the entire response in one newly pinned authorized source snapshot; permission, rewrite and durable-mode refusals cannot become raw facets. Requests are bounded to 100 roster entries, the shared selection ceiling and the shared response-byte budget; oversized requests are refused or coherently fall back, never silently truncated.
The Angular page owns signal state rather than merging a partial FullStats object into global
entities. Route entry skips eager FullStats loading for this consumer, and notifications, reconnect
and a 30-second recovery poll refetch the authorized bundle. Identity changes cancel pending reads;
a refresh keeps the last coherent snapshot until its replacement arrives, and a failed refresh hides
prior counts. Regressing invalidation revisions are rejected. A definitive 401/403/404 ends the poll
and invalidation subscription until the project, stage or user changes. The page does not request
the bundle at all when the caller's permission report grants neither statistics graph. Explicit
enabled: false (including non-pilot projects) and flag rollback restore legacy loading; errors and
refusals do not. Legacy entity pushes cannot overwrite the page snapshot or retain removed reviewer rows.
Successful local review-settings saves and accepted cross-user project updates that change review settings also trigger the
bundle refresh, including saves that release no claims and therefore emit no family invalidation.
These triggers share the existing 100 ms coalescing window with family notifications; failed saves,
rejected project updates, job-progress-only updates and other projects do not trigger a refresh. Polling remains the missed-event
fallback.
This cutover does not retire the server's legacy SignalR producer or other FullStats consumers. Deployment, cross-pod browser proof, before/after latency and aggregation-count acceptance remain separate from local regression tests. No environment flag is activated by this PR.
The endpoint¶
GET api/projects/{projectId}/stages/{stageId}/annotation-stats returns one StageAnnotationStats —
the same value object the legacy full-stats response carries in its stageAnnotation collection,
for the requested stage. A materialized answer and a fallback answer are byte-identical on the wire,
so the flag is a pure substitution rather than a new contract.
The route requires project view authorization and the stage's ViewAnnotationProgressGraph
permission, both rechecked on the loaded Project before either statistics source is consulted. The
recheck and the caller handed to either source use the caller's effective application groups (claim
groups plus investigator application roles), as the authorization handlers do (#3642).
Within the controller, an unknown project and a stage the project does not have return the same
bare 404, taken before any permission is evaluated, so the stage id cannot be used to probe. End to
end the responses are not identical, because the project-view policy runs first: an unknown project
id is answered by AuthorizationHandler with a 404 carrying a project with projectId ... not found
body, and a project the caller may not view is answered 403. Only an unknown stage inside a project
the caller can view reaches the controller's bare 404. Hiding project existence from an unauthorized
caller is the shared policy's job and is unchanged by this slice.
Its dedicated materializedProjectStatisticsStageOverview flag defaults to false, is declared for
both the API and the project-management host, and requires the materializedProjectStatisticsPages
kill switch, global serving and the stage annotation family in the runtime catalogue. Both consumer
switches, writes, serving, the annotation family and the explicit project allowlist are evaluated by
StageAnnotationStatisticsQuery before it touches storage, and the bundle reader enforces durable
freshness, fences, write epochs, catalogue/source/digest compatibility, the one-snapshot predicate
and authorization inside its own snapshot.
There is exactly one free-gate decision, and the query owns it. StageStatisticsController reads no
materializedProjectStatistics* flag of its own: it asks
IStageAnnotationStatisticsQuery.IsMaterializedReadRequested, which is the same PreSnapshotGate
the read itself runs, so the endpoint and the read it guards cannot disagree about whether this
surface is switched on. This mirrors the screening consumer, where ReviewController gates on
IProjectScreeningStatisticsQueryAdapter.IsMaterializedReadRequested.
With the flag off the endpoint's answer is byte-identical to today's numbers. With either page
gate off the gate answers no and nothing is read: no snapshot is pinned on the projection's behalf,
no bundle is fetched, and the endpoint answers from the guarded authoritative query, which runs the
production StudyStatsQuery.GetFullProjectStatsAsync pipeline — the same $facet aggregation and
the same mapper the Stage Overview renders today — and returns the
requested stage's section from it. The stage-annotation section of that response is
investigator-independent (its facets carry no investigator predicate), so the section this route
returns for a member equals the section the page's existing full-stats call renders for that member.
The guarded authoritative query¶
The authoritative delegate is lazy: a Fresh materialized response invokes no study aggregation.
Every authoritative response, including a disabled-consumer request, opens a pinned read-only Mongo
snapshot. It reads the current Project without the repository aggregate cache, rechecks Project.View
and ViewAnnotationProgressGraph, and reads the global and project control rows before running the
facets in that same session. Inclusion and definition-rewrite fences, the legacy inclusion-job flag
and durable-mode disagreement return HTTP 503 with a typed reason; revoked permissions return 403; an
unpinnable snapshot also returns 503. No refusal executes the aggregation. These admission reads are
required source-consistency work even while the consumer flag is off, so performance comparisons must
include them.
The fallback deliberately computes the whole FullStats bundle and takes one stage's section from
it, reusing IProjectScreeningSourceReader so this path and the rebuild path cannot diverge from the
live query. It therefore makes no facet-count claim: the saving this consumer can demonstrate is
on the materialized path, not on its fallback. A narrower stage-only aggregation would be a second
transcription of the same formulas and is deliberately not attempted here.
A refusal never authorizes use of the request's cached Project settings, and it is never converted back into raw facets.
Row validation, and what it reuses¶
The served row is validated by ProjectStatisticsDerivedSummaries.StageAnnotation — the same decoder
the coherent derived summaries use — which checks row identity, publication state, provenance,
catalogue and source versions, the configuration digest, the content digest and the family's counter
keys. The consumer keeps no second copy of that predicate; a row the decoder rejects falls back with
a distinct RowRejectedByDecoder reason so a rollout dashboard can tell corruption apart from
"nothing is switched on". A response carrying the all-zero checkpoint identity is not auditable and
falls back before the row is decoded at all.
Nothing on that path can break the page: a refusal, a bounded capacity failure, a decoder rejection and an unexpected exception all return the guarded authoritative section with a recorded reason. The two deliberate exceptions are a cancellation, which is the caller going away, and the authoritative query's own typed refusals, which are the source being genuinely unavailable and must reach the caller as a 503 rather than as numbers of unknown freshness.
A permission fix this slice required¶
StagePermission.IsStageAuthorized resolved the caller's ProjectMembership before evaluating the
project-level grant, so an explicitly authorized nonmember — an application claim group, a public
project — raised ArgumentOutOfRangeException instead of being admitted, and an ordinary nonmember
raised it instead of being denied. The project grant is now evaluated first and the membership lookup
is the non-throwing one. A caller with neither grant is simply not authorized.
This is an unflagged behaviour change on every stage-gated route — AuthorizationHandler,
SignalRAuthorizationHandler, ProjectAuthorizationContext and Project.GetProjectPermissionReport
all reach it through Stage.IsAuthorizedForActivity. A plain non-member moves from a 500 to a
denial; a non-member carrying an application claim group the stage permission names moves from a 500
to being allowed. No shipped default is widened: every stage activity in ResourceSecurity.json has
AllowAllApplicationUsers: false and an empty AllowedApplicationGroupNames, so the second case is
unreachable until a deployment deliberately adds such a grant. Both directions are now pinned by
direct tests.
Establishing the baseline this consumer serves¶
The read path alone cannot be activated: with no Fresh stage-annotation row for a stage, the query
falls back and the pie renders exactly as it does today. The baseline is established through the
family-routed administrative trigger, which carries the same BatchAdminProjects policy, the same
reviewed allowlist and the same write and family gates as every other family:
POST /api/admin/project-statistics/{projectId}/stage-annotation/backfill
POST /api/admin/project-statistics/{projectId}/stage-annotation/rebuild
backfill skips every scope whose published row already matches the authoritative calculation, so
repeating it is a no-op; rebuild forces every scope and is also the sanctioned reconciliation for a
control whose configuration identity or versions have moved. One scope is declared per stage of
the project — every stage, not only the annotation-mode ones, because the legacy aggregation projects
a StageAnnotationStats for each of project.Stages with no review-mode filter and a screening-mode
stage therefore carries an all-zero annotation section the page can still ask for. Scopes are
enumerated from the authoritative Project rather than from the projection — which on a first run
holds nothing — so a multi-stage project publishes every stage in one sweep. The work runs inline on
the request's cancellation token; treat the 202 as a 200 and size the client timeout for the
whole rebuild.
StageAnnotationBackfillService adds no protocol: it is the screening families'
ProjectScreeningBackfillService with this family's scope enumeration and its empty-family seams, so
the forced-versus-automatic digest semantics, the revision-equality already-current pre-check, the
per-scope leases, the Contended-versus-Stale dispositions, the publication compare-and-swap and
the single immutable backfill-observed bootstrap checkpoint are the ones already proven for
screening. A project with no stage completes with an empty scope set rather than answering 404,
which is reserved for a project that does not exist. A project carrying no agreement threshold is
answered rather than thrown at: the legacy aggregation cannot run without one and the incremental
writer cannot maintain the result, so the scope is reported Absent instead of failing the sweep.
The sequence for a pilot is in the runbook's step 4b: run it after the screening backfill and before any consumer flag is opened.
The pie does not require a membership-stage baseline. Its read consumes exactly one
selection — the stage-scoped StageAnnotation metric key — so a membership backfill would publish
rows nothing reads. It lands with the consumer that needs it.
Browser behaviour and rollback¶
With materializedProjectStatisticsPages and materializedProjectStatisticsStageOverview both on,
the Stage Overview annotation pie renders from this endpoint. With either off nothing is requested
and the pie renders from the existing store aggregation, so a rollback is immediate and needs no data
change.
The Stage Overview route provides one NgRx SignalStore for the current annotation pie. The pie component acquires demand when mounted and releases it when destroyed, so a route injector retained by Angular leaves no polling or invalidation stream behind. The store derives project, stage, caller and Project.View plus stage graph permission from the current route state; any change clears the old answer before another authorized read. Missing permission starts no read. A definitive 401/403/404 ends refreshing until the scope changes, without exposing old counts. The existing Pages and Stage Overview flags remain the rollback controls.
The component renders only that pie, and it is mounted from a @defer (when panelOpenStateAnnotation)
block, so a collapsed annotation panel issues no request at all: mat-expansion-panel renders its
body eagerly, and the defer block is what keeps the poll off until the panel is actually open. The
panel is expanded on first load, so the ordinary case still requests immediately; once opened, the
block stays rendered, which is @defer's contract, so collapsing the panel again does not stop the
poll. It requests the current answer on mount and every 30 seconds; requests time out after 15
seconds; a failure hides the previous counts rather than showing numbers whose freshness is unknown,
and retries on the next interval. A response whose project or stage does
not match the request is refused. Changing stage or leaving the view cancels the pending request and
the refresh timer. SignalR invalidations now trigger immediate authorized reads; polling provides
missed-event recovery, and a hidden browser tab may throttle timers.
Not in this slice¶
- No per-member breakdown. The Stage Overview pie needs only the stage-scoped section; the membership-stage annotation family is untouched.
- No activation, allowlist or configuration change. The code keeps default-off flags
and an empty default
ProjectStatistics:ProjectAllowlist; deployment activation is separate. - No exports consumer (Phase 5.5). SignalR refresh is now implemented below.
- No performance evidence. Reproducible before/after read benchmarks are a separate artifact; the correctness tests below assert no timing improvement on a shared host.
Local correctness proof¶
The real Mongo replica-set suite compares the served section to the legacy full-stats section for
the same stage on a corpus with real tally cells, and covers the disabled consumer, an unlisted
project, an unmaterialized stage, a caller without the stage graph permission, a nonmember, an
unknown project, an unknown stage, a definition-rewrite fence and a durable-mode disagreement.
The Core suite pins the query's whole decision table, including that a closed gate touches no
storage and that a cancellation is never laundered into an authoritative answer. Endpoint tests cover
lazy materialized reads, fallback, the gate's answer in both directions — including that a closed
gate attempts no read at all — refusal mapping and the permission boundary. Browser tests cover the
materialized, legacy and flag-off modes, the bounded refresh, the timeout, a mismatched response, an
immediate rollback, the four flag combinations of selectStageAnnotationPieMaterialized, and that a
collapsed annotation panel issues no request while an open one does.
StagePermission.IsStageAuthorized's own decision table is pinned directly, and the deployed
ResourceSecurity.json is asserted to grant no stage activity to a non-member.
Reproduce the focused checks from the worktree root:
dotnet test src/libs/project-management/SyRF.ProjectManagement.Core.Tests/SyRF.ProjectManagement.Core.Tests.csproj --filter 'FullyQualifiedName~StageAnnotationStatisticsQueryTests'
dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter 'FullyQualifiedName~StageAnnotationStatisticsQueryTests'
dotnet test src/services/api/SyRF.API.Endpoint.Tests/SyRF.API.Endpoint.Tests.csproj --filter 'FullyQualifiedName~StageAnnotationStatisticsEndpointTests|FullyQualifiedName~RuntimeFeatureFlagMaterializedStatistics'
dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter 'FullyQualifiedName~StageAnnotationBackfillTests'
The backfill half is proven over the same replica-set fixture: a run from an empty projection
publishes Fresh rows that the query above then serves with parity against the legacy
GetFullProjectStatsAsync section, a repeat run publishes nothing, an automatic run refuses a
foreign configuration with DigestMismatch while the forced rebuild re-stamps it, a held lease is
reported Contended rather than Stale with the published row untouched, every stage of a
multi-stage project (annotation-mode and screening-mode alike) gets its own scope and is served, a
project with no stage completes its empty family while one with a disagreeing control is refused, a
threshold-less project reports Absent rather than raising, and the allowlist and family gates
refuse without touching storage.
From src/services/web, run:
Rollout also requires #3371's administrative mode-transition surface, and then the stage-annotation backfill above run for the pilot project. Until a stage's row is Fresh, opening the consumer flag changes nothing: the query falls back to the same section it serves today, so activation is a sequence rather than a single switch.
Live updates¶
With materializedProjectStatisticsSignalR enabled, an authenticated Stage Overview subscribes
through the same scoped refresh stream as Stage Review. Screening, annotation, membership,
visibility and reconnect notifications trigger the existing authorized FullStats request for the
legacy graphs. Bursts coalesce over 100 ms, with a 30-second missed-event recovery poll. The
shared stream also accepts successful local review-settings saves and accepted project updates that change review settings,
including changes that release no claims. Stage Overview dispatches the legacy FullStats read from
this stream only while its coherent bundle is in legacy mode; the active bundle refetches itself.
The subscription stops on flag-off, sign-out, scope loss and view destruction. A definitive
401/403/404 (shared isTerminalHistoryRefusal) of the legacy FullStats read also stops the poll and
the live refetches for that project, across its stages, because the read is project-wide and does not
resume on a stage change; 5xx, timeouts and network failures keep retrying. Only a different project
resumes. A 401 is treated like the other refusals: it stops until navigation or re-authentication
resets the scope. Counters remain in the
existing store; no notification payload supplies values or changes graph permissions.
The independently gated annotation pie listens for stage-annotation and screening invalidations and reconnects in addition to its existing recovery poll. A notification arriving during a read retains one trailing read, so a slow response cannot swallow the update. Its model remains signal-driven.
Combined-stage screening eligibility is separate from statistics display: the study can still need annotation after its screening is complete. The decision card uses the study agreement measure and project threshold to suppress a new screening, while preserving the annotation form. Both ordinary screening endpoints reject a new decision with HTTP 409 once screening is complete, rechecking after every source-version conflict. Existing reviewer corrections and reconciliation are preserved. This data-correctness fix is unconditional; it must not depend on statistics or active-reviewer tracking flags. No new flag, DTO or migration is introduced.
Phase 3 membership-stage baseline¶
The administrative POST api/admin/project-statistics/{id}/membership-stage-annotation/backfill
and /rebuild routes establish membership-stage annotation rows through the shared publication
protocol. They enumerate the authoritative project membership/stage cross-product, including
screening-only stages, and capture counters and retained stage definitions in pinned snapshots.
A project without stages or memberships completes with no fabricated checkpoint; a missing project
remains absent. Missing agreement thresholds produce absent scopes rather than fabricated zeroes.
These routes require the existing administrator policy, project allowlist, write gate and
materializedProjectStatisticsMembershipAnnotation family gate. No flag is enabled by this change and no page consumer switches its read path. Repeat
backfills skip current rows; forced rebuilds retain the common digest reconciliation and publication
compare-and-swap rules. Scope enumeration is bounded by the project's stored memberships and stages;
checkpoint capacity admission retains the existing shared limits.
Phase 3 reviewer annotation baseline and scope compatibility¶
POST api/admin/project-statistics/{id}/reviewer-annotation/backfill and /rebuild build
reviewer annotation at the existing MembershipStage grain, matching the authoritative reviewer
query and the scope already emitted by screening/annotation invalidation. Reviewer annotation remains
a separate family: availability uses Stage.SessionCountTarget and the durable global reviewer-tracking
mode, while membership annotation uses its catalogue's fixed two-session composition. Reserved slots
can affect allocation but never become completed annotations.
The previous catalogue selector mistakenly accepted stage-less MembershipReviewer scopes. Those
selectors are now rejected; they cannot identify the stage whose target and sessions should be read.
No scope encoding, family enum value, shared configuration digest or retained checkpoint is rewritten.
There was no runtime calculator or baseline route for this family. Any manually published experimental
stage-less row remains incompatible and must not be relabelled or served as a reviewer-stage row.
Deploy the corrected reader/writer contract together before activating this family; build new
reviewer-stage baselines through the guarded route. The family stays default-off in this change.
All counts and the durable tracking mode are captured in one pinned snapshot. The shared rebuild's mode-epoch publication check refuses a calculation crossing a mode transition. Retained definition metadata includes the stage target, maximum-in-progress, excluded-study visibility and observed tracking mode. Source transitions retain the existing scoped stale invalidation; this slice does not add point maintenance or switch any page consumer. Gates are the existing write flag, membership-annotation family flag and reviewed project allowlist, with the existing administrator policy on both routes.
Route-owned Stage Annotation observed history — PR #3791¶
The Stage Overview route provides one StageAnnotationHistoryState for the mounted stage annotation
history chart. The existing Pages and Stage History flags and project View plus current-stage
ViewAnnotationProgressGraph permission remain its gates; this ownership change needs no new flag.
The chart acquires demand on mount and releases it on destruction. The route owner keys its data
by project, stage, current user, flags and permission; a change cancels the old request
and clears its page. Two charts on the same route share one history request and one refresh loop.
The same bounded 20-point cursor API, 30-second refresh, StageAnnotation invalidations and
Show more behavior remain. Terminal authorization refusal clears observations and stops the loop;
transient failures retain the existing retry behavior. Leaving the route stops the loop and clears
the page. Reviewer screening dialog history is now owned by the Stage Overview and Screening Overview
routes, with one entry per reviewer per route and shared demand across duplicate dialogs. Reviewer annotation dialog history
still needs route ownership.
Reviewer annotation history dialog¶
The existing member history dialog now has a bounded annotation-history consumer. Its separate
materializedProjectStatisticsReviewerAnnotationHistory flag defaults off and depends on Serving, Pages,
Annotation and MembershipAnnotation. Rollback returns the dialog to an honest history-unavailable
message. This does not activate an environment or add history to the current Stage Review dialog.
GET api/projects/{projectId}/stages/{stageId}/reviewers/{investigatorId}/annotation-history
returns at most 100 retained checkpoint states. It pairs StageAnnotation and
MembershipStageAnnotation blocks from the same checkpoint and uses their matching retained stage
definitions. This is the fixed membership-stage annotation formula, not the distinct current
ReviewerAnnotation family. Missing, incompatible or incompletely paired observations remain gaps;
the endpoint never performs a live aggregation to reconstruct the past.
Per-reviewer history discloses a reviewer's progress over time, so the route applies tiers that
mirror the reviewer screening history (#3574). Annotation has no decisions permission, so the tiers
are those of the MembershipStageAnnotation metric, evaluated against the EffectiveApplicationGroups
union (claim groups plus investigator application roles) that is also passed to the snapshot reader:
| Caller holds | Own history | A peer's history |
|---|---|---|
No stage ViewAnnotationProgressGraph |
Refused | Refused |
Stage ViewAnnotationProgressGraph only |
Allowed while an active member | Refused |
Stage graph + ViewMemberships |
Allowed while an active member | Allowed |
ViewMemberships for a peer is the materialized filter's peer-identity rule; project View is
always required. Own history requires live active membership; a peer's history requires the
peer-identity permission, not membership, so a non-member holding the stage graph and
ViewMemberships through application-level grants may read it. The reviewer screening history
applies the same rule. The stage and membership-stage selections are reauthorized in the reader's pinned
snapshot, and any refused selection refuses the page without checkpoint metadata or a fallback to
current aggregation. The dialog mounts the history only when the caller's permission report allows
the requested reviewer under the same tiers. A 401, 403 or 404 hides the surface and stops polling
and invalidation refetches; server and network failures keep retrying. The Stage Overview
annotation reviewer table opens the dialog from the reviewer's name (#3647) while the consumer gates
are on: the caller's own row with the stage graph permission, a peer's row with ViewMemberships as
well. With the flags off the table is unchanged; the server remains authoritative, because the
permission report is claim-groups-only (#3642).
The chart shows the reviewer's unexcluded completed and in-progress studies; the accessible details table also preserves signed available/unavailable values and retained grouping metadata. Counts are observed states at actual UTC capture times, can decrease, and are not accumulated activity or fabricated daily values.
Acceptance covers exact paired counts, missing definitions/blocks, permission refusal, request bounds, sparse UTC/DST gaps, and cancellation on project, stage, subject or viewer changes. Angular signals update the chart, relevant family invalidations retain a trailing request, and bounded polling recovers missed notifications. No annotation form or draft state is modified. Fleet soak, transport evidence across pods and activation remain the programme's rollout gates.
Own reviewer progress consumer (Phase 5 follow-on)¶
The reviewer-progress slice substitutes the existing
GET api/projects/{projectId}/stages/{stageId}/reviewer-stats response. Its MVP acceptance is
that an active member's screening and annotation sections match the authoritative query for
screening-only, annotation-only and combined stages; both sections come from one authorized
snapshot; a refused read never falls through to an unguarded query. The project-wide reviewer
endpoint and observed reviewer histories are separate slices documented in this page.
materializedProjectStatisticsReviewerProgress is a new, independently reversible, default-off
consumer flag. It also requires Pages, Writes, Serving, MembershipScreening, MembershipAnnotation
and an explicit project allowlist. Both reviewer families must have published baselines before
materialized serving is possible. The flag does not build either family or change maintenance.
All existing deployment-managed maintenance requirements still apply. This change does not
activate any environment.
Activation prerequisite — stricter membership behaviour. With the flag on, own stage progress
is refused (403) for a caller who holds Project.View only through an application-level group and
is not an active member of the project, on both the materialized read and its authoritative
fallback (ReviewerProgressAuthoritativeQuery requires IsActiveMember). With the flag off, the
same caller reaches the legacy reviewStatsQueryService.GetReviewerStatsForStageAsync path, which
has no membership check, and gets 200. This is a deliberate, real behaviour change at activation,
not a parity bug: own progress is defined to exist only for active members, and the web client
already treats a 403 from this endpoint as terminal (#3698). An operator enabling this flag should
verify beforehand that no relied-upon workflow (e.g. administrator accounts holding Project.View
via an application-level group without being active project members) depends on the legacy
permissive read of their own reviewer progress for a project they are not a member of.
The additive own-progress-screening and own-progress-annotation catalogue keys reuse the
existing ReviewerScreening and ReviewerAnnotation physical rows. They require Project.View,
active membership and an exact match between the requested reviewer and the authenticated
caller. They reject history and peer selections. Existing graph keys retain their existing graph
permission requirements; the aliases grant no graph permission, alter no persisted scope keys
and do not change the shared configuration digest.
The route passes the caller's effective application groups (the syrf_groups claim plus the
application roles on their investigator record, the union the authorization handler evaluated) to
the bundle reader and the fallback, so the snapshot never judges the caller at a narrower tier than
the route policy did. The route takes no reviewer parameter: the requested reviewer is always the
authenticated caller, and a caller who is not an active member reads nothing on either path.
The bundle captures stage modes, target, maximum in-progress count, exclusion visibility, membership identity and durable reviewer-tracking mode in the same authorized snapshot as the counters. Definition bytes count against the existing response budget. If either family is unavailable, both sections are recalculated together in one pinned source snapshot. That fallback rechecks membership, project visibility, stage existence, deletion, inclusion/definition fences and durable mode agreement before querying counts. Refusals return 403, 404 or 503 as appropriate.
The existing reviewer page refresh path remains in use: relevant SignalR family invalidations, confirmed review-settings saves and accepted project updates that change review settings are coalesced before fetching this endpoint, and the dialog reads updated store values through Angular signals. Scope loss cancels refreshes and bounded polling recovers missed invalidations. Statistics refresh actions update statistics state; they do not load or replace annotation sessions. Validation covers source parity for all three stage modes, default-off behavior, current-own versus peer/history authorization, malformed counters, controller refusal handling, and the existing SignalR refresh and signal-based dialog tests.
The coherent Stage Overview consumer is included in the shared dark-state startup flag summary.
Project-wide own reviewer progress¶
GET api/projects/{projectId}/stages/reviewer-stats now has an independently default-off
materializedProjectStatisticsProjectReviewerProgress consumer. Pages gates the snapshot consumer;
materialized reads additionally require writes, serving, both reviewer families and the project
allowlist. Non-pilot reads under the consumer use the guarded authoritative snapshot. Turning this
consumer off retains the existing endpoint implementation and does not change the separate stage-
progress consumer.
The response preserves the existing ProjectReviewerStats contract: one screening section and an
annotation section for every project stage, including stages whose current review mode does not
include annotation. A project with no stages still returns screening statistics and an empty
annotation list. Subject identity comes only from the authenticated caller; active membership and
Project.View are checked inside the snapshot, without granting graph or peer permissions. As on the
stage own-progress route, Project.View is evaluated against the effective application groups (claim
groups plus the investigator's application roles), and the materialized path reads only the
own-progress aliases, which refuse any scope that is not the caller's own row. A caller admitted
only by an application-level Project.View grant, without active membership, receives 403 while the
consumer is on.
The stage roster, membership, definitions, durable reviewer mode and counters are read together. One bounded materialized bundle serves the entire response. Missing or invalid evidence discards the whole result and opens a new guarded snapshot for all source calculations; counts are never combined across the two snapshots. Source queries run sequentially on that snapshot. Fences and durable mode disagreement return 503; authorization failures remain 403/404. A Forbidden or NotFound refusal from the materialized reader is returned as is and never retried from source. At most 99 stages plus the screening selection are admitted, and the shared encoded-response budget applies to both paths. Oversized requests return 413 rather than truncating stages or silently expanding work.
Acceptance covers parity with the existing endpoint for all review modes and the empty roster, zero
source-count calls for fresh projections, fallback consistency across a concurrent insert,
authorization and mode refusal, scope capacity, API routing and runtime flag propagation. No new
metric identity, persistent scope or configuration digest is introduced. The additive
currentProgress metadata block records caller/project identity, read source and stage presentation
settings from the same snapshot; flag-off responses omit it. Project Overview consumes this response
in component-local Angular signals. Reviewer-family, visibility/global and reconnect invalidations
refresh the whole response with 100 ms coalescing; a 30-second poll recovers missed events. A
refresh keeps the last coherent snapshot on screen until its replacement arrives. The poll is not
started when the caller is not an active member or the permission report lacks Project.View. A
definitive 401/403/404 (shared isTerminalHistoryRefusal) ends the poll and the invalidation
subscription until the scope changes; 5xx, timeouts and payload mismatches hide the counts and
retry. Project/user changes cancel pending reads and clear current counts. Only flag rollback or a
legacy-shaped response (the server consumer is off) restores the existing selectors; a refusal
never does. While the consumer is on, the project route guard skips the legacy
retrieveProjectReviewerStats load, so this component-local state is the only reader of the endpoint
and no second guarded source read happens per page load. On rollback, or when the server answers
with the legacy shape, the state dispatches that legacy load itself (once, only for a member whose
legacy rows are neither loaded nor loading). Segments use the response's captured excluded-session
grouping and hide-excluded settings rather than cached stage definitions. Successful local
review-settings saves and accepted project updates that change review settings refresh this active view through the same
coalesced invalidation path, even when no reviewer claim changes. Other projects and unsuccessful
saves do not refresh it; the 30-second poll remains the missed-event fallback. The refresh changes
no annotation drafts or global statistics entities.
Activation, soak evidence and performance acceptance remain separate rollout work.
Review and reconciliation route loading¶
When Pages and materializedProjectStatisticsReviewerProgress are enabled, review entry,
study review, review-completed, reconciliation entry and selected-study reconciliation routes
do not dispatch the broad retrieveFullProjectStats request. Review pages consume the existing
own-reviewer screening and annotation response; reconciliation renders candidate sessions
without a statistics selector. The unused membership reconciliation selector is not a dependency.
Initial project matching defers broad loading to the final child route, including nested lazy-route
wrappers.
The own-progress endpoint still supplies the existing source response outside the materialized pilot. Relevant SignalR invalidations continue to refresh that endpoint through the existing coalesced, signal-backed review progress path. Other legacy destinations still load FullStats, and turning either flag off restores the legacy route load on the next navigation. This change does not retire the broad API or its legacy SignalR producer and does not claim a production performance result. Acceptance covers nested review/completed paths, initial project matching, independent Pages gating, reconciliation and flag rollback.
Rollback freshness¶
Project Overview, Screening Info and Stage Overview now force one fresh legacy FullStats request when the same project changes from its new consumer to legacy rendering, including an explicit disabled response. Initial flag-off navigation still deduplicates a cached or loading legacy request. Only project identity and consumer choice drive the rollback effect; loading/success notifications do not dispatch another request. A change of project does not carry the prior project's rollback.
Project Overview's separate own-reviewer consumer likewise requests fresh legacy reviewer statistics once when its flag turns off or the server answers with the legacy shape, because its local signal response intentionally never updated global entities. A definitive 401, 403 or 404 refusal is not a rollback and requests nothing. The transition is scoped to the authenticated user and project. Tests cover cached-success rollback, disabled responses, refusals, initial legacy deduplication, repeated notifications and identity changes. The rollback effect reads no consumer state until a project scope exists.
Transient loading and unavailable states do not change the remembered consumer mode: a disabled response followed by a polling retry and another disabled response does not force another legacy request. A subsequent successful new response followed by disabled does force a fresh rollback. A first scoped attempt or a fresh consumer-enable edge is remembered even before a ready response, so disabling the flag during initial loading or an unavailable response still refreshes once.