Skip to content

Web realtime client plan (October 2026)

This plan rewrites the Angular client's SignalR layer (core/services/signal-r/signal-r.service.ts, 1,635 lines) as a signal-based realtime core: one connection store, a declarative ref-counted group registry, a typed hub contract and explicit message families, using native platform APIs where they are simpler than RxJS. It supersedes and expands §5b "Realtime and routing" of the state-management plan (see §4 for exactly which RT items move here) and follows the frontend bug-fix plan, whose PR-3 still ships first. It is planning only; nothing starts until Chris approves and answers the decisions in §12. The server-side "hub contract v2" belongs to a parallel plan; §9 lists what the client needs from it. Chris's decisions of 2026-10-04 are recorded in §12.0. In particular, the API-wide System.Text.Json migration (plan) is a prerequisite of contract v2, and so of R3 here.

All file:line references were read on main@8f73bcbc9 (4 Oct 2026). Web paths are relative to src/services/web/src/app/.

1. Scope

ID Problem Impact Evidence Issue Status
C1 Groups are not re-subscribed after an automatic reconnect (bug-plan B1) Live updates stop silently after a blip signal-r.service.ts:633 (withAutomaticReconnect), :762/:811 (connected false only on close), :1140-1194; server clears per-connection subscriptions NotificationHub.cs:112-122 #3976 VERIFIED
C2 Reconnect handler leaves a permanent switchMap(() => _currentProjectId$) listener (B2) Extra loadProject on every later project switch signal-r.service.ts:1231-1243 #3976 VERIFIED
C3 Unawaited invoke promises; connected sticks false if the listings subscribe fails after start() (B3) Unhandled rejections; no project subscription until reload :1153, :1165, :1181, :1187, :742-769 #3976 VERIFIED
C4 No onreconnecting handler. The UI only learns of a loss when the library gives up (onclose) Processing page and review presence show "live" while reconnecting :703-719 (only onclose/onreconnected); study-presence.init.ts:125-127 new VERIFIED
C5 Two overlapping retry loops (start-failure retry every 5 s, plus a 5 s state poll), no backoff or jitter, blind to online/visibility Thundering herd after a pod restart; slow recovery after the network returns :760-769, :1208-1228, constants :181-183 new VERIFIED
C6 Connection identity is captured once (take(1) on first user plus impersonated user) A user or impersonation change keeps the old identity's connection :406-415, :627-638 new VERIFIED (whether the app reloads on impersonation change: NOT VERIFIED)
C7 Dead wiring: a ProjectStatsNotification handler, but the client never calls SubscribeToProjectFullStats; _unsubscribeFromProjectListings is never called Dead code that suggests live full-stats pushes :653-656, :918-936, :1632-1634; server NotificationHub.cs:1306-1352 new VERIFIED (grep: no web caller)
C8 Stringly typed contract: 9 server→client names and 13 invocation names are strings; 4 DTO families are hand-typed, one duplicating a generated type Server renames break silently at runtime :641-660, :703-719, :1599-1634, :96-179; project-statistics-invalidation.service.ts:4-13; generated duplicates api-client.generated.ts:16938,16965 new VERIFIED
C9 God service mixing connection, presence, NgRx dispatch, normalizr, dialogs and a ComponentStore that holds one boolean Untestable without prototype spies; blocks #3989 :307-319, imports :13-87 #3989 VERIFIED
C10 Staleness rules are inconsistent: <= version checks re-apply equal versions; full stats unchecked; deletes carry no version Out-of-order pushes can win :965-970, :1027-1037, :1106-1111, :928-934; generated EntityNotification* has only id/dbTime (api-client.generated.ts:15229-15237) new VERIFIED
C11 Missed-event recovery is partial: on reconnect only the current project reloads (with C2's bug) and presence rejoins. Listings and export jobs are never refetched; flags rely on a 5-minute poll Stale lists and export states after an outage allReconnected$ consumers: :1231-1257, study-presence.init.ts:126; runtime-feature-flags.service.ts:85-104 new VERIFIED
C12 Specs mock HubConnectionBuilder.prototype and replaced connected$, which hid C1 Reconnect regressions pass CI signal-r.service.spec.ts:1029-1060; bug plan PR-3 new VERIFIED

Corrections to the brief or appendices. - "NSwag already emits SignalR DTOs via the Generator endpoint" is CORRECTED. The dummy GET Generator([FromBody] Aggregates) (ApplicationController.cs:246-275) emits only the entity-notification DTOs (project, export job, full stats). Presence and access results are generated because REST endpoints return them. ProjectStatisticsChanged, ActivityClaimRevokedDto and all hub method names are not generated. - State plan check 2.R5 is answered here (VERIFIED). Project and export-job payloads carry audit.version (AuditDto.version, api-client.generated.ts:14173-14181). Presence snapshots carry aggregateVersion (signal-r.service.ts:130). Statistics invalidations carry string revisions. Delete notifications carry no version.

2. Current state map

2.1 Server → client messages (all 9 that main handles; a 10th is in PR #3932)

Message Registration What the client does Consumers
ProjectNotification (update/delete) :641-644, :992-1082 Version check → projectDetailActions.projectUpdated (normalizr into about 18 entity reducers) plus statistics settingsChanged; delete → projectDeleted(terminal) Reducers; project-detail.effects.ts:836-870 refetches reviewer stats; requests.ts:78-86 uses the newest version; metadata isSignalRUpdate read in annotation-questions.feature.ts:294-300
ProjectSummariesNotification :649-652, :1084-1137 Version check → projectListingUpdated; delete → projectDeleted(non-terminal) Listing reducers
DataExportJobNotification :645-648, :938-990 Version check → dataExportJobUpdate / dataExportJobDeleted data-export-job.reducer.ts:18
ProjectStatsNotification :653-656, :918-936 → receivedFullStats (no version check) Dead (C7)
UiVersionCheck (sent on connect, NotificationHub.cs:108) :657, :836-916 Compare versions → "Update required" dialog → reload User
RuntimeFeatureFlagsRevision :658-660 Revision hint → full snapshot refetch runtime-feature-flags.service.ts:85-104
ProjectStatisticsChanged :710-712 → ProjectStatisticsInvalidationService.receive, deduplicated by revision; subscribers reload 16 non-spec files (statistics stores and states; grep ProjectStatisticsInvalidationService)
StudyPresenceUpdated :713-715 Forward the snapshot (last write wins on aggregateVersion) study-presence.init.ts:94 → StudyPresenceStore
ActivityClaimRevoked :716-718 Forward study-presence.init.ts:121 → annotationClaimReleased plus access refresh
InboxChanged (#3932, not on main) #3932 adds hubConnection.on Invalidation → refresh(); also refreshes on allReconnected$ Notification inbox

2.2 Client → server, lifecycle, auth and the RxJS surface

Concern Today
Group invocations SubscribeToProject/UnsubscribeFromProject (current project and member, :1140-1166); SubscribeToProjectSummaries (only after start(), :759); SubscribeToDataExportJob/Unsubscribe… (unresolved-job diff, :1169-1194)
Presence invocations JoinStudyReview, LeaveStudyReview, StartedAnnotating, StoppedAnnotating, Heartbeat (30 s), Subscribe/UnsubscribeFromStudyPresence, through _invokeWithRetry with per-method policies and a generation guard (:209-221, :440-615, :1259-1399, :1453-1597)
Lifecycle withAutomaticReconnect() with library defaults; onclose → closeEvents$; onreconnected → hubReconnected$ → allReconnected$; manual retry loops (C5). Started by an app initializer that injects the service (main.ts:680-685); disabled by signalRActive=false (:388-393)
Auth BFF: withCredentials; Auth0/OIDC: accessTokenFactory → getAccessTokenSilently() (:672-680). Impersonation is passed as query parameters (:682-696)
Status consumers SignalRStore.connected (a ComponentStore) via toSignal: environment-banner.component.ts:284, environment-indicator.component.ts:185, version-info-dialog.component.ts:89, processing-page.component.ts:174-180
RxJS 12 Subjects, 5 fromEvent, 1 fromEventPattern, combineLatest, pairwise, withLatestFrom, switchMap, timer, takeUntil, tap; no share. ProjectStatisticsInvalidationService adds 3 Subjects
Zoneless Status reaches templates through toSignal, and presence is a SignalStore, so it is safe as far as read; there are no NgZone call sites. The suite already runs zoneless (test-providers.ts)
Library @microsoft/signalr ^8.0.7, resolved to 8.0.17 (pnpm-lock.yaml:1536). Latest is 10.0.11 (npm view, 4 Oct 2026); same deep-import layout, JSON hub protocol version 2

3. Target design

3.1 Layers (core/realtime/)

Piece Responsibility Built from
realtime.contract.ts Typed maps ServerToClient (method → payload tuple) and ClientToServer (method → args and result), plus the RealtimeGroup union (project, projectSummaries, dataExportJob, studyPresence), each with its subscribe and unsubscribe method in one exhaustive table Generated DTOs plus a manifest checked against the server (§3.5)
HubTransport (token, with a factory) The only code that touches HubConnection: build (withAutomaticReconnect(policy), optional withStatefulReconnect), typed invoke/on, lifecycle callbacks. A fake implementation backs the specs @microsoft/signalr, wrapped
RealtimeConnectionStore (root SignalStore) State machine disabled → idle → connecting → connected ⇄ reconnecting → retrying, plus offline. Signals: status, connectionId, epoch (increments when the connection ID changes), lastError. One retry loop with backoff and jitter. Rebuilds the connection when the identity key (user, impersonation, auth mode) changes effect with onCleanup → AbortController; online/visibilitychange listeners registered with { signal }
RealtimeGroupRegistry (root) Desired set = ref-counted declarations. Confirmed set = linkedSignal keyed on epoch, so it resets on every new connection. A reconciler invokes the difference, one serialized chain per group, retries failures with backoff, records denials, and emits realtimeEvents.resynced once all groups are re-acknowledged after a new epoch linkedSignal, effect, AbortSignal.any([epochSignal, groupSignal])
injectRealtimeGroup(() => group \| null) Injection-context helper. It acquires the group for the caller's lifetime and swaps it when the key changes. Returns { status, denial, resyncs } signals assertInInjectionContext, DestroyRef.onDestroy, effect
Message handlers One handler per message family (§3.3). They dispatch realtimeEvents.* (NgRx Events plugin). During migration a legacy adapter re-dispatches today's global actions @ngrx/signals/events
StudyPresenceChannel Today's presence code (generation guard, retry policies, annotation-state chain, heartbeat, rejoin ordering) moved out of the service almost verbatim, over HubTransport Existing logic plus its 595-line spec

Route-level subscriptions: a route-scoped store in Route.providers calls injectRealtimeGroup(() => projectGroup(routeProjectId())). With withAutoCleanupInjectors() (stable, @publicApi 22.2), leaving the route destroys that injector, so the group is released. Moving from project A to B swaps the group. Page data reloads through the route resource's reload() when the group is invalidated or resynced. ResourceResult exposes reload (_router_module-chunk.d.ts:2014, developer preview 22.2). The state plan's check 2.R4 still decides between this and its fallback store-owned httpResource.

3.2 API evaluation (typings read from the published packages via npm pack; checked 2026-10-04)

API Status Verdict
linkedSignal Stable, @publicApi 20.0 Use: the confirmed-group set linked to epoch (it resets by construction, which removes C1)
effect with onCleanup Stable, @publicApi 20.0 Use: identity → connection lifecycle; group-key swaps
DestroyRef (onDestroy, destroyed) Stable Use: group release in injectRealtimeGroup
resource (loader or stream, abortSignal in params) Stable, @publicApi 22.0 Not for the connection. A connection is a long-lived side effect: error is sticky until the params change, the value resets on a param change, and state-plan rule 3 keeps resources for reads. Use it for the data that realtime invalidates
httpResource, rxResource Stable, 22.0 Reads reloaded on invalidation (state plan)
Router resources + withRouterResources() Developer preview 22.2 Route page data (state plan D8); reloaded by realtime
withAutoCleanupInjectors() Stable, @publicApi 22.2 Use: route-lifetime group declarations
debounced, resourceFromSnapshots Experimental 22.0 Not used; coalescing reloads is a few lines in the store
pendingUntilEvent Developer preview 20.0 Not needed (no SSR)
NgRx Events plugin (eventGroup, withEventHandlers, withReducer, injectDispatch) 22.0.1 No experimental markers Use: hub messages become events; producers never know consumers (state plan D1)
ngrx-toolkit 22.0.0 Community withDevtools only. It has no cross-tab or realtime feature (export list checked)

3.3 Message families: invalidate or patch

Family Decision Why Staleness rule
Project details (ProjectNotification) Invalidate → reload the route resource (coalesced: a notification during a load triggers one more load) Page data has one owner, the route; the payload's shape is per-user and broad Ignore if version <= the loaded version
Project listings (ProjectSummariesNotification) Patch in place in the root ProjectSummariesStore High frequency; root collection Apply only if version > the held version (fixes C10's <=)
Export jobs Patch in place (progress), remove on delete Progress ticks; root collection version >; a delete removes regardless
Statistics (ProjectStatisticsChanged) Invalidate (already). This stays the MVP and production behaviour. RS.1, a staging-only follow-up, adds versioned snapshots No payload is trusted Existing revision dedupe (project-statistics-invalidation.service.ts:29-38,73-81)
Statistics snapshot (ProjectStatisticsSnapshot, an additive v2 message; the name follows the server plan) Patch the statistics store (RS.1; staging only) It saves the refetch after every invalidation Apply only if its project revision is strictly greater than the one held (from HTTP or a push) and its global revision is not lower; drop it if older; at an equal revision never overwrite, and refetch over HTTP if the held value came from HTTP (unknown provenance) or its pendingFingerprint differs (server plan R4 item 10); a lower-revision HTTP response is dropped and the held value kept; refetch on a gap or ResyncRequired. Only the stores backed by StageOverviewStatistics/ScreeningOverviewStatistics take pushes
Presence (StudyPresenceUpdated) Patch (already) Snapshot semantics aggregateVersion, then serverTimestamp
ActivityClaimRevoked Command event Targeted at this user studyRevision
Flags, inbox Invalidate → refetch Already the pattern Revision numbers
UiVersionCheck Command (dialog) Unchanged —
Full stats Delete the dead handler (C7) Never subscribed —

Missed-event recovery. After a new epoch and once every declared group is re-acknowledged, realtimeEvents.resynced fires once. Route stores reload their resources, root stores refetch active collections, flags and inbox refetch, and presence runs its ordered rejoin. A stateful-reconnect resume (same connection ID) is not a new epoch: no resubscribe, no resync.

3.4 Native JS/TS versus RxJS

Browser support is from @mdn/browser-compat-data 8.1.4 (Chrome / Firefox / Safari).

Native API Support Verdict
AbortController/AbortSignal, AbortSignal.any any: 116 / 124 / 17.4 Use: per-attempt and per-epoch cancellation of start, retries and group chains; addEventListener(…, { signal }) for teardown
Promise.withResolvers 119 / 121 / 17.4 Use narrowly (whenConnected(), fake hub). If the effective browserslist excludes it (NOT VERIFIED), use a 3-line local helper
EventTarget/CustomEvent Universal Not as an app bus. The Events plugin is the bus. Native listeners only for online, offline and visibilitychange
Async iterators Universal Rejected: pull iteration fights push fan-out; there are no streaming hub methods
using / Symbol.dispose / DisposableStack (TypeScript supports the syntax) 134 / 141 / Safari preview only, iOS no Rejected for runtime code (it would need a polyfill and downlevelling). DestroyRef plus AbortController cover it. Revisit when Safari ships it
Page Visibility, navigator.onLine, online event Universal Use: offline → status offline and the retry loop pauses; online → retry now. Visible again after more than 60 s hidden (PROPOSAL) → health check plus resync if the epoch changed. Never disconnect a hidden tab: the server would suspend the reviewer's reservation (NotificationHub.cs:265-312)
BroadcastChannel + Web Locks (one connection per browser, leader election) 54 / 38 / 15.4; locks 69 / 96 / 15.4 Rejected for now (D3). Server state is per connection: presence ReviewSessionConnection and heartbeat per study, per-connection authorization, impersonation parameters per connection. Closing the leader tab disconnects every tab's presence, and a handoff is a reconnect with missed events. Revisit only if connection count is measured as a cost
SharedWorker Android Chrome only since 148 Rejected: no DI, harder to debug, same per-connection semantics problem

RxJS stays where it is better: 1. withEventHandlers requires Observables. 2. Write concurrency (exhaustMap, concatMap in rxMethod). 3. Debounce and audit of reload bursts inside handlers. 4. ProjectStatisticsInvalidationService.forProject(), used by the statistics stores (16 non-spec files) with pollCoherentStatistics: it works and is invalidation-based, so it is only re-fed. 5. A legacy facade for not-yet-migrated global effects until R5.

Everything else becomes signals or promises: the 12 Subjects, the ComponentStore, combineLatest gates and timer polls.

3.5 Typed contract and transport

  • Keep @microsoft/signalr (wrapped by HubTransport) and upgrade it to 10.0.x. A raw WebSocket would mean re-implementing negotiate, handshake, keep-alive and server timeout, invocation IDs and completions, automatic and stateful reconnect, long-polling fallback, and token and cookie auth. That is all protocol surface the server already speaks through the library.
  • Contract:
  • DTOs come from NSwag by extending the Aggregates trick, or from the server plan's contract v2.
  • Method names and payload types come from a server-emitted manifest (CR-8). A spec fails if realtime.contract.ts and the manifest disagree.
  • Exhaustiveness: handlers satisfies { [K in keyof ServerToClient]: (...a: ServerToClient[K]) => void } and the RealtimeGroup protocol table are compile-time checked.
  • Stateful reconnect:
  • The client builder option (withStatefulReconnect) exists in 8.0.17 and 10.0.11 (typings read).
  • The server does not enable it: there is no AllowStatefulReconnects on MapHub<NotificationHub> (Program.cs:822).
  • Staging runs 2 API replicas (cluster-gitops/syrf/environments/staging/api/values.yaml:6) with cookie affinity (_ingress.tpl:20-23), so a resume normally reaches the same pod. A pod restart falls back to a new epoch.
  • Client-side, this ships behind the same flag once the server enables it (RV.3).
  • Protocol support is VERIFIED (2026-10-04, by the coordinator): ASP.NET Core's NewtonsoftJsonHubProtocol declares ProtocolVersion = 2 and handles the Ack/Sequence messages. Contract v2 will use the System.Text.Json protocol in any case (next bullet).
  • System.Text.Json prerequisite (Chris, 2026-10-04). The server moves API-wide to System.Text.Json, and hub v2 uses the STJ hub protocol. Impact on the browser:
  • Wire protocol: none. The browser always speaks the SignalR JSON hub protocol; Newtonsoft and STJ are server-side serializers of the same format.
  • Payload shape: possible. Casing, enum representation (number or string), null and default omission, dictionary-key casing and polymorphic converters (AnnotationConverter and its siblings, Program.cs:390-402) may change. The regenerated NSwag DTOs (api-client.generated.ts) can then change, and so can every hand-typed hub type (C8).
  • Mitigation. RC.2.3's manifest spec, plus a payload-fixture spec: recorded v1 frames are replayed through the handlers, and the server plan supplies matching STJ frames. Any client DTO regeneration rides with the STJ migration PRs, not with this plan.

3.6 Zoneless

Every realtime state is a signal written from hub callbacks. Signal writes schedule change detection under zoneless, and NgRx dispatches reach templates through selectSignal/toSignal. No NgZone, no plain fields assigned in .subscribe. Specs run under the global zoneless provider; status rendering is asserted without manual detectChanges.

4. Relationship to state-plan §5b (what this plan supersedes)

State-plan item Disposition
§5b "Ownership" table and "Route resources inside a route-scoped SignalStore" Kept in the state plan; this plan implements the realtime half
§5b "Declarative SignalR subscriptions" points 1-9 Replaced by §3.1-§3.3 here (the root store is split into connection store plus registry; realtime.need() becomes injectRealtimeGroup())
R0.2 kit bullet "RealtimeStore with the declarative group registry" Moved to RC.3 here
RT.1, RT.2, RT.3, RT.5, RT.8 Replaced by RC.3.1, RF.2.1, RC.3.2, RC.3.3 and RC.3.4
RT.4 (two contexts offline, then resync) Replaced by RC.5.1
RT.6 (stale event leaves an entity unchanged) Replaced by RF.1.2
RT.7 (event during a load → exactly one reload) Replaced by RF.2.2
Pilot check 2.R4 Stays in the state plan (a router question)
Pilot check 2.R5 Answered in §1 (VERIFIED)
Pilot check 2.R6 Replaced by RF.1
"Relationship to bug-plan PR-3" Amended: PR-3 still ships first. The old service is deleted at RC.6 (after the flag soak), not at state R6

Suggested edit to the state plan: replace §5b points 1-9, RT.1-RT.8 and 2.R5/2.R6 with a link to this document.

5. MVP boundary, out of scope, flag decision

MVP = R1 + R2. Every current consumer runs on the new core behind a flag, and reconnect correctness holds by construction: - groups re-subscribed per epoch; - resync refetch; - a reconnecting/offline status; - one backoff loop; - identity rebuild; - a typed contract and a fake hub; - an E2E reconnect suite.

After a staging soak and a Chris-approved production default, the old service is deleted.

Shortest critical path: RC.1 → RC.3 → RC.4 → RC.5 → staging soak → (Chris: production) → RC.6.

Out of scope:

Excluded Tracked in
Server hub contract v2, backplane, the EnableDetailedErrors fix, DataExportJob disconnect cleanup Server plan (parallel); review items 3 and 8
Migrating global-store consumers to SignalStores State plan #3989 (R2-R6); RF.x here only move their realtime inputs
Presence behaviour changes, banner styling Stage-review programme; #3021 (presence banners to M3)
Notification inbox features Notifications stream (#3932, #3938, #3941-#3945, #3965)
A new global "reconnecting" UI D9
The System.Text.Json migration itself (server serializers, NSwag regeneration) STJ migration plan
Production rollout of statistics snapshots (RS.1) Not planned (Chris, 2026-10-04): staging only

Flag decision: flagged. One web-only generated flag, realtimeClientV2: - default false, catalogued pageReload (the connection is built at bootstrap); - added through env-mapping.yaml and pnpm run generate:flags; - it selects which core provides the RealtimeStatus token and the legacy facade.

Two cores cannot run side by side, because two connections would double presence. The rewrite touches every page's live updates and wants a kill switch. RF.x feature migrations run after RC.6, when only one core exists, so they need no flag of their own. The library upgrade (RC.0) and the extraction refactor (RC.1) are behaviour-preserving and unflagged.

6. Common acceptance criteria (every PR)

# Criterion Verification
C1 Regression or behaviour specs are written first and pass via pnpm exec ng test --no-watch --include=<touched> plus the three repo-wide guard specs Commands and output in the PR body
C2 Specs use the fake HubTransport (no HubConnectionBuilder.prototype spies in new specs) Review; grep
C3 Zoneless-safe: signals drive templates; no NgZone, no manual detectChanges added Zoneless guard spec
C4 No new strict-TypeScript errors or ESLint errors in touched files; no new old-idiom imports (state-plan ratchet) tsc count before and after; ng lint
C5 The flag state is stated; with the flag off, behaviour is byte-for-byte today's (RC.4-RC.5) Spec run in both flag states
C6 Docs updated: docs/architecture/realtime-client.md (new, once), src/services/web/CLAUDE.md "Realtime" rules, the feature-flag catalogue Review
C7 Settled review gate on the head, CI green; UI-visible PRs get Chris's preview acceptance pr-review-settled.sh; sign-off

7. Releases and pull requests

R0: prerequisites (parallel)

  • Bug-plan PR-3 (C1-C3, #3976) ships as planned in the old service, because users need it before the rewrite reaches production (D2).
  • State-plan R0.1 (Angular 22.2, NgRx 22) is needed for withAutoCleanupInjectors and router resources.

RC.0: upgrade @microsoft/signalr 8.0.17 → 10.0.x (effort S; unflagged)

# Criterion Verification
0.1 pnpm install → the lockfile resolves 10.0.x and the deep import dist/esm/HubConnectionBuilder still builds Build log
0.2 The existing SignalR specs pass unchanged CI Test Web (Angular)
0.3 Hermetic E2E: disconnection-and-save-guard, stage-progress-live and materialized-statistics-two-api pass e2e/run-local.sh --spec …
0.4 PR preview: WebSocket connects in both BFF and bearer modes Preview check

R1: realtime core behind realtimeClientV2 (sequential: RC.1 → RC.3 → RC.4; RC.2 can run in parallel with RC.1)

RC.1: extract without behaviour change (effort M; unflagged)

Move the following out of signal-r.service.ts, which becomes a thin shell over them: - StudyPresenceChannel (:440-615, :1259-1419, :1453-1597); - legacy-store-handlers.ts (project, listings and export → today's actions, :918-1137); - ui-version-check.ts (:836-916); - realtime.contract.ts (types only).

The dead full-stats wiring is deleted. The existing 3 spec files move with their code.

# Criterion Verification
1.1 Every existing SignalR and presence spec passes with only import-path changes Spec diff
1.2 The service shrinks below 500 lines (PROPOSAL), with no public API change wc -l; tsc on consumers
1.3 No ProjectStatsNotification registration remains grep plus spec
1.4 The handlers are pure functions (snapshot in → actions out), spec-covered for the version rules Unit

RC.2: typed HubTransport plus fake hub (effort M; unflagged, not wired)

# Criterion Verification
2.1 invoke and on accept only contract names, and a wrong payload type fails tsc Type-level spec (// @ts-expect-error)
2.2 The fake hub can emit reconnecting, reconnected(newId), close, reject invokes and record call order Fake-hub self-spec
2.3 The contract-manifest spec fails when a method is added to the manifest but not to the contract Spec with a fixture manifest (the real manifest arrives with CR-8)

RC.3: RealtimeConnectionStore, RealtimeGroupRegistry, injectRealtimeGroup (effort L; not wired)

# Criterion Verification
3.1 A component declaring group G is destroyed → G is unsubscribed unless another live declaration needs it (ref count) Unit (fake hub)
3.2 Connection ID changes → every live group is re-subscribed exactly once on the new connection, then resynced fires once Unit
3.3 A subscribe rejects with a retryable error → retried with backoff (0.5, 1, 2, 5, 10, 30 s cap plus ±20% jitter, PROPOSAL); no unhandled rejection Unit (unhandledrejection spy, fake timers)
3.4 A non-member declaration (key null) → no invoke is ever made Unit
3.5 A subscribe is denied (an authorization HubException; typed nack after CR-3) → status denied with a reason; no retry loop Unit
3.6 onreconnecting → status() becomes reconnecting within the same task Unit
3.7 offline event → status offline and no start attempts; online → one immediate attempt Unit (dispatch events on window)
3.8 Identity key changes (user, impersonation, auth mode) → the old connection stops and the new one starts with the new URL and credentials; the old connection's late callbacks are ignored Unit
3.9 Start fails repeatedly → exactly one retry loop runs (no parallel start()) Unit
3.10 Status renders in a zoneless host component without manual change detection Component spec

RC.4: flagged wiring and legacy facade (effort L; flagged)

Files: - env-mapping.yaml and generated flag files; - main.ts provider selection; - core/realtime/legacy-facade.ts, which exposes today's SignalRService Observables and methods on the new core; - a root LegacyRealtimeDeclarations, which declares the listings group (while authenticated), the current-project group (from selectLoadedProjectId plus membership) and one group per unresolved export job; - the 4 status consumers repointed to a RealtimeStatus token.

On resynced the facade dispatches one loadProject for the current project (fixing C2 by design), refetches listings and unresolved export jobs, and emits the legacy allReconnected$.

# Criterion Verification
4.1 Flag off → the old service runs, and the full SignalR, presence and inbox suites pass unchanged CI
4.2 Flag on → the same suites pass against the facade (a shared contract spec runs in both modes) CI
4.3 Flag on, reconnect with a new ID while viewing project P → SubscribeToProject(P), SubscribeToProjectSummaries and one SubscribeToDataExportJob per unresolved job are invoked once each; exactly one loadProject(P) Unit
4.4 Flag on, the processing page during reconnecting → shows the stale notice Component spec
4.5 Flag on, presence → join, heartbeat, rejoin order and denial handling match today (the ported presence spec passes) Spec
4.6 Only one hub connection exists in either flag state Unit (factory call count)

RC.5: hermetic E2E reconnect suite (effort M)

# Criterion Verification
5.1 Two contexts on project P; the reviewer's socket is closed and the context offline for 10 s (PROPOSAL), then back → the other context's edit appears without reload within 10 s (PROPOSAL) E2E, using the socket-close plus setOffline pattern (materialized-statistics-two-api.spec.ts:440-480)
5.2 Same outage on the project index and the exports page → listings and export state converge after resync E2E
5.3 Same outage on the review page → presence rejoins and the reservation is kept (inside the server grace period) E2E
5.4 Two tabs of the same user → each keeps its own connection and both receive updates E2E
5.5 The suite runs with the flag on and off; both green before the soak starts Two E2E runs quoted

Gate: 1. Staging soak with the flag on: Chris plus testers, 1 week (PROPOSAL). 2. Turning the production default on is a separate Chris-approved step.

RC.6: delete the old service and the flag (effort S). Done when 6.1 grep → no SignalRService/SignalRStore symbols remain outside the legacy facade; 6.2 the flag is removed through the generator; 6.3 the E2E suite is green.

R2: feature migrations onto declarations (after RC.6; one PR per area, in step with the state plan)

PR Area Replaces State-plan link
RF.1 Pilot. The project index: ProjectSummariesStore declares projectSummaries, patches with version >, refetches on resync Listing declaration in LegacyRealtimeDeclarations; projectListingUpdated R2 (2.R6)
RF.2 The project route store declares project(P); invalidation and resync → route resource reload() Current-project declaration; projectUpdated patching; loadProject on resync R3.1
RF.3 The export-job store declares one group per unresolved job (a computed list) Export declarations; tapOnArrayChange R3.5
RF.4 Statistics: the registry's confirmed(project) replaces ProjectStatisticsInvalidationService.subscribed(); forProject() is unchanged Hook at signal-r.service.ts:1603 —
RF.5 Presence declarations (studyPresence group) from the review page; StudyPresenceChannel keeps its join and heartbeat logic Presence init subscriptions R4, with the stage-review owner (D8)
RF.6 Inbox and flags consume realtimeEvents and resynced inboxChanged$, allReconnected$, runtimeFeatureFlagsRevision$ With the notifications owner
RF.7 Delete the legacy facade and handlers — R6
# Criterion Verification
RF.1.1 A project created or renamed by another user appears in the index without reload E2E (two contexts)
RF.1.2 A stale listing push (version <= held) → the entity is unchanged Store spec
RF.2.1 Navigate from A to B → UnsubscribeFromProject(A) then SubscribeToProject(B), once each, in that order Unit plus E2E (recorded hub frames)
RF.2.2 projectChanged arrives while the route resource loads → exactly one extra reload; the final value matches the server Store spec
RF.2.3 The project is deleted elsewhere → the route store navigates to the index with a message; no group is left Spec plus E2E
RF.x.1 Each area's realtime declarations are removed from LegacyRealtimeDeclarations in the same PR (one owner per group) Review; grep

R3: contract v2 adoption (after the server plan ships the matching items; parallel PRs)

Prerequisites: - the System.Text.Json migration, including the regenerated client DTOs; - the server's hub v2 behind realtimeHubV2; - RC.6.

Before RV.x starts, the payload-fixture spec (§3.5) passes against STJ frames.

PR Adopts Criterion → result Verification
RV.1 Typed subscribe acknowledgement (CR-3) A denied subscribe → denial carries the server reason; the UI explains "no access" instead of retrying Unit plus E2E (non-member)
RV.2 Per-group sequence numbers (CR-2) A gap in the sequence → a targeted resync of that group only Unit
RV.3 Stateful reconnect (CR-6), client option behind the flag Socket drop for under 5 s (PROPOSAL) → same connection ID, no resubscribe, no missed message E2E
RV.4 Revocation notices (CR-5) Membership revoked → the group is dropped, a realtimeEvents.accessRevoked event fires and the route leaves the project E2E

RV.2 is deferred together with CR-2 (§9 Resolution). RV.3 needs only the server's protocol test (server R1.3b), because protocol support is verified.

R4: statistics snapshot follow-up (RS.1; staging only; production rollout not in scope)

RS.1: apply ProjectStatisticsSnapshot in the statistics store (effort M)

  • When: after RF.4 (statistics migrated onto the registry) and after the server's snapshot release.
  • Behind: the server's snapshot flag plus realtimeClientV2.
  • Approach:
  • The server shapes each snapshot per caller, with the same output as the materialised HTTP read for that caller (server plan R4, 2026-10-05); there is no visibility class. The snapshot is accepted only for the store's current scope: project and, for Stage Overview, stage.
  • Scope of stores. Only the stores backed by the two coherent DTOs, StageOverviewStatistics and ScreeningOverviewStatistics, take pushes: they are the only HTTP responses that carry clientInvalidationRevision and globalClientInvalidationRevision. The reviewer-progress (ProjectReviewerProgressMetadata) and Project Overview screening-stats responses carry no revision, so those stores keep invalidate-and-refetch.
  • The snapshot carries revision and globalRevision (Int64, sent as strings), compared as integers, because pollCoherentStatistics (coherent-statistics-snapshot.ts:126-166) tracks both. A push is applied only when its project revision is strictly greater than the one held, whether that came from HTTP or from a push, and its global revision is not lower.
  • An older snapshot is dropped. An equal-revision snapshot never overwrites the held value. A value read over HTTP has unknown provenance (the coherent DTOs carry no pending fingerprint), so an equal-revision push for an HTTP-held value triggers one HTTP refetch; against a push-held value, the same pendingFingerprint means drop and a different one means one refetch.
  • A lower HTTP revision is dropped, not an error. Today pollCoherentStatistics throws "Statistics revision regressed" when either revision goes down, and the page goes unavailable. With pushes, a push can raise the held revision while a timer or invalidation GET is in flight, so RS.1 changes this: an HTTP response whose revision (project or global) is lower than the one held is dropped and the held value kept; the page stays ready.
  • An equal-revision HTTP response is still accepted, as today. Accepted transient: a GET issued before a push can return at the same revision with a smaller pending set and overwrite the fresher push; the next fold heals it at revision+1 (steady-state lag under 1 s).
  • While subscribed, an invalidation does not refetch the sections the last push carried, because the push for that revision follows; sections the push omitted still refetch on invalidation. Without this, every change costs a GET plus a push and the saving does not happen.
  • A sequence gap or ResyncRequired falls back to today's refetch (pollCoherentStatistics).
  • Invalidate-and-refetch stays the default whenever the flag is off, and is the only behaviour in production.
# Criterion (condition → result) Verification
RS.1.1 Snapshot with a revision greater than held, for the current project and stage → the store shows it with no HTTP request Store spec (fake hub, HTTP spy)
RS.1.2 Snapshot with a lower project or global revision, or an equal revision and the same pendingFingerprint as a push-held value → dropped; the store is unchanged. Equal revision against an HTTP-held value, or a different fingerprint → the held value is not overwritten and exactly one HTTP refetch runs Store spec
RS.1.3 Snapshot for another project or stage → ignored Store spec
RS.1.4 Sequence gap or ResyncRequired → exactly one refetch, then the refetched value is held Store spec
RS.1.5 Snapshot flag off, or snapshots never arrive → behaviour is identical to invalidate-and-refetch (the existing statistics specs pass) Spec run in both flag states
RS.1.6 Staging with both flags on → Screening Overview and Stage Overview update after a screening decision without a statistics GET Staging rollout check (human); hermetic E2E with both flags on
RS.1.7 Production values → no snapshot flag is enabled; the PR states "production rollout not in scope" Governance; cluster-gitops diff shows none
RS.1.8 A push raises the held revision while an HTTP GET is in flight, and the GET returns a lower project or global revision → the response is dropped, the held value stays, the page stays ready (no "revision regressed" error, no unavailable) Store spec (fake hub, delayed HTTP)
RS.1.9 While subscribed, an invalidation at a revision whose push carries section X → no GET for X; a section the push omitted → refetched once Store spec (HTTP spy)
RS.1.10 Reviewer-progress and Project Overview stores → never apply a push; they keep invalidate-and-refetch Store spec

8. Order and critical path

flowchart LR
  PR3[bug PR-3] --> RC1
  RC0[RC.0 lib 10.x] --> RC4
  RC1[RC.1 extract] --> RC3[RC.3 store+registry] --> RC4[RC.4 flag+facade] --> RC5[RC.5 E2E] --> SOAK{{staging soak; Chris: prod}} --> RC6[RC.6 delete old]
  RC2[RC.2 transport+fake] --> RC3
  RC6 --> RF1[RF.1 pilot] --> RF2[RF.2] --> RF3[RF.3]
  RC6 --> RF4[RF.4]
  RC6 --> RF5[RF.5 with owner]
  RC6 --> RF6[RF.6 with owner]
  STJ[[STJ migration]] --> SRV[[server contract v2]] --> RV[RV.1, RV.3, RV.4]
  RC6 --> RV
  RF4 --> RS1[RS.1 snapshots, staging only]
  SNAP[[server stats snapshot release]] --> RS1
  • RC.0, RC.2 and bug-plan PR-3 can run in parallel; their files do not overlap.
  • RC.1 starts after PR-3 merges (same file).
  • RF.1-RF.3 follow the state plan's R2 → gate → R3 order. RF.4-RF.6 are independent of each other.
  • R3's critical path: System.Text.Json migration → server hub v2 (realtimeHubV2) → RV.x. R1 and R2 do not depend on STJ, because they run on today's hub.
  • RS.1 follows RF.4 and the server's snapshot release, and it ends at staging.
  • The workers on this host run only focused ng test includes and targeted E2E specs, never the full suite.

9. Contract requirements from the client (for the server plan's hub contract v2)

ID Requirement Why the client needs it
CR-1 Invalidation messages { kind, entityType, entityId, projectId, version (aggregate Audit.Version), changed?: string[] } for the project family, with an optional payload RF.2 reloads instead of patching; version drops stale messages
CR-2 Per-subscription sequence number seq, starting at 1 after the acknowledgement and strictly increasing per (connection, group) Gap detection without waiting for a reconnect (RV.2)
CR-3 Subscribe acknowledgement or denial as a return value, not a HubException string: { group, granted, reason?: 'NotAMember' \| 'NotFound' \| 'Disabled' \| 'AuthorityUnavailable' \| …, retryable, version?, seq0 }. Subscribe and unsubscribe idempotent The registry must tell "retry" from "stop" (RC.3.5). Today the reason is free text (NotificationHub.cs:1403-1408), and EnableDetailedErrors is on in production (Program.cs:381)
CR-4 Batch resubscribe Resubscribe(groups[]) → ack[], plus server cleanup of every per-connection subscription on disconnect, including export jobs (NotificationHub.cs:120-121) One round trip per epoch; no leaked server subscriptions
CR-5 Revocation notice SubscriptionRevoked { group, reason } sent before the server drops a group (membership disabled, project deleted, authentication expired, impersonation ended) Replaces "an update rendered as a delete" (NotificationHub.cs:1160-1170) with an explicit signal
CR-6 Stateful reconnect enabled (AllowStatefulReconnects, buffer size stated). Protocol version 2 is VERIFIED for Newtonsoft and is native to STJ; the client JSON protocol is v2 Brief drops lose nothing (RV.3)
CR-7 Delete notifications carry the version (or a tombstone version) Ordering for deletes (C10)
CR-8 Machine-readable contract manifest (server→client and client→server names with DTO type names), emitted by a .NET test, plus a HubContractVersion sent on connect Exhaustive typing (RC.2.3); mismatch detection
CR-9 Ordered or versioned delivery per group (the full-stats .Merge() can reorder, review item 10) The client never applies an older state
CR-10 User-group messages (InboxChanged in #3932) reach a user regardless of the pod: there is no backplane The inbox stays live on staging's 2 replicas
CR-11 Documented keep-alive and timeout values (server KeepAliveInterval/ClientTimeoutInterval) The client sets matching withServerTimeout/withKeepAliveInterval
CR-12 Statistics snapshot (server follow-up; staging only), carrying projectId, optional stageId, monotonic Int64 revision and globalRevision, per-section provenance, a per-group sequence or ResyncRequired, and the same bundle shape as the REST read for this caller (server plan R4: per-caller shaping replaced the visibility class, 2026-10-05) RS.1 can apply or drop the snapshot without guessing scope or order
CR-13 Recorded STJ payload fixtures for each v2 message The client fixture spec proves the shapes before RV.x

Resolution: the server plan's §5.3 resolves each requirement. In short: - CR-3, CR-4 and CR-11 are adopted in contract v2. - CR-2 is deferred, and RV.2 with it. - CR-6 depends on a protocol test in server R1.3b. - realtimeClientV2 is catalogued as requiring the server flag realtimeHubV2.

10. Risks

Risk Mitigation
A presence regression (reservations, heartbeats) under the new core RC.1 moves presence code verbatim and keeps its spec; RC.5.3 E2E; flag kill switch; RF.5 waits for the stage-review owner
Duplicate connections while both cores exist One provider selected at bootstrap (pageReload flag); RC.4.6
Developer-preview router resources change Only RF.2 depends on them; the state plan's fallback store-owned httpResource changes one file
Resync storms after a pod restart (every client refetches) Backoff plus jitter (RC.3.3); resync is coalesced per store; measure on the staging soak
The library upgrade changes callback ordering RC.0 lands alone with the targeted E2E specs
The System.Text.Json switch changes payload shape (casing, enums, nulls, polymorphic converters) and breaks handlers silently Payload-fixture spec (§3.5, CR-13); regenerated DTOs ride with the STJ PRs; RV.x waits for it
Statistics snapshots and refetches disagree, so a stale snapshot wins Strict revision >, never overwriting at an equal revision (refetch instead), a project-and-stage scope match, and a refetch on a gap (RS.1.2-RS.1.4); staging only
Hidden-tab timer throttling trips the server timeout NOT VERIFIED: an RC.3 spike records Chrome and Safari behaviour; visibility resync covers recovery
Collisions with #3932 (signal-r.service.ts) and #2469 (dormant, touches the same file) Rebase order: PR-3, then #3932, then RC.1. #2469 gets a coordination comment

11. Coordination with active work

PR or stream Touches Plan
#3932 inbox (notifications stream) signal-r.service.ts (+inboxChanged$), NotificationHub.cs Lands before RC.1; RC.1 carries InboxChanged into the contract; RF.6 with the owner
#3938, #3941-#3945, #3965 Inbox and study-attention UI; no SignalR files (checked with gh pr view --json files) No conflict; their realtime needs go through RF.6
#3021 M3 banners idle-notification-banner, surplus-warning-banner (presence banners, styling only) No conflict; any new connection UI (D9) follows FEAT-023 M3
#2786 zoneless flip main.ts RC.4 edits main.ts providers: whichever lands second rebases
#2469 (dormant) signal-r.service.ts, spec, NotificationHub.cs Ask its owner before RC.1
Bug plan PR-3 / #3976 signal-r.service.ts Ships first (D2)
State plan #3989 Stores consuming realtime §4 mapping; RF.x paced by its gates
Server plan (hub contract v2; reconciliation in its §5.3) NotificationHub.cs §9; RV.x after the matching server items; RS.1 after its snapshot release
STJ migration plan API serializers, NSwag output Prerequisite of R3; client DTO regeneration rides with it
Auth migration session accessTokenFactory vs BFF cookie Both modes stay in HubTransport until Auth0 retires (#3988); no change made on its behalf

12. Decisions

12.0 Recorded (Chris, 2026-10-04)

  • System.Text.Json migration is a prerequisite of hub contract v2, and hub v2 uses the STJ protocol. Here that gates R3 (RV.x) only.
  • Stateful reconnect: protocol support is verified (NewtonsoftJsonHubProtocol is ProtocolVersion = 2 with Ack/Sequence). D4 below is therefore a question of timing only.
  • Statistics snapshots: a staging-only follow-up (RS.1) after the statistics migration. Invalidate-and-refetch stays the MVP and production behaviour. Production rollout is not planned.

12.1 Open

  • D1. Flag shape. One web-only realtimeClientV2 flag (pageReload) switches the whole core, with feature migrations after the old core is deleted. Recommended, because per-feature flags would need two live connections.
  • D2. Ship bug-plan PR-3 anyway. Recommended: yes. Users need it now, and the rewrite reaches production only after a soak and your approval.
  • D3. One connection across tabs (BroadcastChannel plus Web Locks, or SharedWorker). Recommended: no, because server presence and authorization are per connection; revisit only with connection-cost evidence.
  • D4. Stateful reconnect. Adopt it once the server enables it (CR-6, RV.3). The protocol is verified, and the server plan gates it on its R1.3b test. Recommended: yes.
  • D5. Invalidate versus patch per family as in §3.3. Recommended.
  • D6. Contract typing route. A server-emitted manifest plus NSwag DTOs, or a third-party generator (TypedSignalR.Client.TypeScript; not evaluated in code). Recommended: the manifest (no new toolchain).
  • D7. Upgrade @microsoft/signalr to 10.x (RC.0). Recommended: yes.
  • D8. Presence migration (RF.5). Done by the stage-review owner, or by this plan with that owner's agreement?
  • D9. Global connection UI. No new UI in the MVP; expose status only (the processing page and presence already show staleness). If you want a global "reconnecting" indicator, it is a separate M3 PR with preview acceptance.
  • D10. PROPOSAL values:
  • backoff 0.5/½/5/10/30 s ±20%;
  • visibility resync after 60 s hidden;
  • E2E outage 10 s, recovery ≤ 10 s;
  • shell below 500 lines after RC.1;
  • stateful-resume window 5 s;
  • staging soak 1 week.

13. Not verified

  • Whether an impersonation change reloads the app (affects C6's severity; RC.3.8 makes it irrelevant).
  • The app's effective browserslist (matters for Promise.withResolvers, AbortSignal.any); it is checked in RC.2.
  • Hidden-tab throttling against the server timeout (RC.3 spike).
  • TypedSignalR.Client.TypeScript's fit (D6); not evaluated.