Backend bug-fix plan (October 2026): security first, then correctness¶
This plan fixes the backend defects confirmed by the architecture review (architecture-review-2026-10.md, items V5–V9 and V14–V19, plus Appendices B, C, E and G). Security comes first (#3972), then correctness (#3973–#3975 and
3978–#3980). It is planning only; nothing starts until Chris approves and answers the decisions¶
in §9.
Every item was re-checked against main@85e6facf7 (3 Oct 2026, 20:27) by reading the code. Line
numbers below are from that commit. Markers: VERIFIED (I read the code path), CORRECTED (the
review was wrong or out of date; the note says how), NOT VERIFIED (the note says why).
Already fixed and merged; listed only for completeness: - #3967 (invitation token, V1); - #3968 (change-password gate, V2); - #3970 (per-source password budget, V3); - #3964 (owner-only ownership transfer, V12).
The Swagger client secret (V4, #3992, draft #3966) belongs to the authentication migration session.
1. Scope¶
1a. Security (#3972)¶
| ID | Problem | Impact | Evidence (main@85e6facf7) | Status |
|---|---|---|---|---|
| S1 | No rate limiter anywhere in the backend | Every anonymous and credential endpoint can be hammered without limit | 0 matches for AddRateLimiter/EnableRateLimiting/RequireRateLimiting in src/**/*.cs. Identity has hand-rolled per-feature throttles only (RecoveryEmailOutbox.cs:114, RegistrationNoticeThrottle.cs, PasswordAttemptStore.cs) |
VERIFIED |
| S2 | Anonymous contact and support forms send a confirmation to any address, using a caller-chosen Name and Subject | Open relay for phishing and spam, from SyRF's sending domain and on its SES quota | ApplicationController.cs:199-217 and :219-245, both [AllowAnonymous], call SendContactUsConfirmation(dto.Email, dto.Name, dto.Subject) |
VERIFIED (V16) |
| S3 | Email-verification codes can be brute-forced | An account holder can verify an address they don't own, then claim that address's pending invitations | Investigator.cs:121-152 (each resend appends a code), :272-277 (Verify matches any issued code), :280-288 (6 digits, 24 h); AccountController.cs:478-529 (no attempt count; a failed guess is never saved); NewEmailAddressAssociatedWithInvestigatorHandler.cs:20 → ClaimInvitationsForEmailAsync |
VERIFIED (V17) |
| S3b | profile/tickets/email sends a code to any address, and also answers "already in use" |
A signed-in mail cannon, and an address-enumeration oracle | AccountController.cs:478-505 (EmailAlreadyInUse at :489-493) |
VERIFIED (new) |
| S3c | profile/email/confirm with an address that isn't pending returns 500 |
Noise; the error is an unhandled exception | Investigator.cs:140-142 throws InvalidOperationException |
VERIFIED (new) |
| S4 | Anonymous GET api/projects/keywords aggregates the keywords of every project |
Private projects' keywords leak. The endpoint is an unauthenticated full-collection scan, and one null keyword makes ToDictionary throw, a 500 for everyone |
ProjectController.cs:92-99 → ProjectRepository.cs:123-128 (no IsPublic filter). The only web caller is project-detail.effects.ts:781, which runs signed in |
VERIFIED (V15) |
| S5 | Anonymous email-lookup returns investigator GUIDs and whether an Identity user exists |
Account enumeration plus identifier disclosure | AccountController.cs:160-172. Its intended read:investigator.ids policy names an "actions" scheme that is never registered (Program.cs:403-411), and the endpoint doesn't use that policy anyway. The only consumer is the Auth0 Action docs/auth0-actions/transform-token.js:66-78, which calls without a token |
VERIFIED. CORRECTED: the deployed Action reads data.auth0Ids, a field the current response doesn't have (AccountCheckResponse.cs:6-16). Whether the deployed Action matches the copy in the repo is NOT VERIFIED (it lives in the Auth0 tenant) |
| S6 | The API's anonymous signup proxy and the BFF login endpoints have no limit |
Registration abuse through the API host | AccountController.cs:639-642 ([AllowAnonymous] signup proxy); BFF and e-auth controllers are class-level [AllowAnonymous] |
VERIFIED |
| I1 | Identity POST /api/account/signup and /Account/Register have no request limiter (G 5.3) |
Each request does PBKDF2 hashing plus up to 3 pmInvestigator scans. A per-address notice throttle exists, but no per-client limit |
AccountApiController.cs:75-76; Register.cshtml.cs:47. RegistrationNoticeThrottle bounds the mail sent to one address only |
VERIFIED |
| I2 | Credential changes need only the session cookie: adding or removing a passkey, disabling 2FA, resetting the authenticator (G 5.6) | A stolen session can entrench itself | Passkeys.cshtml.cs (class [Authorize], OnPostRegisterAsync at :89, no step-up); TwoFactor.cshtml.cs OnPostDisableAsync/OnPostResetAsync. The step-up infrastructure exists (Manage/StepUp.cshtml.cs) but only ExternalLogins.cshtml.cs uses it |
VERIFIED |
| I3 | Verification links are built from the Host header (V19) | Low exploitability: forwarded headers only honour the issuer host | Register.cshtml.cs:268, ExternalLogin.cshtml.cs:385 |
VERIFIED (V19) |
| I4 | No index on NormalizedEmail/NormalizedUserName; no OpenIddict indexes (G 5.8) |
Full scans on every sign-in and registration lookup; concurrent seeding can create duplicate clients | IdentityUserIndexInitializer.cs creates only SyrfUserId, Passkeys.CredentialId and Logins.* |
CORRECTED: some indexes exist. The NormalizedEmail/NormalizedUserName and OpenIddict gaps are VERIFIED by grep; token pruning NOT VERIFIED |
| I5 | Google first sign-in: an exception after CommitClaimAsync deletes the bound account (G 5.5, a #3935 relative) |
The address is left permanently RefusedPreviouslyMapped |
ExternalLogin.cshtml.cs:369-377 commits; the generic catch (Exception) at :407-411 runs CompensateExternalRegistrationAsync → RegistrationCompensation.DeleteAccountAsync (:424-435) |
VERIFIED (by reading the code) |
| I6 | PUT/DELETE api/account/profile are authorised with the OpenIddict server scheme (G 5.9) |
Reported to always return 500 (ID0002) |
AccountApiController.cs:511,641 use OpenIddictServerAspNetCoreDefaults, while AdminApiController.cs:23 uses the validation scheme |
VERIFIED attribute; the 500 is NOT VERIFIED by a run |
1b. Correctness¶
| ID | Problem | Impact | Evidence | Issue / status |
|---|---|---|---|---|
| C1 | Domain events are fire-and-forget | Handler failures vanish (invitation email, invitation claim, threshold recalculation). Handlers outlive the request, so ordering is lost and the scope they run in is already disposed | MongoUnitOfWorkBase.cs:730-741: the void DispatchEvents discards _eventManager.DispatchAsync((dynamic)…) (the dynamic call hides CS4014). EventManager.cs:21-31 has no try/catch. 13 IHandles<> implementations; about 35 DispatchEvents call sites through the sync IUnitOfWork.DispatchEvents (IUnitOfWork.cs:187) |
#3973, VERIFIED (V5) |
| C2 | Liveness runs every health check; readiness runs only ready-tagged ones |
A dependency blip restarts pods instead of taking them out of rotation | SyrfHealthCheckExtensions.cs:34-43 (/health/live has no predicate). The API registers change-streams tagged live (Program.cs:167-170). Quartz's sql and DbContext checks have no tag (Quartz/Program.cs:58-59). API, PM and Quartz charts: liveness /health/live, timeoutSeconds: 1 (api/.chart/values.yaml:283-289, PM :247-253, Quartz :151-157). Identity already does it correctly (Identity/Program.cs:450-462) |
#3974, VERIFIED (V9) |
| C3 | Change-stream health never recovers on a quiet collection, and the retry budget is cumulative | Once 5 consecutive failures are counted, the check stays Unhealthy until an event arrives. Together with C2, pods are liveness-killed after the database has recovered. After 15 failures without any event in between, the shared stream terminates for good | MongoContext.cs:302-309: success is recorded only in the .Do on an event (:396-404), and attempt resets there too; ChangeStreamHealthTracker.cs (5-failure threshold) |
#3974, VERIFIED (B2) |
| C3b | One subscriber exception ends the shared change stream | Live updates stop on that pod, while health still reports Healthy (a non-retryable error never calls RecordFailure) |
MongoContext.cs:263 calls obs.OnNext inline; shouldRetry (:306) excludes non-Mongo exceptions |
#3974. Mechanism VERIFIED; the "terminated Publish subject" consequence is NOT VERIFIED (needs a test) |
| C4 | The Lamar convention scan overrides the factory-registered singletons | Every resolve of IRuntimeFeatureFlagProvider builds a new provider, whose constructor resets the shared FeatureFlags to deployed values. Non-production runtime overrides (staging and preview admin UI) are undone for interface consumers. IBffSessionTokenValidator loses its 5 s timeout and its pooled client in every environment |
Program.cs:193-198 and :320-321; scan at :611-615; RuntimeFeatureFlagProvider.cs:29-34,83 |
#3975, VERIFIED (V8, reproduced by the review on Lamar 15.0.1) |
| C5 | Forbid(StudyNotInProjectMessage) passes a message as a scheme name |
500 instead of a refusal, whenever ReviewEligibilityPolicy is off (the default) |
ReviewController.cs:101,315,477,605,677,734; Refuse at :1705-1706 |
#3978, VERIFIED (V14) |
| C6 | PageSize = 0 means no limit; a negative FromRow gives a 500 |
An unbounded study query on request | StudyController.cs:64-89 → StudyRepository.cs:1286-1287 (Skip/Limit); IncomingTableParamsDto.cs:9-34 has no bounds |
#3978, VERIFIED |
| C7 | Anonymous RetrieveInvitation reads CurrentUserId |
500 for anonymous callers, on an [AllowAnonymous] endpoint |
ProjectController.cs:586-593; SyrfBaseController.cs:9-10 throws |
#3978, VERIFIED |
| C8 | Data-export job hub subscriptions are never cleaned up on disconnect | Subscription leak per connection | NotificationHub.cs:112-131 unsubscribes projects and studies, but not _dataExportJobSubscriptionManager (field at :78) |
#3978, VERIFIED |
| C9 | API and Quartz exit 0 after a fatal startup exception | Kubernetes and CI see a fatal crash as a clean exit | API Program.cs:815-822, Quartz Program.cs:118-125: catch { Log.Fatal } with no exit code |
#3978, VERIFIED |
| C10 | SignalR EnableDetailedErrors = true unconditionally |
Exception messages reach clients in production | Program.cs:379-381 |
#3978, VERIFIED |
| C11 | Developer exception page on Staging | Stack traces on internet-facing staging | Program.cs:767-769; staging runs ASPNETCORE_ENVIRONMENT: "Staging" (cluster-gitops/syrf/environments/staging/api/values.yaml:17) |
#3978, VERIFIED |
| C12 | AuthorizationHandler writes a 404 body itself, then the policy fails |
"Response already started" exception; the client gets a truncated, non-JSON body with a literal {projectId} |
AuthorizationHandler.cs:153-162 and :349-355 |
#3978, VERIFIED (reproduced by the review) |
| C13 | The Study Library session filter hard-codes 2 reviewers | Single- and three-reviewer stages show wrong Completed and In-progress results | StudyRepository.cs:1653-1725 (>= 2/< 2 at :1665,1669,1712,1725). The eligibility filters already use scope.SessionCountTarget (ReviewEligibilityPoolFilters.cs:84,108,223) and Filters.HasMinCompletedStageSessions |
#3979, VERIFIED (V18) |
| C14 | ValueObject.Equals reads only the runtime type's fields, so it ignores the fields of a base class |
Any two ProjectAgreementThresholds (and StudyAgreementMeasures) are equal. The guards at Project.cs:180,192 and ProjectManagementService.cs:1247 never trigger |
ValueObject.cs:43-71 (GetFields on the runtime type) vs GetHashCode, which walks the hierarchy (:73-87); AgreementMeasure.cs:22-23 declares the fields |
#3980, VERIFIED (V6) |
| C15 | The agreement threshold is lost in MassTransit System.Text.Json deserialization | PM receives (null, 0). The consumer recalculates inclusion with it (UpdateStudyScreeningStatsConsumer.cs:94-95) and writes a junk (0,null) inclusion element |
AgreementMeasure.cs:22-23 (protected setters, no [JsonConstructor]) |
#3980, VERIFIED (V7: repro, plus a production sample: 1 junk element in 3,000 studies) |
A hidden coupling in C14/C15 (new): today CompleteInclusionInfoCalculation
(Project.cs:187-196) passes only because equality is broken. The message carries (null,0), and
broken equality says it matches the active job. If equality is fixed before serialization, every
inclusion recalculation throws and the project latches in CalculatingInclusionInfo. Separately,
StartInclusionInfoCalculation (:178-181) calls .Max() on a filtered sequence. That sequence
becomes empty, and .Max() then throws, once equality really filters. Both must be fixed in one PR,
serialization first.
2. MVP boundary, exclusions, flag decision¶
MVP (R0 + R1): - correct client IP; - a configurable rate limiter with an Observe mode and a kill switch; - the email relay closed; - verification codes capped; - anonymous keyword, email-lookup and invitation leaks closed; - liveness that no longer restarts pods for dependency blips.
R2–R3 deliver the remaining correctness fixes. Identity items (R-ID) run on the migration session's schedule.
Out of scope, and where each item is tracked:
| Item | Tracked in |
|---|---|
| Transactional outbox, global MassTransit retry/redelivery, contract round-trip tests for every message | #3984 (persistence and messaging plan) |
Non-upsert saves, Audit.Version bumps on direct Study writes, the RepositoryCache mutate-then-409 pattern, isolated reads |
#3985 (persistence and messaging plan) |
| Batched saves dropping events on cancellation (B8), import-progress rollback (PM B7) | Persistence and messaging plan |
| Swagger client secret (V4) | #3992 / #3966, migration session |
Retiring email-lookup and the Auth0 Actions |
#3988, after Auth0 retirement (migration session) |
ValueObject → records everywhere; primitive-only message contracts |
#3988 / #3984 follow-up (see D7) |
| Explicit DI modules instead of convention scans (V7 composition item) | Architecture roadmap; this plan only pins the two broken singletons |
| S3 signature rate limit | #806 |
Flag decision:
| PR | Flagged? | Mechanism and reason |
|---|---|---|
| PR-1 client IP | No | It restores correct RemoteIpAddress. Verified with an Observe-only log line before PR-2 enforces anything |
| PR-2 rate limiter | Yes | RateLimiting:Mode = Off / Observe / Enforce, with per-policy limits, as Helm values through env-mapping.yaml. It changes observable behaviour (429s). Off is the kill switch. Production starts in Observe (D2). Not a runtime flag: runtime overrides are non-production only (.claude/rules/feature-flags.md) |
| PR-3 anonymous surface | Partly | Contact-form confirmation and keywords: unflagged security fixes. Email-lookup: no code change unless D4 |
| PR-4 verification codes | No | Security fix. Existing codes stay valid until expiry, so nothing breaks mid-flow |
| PR-5 health | No (GitOps-reversible) | Probe semantics change, but it is a chart and library change with a one-line revert. Proven on staging first |
| PR-6 domain events (observe) | No | Adds logging and metrics only; dispatch timing is unchanged |
| PR-6b domain events (await) | Yes | DomainEvents:DispatchMode = FireAndForget / Await. It changes request latency and failure semantics. Pending D6 |
| PR-7 Lamar | No | Restores the singletons as documented. A container test pins it |
| PR-8 / PR-9 API defects | No | Each turns a 500 into the intended status. C10/C11 are environment hardening |
| PR-10 study filter | No | Restores intended filtering. Docs note (D8) |
| PR-11 value objects | No for code. The data cleanup is a separate, Chris-approved migration | The code fix is a hidden-coupling bug fix that must ship atomically |
3. Common acceptance criteria (every PR)¶
| # | Criterion | Verification |
|---|---|---|
| C1 | The regression test is written first and fails on main, then passes |
Red and green output in the PR body |
| C2 | Only focused test projects run locally, niced (nice dotnet test <one project> --filter …); never the full solution on this host |
Commands in the PR body; CI Test .NET green |
| C3 | .claude/rules/* invariants for touched areas hold (identity, feature flags, repository cache) |
PR review |
| C4 | Docs ride in the same PR: /docs/ for engineers, /user-guide/ where users see a change (429 messages, the contact form) |
PR review; validate-docs --skip-indexes |
| C5 | The PR body states the flag decision (§2) and links the issue and finding IDs | PR review |
| C6 | Reviews settled on the head (pr-review-settled.sh exit 0), the bot summary has no request changes, CI green |
Settled-gate output |
| C7 | Staging rollout check after merge for PRs marked ★; production rollout is a separate Chris-approved step | Staging check comment on the issue |
| C8 | No new [AllowAnonymous] endpoint and no new unlimited anonymous endpoint (enforced by the PR-2 guard test from then on) |
Guard test |
4. Releases and pull requests¶
PROPOSAL marks every numeric value that needs Chris's confirmation.
R0: rate-limiting foundation (sequential: PR-1 → PR-2)¶
PR-1: trustworthy client IP (S1 prerequisite; effort S) ★
The API's ProxySettings:ProxyUsed defaults to false (appsettings.json:28-30). CORRECTED
(orchestrator check): staging overrides it (cluster-gitops syrf/environments/staging/api/values.yaml:67-75,
ProxyUsed=true, ForwardLimit=2, known network 10.96.0.0/14). Production has no override. So
production's API may see the ingress pod's address for every caller, unless the ingress preserves
source IPs. Per-IP partitioning would then turn into one global bucket, which is a self-inflicted
lock-out. NOT VERIFIED live; this PR's first step is to measure, and then to carry staging's
settings across to production through a Chris-approved production change.
Files:
- Program.cs (forwarded-headers block :216-231, :762-765);
- env-mapping.yaml / API values (the ProxySettings known networks);
- a cluster-gitops values change for staging.
| # | Acceptance criterion | Verification |
|---|---|---|
| 1.1 | Request through staging ingress → the logged client IP equals the caller's public IP, not an RFC1918 address | Staging rollout check (a temporary diagnostic log line, removed in PR-2) |
| 1.2 | A spoofed X-Forwarded-For from outside the known networks → ignored |
Integration (WebApplicationFactory with ForwardedHeadersOptions) |
| 1.3 | ProxyUsed=false (local, E2E) → behaviour unchanged |
Existing E2E smoke |
PR-2: API rate limiter with Observe/Enforce and kill switch (S1, S6; effort M) ★
Files:
- new RateLimiting/ folder: options, policy table, middleware, guard test;
- Program.cs: one registration line and one app.Use… line, placed after UseForwardedHeaders and authentication;
- env-mapping.yaml and the generated values.
No controller edits. Policies attach through an IApplicationModelConvention keyed by
controller and action, so PR-3, PR-4, PR-8 and PR-9 can edit those controllers in parallel.
Approach:
1. Build on System.Threading.RateLimiting partitioned limiters, in a thin middleware rather than
the stock UseRateLimiter, so that Observe logs and counts would-be rejections without
rejecting.
2. Partition anonymous traffic by client IP (IPv6 normalised to /64) and authenticated traffic by
SyRF user ID.
3. In-memory, per pod. A distributed store is deferred (D3).
Policies (all limits PROPOSAL):
| Policy | Endpoints | Limit |
|---|---|---|
contact |
submit-general-inquiry, submit-support-request |
3 per 10 min per IP |
anon-lookup |
email-lookup, email-lookup-p, keywords, invitations/{token} |
20/min per IP |
signup |
API account/signup proxy |
5 per hour per IP |
auth |
BFF/e-auth login and callback | 30/min per IP |
email-code |
profile/tickets/email |
5 per hour per user |
email-code |
profile/email/confirm |
10 per hour per user |
global-anon |
Any other anonymous request | 300/min per IP |
Rejections return 429 + Retry-After as ProblemDetails, and increment a
syrf.ratelimit.rejected{policy,mode} metric.
| # | Acceptance criterion | Verification |
|---|---|---|
| 2.1 | Mode=Enforce, a 4th contact submission within 10 min from one IP → 429 with Retry-After; no email sent |
Integration (fake email service) |
| 2.2 | Mode=Observe, the same burst → all succeed; one structured log and one metric per would-be rejection |
Integration |
| 2.3 | Mode=Off → the middleware is not in the pipeline; behaviour identical to main |
Integration + startup test |
| 2.4 | Two IPs → independent buckets; two users behind one IP on authenticated policies → independent buckets | Unit (partition key) |
| 2.5 | Every [AllowAnonymous] action in the API assembly → mapped to a named policy, or the guard test fails |
Reflection guard test |
| 2.6 | Limits changed in values → applied on restart, with no code change | Options binding test |
| 2.7 | Hermetic E2E smoke with Mode=Enforce and default limits → no 429s in the normal journeys |
E2E (e2e/run-local.sh --spec smoke) |
| 2.8 | Staging in Enforce for 7 days (PROPOSAL) → zero rejections of real users in logs; production deploys in Observe |
Staging rollout check; production is a separate Chris-approved step |
R1: close the anonymous and verification holes (parallel after PR-2's options exist; disjoint files)¶
PR-3: anonymous surface (S2, S4, C7; effort M)
Files:
- ApplicationController.cs;
- ProjectController.cs (GetKeywords, RetrieveInvitation only);
- ProjectRepository.cs (GetKeywords, GetProjectWithPendingInvitationByTokenAsync);
- the web contact and support components (429 message only).
Approach:
1. Contact forms: still alert the helpdesk, but stop sending the confirmation to a caller-supplied
address (D4a; alternatively send it only when the caller is signed in and the address is one
of theirs). Never echo the caller's Name or Subject into outbound mail. Cap field lengths.
2. Keywords: require authentication. Restrict to public projects plus the caller's projects, and
skip null keywords. $unwind/$group server-side instead of ToList() over the collection.
3. RetrieveInvitation: take a nullable caller ID; anonymous → 401 (D4b). Keep the token-shaped 404
for an unknown token.
| # | Acceptance criterion | Verification |
|---|---|---|
| 3.1 | Anonymous contact submission with any Email → the helpdesk alert is sent; nothing is sent to Email |
Controller test with a fake email service |
| 3.2 | Name/Subject containing a URL or newlines → never appears in any outbound mail to a third party | Unit |
| 3.3 | Anonymous GET projects/keywords → 401 |
Integration |
| 3.4 | Signed-in user; a private project they aren't a member of has keyword K → K is absent | Integration (Testcontainers Mongo) |
| 3.5 | A project with a null keyword → 200, no exception | Integration |
| 3.6 | Anonymous GET projects/invitations/{token} → 401 (or the D4b outcome), never 500 |
Integration |
| 3.7 | The web contact form gets a 429 → it shows "too many requests, try again later" | Component spec |
| 3.8 | The user guide's contact-form page states that no confirmation email is sent (if D4a) | Docs |
PR-4: email-verification hardening (S3, S3b, S3c, S5 option; effort M)
Files: Investigator.cs (NewEmailVerifier, VerificationCode), AccountController.cs (profile
email endpoints; email-lookup only if D5 chooses it), and their tests.
Approach:
1. At most 1 live code per verifier: a resend replaces the code instead of appending one.
2. Persist a failed-attempt count. 5 failures (PROPOSAL) invalidate the verifier, and the user
must request a new code.
3. Expiry 1 h (PROPOSAL, down from 24 h).
4. Constant-time compare.
5. The failure path saves the investigator through the existing SaveCurrentInvestigatorAsync
concurrency handling.
6. An unknown pending address → 422 Invalid, not 500.
7. tickets/email returns the same 200 whether or not the address is in use; an in-use address gets
a "this address already has an account" mail instead of a code (D5a).
Stored data: old documents with several codes stay readable. Verify keeps accepting any
still-unexpired legacy code until it expires, so no migration is needed.
| # | Acceptance criterion | Verification |
|---|---|---|
| 4.1 | Resend twice → only the newest code verifies | Unit (domain) |
| 4.2 | 5 wrong guesses → the 6th guess, even with the right code, returns Invalid, and the verifier is gone |
Unit + controller test |
| 4.3 | Wrong guesses → the count survives a reload (persisted) | Integration (Testcontainers Mongo) |
| 4.4 | Code older than 1 h → Expired |
Unit with a fake clock |
| 4.5 | Confirm with an address that isn't pending → 422, not 500 | Controller test |
| 4.6 | tickets/email for an address already in use → same status and body as for a free address |
Controller test |
| 4.7 | Investigator documents with legacy multi-code verifiers → still deserialize, and the newest unexpired code verifies | Integration (BSON fixture) |
R2: correctness, wave 1 (all parallel; disjoint files except one line each in API Program.cs)¶
PR-5: health probes and change-stream recovery (C2, C3, C3b; #3974; effort M) ★
Files:
- SyrfHealthCheckExtensions.cs;
- MongoContext.cs (retry, success recording, subscriber isolation);
- ChangeStreamHealthTracker.cs;
- API Program.cs:170 (tag ready);
- Quartz Program.cs:58-59 (tag ready);
- API, PM and Quartz .chart/values.yaml (readiness failureThreshold: 3, liveness timeoutSeconds: 3, both PROPOSAL).
Approach:
1. /health/live gets the predicate live, keeping the version JSON body that
environment.effects.ts:178 reads.
2. Dependencies (change streams, SQL, MassTransit) report only to ready. Check in the PR whether
MassTransit 8.4's own checks carry ready by default (NOT VERIFIED).
3. Record a success when the cursor opens, and reset attempt there.
4. Retry without a lifetime cap (the 180 s backoff ceiling is kept).
5. Wrap each subscriber so its exception is logged and doesn't reach the cursor loop.
6. A terminated stream marks itself Unhealthy and is rebuilt on the next subscribe.
| # | Acceptance criterion | Verification |
|---|---|---|
| 5.1 | A ready-tagged check fails → /health/live 200, /health/ready 503 |
Integration (TestServer) |
| 5.2 | /health/live body → still includes the version fields |
Integration; web environment.effects spec unchanged |
| 5.3 | Change stream fails 6 times, then reopens with no events → health returns to Healthy within one poll | Unit (Rx TestScheduler) |
| 5.4 | 20 failures spread over reconnects with no events → the stream is still retrying | Unit (TestScheduler) |
| 5.5 | One subscriber throws on an event → other subscribers still receive later events | Integration (Testcontainers Mongo replica set) |
| 5.6 | Quartz SQL unavailable → Quartz pod becomes unready and is not restarted | Staging rollout check (scale the SQL connection off briefly; staging is not mission critical) |
| 5.7 | Rendered charts → liveness and readiness paths unchanged; thresholds as proposed | helm template diff in the PR |
PR-6: domain events, observed dispatch (C1 part 1; #3973; effort S)
Files: EventManager.cs, MongoUnitOfWorkBase.cs (DispatchEvents).
Approach: dispatch the batch on one task chain, so handlers run sequentially in chronological
order. Catch per handler, log with event type, handler and aggregate ID, and increment
syrf.domain_event.handler_failed. Attach the chain to a tracked registry so that shutdown drains
it (bounded at 10 s, PROPOSAL). The request still doesn't wait, so timing is unchanged.
| # | Acceptance criterion | Verification |
|---|---|---|
| 6.1 | A handler throws → one error log with event type and handler name, metric +1, and later handlers for the same batch still run | Unit (Lamar test container) |
| 6.2 | Two events in one batch → handled in DateTimeEventOccurred order, never concurrently |
Unit |
| 6.3 | Host shutdown with handlers in flight → drained or logged as abandoned after the bound | Unit |
| 6.4 | No DispatchAsync Task is discarded unobserved |
Analyzer/grep guard test over MongoUnitOfWorkBase.cs |
PR-6b: domain events, awaited (C1 part 2; flagged; effort L; pending D6)
Add IUnitOfWork.DispatchEventsAsync, and await it on the async save paths when
DomainEvents:DispatchMode=Await. Handler failures are logged and not rethrown into the request
(the save has already committed). This touches about 35 call sites and overlaps the outbox design
(#3984), so D6 decides whether it ships here or folds into the outbox.
PR-7: Lamar singleton identity (C4; #3975; effort S)
Files: API Program.cs:611-615 (exclude the two types from the scan, or re-assert the singletons
after it), plus a new container test.
| # | Acceptance criterion | Verification |
|---|---|---|
| 7.1 | Resolve IRuntimeFeatureFlagProvider twice and resolve RuntimeFeatureFlagProvider → the same instance |
Container test on the real API registry |
| 7.2 | A runtime override is applied, then a controller resolves the interface → the override is still visible | Integration |
| 7.3 | IBffSessionTokenValidator → typed-client instance with a 5 s timeout |
Container test |
| 7.4 | Every interface the API registers with a factory singleton → not overridden by the scan | Generic guard test over the service collection |
PR-8: API host hygiene (C9, C10, C11; #3978; effort S) ★
Files: API Program.cs (:381, :767-769, :815-822), Quartz Program.cs:118-125.
| # | Acceptance criterion | Verification |
|---|---|---|
| 8.1 | Fatal startup exception → process exit code 1 (API and Quartz) | Process test (dotnet host with a broken config, run in CI only) |
| 8.2 | Production and Staging → SignalR detailed errors off; Development → on | Options test per environment |
| 8.3 | Staging unhandled exception → ProblemDetails without a stack trace | Integration (environment Staging) |
| 8.4 | Staging pod with a bad config → CrashLoopBackOff (not Completed) |
Staging rollout check |
PR-9: API controller defects (C5, C6, C8, C12; #3978; effort M)
Files:
- ReviewController.cs (6 call sites);
- IncomingTableParamsDto.cs + StudyController.cs (validation only; StudyRepository untouched);
- NotificationHub.cs:112-131;
- AuthorizationHandler.cs + a new IAuthorizationMiddlewareResultHandler.
| # | Acceptance criterion | Verification |
|---|---|---|
| 9.1 | Study not in project, ReviewEligibilityPolicy off → 404 ProblemDetails (matching the flag-on ReviewWriteRefusal.NotFound mapping), never 500 |
Controller test per endpoint |
| 9.2 | PageSize 0, negative or above 500 (PROPOSAL) → 400; negative FromRow → 400 |
Controller test |
| 9.3 | The web Study Library's existing page sizes → unchanged results | Existing web specs + E2E Library smoke |
| 9.4 | Connection with export-job subscriptions disconnects → its subscriptions are removed | Hub unit test |
| 9.5 | Routed project doesn't exist → a complete 404 ProblemDetails naming the ID; no "response already started" log | Integration (WebApplicationFactory) |
| 9.6 | Web project guard on a missing project → still shows the not-found UI | E2E (hermetic) |
R3: correctness, wave 2 (parallel with R2; disjoint files)¶
PR-10: Study Library session filter uses the stage target (C13; #3979; effort M)
Files: StudyRepository.cs (GetSessionFilter, CreateMongoFindQuery signatures),
IStudyRepository.cs, and the callers that pass the stage's SessionCountTarget. Reuse
Filters.HasMinCompletedStageSessions.
| # | Acceptance criterion | Verification |
|---|---|---|
| 10.1 | Stage target 1; study with 1 completed session → appears under Completed, not In progress | Integration (Testcontainers Mongo) |
| 10.2 | Stage target 3; 2 completed → In progress | Integration |
| 10.3 | Target 2 → results identical to main (golden query test) |
Integration |
| 10.4 | "Not yet started for me" / "unavailable" filters → use the same target | Integration |
| 10.5 | Study Library docs describe Completed as "reached the stage's reviewer target" | Docs |
PR-11: agreement threshold serialization and ValueObject equality (C14, C15; #3980; effort M)
Files: AgreementMeasure.cs, ValueObject.cs, Project.cs (StartInclusionInfoCalculation
.Max guard only), plus tests in SharedKernel.Tests and PM.Core.Tests.
D7 recommendation: fix ValueObject, not records. 28 types derive from ValueObject<T>. They
use BSON class maps, private setters and ISupportInitialize, and records would compare List<>
members by reference exactly as today, so records change risk without fixing more. Only
AgreementMeasure's two subclasses inherit fields from a base, so the equality change is confined to
them and to the value objects that contain them (ScreeningInfo, the inclusion jobs).
Order inside the PR:
1. [JsonConstructor] (or [JsonInclude] on the protected setters), with a round-trip test that
uses MassTransit's own STJ options. Messages already in flight carry the values in JSON, because
only deserialization dropped them, so no contract change is needed.
2. ValueObject.Equals walks the hierarchy, like GetHashCode.
3. .Max → a guarded DefaultIfEmpty.
4. List every equality call site on the affected types in the PR body.
| # | Acceptance criterion | Verification |
|---|---|---|
| 11.1 | ProjectAgreementThreshold(0.333, 2) through MassTransit's STJ serializer → arrives as (0.333, 2) |
Contract round-trip test |
| 11.2 | (0.333,2) vs (null,2) → not equal; equal values → equal, with matching hash codes |
Unit |
| 11.3 | Inclusion recalculation end to end with a threshold change → completes, the job closes, no (0,null) element is written |
Integration (MassTransit test harness + Testcontainers Mongo) |
| 11.4 | StartInclusionInfoCalculation with no completed job for the current threshold → Started, no exception |
Unit |
| 11.5 | Stored pmProject and pmStudy documents → deserialize unchanged (BSON is unaffected by the STJ attributes) |
Integration (BSON fixture) |
Data note (#3980), read-only analysis first:
- Production syrftest.pmStudy: 1 junk (0,null) inclusion element in a $sample of 3,000 (V7). A
full count timed out and was deliberately not retried.
- Next step, with no writes:
1. Count on the preview cluster's staging copy.
2. Then, with Chris's approval, run a production count in a quiet window, batched by project.
3. Report the projects affected.
- Any cleanup is a separate Chris-approved migration:
- dry-run first, listing the _ids;
- $pull the elements matching {NumberScreened: 0, AbsoluteAgreementRatio: null}, plus
$inc Audit.Version, because direct writes must bump the version (see #3985);
- a pre-image export as rollback; CSUUID filters;
- coordination with the materialised-statistics session, because pmStudy writes feed its change
stream and folds.
R-ID: Identity items (coordinated with the authentication migration session; not planned on its behalf)¶
Identity is not the production IdP yet; the S09 staging switch is next. The migration session owns these files and decides the schedule. This plan supplies specs and acceptance criteria only.
Status and slots (reply from the authentication migration session, 2026-10-05). The session accepted every item and records each one as an M005 slice. The "Slot (auth session)" column is the session's decision and replaces this plan's original recommendation, which is kept in the last column for the record.
| PR | Items | Files | Status (2026-10-05) | Slot (auth session) | Original recommendation |
|---|---|---|---|---|---|
| ID-1 | Request limiter on signup, register, login, forgot-password and resend (I1), using the same Observe/Enforce options shape as PR-2 | Identity Program.cs + new RateLimiting/ |
Accepted | After S09, before S10. It depends on backend PR-1 (client IP), without which Observe mode would record one shared ingress address | Before S09 |
| ID-2 | Profile endpoints authorised with the validation scheme (I6) | AccountApiController.cs:511,641 |
Accepted | Any time. The PR starts with a failing test that proves the 500 | Any time; first confirm the 500 with a test |
| ID-3 | One IdentityLinkBuilder from the issuer (I3/V19) |
Register.cshtml.cs:268, ExternalLogin.cshtml.cs:385, VerificationLinkFactory.cs |
Accepted; partly fixed: #4030 fixed part of the redirect work, and #4040 holds the leftovers | After S09, bundled with the redirect work (#4040) | After S09, or bundled with ID-4 |
| ID-4 | Google first sign-in: no deletion after a committed claim (I5) | ExternalLogin.cshtml.cs:365-411, RegistrationCompensation.cs |
Accepted; in progress now (migration session). The PR keeps the account after a committed claim and pins the pre-commit compensation with tests | Now, before S09 | Fix before S09 |
| ID-5 | Indexes on NormalizedEmail/NormalizedUserName and OpenIddict collections, plus pruning (I4) |
IdentityUserIndexInitializer.cs |
Accepted | Before S10. Non-unique indexes first; the unique index only after a duplicate check on both identity databases (staging and production) | Before the production cutover (S10) |
| ID-6 | Step-up for passkey add/remove, 2FA disable/reset, and passkey removal on password reset (I2) | Passkeys.cshtml.cs, TwoFactor.cshtml.cs |
Accepted | Between S09 and S10 | After S09, before S10 |
| ID-7 | Provision PM only after email verification (the second half of G 5.3) | Registration services | Accepted as backlog | Backlog. A design change that needs Chris's decision before it is scheduled | Migration session's backlog |
Each ID PR uses the same acceptance-criterion pattern as PR-2 and PR-4. For example, ID-1:
- Mode=Enforce and the 6th signup per hour from one IP (PROPOSAL) → 429, with the response
shape unchanged otherwise (still non-enumerating);
- Observe → logged only;
- the uniform-202 test AnonymousSurface_IsExactlyTheAuditedNonEnumeratingSet still passes.
5. Order and critical path¶
flowchart LR
PR1[PR-1 client IP] --> PR2[PR-2 rate limiter]
PR2 --> PR3[PR-3 anon surface]
PR2 --> PR4[PR-4 verification codes]
PR5[PR-5 health]
PR6[PR-6 events observed] --> PR6b[PR-6b events awaited, if D6]
PR7[PR-7 Lamar]
PR8[PR-8 host hygiene]
PR9[PR-9 controller defects]
PR10[PR-10 study filter]
PR11[PR-11 value objects] --> MIG[data cleanup, Chris-approved]
- Critical path: PR-1 → PR-2 (staging Observe → Enforce) → PR-3 / PR-4.
- PR-3 and PR-4 can start coding in parallel with PR-2 (the policy names are fixed in this plan); they merge after it.
- Start immediately, in parallel, one worker per worktree: PR-1, PR-5, PR-6, PR-7, PR-10, PR-11. Their files are disjoint.
- Shared file: API
Program.csis touched by PR-1, PR-2, PR-5 (one line), PR-7 and PR-8, each in a different region. Merge order is PR-1 → PR-7 → PR-5 → PR-8 → PR-2, rebasing each (trivial). Bundling PR-7 and PR-8 is possible if Chris prefers fewer PRs. - PR-9 is independent, but touches
ReviewController.csandNotificationHub.cs, which open PRs also touch (§8).
6. Issues¶
| Issue | PRs |
|---|---|
| #3972 | PR-1, PR-2, PR-3, PR-4; ID-1…ID-7 cross-linked for the migration session |
| #3973 | PR-6, PR-6b |
| #3974 | PR-5 |
| #3975 | PR-7 |
| #3978 | PR-8, PR-9, plus C7 in PR-3 |
| #3979 | PR-10 |
| #3980 | PR-11, plus a separate data-migration issue |
On approval: - split #3972 into a security epic with child issues; - create the data-migration issue; - each issue copies its acceptance-criteria table from this plan.
7. Risks¶
| Risk | Mitigation |
|---|---|
| Per-IP limits collapse to one bucket behind the ingress, locking everyone out | PR-1 first, proven on staging; PR-2 ships in Observe; Off kill switch through GitOps |
| Universities behind NAT share one IP | Authenticated policies partition by user; generous anonymous limits (PROPOSAL); Observe data sets the final values |
| In-memory limits are per pod (limit × replicas) | Acceptable for abuse damping. A distributed limiter (the shared Valkey from S08A) is D3 |
| Fixing equality before serialization latches inclusion jobs | One PR, serialization first, end-to-end test 11.3 |
| Liveness no longer restarts a wedged process | Change-stream self-healing (5.3–5.5) plus readiness removes it from traffic; PM keeps its single replica, so watch its readiness alerts |
| Awaiting events turns handler latency into request latency | PR-6b is flagged and decided separately (D6); PR-6 first gives failure visibility |
The Auth0 Action breaks if email-lookup changes |
No functional change to email-lookup before Auth0 retires (D5); rate-limited only. The migration session (2026-10-05) makes no change until the deployed Auth0 Action has been compared with docs/auth0-actions/transform-token.js, which needs a management token from Chris |
| Hidden consumers of SignalR detailed errors or the dev exception page | Web shows its own messages; staging check 8.4; Development keeps both |
| The Study filter change surprises users | Docs note; target-2 stages are unchanged (10.3) |
8. Coordination with other active work¶
Open PRs touching this plan's files (gh pr list --state open, 3 Oct 2026):
| File | Open PRs | Action |
|---|---|---|
API Program.cs |
#3966 (Swagger, migration), #3965, #3947, #3945, #3944, #3943, #3942, #3932 (notifications stack), #2469 | Rebase; changes are regional |
RuntimeFeatureFlagProvider.cs |
#3965, #3944, #3942, #3932 | PR-7 does not edit it |
NotificationHub.cs |
#3932, #2469 | PR-9 edits OnDisconnectedAsync only; whichever lands second rebases |
ReviewController.cs |
#3939 (progressive batches), #3746 (review eligibility, paused) | PR-9 changes 6 one-line returns; coordinate with the review-eligibility owner |
ProjectController.cs |
#3941 (notifications), #2224, #2469 | PR-3 edits two actions only |
Project.cs / ProjectRepository.cs / StudyRepository.cs |
#3941, #2934 (deletion kernel), #2572, #2224, #2858 | PR-11 changes one guard in Project.cs; PR-10 changes one private method plus signatures |
MongoContext.cs |
#2934 | PR-5 edits the change-stream block only |
StudyController.cs |
#3947 | PR-9 edits the DTO bounds only |
env-mapping.yaml |
Most feature PRs | Generated sections; regenerate after rebase |
Owning streams:
- the authentication migration session (R-ID; #3992; email-lookup and Auth0 Actions);
- materialised project statistics (the PR-11 data cleanup; pmStudy writes);
- notifications/study attention (Program.cs, NotificationHub.cs);
- review eligibility (paused; ReviewController.cs);
- the persistence and messaging plan (#3984/#3985; PR-6b boundary).
On 3 Oct no open PR touched the Identity files named in R-ID. Since then the migration session has #4040 (the ID-3 redirect leftovers) open and ID-4 in progress (2026-10-05); re-check gh pr list before any R-ID work.
9. Decisions needed from Chris¶
- D1. Rate-limit mechanism: in-app limiter (recommended: per-endpoint, per-user partitions, testable in the hermetic stack), or ingress annotations in cluster-gitops (coarser, no per-user keys)? I haven't checked whether the ingress controller supports them.
- D2. Rollout: staging
Enforcefrom day one and productionObservefor 7 days, thenEnforceas a separate approved step (recommended)? - D3. Distributed limits: accept per-pod in-memory limits now (recommended), and revisit with the shared Valkey after S08A?
- D4a. Contact-form confirmation: stop sending it to the caller-supplied address (recommended), send it only to signed-in users' own addresses, or keep it behind a CAPTCHA (a new third-party dependency)?
- D4b. Anonymous invitation preview: 401 and send the user to sign in (recommended), or a minimal anonymous view (project name only)?
- D5.
email-lookup: rate-limit only and leave it for Auth0 retirement (recommended), or ask the migration session to replace it now with a token-gated variant for the Action? Migration session, 2026-10-05: no change toemail-lookupuntil the deployed Auth0 Action has been compared withdocs/auth0-actions/transform-token.js; that comparison needs a management token from Chris. - D5a. Make
tickets/emailnon-enumerating (recommended)? - D6. Awaited domain events (PR-6b): ship flagged here, or fold into the outbox work (#3984)? Recommended: fold into #3984, because PR-6 already removes the "silently lost" part.
- D7. Value objects: fix
ValueObject.Equals(recommended) vs migrate to records? - D8. Study filter: ship unflagged with a docs note (recommended)?
- D9. Identity: answered by the migration session, 2026-10-05 (§4 R-ID): ID-4 is in progress before S09; ID-2 any time; ID-1, ID-3 and ID-6 after S09 (ID-1 needs backend PR-1 first); ID-5 before S10; all recorded as M005 slices. Still open for Chris: ID-7 (provision PM only after email verification) is a design change on the session's backlog.
- D10. PROPOSAL values:
- rate limits (§4 PR-2 table);
- the 7-day staging soak;
- verification codes: 5 attempts, 1 h expiry;
- page size cap 500;
- probes: readiness
failureThreshold3, livenesstimeoutSeconds3; - event drain 10 s;
- ID-1: 6 signups per hour per IP.
- D11. Data cleanup (#3980): approve a read-only full count (staging copy first, then production in a quiet window) before any migration is proposed?