Skip to content

SignalR Active Reviewer Tracking for Study Annotation Distribution

Delivered Runtime Contract

Relocated from the root CLAUDE.md. This section states what the shipped code does; the design narrative below records the original intent and still uses some earlier terminology.

activeReviewerTrackingEnabled gates the cross-service review-presence workflow. The API SignalR hub authorizes every join and mutation against the supplied project, stage, and study before subscribing a connection to a study group. A SlotReservation embedded in the Study aggregate represents an unsaved reviewer allocation; MongoDB atomic claim and save filters enforce SessionCountTarget only when the stage's EnforceAnnotationTarget setting is enabled. Reconciliation sessions never consume or require these annotation reservations.

SignalR connections are stored separately from durable ReviewerPresence engagement windows. Multi-tab connections share one current presence. Concurrent first joins that collide on the unique current-presence index read back the winning record without changing its identity or annotation session; other database failures still propagate. Involuntary disconnects enter a bounded suspension grace period, and scheduled Project Management consumers remove expired reservations/presences. The browser consumes authoritative per-study snapshots, ignores stale or duplicate event versions, warns when allocation exceeds the target, and locks saving only when target enforcement is enabled. This is a per-study subscription contract; it does not provide stage-wide live-presence aggregation.

Application authority (M4b, enforced mode only). Presence snapshots are re-decided on fresh facts when each one is sent: the reviewer must be active (not suspended or pending deactivation) and an active member of the project. A refused snapshot ends the presence stream; an unreadable authority withholds that snapshot only. A suspended reviewer's JoinStudyReview, Heartbeat, StartedAnnotating and StoppedAnnotating are refused, and a removed or disabled member's are refused by the stage policy. Revocation never removes a reservation directly: the capacity contract above is unchanged, so the reservation is released by the liveness check and suspension grace period, or at once by LeaveStudyReview, which is never gated. Off/shadow: unchanged.

