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.csand 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 editsProjectStatisticsProductionRegistry.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 mvkeeps 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(AddProjectStatisticsNotificationssits 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) touchesReviewControllerandStageReviewServicebut 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) |