Skip to content

ProjectStatistics (FEAT-024): decision brief and isolation plan (October 2026)

This brief supports a decision on issue #3987: activate, freeze, narrow or retire the materialized project statistics programme (FEAT-024, epic #1831). It also plans how to isolate the code. It is planning only; nothing starts until Chris approves and answers the decisions in §10. It supports the "materialised project statistics" owner session and does not override it. That session reviewed this brief on 2026-10-05; its corrections are applied here and marked "(statistics session review, 2026-10-05)". Facts marked as owner facts come from that session and were not re-verified for this brief.

Evidence was read on main at 85e6facf7 on 3 Oct 2026, plus read-only checks of cluster-gitops main (GitHub API), the staging database (syrf_staging, preview cluster) and the production collection list (syrftest, names only). The 2026-10-05 corrections were checked by the statistics session against origin/main at 5245941e9, cluster-gitops origin/main at 224f24f7 (which contains #1566 and #1569), a read-only look at staging, and decompiled Lamar 15.0.1 and MassTransit 8.4.0 for the DI and scheduling claims. Marks: VERIFIED (I read the code, data or config), CORRECTED (the review or a doc was wrong, with the correction), NOT VERIFIED (with the reason).

1. Where FEAT-024 is now

Item State Mark
Phases 0–5 (inventory, foundation, 9 physical families, consumers) Code merged, all behind flags. The phase table is in STATUS.md:186-199 VERIFIED (doc)
Async point fold (ADR-019), slices 0–7 Slices 0–6 merged (#3880 … #3949); slice 7 docs #3962 merged as d08e4421b. Protocol 4 is the production baseline P0 VERIFIED (git log)
Gate (b), write overhead Transactional path: FAIL on an idle host, all 8 cells, +46% to +1,283% p95, 25 commands per save against 2 (screening-write-benchmark.md §"Idle-host run 2026-09-30"). Fold path: the idle-host run on Bramble (2026-10-03, load ≤ 0.27/CPU) FAILED gate (b) as then defined (1–2 of 16 cells passed), with zero statistics-caused conflicts or failures in every cell. The misses were latency (the pre-write durable-mode re-read, ~1.3 ms of the 2 ms allowance; a 9-command, 5-round charged retry) and the literal exhaustion equality. On 2026-10-04 Chris decided, and #4011 implemented: drop the pre-write re-read; make a charged retry 2 rounds; enforce invariant 13 in code; re-baseline the gate (statistics-caused exhaustion 0; latency gated on the different-Study cells and the 1-reviewer same-Studcells; the same-Study ⅖/10 cells are stress figures that still need zero statistics-caused conflicts). The acceptance rerun of the #4011 code is running on Bramble (2026-10-05). The loaded-host 2026-10-02 run is superseded. Sources: screening-write-benchmark.md §"Idle-host fold run 2026-10-03 (Bramble)", §"Gate (b) re-baselined 2026-10-04", §"The acceptance rerun"; ADR-019 "Amendment 2026-10-04" CORRECTED (statistics session review, 2026-10-05). Rerun in progress: owner fact
Gate, read performance Adapter benchmark passes by two orders of magnitude (§2). The HTTP harness acceptance run on an idle host has not been done (STATUS.md:411) VERIFIED (doc; line ref updated in the statistics session review, 2026-10-05)
Soak gate #3510 Open; revised 2026-10-04 by Chris (#4017). Two parts, and both need 10,000 reads / 1,000 mutations, 100% exact parity, zero stale serves, 100% injected-fallback success and bounded growth. (1) An isolated soak of about 24 hours in the local e2e stack on Bramble (#3952; driver PR #4010 open), covering every materialised family (Chris 2026-10-04: all families in one run, not just ProjectScreening). It uses accelerated schedules, randomised seeded traffic with bursts and same-Study collisions, and a stage-configuration matrix with mid-run settings changes. (2) One week of passive staging checking with every family enabled for the pilot: real schedules, real day boundaries and the six-hourly parity check. There is no longer a 7-day isolated soak CORRECTED (statistics session review, 2026-10-05; technical-plan.md "Acceptance and tests" as changed by #4017)
Production prerequisites Production code blockers for every family are merged: #3933/#3955 (the QuestionAnswers scope half of #3840); #4007 (the #3840 flag coupling); #3956 (the #3937 reviewer drift guard); #4008 (admin re-persist-drift, #3960); #4018 (the guard extended to ProjectScreening and the four annotation families, with re-persist fencing all seven project-wide families). Open follow-ups are non-blocking: #3953, the #3960 remainder, #4014 (annotation writer partial-flag follow-ups) and #4015. Still operational, per production step: promote main; build IX_Study_PendingStatistics in an approved window; run the drift check, then re-persist-drift with allowProduction: true (Chris's approval), then backfill all seven project-wide families; then a separately approved PR lifts the syrftest refusal (ProjectStatisticsFoldAdministration.IsCoverageSufficient). The Atlas version check from the original brief still applies (not re-checked) CORRECTED (statistics session review, 2026-10-05; STATUS.md L18-26, L96-115, L117-137). Non-blocking status: owner fact. The owner list said "#4013", a closed docs PR; the follow-up is #4014
Staging Since cluster-gitops#1566 and #1569 (2026-10-04), every family flag and every page/consumer flag is on, on both hosts, for the single allowlisted pilot …0102. That includes the four history-chart flags and materializedProjectStatisticsSignalR. The fold is enabled at protocol 4. Daily observations, scheduled maintenance (with receipt reclamation), the weekly drift check, repair, history/delta/receipt maintenance and the fleet runner are on. CopyFreshMaterializedRows is still false (a pending follow-up). The API still sets Fold:AllowPartialCoverage: true. Pilot control row: FoldMode=1, FoldProtocolVersion=4, FoldEverEnabled=true, last modified 2026-10-04 21:15Z. IX_Study_PendingStatistics exists on staging pmStudy. All nine families are backfilled, the second and third passes report AlreadyCurrent, and screening parity is 17/17. 30 pmProjectStatistics* collections existed in syrf_staging on 3 Oct CORRECTED (statistics session review, 2026-10-05: cluster-gitops origin/main syrf/environments/staging/{api,project-management}/values.yaml; staging index list and control row). Backfill and parity: owner fact
STATUS.md:419-422 (it was at 372-374 on 3 Oct) says "the fold flag is off in every environment … and no project has fold mode on"; statistics-reference.md:159-160 repeats it Wrong for staging since cluster-gitops#1482 merged 2026-10-02 15:17Z CORRECTED. The owner session fixes both docs and the other stale STATUS lines (review §2); this brief does not edit them
Production Runs 9.44.1-restore.104eca9, a restoration build that is not an ancestor of main and contains zero ProjectStatistics files. syrftest has no pmProjectStatistics* collection VERIFIED (git merge-base, git ls-tree, prod collection list)
Open statistics PRs 5 Oct: #4010 ("isolated all-families soak driver (~24 h) and read-only receipts export") and #4012 (read-only projection-read admin route; it edits ProjectStatisticsProductionRegistry.cs, an R1 file). Merged since 3 Oct: #4007, #4008, #4011, #4017, #4018 and #4027. Queue: the gate (b) acceptance rerun (running), the read-gate acceptance run, #3952/#4010, #3953, the #3960 remainder, #4014, #4015, #3849, #3845, #3846 and #3910 CORRECTED (statistics session review, 2026-10-05; gh pr list, gh api issues/N)
Cross-programme cost Review eligibility has been paused since 2026-09-25 by Chris "until the materialised stats work … completes" (#3742, #3746 parked) VERIFIED (memory record)

What production activation needs (in order; statistics session review, 2026-10-05): the gate (b) acceptance rerun passes (the re-baselined gate) → read-gate acceptance run (idle host) → #3952/#4010 → the 24-hour isolated all-families soak passes, plus a one-week passive staging check with every family on (these two can overlap; the staging week does not depend on #3952) → main promoted to production (FEAT-024's first time there) → pending index built in a window → drift check, re-persist-drift (allowProduction) and a backfill of the seven project-wide families, for each pilot project → a PR lifts the syrftest refusal → per-project enable. The

3960/#3840 code is merged; what remains of them is the operational re-persist run. **Each production step is a

separate Chris-approved step.**

2. What it buys

Evidence Number Mark
Legacy GetFullProjectStatsAsync: one aggregate with 15 facets p95 85.7 ms at 300 studies (PS-DS-01), 1,414 ms at 5,000 (PS-DS-02) (phase0-benchmark…:834-856) VERIFIED (doc)
Read benchmark, PS-DS-02 (5,000 studies), 2 Oct run Broad FullStats p95 1,199.8 ms; Fresh materialized 12.25 ms (−99.0%); fold mode with 0 pending 13.71 ms; with 32 pending 19.15 ms (−98.4%). Zero authoritative aggregates on Fresh reads (screening-read-benchmark.md:65-85) VERIFIED (doc)
Limit on that number Adapter level on a loaded host. It excludes HTTP and page polling. ReviewController.GetFullStats is equality-gated: it still runs the aggregate, so it gets no latency win today (#3311) VERIFIED (doc, ReviewController.cs:1214)
Legacy SignalR push (the multiplier) For each subscribed investigator, every Study update in the project re-runs the per-investigator full-stats facet. There is no throttle or sample, only DistinctUntilChanged after the query (AggregateRootEntitySubscriptionManager.cs:315-340, :495-512). The cost is viewers × saves × aggregate VERIFIED (code)
Production project-size distribution Not measured, so we cannot yet say how many production projects sit near 300 studies (~90 ms, a small gain) and how many near 5,000 (~1.2 s, a large gain). The Phase 0 doc records that no production-shaped snapshot was available (:75, :654) NOT VERIFIED (no production query run for this brief)

User-facing views served (STATUS.md:226-243; .claude/rules/materialized-stats.md):

View Consumer state Staging
Project Overview screening totals Dedicated endpoint, authoritative fallback On (flag …ProjectOverview)
Stage Overview annotation pie Dedicated stage-annotation consumer On (family and consumer)
Reviewer progress (own and project), Stage Review header and progress dialog Guarded SignalStore consumers (#3781, #3784) On (reviewer families and consumers)
Screening Overview leaderboard, question counts, search counts, 4 history charts Guarded consumers On; all four history charts now have every family backing them
Exports n/a. Source-row exports stay authoritative; the flag is on in staging (#1566/#1569) but no code reads it, so it changes nothing; retire it in R3 n/a (flag on, unread)

Every staging value above except Exports has been On for the pilot since cluster-gitops#1566/#1569 (2026-10-04) (statistics session review, 2026-10-05; VERIFIED there against cluster-gitops). materializedProjectStatisticsExports is also on, but nothing reads it apart from the startup dark-state log (statistics session check, 2026-10-05).

3. What it costs

Cost Measurement on main Mark
Production code Core statistics dirs 48,649 lines plus 1,470 in statistics-named files elsewhere in Core (Core total 90,075, so ~56%); Mongo.Data 5,293 plus 5 root registries/services; API 3,293 (statistics- or fold-named files only); PM endpoint 857. ≈ 59.5K VERIFIED (review's 59K and 56% hold)
Test code Core.Tests 17,531; Mongo.Data.Tests 5,099 (dir only); API tests 10,317 and PM tests 1,599 (named files only). ≥ 34.5K by this narrow count. The review's 51K likely includes statistics tests outside these dirs CORRECTED/partial: lower bound only
Types 436 distinct non-test type names start with ProjectStatistics, plus 76 IProjectStatistics* interfaces. The review said 381. 409 files already sit in *.ProjectStatistics* sub-namespaces (Fold, Lifecycle, Families.Screening, …) CORRECTED
Collections 36 distinct "pmProjectStatistics*" names in source; 30 exist on staging; 0 in production. Study.PendingStatistics sub-document and IX_Study_PendingStatistics on staging pmStudy VERIFIED
Flags 25 MaterializedProjectStatistics* booleans in FeatureFlags.cs plus the allowlist, and ≈11 server settings (ProjectStatistics:Fleet, :HistoryMaintenance, :DeltaMaintenance, :ReceiptMaintenance, :Fold:AllowPartialCoverage, :Fold:StampAdvanceAllowed, ProjectStatisticsMaintenance, …DriftCheck, …Repair, …DailyObservations, ProjectStatistics:ProjectAllowlist) VERIFIED (review's "about 25" counts only the booleans)
DI fragility 6 registries: ProjectStatisticsRegistry (Core), …LifecycleRegistry, …ProductionRegistry, …FlagAdapterRegistry (declared inside the Production file), …FamilyCalculatorRegistry, …SourceFenceRegistry. PM includes all 6; the API includes 5: it never includes ProjectStatisticsFlagAdapterRegistry and registers RuntimeProjectStatisticsFlagSource explicitly (Program.cs:648). Lamar's last-wins order is load-bearing (rules file), and Lamar scans are positional (see PR-2). The ordered block appears 4 times: PM index-init (Program.cs:64-93), seed-data (:180-209) and main (:287-375), and API (Program.cs:146-150, 611-705), with different orders per host and API extras inline (flag source, fold signal, transaction coordinator, notifications). Guarded by ProjectStatisticsHostRegistrationTests (452 lines) and ProjectStatisticsRegistrationSmokeTests (768) CORRECTED (statistics session review, 2026-10-05)
Hosts loading it API and PM (3 modes) register it because the SyrfRegistry convention scan (SyrfRegistry.cs:14-27, every SyRF* assembly in the app base dir) finds the services, and AssertConfigurationIsValid then needs their seams. CORRECTED (statistics session review, 2026-10-05): the Program.cs comments say PM index-init and seed-data have no call path, but index-init resolves ProjectStatisticsFoldIndexes and creates the fold collections' indexes (Program.cs:136-137), and both modes resolve IPmUnitOfWork, whose Mongo implementation takes statistics repositories as required constructor parameters. PdfAgent references Core, so it ships the code in its image, but it uses Microsoft DI with no scan. ApplicationRoleBootstrap references Mongo.Data VERIFIED
Coupling into non-statistics code Non-statistics files that reference statistics types: Core 15 (ProjectManagementService 49 refs, IPmUnitOfWork 25), Mongo.Data 10 (MongoPmUnitOfWork 74, StudyRepository 36), API 27 (ReviewController 54, SubmitAnnotationSessionService 29, flag catalogue 68), PM 7 VERIFIED (grep counts)
Test time Mongo.Data.Tests' serial MongoDB-RS collection (~765 s, the CI critical path per #3963) has 94 classes. ~57 are statistics or fold by file name, and most of the rest are statistics fence, backfill or parity tests (my estimate: ~85 of 94). The longest tests there are statistics simulations (a 420-day maintenance soak, 108 s) VERIFIED (count) / estimate
Cognitive load .claude/rules/materialized-stats.md is a ~300-line invariants file attached to StudyRepository, ReviewController, StageReviewService and SubmitAnnotationSessionService. Command budgets are pinned per save shape. Any change on the review hot path must respect them VERIFIED
Production exposure The next promotion of main to production ships all of it dark. Three source-correctness changes in the search lifecycle are unconditional (rules file, "What the search lifecycle costs with the flags down"). Fold collection indexes are created at host start-up, so collections will appear in syrftest VERIFIED (rules file)
Docs 17.8K lines (review) NOT VERIFIED (not recounted)
Frontend statistics code Route-owned SignalStores and consumers (#3776–#3784) NOT VERIFIED (not measured)

4. Options

(a) Activate on a dated plan (b) Freeze © Narrow (d) Retire
What Close gate (b), the read gate and the soak; production pilot on ProjectScreening + Project Overview; other families follow on evidence Keep code and staging pilot; no new work beyond fixes; flags stay Keep the slice with the longest evidence: ProjectScreening (screening parity 17/17; + maybe the reviewer screening families) on the fold path, the Project Overview consumer, rebuild/parity/operator tooling. Keep the stored-inclusion drift guard (#3937/#3956 and #4018: InclusionStatusDrift) and re-persist-drift (#4008); without them a backfilled ProjectScreening row can be served Fresh while wrong. Keep the 3 unconditional search-lifecycle source-correctness changes, and the search-import fence (#3839, fixed by #3842: the fence goes up before the first parsed Study) narrowed to the families © keeps; without it a kept ProjectScreening row would be served Fresh during the parse window. Delete history/checkpoints, snapshot copy, the fleet pilot, derived summaries, search population and the annotation/question families with their consumers. The periodic drift check (#3636) is optional after the soak. Note: all nine families have been backfilled and running on the staging pilot since 2026-10-04, and the soak gate covers all of them, so this is not a cut between "proven" and "unproven" Delete FEAT-024 and return to the legacy $facet path, keeping the 3 unconditional source-correctness fixes. The search-import fence (#3839/#3842) is a statistics fence with no purpose once no rows exist, so it is deleted with the families
Engineering effort The lever was already used (#4011), and the #3960 re-persist and #3840 are done. Remaining: the acceptance rerun (running); the read gate (idle host); #3952/#4010 (M–L); the 24 h isolated soak, with the one-week staging check in parallel; the production rollout PR (S) plus the operational steps (re-persist, backfill, index). The three Bramble runs (rerun, read gate, soak) run one after another. Calendar: re-derived without the 7-day isolated soak, which no longer exists; the owner sets the dates (D3) ~0 L: cutting seams in 4 hosts and the web app, plus a breaking fold-protocol change (see Risk). The owner session must define the exact cut L: ~59K + tests + web consumers + 25 flags; mostly mechanical, but it touches the review hot path
Risk The acceptance rerun of the re-baselined gate may still fail. Since #4011 the uncontended save is 4 commands in 2 sequential rounds (it was 5 in 3), and the ~1.3 ms re-read is gone; latency is gated only on the different-Study cells and the 1-reviewer same-Study cells. Operational complexity in production (worker, leases, quarantine, operator identity) Code rots against moving domain code; the carrying cost continues (test time, rules, DI); dark code goes to production anyway Medium–high: deletions near the save paths. Deleting the annotation/question families is a breaking fold-protocol change, not just a deletion: P0 is protocol 4, which includes the annotation session writers (protocol 3) and reservations (protocol 4); both append annotation-namespace entries carrying the ProjectAnnotationFoldContext digest, and the fold writers need all three move families (with a partial set, saves take the transactional path; STATUS.md L25-26). Cutting them means removing those transition kinds and handlers and re-baselining P0, which is allowed only because production has never run a protocol; staging must first drain its pending entries and run fold/reset. © reverses two recorded Chris decisions: 2026-10-01 (ADR-019 decision (a): every family migrates in one MVP) and 2026-10-04 (the soak covers all families in one run; the staging week runs with every family on) Medium: hot-path edits in StudyRepository/ReviewController; must not drop the unconditional fixes or the review-eligibility statistics evidence (StageReviewStatisticsEvidence.cs)
User impact Large projects: Project Overview p95 ~1.2 s → ~15 ms (adapter level); SignalR recompute load drops for migrated views None (production never had it) As (a) for screening views; annotation views keep the legacy speed None today; the legacy cost stays. Can be offset by throttling the SignalR recompute (S) and a screening-only source query (M)
36 collections Created in production on promotion; retention by maintenance services Stay on staging; created in production empty on promotion ~half deleted from code; staging drains pending entries and runs fold/reset first, then drop or reseed Staging: drop 30 collections, unset Study.PendingStatistics, drop the index (or reseed; staging is disposable). Production: nothing to migrate, if retired before main reaches production
Flags Collapse as families are accepted (target PROPOSAL: Writes, Serving, Fold kill switches, allowlist) All 25 + ≈11 stay Delete flags of the removed parts (~15 of 25) All deleted in one PR set
Isolation plan (§6) Yes, after the gate verdict Yes, the main remaining work Yes, after the deletion No

5. Scope, MVP boundary and flag decision

ID Problem Impact Evidence Issue
P1 No dated activation or freeze decision for a 56%-of-Core dark feature Carrying cost without user value; review eligibility paused §1, §3 #3987
P2 Ordered Lamar block copied 4 times; last-wins ordering is load-bearing A misordered include silently restores a fail-closed placeholder PM Program.cs:64-93, :180-209, :287-375; API :146-150, :611-705 #3987
P3 PM index-init and seed-data load all of statistics, although they need only the fold indexes (index-init) and the Study class maps and UoW repositories (both) Boot fragility; startup work PM Program.cs:60-63 comments; :112, :136-137; MongoPmUnitOfWork.cs:43-52 (statistics session review, 2026-10-05) #3987
P4 Statistics lives inside PM Core and Mongo.Data No assembly boundary; PdfAgent ships it; ownership unclear SyRF.PdfAgent.csproj:29 #3987
P5 Statistics repositories hang off IPmUnitOfWork Every UoW consumer depends on statistics IPmUnitOfWork.cs (25 refs) new
P6 STATUS.md says no environment has fold on; staging does Misleading status record STATUS.md:419-422 and statistics-reference.md:159-160 vs cluster-gitops owner session (review §2)

MVP of this brief: the decision (D1) plus isolation release R1 (PR-1 to PR-4; PR-3 depends on PR-4): guard tests, one AddProjectStatistics() entry point, the statistics store off the UoW, and the PM modes that need only the fold indexes and class maps stop loading the rest. Out of scope: the gate work itself (owner session: the acceptance rerun, the read gate, #3510, #3952/#4010, the #3960 remainder, #4014); type renames (D4); collection-ownership modules (review item 10, tracked with #3987's sibling ADR candidates); the Lamar-scan singleton bug V8 (separate issue in the review plan set).

Flag decision: the isolation PRs are unflagged. They are refactors that must keep resolved registrations, collection names and message entity names identical, which is proven by the guard tests in PR-1, not by a runtime switch. No observable semantics change. Flag retirement (R3) removes flags; it adds none.

6. Common acceptance criteria (every isolation PR)

# Criterion Verification
C1 Resolved implementation and lifetime per statistics service type is identical in every host and mode, before and after; so are the hosted services in start order, the IRunAtInit work (CreateIndexesAndMapping with ProjectStatisticsFoldIndexes), and the MassTransit consumers with their endpoint names Golden registration map test (PR-1), all hosts
C2 Every persisted statistics aggregate resolves to the same collection name Collection-name pin test (PR-1)
C3 MassTransit message contracts and their entity/queue names are unchanged Entity-name pin test (PR-1); commands stay in SyRF.ProjectManagement.Messages.Commands; ProjectStatisticsChanged stays in SyRF.API.SignalR.Statistics
C4 Command-budget tests unchanged (FoldSaveCommandBudgetTests etc.) Existing tests, untouched
C5 No content edits mixed into moves; git mv keeps rename detection git diff -M --stat in the PR body
C6 Owner session named as reviewer, and the move window agreed (§8) PR body; governance
C7 Rules file, CLAUDE.md pointers and STATUS.md updated in the same PR Docs review; validate-docs --skip-indexes
C8 Tests run narrow and niced on the CI host (one project, --filter) PR body commands
C9 The persisted Quartz ScheduleId/ScheduleGroup of the five statistics recurring schedules are unchanged Pin test (PR-1)

7. Releases and PRs

R0: decision record (docs only)

PR-0 (S): ADR "FEAT-024 activation decision" in docs/decisions/, recording D1–D5 and the dates. Written by or with the owner session.

# Acceptance criterion Verification
0.1 ADR states the chosen option, dated gates, and the review date for unactivated families → present Docs review
0.2 STATUS.md fold/staging line corrected (P6) → matches cluster-gitops Docs review

R1: isolation without moving files (valuable under (a), (b), ©)

PR-1 (S): guard tests. Files: new ProjectStatisticsRegistrationMapTests in API and PM endpoint tests; ProjectStatisticsCollectionNameTests in Mongo.Data.Tests; a message-entity-name test; a Quartz schedule-identity pin test. Approach (golden map scope corrected in the statistics session review, 2026-10-05): - snapshot the resolved concrete type and lifetime for every IProjectStatistics*/ProjectStatistics* service per host and mode; - snapshot the hosted services in order (multi-registrations, so the order is start order, not last-wins): - PM: DailyProjectStatisticsScheduleService, …MaintenanceScheduleService, …DriftCheckScheduleService, …RepairScheduleService (each with ValidateOnStart options), ProjectStatisticsFleetMembershipService (foldWorker: true), ProjectStatisticsFoldHeartbeatService, ProjectStatisticsFoldScheduleService, plus the singletons ProjectStatisticsFoldWorkerIdentity/ProjectStatisticsFoldWorker; - API: fleet membership (foldWorker: false), ProjectStatisticsFoldSignalCoalescer, ProjectStatisticsDispatchService; - snapshot IRunAtInit CreateIndexesAndMapping in both hosts (it needs ProjectStatisticsFoldIndexes); - snapshot the MassTransit consumers and endpoint names: PM StartDailyProjectStatistics, ObserveDaily…, RunProjectStatisticsMaintenance/DriftCheck/Repair, FoldProjectStatistics, RunProjectStatisticsFoldSweep, ProjectStatisticsFoldStampAdvance (Program.cs:489-501) and the fenced UpdateStudyScreeningStatsConsumer; API ProjectStatisticsChangedConsumer on the temporary per-instance queue api-project-statistics-invalidations; - pin the Quartz ScheduleId/ScheduleGroup strings of the five DefaultRecurringSchedule subclasses (DailyProjectStatisticsSchedule, ProjectStatisticsMaintenanceSchedule, …DriftCheckSchedule, …RepairSchedule, ProjectStatisticsFoldSweepSchedule, all in SyRF.ProjectManagement.Endpoint.Services). None overrides the id; MassTransit 8.4.0 sets ScheduleId = TypeCache.GetShortName(GetType()) and ScheduleGroup = <assembly name>. If a move or rename changes them, a second recurring schedule appears while the old one keeps firing from the Quartz SQL store: duplicate maintenance, repair and sweep runs until someone unschedules the old one; - snapshot pm + type name for every statistics aggregate root (MongoContext.cs:154-163, 317-325: the prefix comes from the namespace starting with SyRF.ProjectManagement, and the name from the type name).

# Acceptance criterion Verification
1.1 Reordering any two statistics includes in a host → registration map test fails Unit (mutation check in the PR)
1.2 Renaming a statistics aggregate root or moving it outside SyRF.ProjectManagement* → collection test fails Unit
1.3 Moving a statistics command contract's namespace → entity-name test fails Unit
1.4 Committed snapshot lists 36 collection names → equals the source grep Unit + review
1.5 Reordering two statistics hosted services, or changing a statistics service's lifetime → map test fails Unit (mutation check)
1.6 Renaming or moving a statistics recurring schedule type → the Quartz id/group pin test fails Unit
1.7 Changing a statistics consumer's endpoint name, or moving ProjectStatisticsChanged out of SyRF.API.SignalR.Statistics → test fails Unit

PR-2 (M): one AddProjectStatistics(ProjectStatisticsHost host). Files: new ProjectStatisticsServiceRegistration.cs (Mongo.Data for now); API and PM Program.cs. It is right to do, but there is no single documented order to apply (statistics session review, 2026-10-05). The extension must reproduce each host's current order: - PM (all 3 modes): ProjectStatisticsRegistry → [PM main only: TaskRegistry, ParserRegistry, auth, OpenTelemetry] → MongoLamarRegistry → Lifecycle → Production → FlagAdapter → FamilyCalculator → SourceFence (PM Program.cs:64-93, :180-209, :287-375). - API (5 registries, not 6): MongoLamarRegistry (:146) → Lifecycle (:150) → … ~460 lines … → own calling-assembly scan (:611) → SyrfRegistry (:616) → ProjectStatisticsRegistry (:621) → Production (:627) → inline extras (:648-686) → FamilyCalculator (:698) → SourceFence (:704) → AddProjectStatisticsNotifications() (:705). The API never includes ProjectStatisticsFlagAdapterRegistry.

Lamar positional-scan constraint. In Lamar 15.0.1 each Scan() is applied where it is declared and sees only earlier descriptors; DefaultConventionScanner (OverwriteBehavior.NewType) appends IFoo→Foo unless that exact pair already exists. An explicit statistics registration placed before the first SyRF-assembly default-convention scan is silently overridden by the conventional default appended after it. So AddProjectStatistics must be called after the first WithDefaultConventions scan of the SyRF assemblies in each host: PM SyrfRegistry; API MongoLamarRegistry (:146). Moving the API's Lifecycle include from :150 into the combined call is safe only because MongoLamarRegistry's scan stays before it. ProjectStatisticsRegistry and Lifecycle register disjoint service types (IProjectStatisticsFlagSource/Clock/AuthorizationContextSource/ Metrics vs SessionFactory/ScopeCalculator), so their relative order can be unified.

Host extras with last-wins or lifetime dependencies that the extension must keep after specific registries: - API RuntimeProjectStatisticsFlagSource (:648): after ProjectStatisticsRegistry's static all-off source. - API ProjectStatisticsFoldSignalCoalescer: replaces Production's no-op IProjectStatisticsFoldSignal, and is also a hosted service. - API AddScoped<IProjectStatisticsTransactionCoordinator, …> (:680): a lifetime change; Production registers it transient (ProductionRegistry.cs:227). - API AddProjectStatisticsNotifications(): replaces Production's fail-closed singleton UnavailableProjectStatisticsNotificationSink with a scoped SignalR sink, re-registers ProjectStatisticsOutboxDispatcher as scoped (Production registers it singleton), and adds ProjectStatisticsDispatchService. - PM SharedReaderMode == StatisticsServing → SharedProjectStatisticsFlagSource (Program.cs:407-408): must stay after FlagAdapterRegistry. It is live on staging (PM sets SharedReaderMode: StatisticsServing).

Replace the 4 blocks and their copied comments with one call and one comment pointing at the extension. TryAdd/Replace semantics stay out of scope (Lamar keeps last-wins inside one method, so the order lives in one place). PR-2 rebases on #4012 (open; edits ProjectStatisticsProductionRegistry.cs) or waits for it.

# Acceptance criterion Verification
2.1 API and PM (3 modes) call AddProjectStatistics exactly once → no other statistics IncludeRegistry remains in Program.cs grep in test or CI script
2.2 Registration map identical before and after → PR-1 tests pass unchanged Unit
2.3 AssertConfigurationIsValid passes in all 4 host modes Existing host registration tests
2.4 API Program.cs and PM Program.cs shrink by the removed block lines (PROPOSAL: ≥ 120 lines total) Diff stat
2.5 AddProjectStatistics moved before the first SyRF default-convention scan in a host → a registration map test fails Unit (mutation check in the PR)
2.6 API transaction coordinator scoped, API outbox dispatcher and notification sink scoped, PM SharedProjectStatisticsFlagSource under StatisticsServing → all preserved PR-1 map (lifetimes) plus a PM test with SharedReaderMode: StatisticsServing

PR-3 (S–M): index-init and seed-data load only what they need. Depends on PR-4 (statistics session review, 2026-10-05). Files: SyrfRegistry.cs, MongoLamarRegistry.cs (opt-in scan filters), PM Program.cs. Constraints: - Index-init owns statistics indexes: Program.cs:136-137 resolves ProjectStatisticsFoldIndexes and creates the fold collections' indexes. PR-3 keeps that (or moves it deliberately, with the owner session). - Both modes resolve IPmUnitOfWork (index-init at :112; seed-data through DatabaseSeeder), and MongoPmUnitOfWork's constructor takes IProjectStatisticsGlobalControlRepository, …ControlRepository, …CurrentRepository and others as required parameters (MongoPmUnitOfWork.cs:43-52+). So "resolve no statistics service" cannot pass until PR-4 takes the statistics repositories off the UoW. - StudyRepository's constructor calls StudyPendingStatisticsClassMaps.Register() (StudyRepository.cs:72-73), a static, idempotent BsonClassMap registration in Mongo.Data (not a DI service) that first calls ProjectStatisticsClassMaps.Register() (25 maps). The Study BSON model needs those maps in every mode that reads or writes Studies, seed-data included, so they stay with StudyRepository in Mongo.Data and are never unloaded. - Registry exclusion is opt-in per host: SyrfRegistry/MongoLamarRegistry are shared by every Lamar host, so PR-3 adds a parameter that index-init and seed-data pass, and never changes the default.

Approach: with those exceptions kept, exclude the other *.ProjectStatistics* namespaces (later the Statistics assemblies) from the convention scans in index-init and seed-data, and replace their AddProjectStatistics call with the narrow registrations they need.

# Acceptance criterion Verification
3.1 Index-init and seed-data containers → resolve no statistics service except ProjectStatisticsFoldIndexes (index-init); the static Study class-map registration still runs from StudyRepository; AssertConfigurationIsValid passes Host registration test
3.2 Index-init still creates the same indexes, the fold collections' indexes included → same index list Testcontainers index list diff
3.3 Preview reseed (/reseed-db) on a PR preview → completes Preview check
3.4 API and PM main containers → registration map unchanged (the scan default is untouched) PR-1 map tests

PR-4 (M): statistics store off IPmUnitOfWork. Files: IPmUnitOfWork.cs, MongoPmUnitOfWork.cs, statistics callers. Approach: new IProjectStatisticsStore exposing the statistics repositories; UoW keeps source aggregates only.

# Acceptance criterion Verification
4.1 IPmUnitOfWork → no ProjectStatistics* member Compile + grep test
4.2 Transactions that span source and statistics writes → still share one session Existing Testcontainers transaction tests

R2: assembly split (after the D1 verdict, inside an agreed move window)

PR-5 (S, spike): dependency cut analysis. Output: a script plus a table of which statistics types non-statistics code needs (the seams the source writers call, StudyPendingStatistics on the Study aggregate, the fences). Proposed target, NOT VERIFIED for feasibility:

Assembly Contents References
SyRF.ProjectManagement.Statistics.Contracts Seam interfaces and value types called by source writers (fences, writer interfaces), the pending-entry types and ProjectStatisticsScopeKey. The class-map registration (StudyPendingStatisticsClassMaps) stays in Mongo.Data beside StudyRepository; moving it to Statistics.Mongo would create a reference cycle. ProjectStatisticsClassMaps is split: the maps the Study needs stay in Mongo.Data, the rest move to Statistics.Mongo SharedKernel only (MongoDB.Bson comes through SharedKernel, which already references MongoDB.Bson 3.10.0; no new reference); referenced by Core
SyRF.ProjectManagement.Statistics Model (ProjectStatisticsAggregate), services (Read, Write, Lifecycle, Fold, Families) Core, Contracts
SyRF.ProjectManagement.Statistics.Mongo Repositories, class maps, the 5 root registries, AddProjectStatistics Mongo.Data, Statistics
…Statistics.Tests, …Statistics.Mongo.Tests Moved tests as above

Hard naming constraint: every namespace must stay under SyRF.ProjectManagement. or collections silently move to unprefixed names (MongoContext.GetBoundedContextCode, MongoContext.cs:154-162, 317-325; correct per the statistics session review). The commands in SyRF.ProjectManagement.Messages.Commands stay where they are.

Three more persisted or wire identities (statistics session review, 2026-10-05): - (a) Quartz ScheduleId/ScheduleGroup (hard constraint). The five statistics recurring schedule types stay named as they are and stay in the SyRF.ProjectManagement.Endpoint assembly. Any move or rename needs an explicit, owner-approved unschedule step for the old schedule, otherwise the old one keeps firing from the Quartz SQL store beside the new one. - (b) ProjectStatisticsChanged lives in SyRF.API.SignalR.Statistics, not in SyRF.ProjectManagement.Messages. Its MassTransit URN and exchange derive from that namespace, so moving it mid-rollout drops invalidations between old and new pods (the client poll recovers). It does not move. - © Consumer endpoint names that come from definitions are pinned (PR-1 1.7).

# Acceptance criterion Verification
5.1 Spike lists every Core/Mongo.Data/API type that would cross the boundary → table in the PR Review
5.2 Spike finds no persisted type-name string (outbox, discriminators) that a move would change, or lists them grep + Testcontainers read of staged docs
5.3 Spike lists the Quartz schedule ids/groups, the ProjectStatisticsChanged URN and the consumer endpoint names as fixed identities, with the PR-1 test that pins each Review

PR-6a/6b/6c (L total, each M): move Contracts, then Statistics, then Statistics.Mongo, as git mv plus namespace and using edits only. PR-6d (M): move tests. This also gives the statistics Mongo tests their own test host, so they run in parallel with the rest of Mongo.Data.Tests.

# Acceptance criterion Verification
6.1 PM Core contains no ProjectStatistics* type except the declared Contracts seams → 0 grep test
6.2 PdfAgent → references no Statistics assembly csproj check
6.3 PR-1 guard tests pass unchanged at each step Unit
6.4 Mongo.Data.Tests serial collection wall-clock falls (PROPOSAL: ≥ 40%) CI TRX timings before and after
6.5 Staging deploy of the moved build → preflight workflow passes, pilot parity in parity Staging rollout check (Statistics Staging Preflight)
6.6 Quartz schedule ids/groups, consumer endpoint names and the ProjectStatisticsChanged URN → unchanged at each step PR-1 pin tests (1.6, 1.7)

R3: flag retirement (option-dependent)

PR-7 (M per family group): under (a), remove consumer and family flags once each is accepted in production; under ©, delete the flags of removed parts with their code; under (b), none. Generator workflow: env-mapping.yaml → pnpm run generate:flags, plus the cluster-gitops values in both hosts.

# Acceptance criterion Verification
7.1 Removed flag → absent from FeatureFlags.cs, generated web types, Helm values and cluster-gitops staging values Generator diff + preflight (flags match GitOps)
7.2 Flag removal never precedes the production acceptance of what it gated → recorded in the ADR Governance

8. Order, critical path and coordination with the owner session

D1 decision ─► PR-0 ADR
PR-1 guards ─► PR-2 AddProjectStatistics ─┐
PR-4 store off UoW ───────────────────────┴► PR-3 modes ─┐
gate (b) acceptance rerun of #4011 ─► D1 verdict ────────┴► PR-5 spike ─► [move window] PR-6a ► 6b ► 6c ► 6d
(the rerun has been running on Bramble since 2026-10-05)
  • PR-1 and PR-4 can run in parallel: no shared files. PR-2 and PR-3 both edit Program.cs, so they run in sequence. PR-3 also needs PR-4 (PR-4 → PR-3; statistics session review, 2026-10-05).
  • R1 touches Program.cs and the registries, which are owner-session files. Each R1 PR merges only when the owner session has no open statistics PR touching those files. #4012 is open and edits ProjectStatisticsProductionRegistry.cs, so PR-1/PR-2 rebase on it or wait for it.
  • R2 touches every statistics file. It runs only in a declared move window: the owner session lands or parks its branches, the move PRs merge in one sitting, and the owner session then rebases (git mv keeps rename detection). The gate (b) optimisation (#4011) and the #3960 core (#4008/#4018) have merged. Do not schedule R2 while any of these is in flight: the acceptance rerun, #4010 (#3952), #4012, the #3960 remainder and #4014.
  • Under option ©, R2 runs after the deletion PRs, so that deleted code is never moved.
  • Other streams: the notifications stack (#3932, #3938–#3947, #3965) edits API Program.cs (AddProjectStatisticsNotifications sits beside it), so rebase PR-2 on whichever lands first. The authentication migration session does not touch these files. The review-eligibility resume (#3742, #3746) touches ReviewController and StageReviewService but not the registration code; R2 must wait for it if it resumes first.

9. Risks

Risk Mitigation
A type rename or namespace move changes a collection name (collection = pm + type name) PR-1 collection pin; keep the SyRF.ProjectManagement. namespace root; D4 keeps the type prefixes
Registration order changes silently during PR-2 PR-1 golden map, all hosts and modes, including lifetimes, hosted-service order, consumers and endpoint names
AddProjectStatistics lands before a SyRF default-convention scan, and Lamar's positional scan silently overrides an explicit registration PR-2 constraint and AC 2.5
A move or rename of a recurring schedule type leaves the old Quartz schedule firing beside the new one (duplicate maintenance, repair, sweep) PR-1 pin test (1.6); hard R2 constraint (a)
Moving ProjectStatisticsChanged changes its URN and drops invalidations between old and new pods It does not move (R2 (b)); PR-1 1.7
Merge conflicts with the owner's in-flight slices Move window; pure moves; R1 is small
The acceptance rerun of the re-baselined gate (b) fails Decision tree in D2. The pre-write re-read lever has already been spent (#4011)
Dark code reaches production with the next promotion before D1 D3: decide explicitly; the 3 unconditional fixes are source correctness and should ship regardless
Option © or (d) deletes the review-eligibility statistics evidence or the unconditional fixes Owner session defines the cut; Testcontainers suites for search import and eligibility must stay green
The 6.4 test-time saving does not materialise Measure in PR-6d; the split is still justified by boundaries

10. Decisions for Chris

The owner session reviewed this brief on 2026-10-05, especially §1, §4© and §8; its corrections are applied above. D1–D3 below reflect them.

# Decision Recommendation
D1 Which option? (a), with a narrowed production activation and a date. The production pilot activates ProjectScreening + Project Overview first; the other families follow on evidence (Chris's call). Until that production pilot is accepted, "freeze" means only no production enable and no new scope for history, fleet, snapshot copying, derived summaries and the other families. It does not mean stopping staging or soak coverage: those features have been on in staging since 2026-10-04 and are inside the soak gate by Chris's 2026-10-04 decisions. Drift is not frozen out: the inclusion-drift guard and re-persist-drift stay, and the periodic drift check stays as the staging week's correctness monitor (optional only after the soak; §4©). Hold a review date (PROPOSAL: 8 weeks after the pilot) to activate, narrow © or delete each unactivated family. Rationale: the read win is two orders of magnitude at 5,000 studies; production has no data to migrate under any option; the write gate is the only open technical unknown, and its acceptance rerun is already running
D2 What if the acceptance rerun of the re-baselined gate (b) fails? The current gate is the re-baselined one. The optimisation round has already been spent: on 2026-10-04 Chris dropped the pre-write re-read and re-baselined gate (b) (ADR-019 amendment, decision 3; implemented by #4011). If the acceptance rerun fails, the owner session reports which cells failed and why; any further optimisation or gate change is a new Chris decision, not a default. Otherwise switch to © with ProjectScreening only (with the fold-protocol cost in §4©), or to (d) plus the tactical legacy fixes (SignalR recompute throttle, screening-only source query). Recommendation: no further change to the gate
D3 Dates (PROPOSAL; re-derived without the 7-day soak, statistics session review, 2026-10-05) Constraint: the gate (b) rerun, the read-gate harness and the 24 h soak each need an idle Bramble. They run one after another, never overlapped, or the idle-host measurements become invalid. Dates: the gate (b) acceptance rerun started 2026-10-05; its verdict date is set by the owner. Read-gate acceptance run by 2026-10-14 (PROPOSAL), after the rerun has finished. The 24 h isolated all-families soak runs as soon as #3952/#4010 lands and Bramble is idle. The one-week passive staging check can start now (every family has been on since 2026-10-04), once the parity schedule (STATISTICS_PREFLIGHT_SCHEDULE) and the evidence capture are on; it ends at the earliest 7 days after that and runs in parallel with the Bramble chain. The earliest production pilot request is re-derived by the owner session (the early-November date assumed a 7-day soak after 24 Oct and no longer holds): the critical path is rerun → read gate → #4010 → 24 h soak, and the request also waits for the staging week to finish. Each production step is a separate Chris-approved step
D4 Drop the ProjectStatistics* type prefixes for namespaces? No for persisted types and message contracts (collection and entity names derive from them). Namespaces under SyRF.ProjectManagement.Statistics.* are the organising unit; leave service-type renames to touch-as-you-go
D5 When to isolate R1 now (after D1, unless (d)); R2 after the D1 verdict, inside a move window agreed with the owner session
D6 Resume review eligibility? Resume once the gate (b) verdict is in. It does not depend on activation, only on the owner session's hot-path files settling
D7 Measure the production project-size distribution? Yes, read-only on a restored snapshot rather than live syrftest, to size the real benefit (how many projects exceed ~1,000 studies)