Review-context refusal (#4158). JoinStudyReview, LeaveStudyReview, StartedAnnotating, StoppedAnnotating, Heartbeat, SubscribeToStudyPresence and UnsubscribeFromStudyPresence resolve the project, stage and study through one lookup before any state change. An unknown project, unknown study, unknown stage, or a study that belongs to a different project is refused with HubException("The requested review context is not available."), the same text for every case, so the refusal does not reveal whether an id exists in another project. The refusal reads only: no reservation, connection, presence, schedule or subscription is created, and nothing is broadcast. JoinStudyReview throws this refusal rather than returning a typed StudyReviewAccessResult denial, as it always has for a wrong-project study, because a typed denial carries a presence snapshot of a study that does not exist here. Before #4158 an unknown project or study surfaced as an unhandled InvalidOperationException ("Sequence contains no elements") instead of this refusal.

Hard-deleted studies (#4253). Legacy immediate search and import-job deletion (deletionLifecycle off, its default) and preview seeding remove pmStudy documents without touching ReviewSessionConnection records or joins in flight, so every later hub read of the study may find nothing. A study that disappears after the review context was read is refused like an unknown study: JoinStudyReview returns the same HubException when its combined-stage claim re-read or its post-claim capacity re-read finds no document. It removes the connection record it created and writes nothing else. Cleanup paths treat a missing study like one moved by a search deletion: they drop the connection record and write nothing else. These paths are disconnect (logged at Information, not Error), dirty-tab reconcile, failed-join rollback, and the prior-study release when a connection moves to another study. The release no longer fails the new study's join. The legacy deletion itself closes the reviewers' current ReviewerPresence windows on the studies it erases, with SearchDeleted, as the reversible deletion's sweep does (#4273). It reads the study ids first and closes their presences only after the erasure completed, so an erasure refused by a bulk-update lock closes nothing. It reads the search again after the erasure and leaves the presences of any study the erasure kept (a lock taken after its pre-check) open. A failed close is logged at Error and does not fail the completed deletion. A retry after a partial erasure only closes the presences of the studies that retry erases; those the failed attempt erased stay open. Preview seeding's erasure does not close presences.

The same holds after the review context was read in StartedAnnotating and StoppedAnnotating: with materializedProjectStatisticsFold on, a lost upsert race reloads the study uncached, and a reload that finds no document is refused with the same HubException. Nothing more is saved or scheduled, and the presence is not marked. The scheduled Project Management review-session commands (CheckConnectionLiveness, MarkSessionIdle, RemoveIdleSession, RemoveSuspendedSession) treat a missing study like a deletion-marked one, on the first load and on a conflict reload alike (DeletedStudySessionCleanup). They log at Information ("no longer exists"), write and schedule nothing more, and complete instead of faulting the message to the error queue. The liveness check removes the stale connection record before it reads the study, so that record is still dropped (#4261).

Client handling of refusals (#4254). The browser sees a refused hub invocation in one of two shapes. ASP.NET Core SignalR always sends a HubException's message, whatever EnableDetailedErrors says, as An unexpected error occurred invoking '<method>' on the server. HubException: <message>; a failed hub policy is always the fixed text Failed to invoke '<method>' because user is unauthorized, because SignalR never sends the failure reason. An exception thrown by an authorization handler is no way out either: SignalR's invocation semaphore swallows it and never answers the call.

So the server separates "authority temporarily unavailable" from a real denial before the policy fails. When an enforced application or project/stage decision is Unavailable, SignalRAuthorizationHandler defers ApplicationAuthorityFailure.UnavailableMessage (application-authority-unavailable) to the per-invocation HubAuthorizationRefusal and lets the requirement pass; HubAuthorizationRefusalFilter, the first hub filter, then refuses the invocation with that code before any other filter or the hub method runs. Without the scoped refusal the handler keeps the plain policy failure (fail closed). Denials, unknown and foreign projects, and the reversible- deletion refusal are unchanged policy failures, so no existence signal is added. HubAuthorizationRefusalPipelineTests pins this against a real in-process SignalR server.

Because a thrown handler exception stalls the whole connection (with the default one invocation at a time per client, every later call waits for a permit that never comes back), the off and shadow modes read the project and the caller's investigator with non-throwing reads: an unknown project, a missing investigator or an unresolvable user id is the ordinary policy failure, exactly as enforced mode refuses an unknown project (#4270). SignalRAuthorizationPipelineTests runs the real handler through the same in-process server and checks that the connection still answers the next invocation.

An unexpected failure inside the handler, in every mode, is never thrown either (#4283): a read that faults (a Mongo timeout on the project, investigator or export-job read, or on the registration check) or a failed UserHasRegisteredEvent dispatch (for example a duplicate-key race on first login). The handler logs it at error level with the requirement, hub method, first hub argument (the project id, or the export job id for ProjectExportData) and the exception type only (an exception message can carry user data), then refuses with the same deferred application-authority-unavailable code as an enforced outage. Cancellation of the hub connection itself is not a failure: nobody is left to answer, so it propagates as cancellation. Any other OperationCanceledException counts as unexpected. The browser retries the code as transient: each burst is retried within the call and reported to Sentry (rate-limited). A fault that keeps recurring ends the review's retries after three failed bursts in a row, with the temporary refusal and Try again (#4317, below). SignalRAuthorizationPipelineTests checks a faulting project read and a failed registration end to end, each followed by an answered call on the same connection.

hub-refusal.ts (isHubRefusal, hubRefusalReason) treats a policy failure and every non-transient HubException as a deterministic refusal: no retry and no Sentry report. Transient ones keep the retry-then-Sentry path: the server's own unavailable outcomes (Authorization is temporarily unavailable., *-unavailable codes, authority-malformed) and the concurrent-change retry message. A refusal is the server answering, so it opens no RPC-failure burst (no "Reconnecting…" and no offline countdown); it still emits invocationError$ and rejects. The API test HubExceptionContractTests enumerates every new HubException(...) site in the API, requires each message to be classified transient or refusal, and fails if hub-refusal.ts disagrees or lists a text the server never sends.

A refused JoinStudyReview (initial, heartbeat-triggered or the post-reconnect rejoin) or Heartbeat for the active study retires its claim and heartbeat like a typed denial, keeps the study context, and emits studyReviewRefused$. The post-reconnect self-heal loop (_rejoinActiveStudyPresence) therefore stops rescheduling itself every 5 s after a refusal; a transient failure still reschedules it, up to the limit below. StudyPresenceStore.reviewRefusal puts the review page into the refused display state, which takes precedence over the slot, idle and offline states and does not lock the form. syrf-review-refusal-banner explains the reason in the reviewer's own terms (SESSION_MESSAGES.refused) and offers Try again, which re-runs the join; any join result clears the refusal. That is how a reviewer recovers once the cause is lifted (for example a restored account); a later transport reconnect also runs one rejoin.

A transient failure is retried within the call, but the review does not retry it indefinitely (#4317). - Counting. On a connected hub, each JoinStudyReview or Heartbeat burst the server fails counts once. Bursts can come from the initial join, a join triggered by a heartbeat, a post-reconnect round or the 30 s heartbeat. A call skipped while the hub is disconnected does not count. Any answered join, or a heartbeat that finds its connection, resets the count, as do Try again, opening another study, and every new connection (a burst that failed while the old connection was closing was a lost connection, not a failing server). - At three failures in a row. The claim and heartbeat are retired as for a refusal, and studyReviewRefused$ emits temporary, the client's own reason (the server never sends it). The 5 s rejoin loop stops, and the banner says the problem is usually temporary and offers Try again. - Sentry. Its default dedupe only drops an event identical to the one just before it, and the rejoin loop alternates SubscribeToStudyPresence and JoinStudyReview. So _reportRpcExhausted sends a given method and error message at most once every 10 minutes per page. The limit covers every hub method's exhausted bursts, not just presence. The next report for the same key carries suppressedSinceLastReport. If the failures stop inside the window, that count is never sent: the first event already says the failure happened.

This also bounds the API's own error log for an authorization handler failure (#4283), which writes one entry for each attempt.

Display reason Server signal Sentry level
access policy failure; The requested project is not available. info
account subject-suspended, subject-deactivated, subject-deactivation-pending info
impersonation non-transient SupportImpersonationCodes (impersonation-*) info
unavailable The requested review context is not available. and any other refusal warning
temporary none: three failed join/heartbeat bursts in a row on a connected hub (#4317) warning

Every display of the banner is logged and reported to Sentry. A refused invocation is not sent to Sentry as an RPC failure (see above), but the banner it leads to is. StudyPresenceStore.studyReviewRefused calls ReviewRefusalReporter.report each time the banner appears, or changes reason while shown. A Try again refused for the same reason keeps the banner up, so it is not a new display. Each display writes a console line (console.warn for warning, console.info for info) with the reason, hub method (JoinStudyReview or Heartbeat) and the project, stage and study ids. It also sends a Sentry message Study review refused: <reason> at the level in the table, fingerprinted by reason, tagged review.refusal_reason and signalr.method, with the three ids as extra data. - Why these levels. warning is for the reasons where the cause is on SyRF's side and someone may need to act: a server that kept failing the review, or a review context it could not find. info is for the caller's own expected state (changed access, an inactive account, a read-only impersonation). Those are still counted in Sentry but should not alert anyone. - PII. The app's Sentry scope adds the signed-in user (name, email) and the project, stage and study names to every event (SentryService). This report strips them in a scope event processor (keepOnlyReviewRefusalData): no user, no breadcrumbs, only its two tags and the ids, and the page URL without its query string. - Rate limit. The same reason and method is reported at most once every 10 minutes per page, using ReportRateLimiter, which _reportRpcExhausted uses as well. The next report carries suppressedSinceLastReport. Every display still writes its console line.

Not flagged: every changed path is an invocation the server already refused or could not authorize, and no capacity, reservation or data semantics change. Against an older API, the web shows an unavailable decision as an access refusal (recoverable with Try again); an older web treats the new -unavailable code as transient. The #4317 cap is not flagged either. It only changes what happens after the page has already failed the same review three times in a row on a live connection: it stops retrying on its own and explains itself, where it used to retry for as long as the page stayed open.

Executive Summary

Completed-target regression evidence (2026-09-08)

Investigation #2565, under #2470, distinguishes already-completed annotations from concurrent unsaved reservations. The MongoDB integration theory AlreadyCompletedTarget_WithIncompleteSession_RejectsNewReviewersButPreservesResume in AtomicAssignmentIntegrationTests exercises target two with two or three completed ordinary sessions and one existing incomplete session. Each case runs with current tallies and with the legacy TotalAllocatedSessionCount field absent:

  • Twelve concurrent new-reviewer claims return no assignment and create no reservations.
  • A new reviewer's direct save is rejected as AtCapacity without persisting that session.
  • The existing incomplete reviewer can finish the same session without adding another session.

These are synthetic fixtures against the current repository guards, not a reproduction against the reported production study. They assume coherent stored candidate-session counts; corrupt tallies, deployed feature flags, historical versions and the reported browser path are not established by this test. No runtime behavior or flags change in this test/documentation slice, so no new feature flag is needed.

The focused core regression selection (atomic assignment, distribution, active sessions and stage review) passes 155 tests on base cb20a917c7271980c928f5d10dbb076e8a6c70c2 plus this theory. Separately, E2E run 34172580868, attempt 5 passed annotation-capacity.spec.ts on membership PR #2271's head ef974e28b, covering reservation caps and refresh deduplication. That full suite failed elsewhere; it is not overall E2E acceptance.

Before closing #2565 or user ticket #2446, reproduce the reported completed-target scenario through the browser in an authorized environment, verify its flags/settings and tally consistency, and record the findings and follow-up decision. Stage-wide admin presence aggregation and remaining UI acceptance stay separate follow-ups under #2470.

This feature uses SignalR to track when reviewers are actively viewing or annotating studies on the stage study review page. The tracking state is persisted on study documents and consumed by the annotation distribution algorithm to prevent studies from being annotated beyond the target number of annotation sessions. It also prevents over-screening by accounting for reviewers who currently have a study open.

Objectives:

  1. Track which reviewers currently have a study open on the review page
  2. Record whether a reviewer has started annotating (filled in any form field)
  3. Persist active reviewer sessions on the study document
  4. Integrate with the study distribution algorithm so open/in-progress studies are accounted for when presenting studies to other users
  5. Detect and handle idle reviewers (open study but no interaction) and suspended sessions (connection lost)
  6. Provide real-time warnings and optional hard-lock enforcement when annotation targets are reached

Why This Matters:

  • Prevents over-annotation: Without awareness of concurrent reviewers, the algorithm may present the same study to multiple users simultaneously, resulting in more annotations than the target
  • Prevents over-screening: Same principle applied to the screening distribution
  • Improves distribution accuracy: Studies currently being reviewed should count toward the target when deciding what to present to other reviewers
  • Handles squatting: Idle session detection frees slots from reviewers who open studies but don't interact
  • Real-time feedback: SignalR provides instant state updates without polling

High-Level Design

SignalR Hub

A new hub (or extension of the existing NotificationHub) handles study review presence:

  • JoinStudyReview(projectId, stageId, studyId) — Called when a reviewer opens a study on the review page or on SignalR reconnection
  • LeaveStudyReview(projectId, stageId, studyId) — Called when a reviewer navigates away intentionally (next study, close)
  • StartedAnnotating(projectId, stageId, studyId) — Called when the annotation form becomes dirty
  • StoppedAnnotating(projectId, stageId, studyId) — Called when annotations are removed and the form returns to a clean state without a saved session
  • Heartbeat(studyId, stageId) — Called every 30 seconds to confirm connection liveness

ActiveReviewSession Entity (Study Document)

ActiveReviewSession extends Entity<Guid> (consistent with AnnotationSession and other domain entities). Lives on the Study aggregate root — presence tracking is not extraction data, and active review sessions also serve screening distribution (not just annotation).

ActiveReviewSession : Entity<Guid>
  Id: Guid                          (entity identity)
  InvestigatorId: Guid              (natural key part 1)
  StageId: Guid                     (natural key part 2)
  DateTimeCreated: DateTime         (inherited from Entity)
  HasStartedAnnotating: bool        (form dirty flag)
  SuspendedSince: DateTime?         (null = connected; non-null = connection lost since)
  IdleSince: DateTime?              (null = active; non-null = idle since)

No LastHeartbeatUtc — heartbeats are a connection-level concern tracked on ReviewSessionConnection. No ConnectionIds — connection tracking is in a separate collection.

Upsert semantics: AddOrRefreshActiveReviewSession(investigatorId, stageId, sessionCountTarget) returns the existing session if one already exists for this reviewer on this stage, or creates a new one. Handles multi-device scenarios — the same reviewer on multiple devices occupies one slot, not multiple.

All existing sessions count toward slot reservation. A session reserves its slot for as long as it exists on the study document, regardless of its idle or suspended state. Slots are only freed when the session is removed (intentional leave, idle timeout, or suspended grace period expiry).

ReviewSessionConnection Entity (Separate Collection)

A lightweight entity in its own MongoDB collection that maps SignalR connections to active review sessions. Kept separate from the study document to avoid write amplification from frequent heartbeats.

ReviewSessionConnection
  ConnectionId: string              (natural key, indexed unique)
  StudyId: Guid
  StageId: Guid
  InvestigatorId: Guid
  ConnectedAtUtc: DateTime
  LastHeartbeatUtc: DateTime        (per-connection heartbeat)
  PendingStaleCheckToken: Guid?     (MassTransit scheduled message token for heartbeat timeout)

Indexed on: ConnectionId (unique), (StudyId, StageId, InvestigatorId) (composite for remaining-connection checks).

Relationship Between ActiveReviewSession and AnnotationSession

These are separate entities within the same aggregate (the Study). They are NOT a transformation of each other — they have different identities, different data, and different purposes. They are connected by the natural key (InvestigatorId, StageId) and an aggregate rule that prevents double-counting.

  • ActiveReviewSession: Tracks that a reviewer is currently present on the study review page. Created when the study is assigned. Exists for as long as the reviewer has the study open. Removed when they leave, or when idle/suspended timeouts expire.
  • AnnotationSession: Tracks persisted annotation data. Created when the reviewer saves for the first time. Permanent — part of the study's data forever.

They coexist after the first save (the reviewer saves but is still on the page). Double-counting is prevented not by removing the ActiveReviewSession on save, but by computing reserved sessions as the subset of active review sessions that do NOT have a corresponding AnnotationSession:

reserved = activeReviewSessions.Where(ars =>
    !annotationSessions.Any(s => s.InvestigatorId == ars.InvestigatorId
                               && s.StageId == ars.StageId
                               && !s.Reconciliation))

By definition, reserved sessions and annotation sessions never overlap.

SessionTallies and Computed Properties

The existing SessionTallies computed getter on ExtractionInfo is extended. SessionTally gains:

Field Type Source Description
StageId Guid Direct Which stage
NumberOfCandidateSessions int Direct Non-reconciliation AnnotationSessions (Incomplete + Completed). Existing field.
NumberOfCompletedCandidateSessions int Direct Completed non-reconciliation AnnotationSessions. Existing field.
ReconciliationStarted bool Direct Any reconciliation session exists. Existing field.
ReconciliationCompleted bool Direct Any completed reconciliation session exists. Existing field.
NumberOfReservedSessions int Computed Active review sessions that do NOT have a corresponding AnnotationSession — these are reserving a slot but haven't saved yet
NumberOfAnnotatingReservedSessions int Computed Subset of reserved where HasStartedAnnotating == true — reviewer has started filling the form
TotalAllocatedSessionCount int Computed NumberOfCandidateSessions + NumberOfReservedSessions — total slots in use. Used by distribution algorithm. Getter-only, [BsonElement] for indexing.
TotalEngagedSessionCount int Computed NumberOfCandidateSessions + NumberOfAnnotatingReservedSessions — total sessions with substantive engagement. Used for warning/lock threshold. Getter-only, [BsonElement] for indexing.

The distribution algorithm filters on TotalAllocatedSessionCount < SessionCountTarget to exclude studies from assignment. SessionCountTarget is passed as a parameter from the Stage — not denormalized onto the study.

Atomic Study Assignment (FindOneAndUpdate Pipeline)

When the algorithm assigns a study to a reviewer, the operation must be atomic — filter for a study with capacity AND add the ActiveReviewSession in a single operation, preventing two reviewers from claiming the last slot simultaneously.

Three-stage pipeline executed as a single FindOneAndUpdate:

  1. Push ActiveReviewSession — typed .Set() expression (no computed properties on this type, LINQ3 handles it)
  2. Seed tally if missing — if no SessionTally exists for this stage, append a zero-valued one. Uses raw BsonDocument $cond stage (LINQ3 cannot translate ternary branches with different serializers)
  3. Increment matching tally — $map over tallies, increment NumberOfReservedSessions for the matching stage, recompute stored TotalAllocatedSessionCount and TotalEngagedSessionCount. Uses raw BsonDocument $map stage.

Filter (both cases handled):

$or: [
  { SessionTallies: { $elemMatch: { StageId: X, TotalAllocatedSessionCount: { $lt: target } } } },
  { "SessionTallies.StageId": { $ne: X } }   // no tally = 0 < target
]

LINQ3 limitation: Typed .Set() expressions fail for nested objects with [BsonElement] computed properties due to serializer mismatch in $cond branches. Stages 2 and 3 use raw BsonDocument via .AppendStage(). Stage 1 (flat array push) uses typed .Set(). This hybrid is proven in integration tests (PipelineUpdateProofOfConceptTests).

Audit compatibility: The existing WithAuditCeremony extension uses Builders<T>.Update.Combine() to add audit fields (Audit.Version, Audit.LastModified, Audit.LastModifiedBy, Audit.LastAppVersion). This is incompatible with pipeline updates — Combine cannot merge classic update operators with pipeline stages. The fix: a new WithAuditCeremonyPipeline extension (or overload of PartialUpdateAndGetAsync) that appends audit as an additional $set stage in the pipeline:

// Appended as a 4th pipeline stage:
{ $set: {
    "Audit.Version": { $add: ["$Audit.Version", 1] },
    "Audit.LastModified": <DateTime.UtcNow>,
    "Audit.LastModifiedBy": <userId>,
    "Audit.LastAppVersion": <appVersion>
}}

This keeps audit tracking working identically to the classic update path, just expressed as a pipeline stage rather than a combined update operator.

When to use which update approach:

Operation Approach Why
Algorithm study assignment FindOneAndUpdate pipeline Race-sensitive, new reviewer always = new reserved session
Direct navigation UoW load-modify-save May or may not be reserved (depends on existing AnnotationSession)
First annotation save UoW load-modify-save Both arrays change, getter recomputes correctly
Session removal UoW load-modify-save Conditional tally change based on reserved status
State changes (dirty/idle/suspended) UoW load-modify-save Low frequency, getter recomputes
Heartbeats Atomic on ReviewSessionConnection No study document change

Idle Session Detection and Timeout

Idle detection protects against reviewers who open a study and occupy a slot without interacting with the annotation form ("squatting"). Uses MassTransit delayed messages — no polling job.

Stage settings:

  • IdleSessionTimeoutMinutes: int? (nullable, default 120). Time after a session is marked idle before it is removed. Null = idle tracking disabled.

Phase 1 — Mark as idle (fixed 5 minutes):

  1. Session created with clean form → schedule MarkSessionIdle in 5 minutes
  2. Form becomes dirty (HasStartedAnnotating = true) → cancel MarkSessionIdle. Session can never go idle while form is dirty.
  3. If MarkSessionIdle fires (no interaction after 5 min) → set IdleSince = now on the ActiveReviewSession. Schedule RemoveIdleSession for Stage.IdleSessionTimeoutMinutes in the future. UI shows "Idle" status to reviewer and admins.
  4. If reviewer interacts with form after being marked idle → cancel RemoveIdleSession, clear IdleSince. UI returns to "Active".
  5. If reviewer removes annotations (form returns to clean, no saved session) → schedule new MarkSessionIdle in 5 minutes. Cycle repeats.

Phase 2 — Remove idle session (configurable, default 2 hours):

  1. RemoveIdleSession fires → remove ActiveReviewSession from study document. Slot freed. Annotation form becomes unavailable. Create ExpiredReviewSession audit record.
  2. If EnforceAnnotationTarget is enabled on the stage and the study has now reached its target with other reviewers, the idle reviewer cannot resume.

UI for idle reviewers: A status indicator (active / idle) shown to the reviewer, with a hover tooltip or countdown showing time remaining before the session is released. Admins see the same status when viewing project member activity.

Suspended Session Detection and Grace Period

Suspension handles involuntary connection loss (laptop sleep, network drop, browser crash). Uses MassTransit delayed messages.

Phase 1 — Mark as suspended (immediate on involuntary connection loss):

The distinction between intentional and involuntary disconnect comes from SignalR's OnDisconnectedAsync(Exception? exception):

  • exception == null → clean disconnect (tab closed, browser closed). Reviewer chose to leave. Session is removed immediately.
  • exception != null → involuntary disconnect (network drop, laptop sleep, crash). Session is suspended.

When the last connection for a reviewer is lost involuntarily → set SuspendedSince = now. Schedule RemoveSuspendedSession for 2 hours.

Phase 2 — Remove suspended session (2-hour grace period):

  1. RemoveSuspendedSession fires → remove ActiveReviewSession from study document. Slot freed. Create ExpiredReviewSession audit record.
  2. If reviewer reconnects before it fires (SignalR auto-reconnect, JoinStudyReview called) → clear SuspendedSince, cancel RemoveSuspendedSession.

UI for suspended sessions: Admins see the session marked as "Suspended" when viewing project member activity. The reviewer, on reconnection, either resumes normally (if within grace period) or sees a notification explaining their session was released (if past grace period).

Disconnect Handling (OnDisconnectedAsync)

1. Find ReviewSessionConnection for this connectionId
2. If not found → LeaveStudyReview already handled cleanup, return
3. Remove the ReviewSessionConnection
4. Cancel any pending heartbeat stale-check for this connection
5. Check: any remaining connections for (studyId, stageId, investigatorId)?
6. If yes → other devices still connected, return
7. If no → this was the last connection:
   a. exception == null → clean close. Remove ActiveReviewSession entirely.
   b. exception != null → involuntary loss. Set SuspendedSince = now,
      schedule RemoveSuspendedSession for 2 hours.

LeaveStudyReview (called explicitly by Angular on intentional navigation):

1. Remove ReviewSessionConnection for this connectionId
2. Cancel pending heartbeat stale-check and idle timers for this connection
3. Check remaining connections for (studyId, stageId, investigatorId)
4. If none → remove ActiveReviewSession entirely (intentional leave)

Tab close edge case: When a tab is closed, Angular's ngOnDestroy attempts to call LeaveStudyReview, but the WebSocket may close before it completes. If LeaveStudyReview gets through → session removed directly. If not → OnDisconnectedAsync(null) fires (clean close, step 7a), session removed anyway. Both paths produce the same result.

Heartbeat and Connection Liveness

Each connected device sends heartbeats every 30 seconds via SignalR. Heartbeats are tracked per-connection on ReviewSessionConnection, not on the ActiveReviewSession.

Cancel/reschedule pattern: Each heartbeat cancels the previous scheduled CheckConnectionLiveness message (using PendingStaleCheckToken) and schedules a new one for 2 minutes in the future. At any point, exactly one pending message exists per connection. If heartbeats stop, the single pending message fires at precisely 2 minutes after the last heartbeat.

When CheckConnectionLiveness fires:

  1. Check if the connection's LastHeartbeatUtc is still stale
  2. If yes → remove the ReviewSessionConnection, trigger the same remaining-connection check as OnDisconnectedAsync (treating it as an involuntary disconnect)

Expired Review Session Audit Trail

When an ActiveReviewSession is removed due to idle timeout or suspended grace period expiry, an ExpiredReviewSession record is created in a separate collection:

ExpiredReviewSession
  OriginalSessionId: Guid
  StudyId: Guid
  StageId: Guid
  InvestigatorId: Guid
  CreatedAtUtc: DateTime            (when the original session was created)
  ExpiredAtUtc: DateTime            (when it was removed)
  Reason: enum (IdleTimeout | SuspendedTimeout)
  HasStartedAnnotating: bool
  DurationSeconds: int

Useful for admin dashboards and understanding reviewer behavior patterns.

Multi-Replica API Considerations

No SignalR backplane is needed. The existing notification architecture uses MongoDB change streams as the cross-replica coordination mechanism:

  1. A study document changes (ActiveReviewSession added/removed/modified)
  2. Every replica independently watches the same MongoDB change stream
  3. Each replica's StudyCollectionSubscriptionManager fires on the change
  4. Each replica sends the StudyReviewPresenceSnapshot to its own locally-connected clients

No replica needs to send messages to clients on another replica.

With the server-only realtimePipelineV2 flag on (default off; realtime server plan PR-R1.2), step 3 is the pod's RealtimeDispatcher instead of StudyCollectionSubscriptionManager: SubscribeToStudyPresence registers in the RealtimeRegistry, the Study change feed reader feeds the dispatcher, and the dispatcher reads the study once per 250 ms window (PROPOSAL) and sends the same StudyPresenceUpdated snapshot. Revocation, the subscribe-time checks and the enforced-mode per-delivery decision are unchanged. See SignalR multi-replica analysis.

Connection tracking is in the ReviewSessionConnection collection (not in-memory), so any replica can handle disconnect events and heartbeat updates regardless of which replica the connection was originally established on.

Reviewer Notifications and Enforcement

When a reviewer has a study open and the TotalAllocatedSessionCount reaches the target (due to other reviewers saving or starting annotations), the system notifies them in real-time via the change stream → SignalR pipeline. The reviewer is "surplus" if they are not one of the sessions counted in TotalAllocatedSessionCount — i.e., they have neither a saved AnnotationSession nor a counted ActiveReviewSession (because theirs was removed by idle/suspended timeout and the slot was taken by someone else).

Warning Banner (always shown when surplus):

  • A persistent warning banner appears at the top of the annotation form
  • The banner remains visible for the duration of the reviewer's time on that study
  • If a reviewer is already on the study when the threshold is crossed, they receive a notification dialogue explaining the situation. When dismissed, the warning banner remains
  • Dialogue text: "Your unsaved review session for this study was released after a period of inactivity. This study has since reached its target number of reviewers. Your unsaved changes cannot be submitted. You will be redirected to the next available study."

Hard Lock Mode (configurable per stage):

  • Stage.EnforceAnnotationTarget (boolean, default false) controls whether the system merely warns or actively prevents further annotation
  • When enabled and the reviewer is surplus to the target:
  • The annotation form is hidden
  • Annotation-related buttons are disabled/hidden
  • The warning banner and notification dialogue remain visible so the reviewer understands why
  • No dirty form exception — if the reviewer has unsaved work but their session was removed and the slot was filled, they cannot save. The purpose of this feature is to prevent over-annotation, and allowing a surplus reviewer to save would defeat that purpose regardless of their form state.
  • Applies immediately when the condition is met (no grace period needed — if the reviewer's slot is gone, it's gone)
  • When disabled: warning-only mode (banner + dialogue, no blocking). Reviewer can still save even when surplus.
  • Also blocks direct URL navigation to a study that has reached its target (for reviewers who are not already an existing session)

Selector logic (Angular ngrx):

selectIsReviewerSurplus =
    TotalAllocatedSessionCount >= SessionCountTarget
    AND currentReviewer is NOT one of the counted sessions
        (no ActiveReviewSession AND no AnnotationSession for this stage)

selectShouldShowWarning = selectIsReviewerSurplus

selectShouldLockForm = selectIsReviewerSurplus AND stage.EnforceAnnotationTarget

Design Decisions

Resolved

  • ActiveReviewSession is an Entity (Entity<Guid>) — consistent with AnnotationSession and other domain entities
  • Lives on Study aggregate root, not ExtractionInfo — serves both screening and annotation distribution
  • Upsert semantics for multi-device: AddOrRefreshActiveReviewSession returns existing session rather than throwing on duplicate
  • SessionCountTarget passed as parameter, not denormalized onto study
  • Separate ReviewSessionConnection collection — avoids churning study document on heartbeats
  • No LastHeartbeatUtc on ActiveReviewSession — heartbeats are connection-level
  • No ConnectionIds on ActiveReviewSession — connection tracking is in separate collection
  • MassTransit delayed messages with cancel/reschedule for all timeouts — no polling jobs
  • No SignalR backplane needed — MongoDB change streams coordinate replicas
  • No handoff invariant — ActiveReviewSession and AnnotationSession coexist after save. Double-counting prevented by computing "reserved" as active sessions without a corresponding AnnotationSession.
  • SuspendedSince and IdleSince as nullable DateTimes — consistent pattern, null = state doesn't apply
  • Idle and suspended sessions still count toward slot reservation while they exist. Slot freed only on session removal.
  • Clean vs involuntary disconnect distinguished by OnDisconnectedAsync(exception) — clean = remove session, involuntary = suspend
  • Stage setting EnforceAnnotationTarget — boolean for hard lock mode
  • Stage setting IdleSessionTimeoutMinutes — nullable int, default 120. Time after idle mark before session is removed.
  • Computed properties use [BsonElement] — ensures serialization regardless of class map registration order
  • ExpiredReviewSession audit trail — records timed-out sessions for admin visibility
  • StageId references — follow existing pattern (Stage is a sub-entity of Project aggregate). Revisit Stage aggregate boundaries after PR 2461 (Question Management v2) extracts annotation questions.
  • Atomic assignment via pipeline update — three-stage FindOneAndUpdate pipeline: push session → seed tally if missing → increment tally. Hybrid typed/BsonDocument due to LINQ3 serializer limitation. Proven in integration tests.
  • Audit compatibility — WithAuditCeremony incompatible with pipeline updates (Combine doesn't work with PipelineUpdateDefinition). New WithAuditCeremonyPipeline appends audit fields as an additional $set stage. New PartialUpdateAndGetAsync overload accepts PipelineDefinition<T, T>.
  • Non-atomic operations use UoW — direct navigation, state changes, annotation saves all go through load-modify-save. SessionTallies getter recomputes correctly on save (self-healing).
  • Extend existing NotificationHub — not a separate hub. Single WebSocket connection per client, shared OnDisconnectedAsync for both subscription cleanup and study presence cleanup, avoids second connection in Angular SignalR service. The hub grows from 276 to ~360 lines — manageable. INotificationHubClient gains StudyPresenceUpdated(StudyReviewPresenceSnapshot).
  • Hard lock applies immediately, no grace period, no dirty form exception — if the reviewer is surplus to the target (their session was removed and the slot was filled), they cannot save regardless of form state. The purpose is to prevent over-annotation. The dirty form status is irrelevant to the lock decision when enforcement is enabled. When enforcement is disabled (warning-only mode), reviewers can still save even when surplus.

Scope

In Scope

  • ActiveReviewSession entity on Study aggregate
  • ReviewSessionConnection entity + collection
  • SignalR hub methods (join, leave, startedAnnotating, stoppedAnnotating, heartbeat)
  • Disconnect handling (clean vs involuntary)
  • Idle detection (5-min mark + configurable timeout)
  • Suspended session handling (2-hour grace period)
  • MassTransit consumers for all timeout handlers
  • SessionTally extensions (reserved, annotating-reserved, totalAllocated, totalEngaged)
  • Algorithm integration (screening + annotation distribution)
  • Stage settings (EnforceAnnotationTarget, IdleSessionTimeoutMinutes)
  • Angular SignalR service + ngrx studyPresence feature
  • Warning banner + notification dialogue
  • Hard lock enforcement on annotation form
  • ExpiredReviewSession audit trail
  • Admin visibility of session states (active, idle, suspended)

Out of Scope (for now)

  • UI showing who else is reviewing the same study (names/avatars)
  • Historical analytics on reviewer behavior patterns
  • Stage aggregate promotion (deferred to post-PR 2461)

Technical Components

Component Location Change Type
ActiveReviewSession entity src/libs/project-management/.../Model/StudyAggregate/ New entity on Study aggregate
ReviewSessionConnection entity src/libs/project-management/.../Model/ New entity + MongoDB collection
ReviewSessionConnection repository src/libs/project-management/.../Mongo.Data/Repositories/ New repository
ExpiredReviewSession entity src/libs/project-management/.../Model/ New entity + MongoDB collection
SessionTally extensions src/libs/project-management/.../Model/StudyAggregate/ Reserved/allocated/engaged computed properties
Stage model src/libs/project-management/.../Model/ProjectAggregate/StageEntity/ EnforceAnnotationTarget, IdleSessionTimeoutMinutes
SignalR Hub src/services/api/.../SignalR/ New or extended hub methods
MassTransit consumers PM service or API service MarkSessionIdle, RemoveIdleSession, RemoveSuspendedSession, CheckConnectionLiveness
MassTransit messages src/libs/project-management/.../Messages/ New message contracts
Study repository filters src/libs/project-management/.../Mongo.Data/Repositories/ TotalAllocatedSessionCount filter + indexes
Atomic assignment pipeline src/libs/project-management/.../Mongo.Data/Repositories/StudyRepository.cs FindOneAndUpdate pipeline builder for study claim
Pipeline audit extension src/libs/mongo/SyRF.Mongo.Common/MongoExtensions.cs WithAuditCeremonyPipeline for pipeline updates
Pipeline UoW overload src/libs/mongo/SyRF.Mongo.Common/MongoUnitOfWorkBase.cs PartialUpdateAndGetAsync accepting PipelineDefinition
StageReviewService src/libs/project-management/.../Core/Services/ Atomic study claim with presence
Angular SignalR service src/services/web/.../core/services/signal-r/ New hub methods, heartbeat, reconnect, idle tracking
Angular ngrx studyPresence src/services/web/src/app/ New feature slice, selectors
Warning banner component src/services/web/src/app/shared/ New component
Notification dialogue src/services/web/src/app/shared/ New MatDialog
Annotation form integration src/services/web/src/app/shared/annotation/ Lock form, idle status, dirty/clean signals
Stage settings UI src/services/web/ New toggles for enforcement + idle timeout
Admin session status UI src/services/web/ Session state visibility (active/idle/suspended)