Skip to content

Project statistics reference (as built)

This is the entry point for anyone who wants to know what each materialized statistic counts, how it is recalculated from scratch, what keeps it up to date, and what can make it stale. It describes the code on main at 9eeddbac3 (30 September 2026), not the plan, with later changes noted inline. The async point fold section was checked against main at 86a3caa10 (3 October 2026). QuestionAnswers point maintenance and fold protocol 5 (#4080, 5 October 2026) are described in QuestionAnswers and the fold sections; ReviewerAnnotation point maintenance and fold protocol 6 (#4083, 6 October 2026) in ReviewerAnnotation and the fold sections. Where the plan and the code differ, this page follows the code and says so.

It is written for the product owner and for engineers. The detail lives elsewhere and is linked rather than repeated:

How it works

Stored counts beside the real data

Projects and Studies stay the source of truth. Statistics are stored counts kept in separate collections (pmProjectStatisticsCurrent rows, one per family and scope). Reading a stored count is fast; recalculating it from every Study in a large project is slow. The stored count is only a cache: if it cannot be trusted, the page recalculates from the Studies instead (the authoritative calculation, the same code the pages used before this feature).

Two ways a stored count changes

  • Quick update (point move). When a reviewer saves one decision or one annotation session, the same database transaction that saves the Study also adds or subtracts the exact change on the stored counts (for example "sufficiently screened −1, sufficiently included +1"). The owner is ProjectStatisticsTransactionCoordinator.CommitAsync. A quick update is refused if it would touch more than 500 counters or 100 stored rows.
  • Pending entries (async point fold), staging only. Behind the default-off flag materializedProjectStatisticsFold, a save in a project whose fold mode is Enabled appends its change to the Study it already writes (a pending entry) and writes no statistics document. A background worker in project-management folds entries into the stored counts, usually within a second, and pages read stored counts plus pending changes together, so the number shown is exact before and after the fold. On staging the flag is on for both hosts and the allowlisted pilot project is in fold mode (protocol 4); production has the flag off, no project in fold mode, and enable refused in code. See Async point fold (as built).
  • Full rebuild (backfill). An administrator (or a scheduled job, where enabled) recalculates a family from the Studies inside one pinned database snapshot and publishes fresh rows. The entry point is POST api/admin/project-statistics/{projectId}/<family>/backfill (only rebuilds scopes that are not already current) or /rebuild (forced; also adopts a changed configuration identity). A committed agreement-setting change does not need the forced form: its recalculation adopts the new settings itself (threshold change). Both run ProjectScreeningBackfillService or its per-family subclass, synchronously inside the request.

States

Every stored row, and every family as a whole, is in one of these states (ProjectStatisticsScopeState):

State Plain meaning Served?
Fresh Matches the Studies as of its last change Yes, if every other check passes
Stale Known to have missed a change No: recalculated live
Rebuilding A rebuild holds a lease on it No
Incompatible Built under a different catalogue, source version or configuration No
Missing Never built No
Fenced A large operation is rewriting the underlying data No

Anything the code cannot keep exactly right with a quick update is marked Stale in the same transaction as the source change, or fenced before the change starts. It then stays Stale until a rebuild. Nothing turns Stale back into Fresh except a rebuild. A quick update never does: before moving a row it applies the reader's own row checks (ProjectStatisticsServingGate.EvaluateRow: Fresh, versions and configuration digest, write epoch, projection revision) and refuses a tombstone. A row that fails is not moved; the whole save falls back to source-only, the source change still commits, and the affected rows are marked Stale (#3831, see the backfill side effect).

Saves during a backfill

A backfill or rebuild marks the served row Rebuilding before it calculates. A quick update that lands while that row is Rebuilding does not count as a servable baseline, so the save goes source-only, the row and the family are left Stale, and the backfill loses its publication (PublicationRaceLost) because the source moved. This is safe — pages recalculate live and nothing wrong is served — but it costs performance: the family stays on live fallback until a backfill completes with no save during it. The scheduled repair, where it is enabled, recalculates a scope that lost its race up to three times within a run and otherwise tries again on its next run (below). Recovery without it: re-run Build statistics (POST api/admin/project-statistics/{id}/backfill, or /rebuild for one scope) at a quiet time. A crashed rebuild that leaves a row Rebuilding has the same outcome and the same recovery.

When the scheduled repair republishes a family

The hourly scheduled repair (default off; on for the staging pilot) republishes a Stale or Missing family through its ordinary backfill once none of that family's own invalidations has been recorded since its previous run looked at the family. An invalidation of its own is a writer marking the family's rows or the whole family Stale because its source moved: a dependant of a transactional save, or an intent the fold applies. Saves to other families do not count, and neither does a save that could not move a family because it was already Stale (fleet runbook).

In practice a point-maintained family (ProjectScreening, StageAnnotation, MembershipStageAnnotation, DomainReconciliation, and the reviewer screening families when saves carry point evidence) that goes Stale is republished within about two runs even while the project is busy, unless the busy work keeps invalidating that family too (for example screening decisions that flip Studies between included and excluded invalidate the annotation families). On a fold-mode project the next saves then keep it Fresh. On a transactional project the families one save moves together (ProjectScreening with the reviewer screening families; StageAnnotation, MembershipStageAnnotation and DomainReconciliation) are staled together whenever any of them is not servable, so a republished family can go Stale again at the next save while a sibling is still Stale. Pages stay correct on the live fallback; the repair just spends budget until the whole set is Fresh (#4086 would repair such a set as one unit). QuestionAnswers is point-maintained since #4080 and ReviewerAnnotation since #4083, so an ordinary save no longer invalidates either (on a fold project once the stamp is at protocol 5 for QuestionAnswers and 6 for ReviewerAnnotation). Below its protocol, or in a project with more than 100 members, every annotation save still marks ReviewerAnnotation Stale: it stays on the live fallback while annotation continues and is republished within about an hour of it stopping.

Fences

A fence says "this data is being rewritten; do not trust any stored count for it".

  • Operation fences cover the families a bulk job touches (search import completion, bulk Study update, legacy question-tally refresh). They are admitted in their own transaction and released as Stale (ProjectStatisticsStagedOperationFenceService). While a fence is active, pages recalculate live.
  • Source-visibility fences are stronger. Two jobs rewrite data in several passes that a single snapshot cannot make consistent: the agreement-threshold recalculation (InclusionRecalculationToken) and question deletion (DefinitionRewriteToken). While either token is held, statistics for the whole project answer HTTP 503 "recalculation in progress" instead of a number, because even the live calculation would see a half-rewritten population.
  • Each run fences again, even under an operation id used before (#4060). Both kinds also write one fence record per (operation id, family, scope) and set the family's owner on its publication guard. A record is unique per key and stays behind, Cleared, after its release. Several operations derive a fixed id, so a later run meets the earlier run's record: a second drift re-persist (stats.source.screening.drift-re-persist:{project}), a second legacy question-tally refresh (one id per project), and an inclusion recalculation under settings recalculated before (an administrative repeat at the same threshold, or a change back to an earlier threshold). The later run re-activates that record (ProjectStatisticsScopeFenceWriter.WriteActiveFenceAsync): one record per key, counted once on the guard, and the families are Fenced and owned by the run until it releases them Stale. Only a still-active record is a retry of the same run; that writes nothing and counts nothing twice. Re-activation also means a late admission after its operation's release would raise a fence nobody releases. So two owners whose admission can come late check, inside the admission's own transaction, that the operation is still theirs (#4062):
  • the all-or-nothing bulk update, whose attempts can supersede each other, checks the attempt's generation;
  • a search import's parse start, which a redelivered start command can run a second time, refuses an import already Complete or in Error.

Before #4060 the later run fenced nothing. Its families had no owner, so another operation, such as an import, was admitted over them. The inclusion recalculation relied on its control token alone, and after an administrative repeat each family kept its pre-recalculation Fresh row. Between drift re-persist batches, the drift guard refused only the families whose drift kinds were still present.

  • Admitting and releasing a fence retry transient conflicts (#4124). Every fence transition is one transaction, and it can lose a write conflict to a concurrent writer of the same guard, fence record or family notification slot (a point-maintenance save, another fence, a fold). Each transition now re-runs as a whole, from a fresh snapshot, up to 5 attempts with a short jittered backoff (nominally 50, 100, 200 and 400 ms; ProjectStatisticsFenceRetry.Policy):
  • Releases: the staged release (ReleaseStaleAsync: search import completion and failure, bulk Study update v1 and v2, deletion, legacy tally refresh, drift re-persist) and the source-visibility completion (CompleteAsync: the inclusion recalculation's consumer and administrative routes). A release retries a TransientTransactionError, a write conflict (code 112, or a driver message reporting one), a lost compare-and-swap, and an unknown commit result. Replaying an unknown commit is safe only because a release is idempotent: each attempt re-reads the operation's still-Active fences (or the token), so an attempt after a commit that did land finds nothing held and writes nothing (NotHeld).
  • Admissions: the staged AdmitAsync and the standalone source-visibility BeginAsync retry only a definitely aborted transaction, re-taking every ownership check, the caller's admission guard included. The source-visibility raise inside a settings save (BeginInTransactionAsync) belongs to the caller's transaction and still answers a conflict with HTTP 409.

A release that still fails is logged at Error, naming the operation, and counted in syrf.project_statistics.fence_release_failures once per family and operation kind it still holds. Its fences stay Active, which strands them: an owner that is never retried (a completed search import) leaves the families fenced, and no supported route releases them yet. The operator captures the Error line and escalates (runbook: fence release failures); the stranded-fence sweeper is tracked in #4123. After #4124 only a persistent failure (a conflict lasting the whole backoff, an outage, or a real error) can strand a fence this way. Example (isolated soak, 2026-10-07): a search import's release met a conflict on its notification slot three times in a few milliseconds and gave up; all 28 of the import's fences stayed Active and every backfill of those families was refused FamilyFenced.

  • Fence releases do not depend on the flags (#4288). The flags gate admission only. With materializedProjectStatisticsWrites and serving off, or the project off the allowlist, no fence is admitted or raised, but a release or completion still acts on any fence its own operation holds, so an operation that admitted while the flags were on and finished after they went off does not strand its families. The flags-off release first reads the operation's Active fences (the completion: the control's token) once; holding none, or another job's token, it answers NotRequested, opens no transaction and writes nothing. A flags-off completion never rebuilds; its families are left Stale. The administrative inclusion recalculation's refusal while a settings job owns the token (#3841) follows the held token rather than the flags for the same reason.

What a page does when a row is not Fresh

ProjectStatisticsBundleReader decides for the whole request at once, inside one snapshot:

  • all requested rows pass every check → the page gets the stored counts;
  • any requested row fails → the page gets a live recalculation of the whole request (never a mix of stored and live numbers);
  • a source-visibility token is held, or this server's reviewer-tracking setting disagrees with the durable setting → a typed 503.

What a history chart gets for each checkpoint

Each retained checkpoint is one point. Not every checkpoint observes every scope: a family backfill records only its own family, so the ReviewerScreening backfill checkpoint has nothing for a stage's annotation chart. Such a checkpoint is a gap for that chart, never a refusal. To the reader, a stage still in the project counts as known even when that checkpoint never recorded it; a deleted stage is known only through the checkpoints that recorded it (#4027).

The stage history endpoints answer 404, before reading anything, for a stage that is not in the project. They answer 403 for the whole page only when the caller lacks the permission today. (Inside the reader, a stage that is neither in the project nor recorded by a checkpoint is refused for that checkpoint, but the endpoints' 404 comes first.) The web leaves a 403 from a statistics or history request with the chart that asked. Only a refusal of the project itself (GET api/projects/{id}) sends the user to the project list. Example (staging, 2026-10-04): a project had ten retained checkpoints, nine single-family backfill checkpoints and one daily checkpoint, and only two of them recorded the stage: the StageAnnotation backfill and the daily checkpoint. Before #4027, an administrator opening Stage Overview got a 403 for the stage history and was sent to the project page. Now the chart shows those two points and gaps for the other eight.

Switches

Everything is off unless switched on in configuration:

  • materializedProjectStatisticsWrites — keep stored counts up to date (quick updates, fences);
  • materializedProjectStatisticsServing — allow reading them (requires Writes);
  • one flag per family group (see the table below);
  • one flag per page consumer, plus the materializedProjectStatisticsPages kill switch;
  • ProjectStatistics:ProjectAllowlist — the list of pilot projects. A project not on it gets no quick updates, fences or stored rows, and is never served from them.

Maintenance jobs are separately off by default. Production configures none of them; staging has them on for the pilot project (repair before 4 October, the rest since cluster-gitops#1569): scheduled repair (ProjectStatisticsRepair:Enabled, hourly), drift check (ProjectStatisticsDriftCheck:Enabled, weekly), history, delta and receipt maintenance. Details: fleet operations.

Deleted projects

A project removed by the reversible deletion lifecycle (switched off by default; see ADR-024) keeps its stored statistics exactly as they were until it is restored. Every project-scoped write under api/admin/project-statistics refuses it before doing anything, answering 410 project-deleted (decision D13, ADR-024 §7). That covers backfill and rebuild for every family, delta, history and receipt maintenance, drift re-persist, and fold enable, disable, reset and advance-stamp. reset is no exception: it is refused too, so a deleted project cannot be used to leave a guard. Read routes and fleet-wide routes are unchanged. To run any of these on the project, restore it first.

Async point fold (as built)

The async point-fold design (ADR-019) replaces the same-transaction quick update for every screening- and annotation-dependent family. Slices 0 to 7 are merged (STATUS); the operator procedure is the runbook. Production is dark: the fold flag is off and fold enable on the production database is refused in code. On staging the flag is on for both hosts and the single allowlisted pilot project is in fold mode at protocol 4. With the flag off, or for a project whose fold mode is not Enabled, every save takes the quick-update path described above.

How a fold-mode save is counted

  1. The save. The writer's own Study write also rewrites the Study's whole PendingStatistics set with one more entry (StudyPendingStatisticsSet.Append; a bare $push is never used). The entry carries the classified before/after transition, any invalidation intents, the operation id, a submission digest and the fold protocol. The save opens no statistics transaction and writes no pmProjectStatistics* document. The attempt reads the project's fold mode and the durable reviewer mode once, concurrently with its private Study load, and never re-reads either before the write; the entry records the reviewer mode it assumed, and an entry whose mode is not the durable one is discarded and its families staled, never applied. A charged retry reloads concurrently with its control-plane reads, so it costs two sequential rounds with its write. The real-Mongo command-budget tests (FoldSaveCommandBudgetTests, FoldSaveEligibilityCommandBudgetTests, ProjectStatisticsFoldCommandBudgetTests) pin this per save shape.
  2. The fold. ProjectStatisticsFoldWorker in project-management holds a per-project lease (30 seconds, renewed) and folds entries at least 500 ms old, in batches of up to 32 Studies and 32 entries (16 batches per message). It is triggered by the API's post-save signal (coalesced per project, at most every 250 ms per pod) and by a one-minute sweep. Each batch is one snapshot transaction that moves the rows, stales what the entries' intents name, writes receipts, delta records and outbox slots, advances the clocks, and removes the consumed entries from their Studies. Only the worker removes entries, and each removal bumps the Study's Audit.Version and StatisticsFoldSequence.
  3. The read. For every project that has ever been in fold mode (FoldEverEnabled), the reader adds the derived moves of the project's pending entries to the stored rows, in the same pinned snapshot (ProjectStatisticsPendingOverlay). The whole request falls back to the live calculation when more than 128 Studies or 1,024 entries are pending, the oldest entry is over 10 minutes old, an overflow marker exists, an entry cannot be derived, live pods disagree on the project's allowlisting, an entry was made under a different Project context or reviewer-tracking mode, an entry's intent covers a requested scope, or the overlaid row would break an invariant (a negative counter or a capacity refusal).
  4. The rebuild. A backfill or rebuild of a fold family publishes the authoritative value minus what is still pending, from one snapshot. If anything pending cannot be subtracted it refuses the retryable PendingNotDrained; the worker discards or quarantines the entry within a minute and the backfill is repeated. A rebuild holds the project fold lease from calculation to publication, and so does a family backfill's bootstrap from its scope capture to its checkpoint; each asks a folding worker to yield and waits up to 3 seconds for it (ProjectStatisticsFoldLease.AcquireYieldingAsync) rather than refusing typed-busy at the first contention, so a backfill publishes while saves continue (#4080).

Every reader, the overlay, the fold and the rebuild derive an entry's moves through one deriver (PendingEntryMoves, dispatching per transition kind and entry protocol), so they cannot disagree.

Per family

Family Fold-path writers What the entry carries Served while a fold project is active
ProjectScreening Screening saves: capacity-guarded, plain and the eligibility transaction ProjectScreeningProfile transition Point-maintained: stored + pending, exact. Never published over stored agreement-field drift (#3960, below)
MembershipScreening, ReviewerScreening The same screening saves ReviewerScreeningPoint transition when the project has at most 100 canonical members, an agreement threshold and the persisted inclusion statuses; otherwise family-wide intents (fold.reviewer_fallbacks) Point-maintained. An entry made before a member joined or left is a context mismatch: the reader falls back, the fold discards it and stales both families. Never published over stored derived-field drift (#3937, below)
StageAnnotation, MembershipStageAnnotation, DomainReconciliation Annotation and reconciliation session save, completion and deletion; the screening save's annotation half (its own entry); reservation claims, releases, timeouts and expiry; the eligibility admission. Only while …Annotation and …MembershipAnnotation are both on AnnotationStage per changed stage, AnnotationMultiStage on an include/exclude crossing (family-wide intents above 8 KiB), AnnotationReservationClaim for a claim Point-maintained: stored + pending, exact. Never published over stored exclusion-flag drift (#3960, below)
ReviewerAnnotation The same annotation writers, the reservation claim included From fold protocol 6 (#4083): a ReviewerAnnotationPoint transition (the Study's exclusion and, per changed stage, its tally, reservations and each reviewer's own candidate-session flags) with the membership context, when the project has at most 100 members; the claim derives the moves from its copied pre-image. Above 100 members, or for an entry written for a stamp below 6, intents as before (the whole family when allocation or inclusion moved, otherwise the affected reviewers' scopes) Point-maintained from protocol 6: stored + pending, exact for every member, including members with no session (an allocation or inclusion crossing moves each of their rows). The fold applies it scope by scope, so a member's row that cannot take its move is staled alone. A member added or removed, or a session target changed, since the save is a context mismatch: the reader falls back and the fold stales the family. Never published over stored exclusion-flag drift (#3960)
QuestionAnswers Annotation session save, completion and deletion From protocol 5: an AnnotationQuestionAnswers transition with each changed question's answer count on that Study before and after, while it encodes in at most 8 KiB (129 questions); above that, or for unknown counts, one family-wide intent. At protocol 4: a scoped intent per changed question, or one family-wide intent above 16 (ProjectAnnotationFoldEntries.MaximumQuestionScopeIntents) Protocol 5: point-maintained, stored + pending, exact; the fold applies the family scope by scope, so a question whose row cannot take its move is staled alone (#4080). Protocol 4: the changed questions' rows are marked Stale by the fold and read live until a rebuild (#4056)
SearchPopulation none (no point writer) — Unchanged

A claim is still today's single atomic findAndModify, with the entry written by a prepended pipeline stage that cannot fail. A release that loses its version race only to a fold is retried free (at most twice), so a fold never fails a leave, disconnect, timeout or prior-study release.

Pending caps and overflow

A Study holds at most 32 entries and 48 KiB of pending set (StudyPendingStatisticsSet.MaximumEntries, MaximumEncodedBytes). A save that would exceed either cap still succeeds, but its change is recorded as an overflow drop instead of an entry: its operation id and digest are kept (up to 64 recorded drops), and beyond that only a count and a source-revision range. Counted by pending_entries.overflowed (overflow_recorded or overflow_unrecorded). While a marker exists the overlay falls back for the project; the next fold quarantines the drops and stales every fold family, which then need a backfill. An overflowed save has no receipt.

Quarantine

The fold never stops a project for a bad entry. An entry it cannot apply safely (an unknown protocol or kind, an unreadable entry, a receipt or delta conflict, a retired source revision, an overflow drop, or a Study that aborted the fold 20 times) is recorded in pmProjectStatisticsFoldQuarantine, removed from the Study, and every fold family is staled. Records keep for 30 days. At 90,000 records for a project the fold also disables fold mode with every fold family Stale; at 100,000 it inserts nothing more and records FoldQuarantineIncompleteSinceUtc on the control. Counted by fold.quarantined{reason}. A quarantined entry has no receipt; a retried save whose earlier attempt's result was unknown finds it there by its digest.

Receipts

The fold writes the source-operation receipt, one per consumed entry (applied or discarded, not quarantined), in its own transaction. ObservedAtUtc is the fold time, not the save time; CommittedSourceBeforeRevision and CommittedSourceAfterRevision are the entry's own revisions. The source_operation_receipts.committed counter is recorded by the worker after the fold commit is confirmed, once per consumed entry. Receipt maintenance never retires receipts of a Study that still has a pending set. Evidence timing and counting: runbook Step 8.

Fold mode, protocol and production

  • Enable (POST api/admin/project-statistics/{id}/fold/enable) needs the fold flag, the allowlist, a confirmed rollout, the pending index, a fleet that declares the protocol being stamped and a live fold-worker heartbeat. It stamps the protocol tripwire and marks every fold family Stale; backfills then republish them.
  • Production is refused in code. On the production database (syrftest, which an unknown database name also resolves to) enable and reset answer FoldCoverageIncomplete until gate (b) passes and the production rollout is separately approved (ProjectStatisticsFoldAdministration.IsCoverageSufficient). Elsewhere, complete coverage (slice 6) is enough; the staging-only AllowPartialCoverage setting is no longer needed.
  • Disable sets Disabling; saves take the quick-update path from then on, and the worker finalizes Disabled once the five-minute writer grace has passed and nothing is pending.
  • Protocol. Fold protocol 4 is the production baseline P0. From P0 every protocol bump is additive and every binary accepts entries at its own protocol and the one before (N-1), so an ordinary upgrade needs no reset or rebuild; the control's stamp moves from N-1 to N only through the stamp advance, after the rollout is recorded complete (ProjectStatistics:Fold:StampAdvanceAllowed). Each bump adds one kind and changes no earlier kind:
  • Protocol 5 (#4080) adds AnnotationQuestionAnswers. While a project is stamped 4, a protocol-5 binary writes protocol-4 entries (QuestionAnswers as scoped intents, exactly as before); after the stamp advance it writes protocol 5 and QuestionAnswers is point-maintained. Operator steps: runbook.
  • Protocol 6 (#4083) adds ReviewerAnnotationPoint. The binary is at protocol 6 and accepts 6 and 5. While a project is stamped 5 it writes protocol-5 entries (ReviewerAnnotation as intents, exactly as before); after the stamp advance to 6 ReviewerAnnotation is point-maintained. Operator steps: runbook.
  • A binary refuses a project stamped above its own protocol (the tripwire), and an entry above its protocol that reached it anyway is poison: its reader falls back and its fold quarantines the entry (UnknownProtocol) and stales every fold family.
  • A project stamped 4 is outside a protocol-6 binary's window. Deploying protocol 6 straight onto a project still stamped 4 (with no protocol-5 image run and no 4 to 5 stamp advance in between) makes the project's stamp unreadable to the new binary: the project leaves the fold path and needs a reset and backfills. Deploy protocol 5 first and advance the stamp, or plan the reset.
  • Reset re-stamps the tripwire and marks every materialized family Stale, SearchPopulation included. It is needed after a breaking protocol change and after a rollback window, followed by backfills.

Guards beside the fold

  • Stored derived screening fields (#3937, #3956, #3960). Seven families read a stored derived ScreeningInfo field that any whole-Study save re-persists recalculated, with no statistics move: ReviewerScreening the inclusion status for the project's threshold; MembershipScreening and ProjectScreening the agreement measure, inclusion fraction and included count; StageAnnotation, DomainReconciliation, MembershipStageAnnotation and ReviewerAnnotation the SufficientlyExcluded partition. Each family's rebuild and backfill refuse InclusionStatusDrift (status Blocked, scope Stale, reads fall back) while any Study would change the field that family reads, on the fold path and the transactional path alike; one drift scan serves every scope of a backfill run. QuestionAnswers and SearchPopulation read no such field. Read-only check: GET api/admin/project-statistics/{id}/reviewer-screening/inclusion-status-drift (administrator only; counts per kind). Remedy: POST api/admin/project-statistics/{id}/reviewer-screening/re-persist (administrator, or the operator operation re-persist-drift) saves each drifted Study through the ordinary version-guarded whole-Study replace under a staged-operation fence over every project-wide family (the set the inclusion recalculation fences), then releases them all Stale: backfill every one of them afterwards. Refused on the production database without the operator's explicit override. Details in ProjectScreening, MembershipScreening, ReviewerScreening and StageAnnotation.
  • QuestionAnswers question scopes (#3933, #3955, #4056, #4080). Since #4080 both paths move exactly the questions whose answer count changed (the transactional path in the save's transaction, the fold path from protocol 5), and stale a question only when its row cannot take the move. Before #4080 both paths staled those questions; until #4056 the fold path staled the whole family on every save, which was never served wrong (see the next item) but left every row's own state Fresh (QuestionAnswers).
  • A family-wide stale leaves rows' own state Fresh. A family intent (poison, overflow, an inclusion or allocation change for ReviewerAnnotation in a project above 100 members or on a stamp below 6, more changed questions than the entry can carry) marks the family's guard Stale and advances its write epoch; it does not rewrite the rows. The reader, the daily copier, the repair and the backfill's AlreadyCurrent check all evaluate the guard and the epoch, so none of them serves or keeps such a row, and the backfill republishes it (Rebuilt) even when its counts are unchanged. A check that reads only a row's State field in pmProjectStatisticsCurrent must also check the guard's Lifecycle and the row's AppliedWriteEpoch (how to tell); without them it reports a family-staled row as "Fresh and rebuilt" (soak runs 3 and 4, drill pmRestart).

Before a family is served from fold mode

Family On a pilot project Before production
ProjectScreening Pending index Ready; fold flag on both hosts; project allowlisted on both hosts; rollout confirmed; live heartbeat; the drift check reports driftedStudies: 0 (anything found cleared with the re-persist-drift operation, then every project-wide family backfilled); ProjectScreening backfill; parity audit through the overlay (#3902) Gate (b) passed on an idle host; the isolated soak (#3510, #3952); driftedStudies: 0 on every production project (#3960); the production rollout approved and the in-code refusal lifted as part of it
MembershipScreening, ReviewerScreening …MembershipScreening on (fleet-wide, so every allowlisted project); the drift check reports driftedStudies: 0 for every allowlisted project (clear status drift with the inclusion recalculation, then anything left with the re-persist-drift operation, then backfill every project-wide family); the parity audit of both families reports InParity As ProjectScreening; the same driftedStudies: 0 on every production project, reached with the re-persist (#3960), which refuses the production database unless the operator credential sends allowProduction: true after Chris's approval
StageAnnotation, MembershipStageAnnotation, DomainReconciliation …Annotation and …MembershipAnnotation on both hosts (the fold writers append nothing otherwise: with only one of them on, saves take the transactional path, which moves the served families and stales the others, #3840); the drift check reports driftedStudies: 0 (exclusionDriftedStudies is the count these families refuse on; clear it with the re-persist-drift operation, then backfill every project-wide family, #3960); backfills; the parity audit of each family reports InParity (and the backfill check: backfills answer AlreadyCurrent) As ProjectScreening
ReviewerAnnotation Turned on by …MembershipAnnotation; point-maintained on the fold path only once the project's fold stamp is at protocol 6 (both images at protocol 6, then the stamp advance from 5; runbook), and only while all three annotation move families' flags are on (the fold writers need them; otherwise saves take the transactional path, which moves it as an independently admitted family); the same clean drift check as StageAnnotation; a reviewer-annotation backfill after the stamp advance; the parity audit reports InParity As ProjectScreening
QuestionAnswers #3933 (done). No annotation flag is needed: since #3840 the annotation writer runs whenever …QuestionAnswers is on. Point maintenance on the fold path needs the project stamped at protocol 5 (stamp advance); until then saves stale the changed questions as before. A backfill of the family, then the parity audit reports InParity As ProjectScreening

The soak records each read's provenance through the read-only GET api/admin/project-statistics/{id}/projection-read?family=<family> (administrator or the statistics operator credential, the parity read's policy), for every family. Each family runs its own consumer's read with that consumer's gates in their order: screening-stats (ProjectScreening), screening-overview-statistics (MembershipScreening), the project and stage reviewer-stats as the reviewer (ReviewerScreening, ReviewerAnnotation), annotation-stats (StageAnnotation), the stage overview-statistics (MembershipStageAnnotation), question-answer-counts (QuestionAnswers) and searches (SearchPopulation). DomainReconciliation has no consumer read, so its bundle is read directly through the administrative reader. A dark deployment, a closed consumer switch or an unlisted project answers as its consumer does (AuthoritativeFallback, ConsumerDisabled for the consumer's own switch), and every response carries readSource, fallbackReason, readerFallbackReason, checkpoint, scopeStates and one reads[] entry per consumer read (per stage or reviewer where the consumer is, at most 256). Only the caller's project-permission check is replaced by the route's policy; reviewers appear only as per-project pseudonyms. Only the ProjectScreening read records read instruments, tagged surface=admin_projection_read; the other families' consumers record none. Contract in the runbook.

At a glance, as of 2026-09-30 (cluster-gitops 9f37cb7b)

Superseded on staging. Since cluster-gitops#1566 (merged 2026-10-04) both staging hosts set every family flag and the fold flag to true for the same single pilot project, so every family is on in staging. Production is unchanged (no family on). The rest of this section is the 30 September record.

"Switched on" comes from cluster-gitops main at 9f37cb7b (merge of cluster-gitops#1391): syrf/environments/staging/api/values.yaml and syrf/environments/staging/project-management/values.yaml.

  • Families: staging sets Writes, Serving and materializedProjectStatisticsScreening on and every other family flag off, so ProjectScreening is the only family on in staging. Staging allowlists one pilot project (00000000-0000-0000-0000-000000000102). Production sets none of these keys, so the chart defaults (all false) apply and no family is on in production.
  • Page consumers in staging: materializedProjectStatisticsProjectOverview, …Pages, …SignalR, …Exports and …History are true in both staging values files; …StageOverview is false, and the other consumer flags are unset (default false). These five previously ran on staging only as runtime overrides (runtime feature flags revision 72). #1391 records them in GitOps; an administrator is to clear the runtime overrides, and this page does not claim that has happened.
Family What it means Scope key Page consumers (consumer flag) Family flag On in staging? On in production?
ProjectScreening Screening progress for the whole project project Project Overview screening totals (…ProjectOverview); Screening Info and Stage Overview charts (…ScreeningInfo, …StageOverviewBundle); screening history (…History) …Screening Yes (pilot project) No
MembershipScreening Each member's screening decisions and agreement membership (reviewer) Screening Info and Stage Overview leaderboards; reviewer screening history (…ReviewerHistory) …MembershipScreening No No
ReviewerScreening What one reviewer has screened and can still screen membership (reviewer) Stage Review progress (…ReviewerProgress); Project Overview own progress (…ProjectReviewerProgress) …MembershipScreening No No
StageAnnotation Annotation session progress per stage stage Stage Overview annotation pie (…StageOverview) and charts (…StageOverviewBundle); stage history (…StageHistory) …Annotation No No
MembershipStageAnnotation Each member's annotation sessions per stage membership-stage Stage Overview member tables (…StageOverviewBundle); reviewer annotation history (…ReviewerAnnotationHistory) …MembershipAnnotation No No
ReviewerAnnotation What one reviewer has annotated and can still annotate on a stage membership-stage Stage Review progress; Project Overview own progress …MembershipAnnotation No No
QuestionAnswers How many Studies and answers each annotation question has question Question designer counts and assignment locks (…QuestionCounts) …QuestionAnswers No No
DomainReconciliation Reconciliation progress per stage stage none directly (the same numbers reach pages through StageAnnotation) …Annotation No No
SearchPopulation References imported by each search search Project details and search list counts (…SearchCounts) …SearchPopulation No No
DerivedSummary Percentages and chart segments computed from the families above — (virtual) computed inside the consumers above …DerivedSummaries (read by no current query) — —

… stands for materializedProjectStatistics. The current-statistics page consumers also need Pages, Writes, Serving, their families' flags and the allowlist; the history consumers have their own flag pairs.

The families

Each section covers: what it counts; how it is calculated from scratch and which live calculation it must match; what keeps it current; what marks it Stale or fences it; and known gaps. All examples are invented.

ProjectScreening

What it counts. For the whole project: how many Studies exist, how many have had enough screening decisions, how many of those are included or excluded, how many are over-screened, plus a grid of Studies by (number of decisions, agreement). Example: 40 Studies; 30 have two agreeing decisions; 25 of those are included and 5 excluded.

Calculated from scratch. POST api/admin/project-statistics/{id}/backfill or /rebuild → ProjectScreeningBackfillService → ProjectScreeningScopeCalculator. It reads the Project and runs StudyStatsQuery.GetFullProjectStatsAsync over the project's Studies (through ProjectScreeningSourceReader), then keeps the ProjectScreening section. Parity reference: the same section of the legacy full-statistics calculation (StudyRepository.GetFullProjectStatsAsync, used by ProjectController.GetFullStats). A project with no agreement threshold has no row.

Kept current by quick updates. Every screening decision, correction and reconciliation decision (ReviewController /review, /screening, /reconcile) moves the counters in the Study's save transaction (ProjectScreeningStatisticsWriter, ProjectScreeningClassifier).

Marked Stale or fenced by:

  • Agreement-threshold change: fenced (typed 503) until the recalculation finishes, then Stale. Example: the manager changes "2 reviewers must agree" to "3"; the Overview answers "recalculating", then shows live numbers until a backfill.
  • Search import: fenced from before the first Study is saved until the import completes or fails, then Stale. Example: a 500-reference import is parsed and finishes; screening totals are live throughout and until a backfill.
  • Bulk Study update file: fenced for the whole job, then Stale.
  • Any quick update refused inside the transaction (write epoch changed, project not enabled, capacity): Stale.
  • Drift check (default off) finding a mismatch: row Stale.
  • With the MembershipScreening flag on, a decision that changes the counts of a member who has no membership or reviewer row yet (for example a new member before a backfill): Stale, because the whole commit falls back (see MembershipScreening).

Never published over stored agreement-measure drift (#3960). The legacy aggregation reads each Study's stored AgreementMeasure.NumberScreened, AgreementMeasure.AbsoluteAgreementRatio and Inclusion (the All facet) and stored NumberScreened and IncludedCount (the grid), never its stored inclusion status, while the quick update classifies the recalculated values. Every whole-Study save re-persists them recalculated, with no move unless it is a screening save. So a rebuild or backfill of this family first compares every Study with what a save would write, and while any Study's agreement fields differ it publishes nothing (InclusionStatusDrift, scope Stale, reads fall back). Example: threshold two; an older Study stores NumberScreened 1 and IncludedCount 1 but has two include decisions. A backfill would count it insufficiently screened in cell 1:1, and the next presence or annotation save would rewrite it as sufficiently included in cell 2:2 without moving the row. Read-only check: GET …/reviewer-screening/inclusion-status-drift (agreementDriftedStudies, the count MembershipScreening also refuses on). Prerequisite: run the re-persist (POST …/reviewer-screening/re-persist, operator operation re-persist-drift), which saves every drifted Study and leaves every project-wide family Stale, check again, then backfill every project-wide family.

Known gaps.

  • Any screening save during a backfill leaves the family Stale until a backfill completes with no save during it (saves during a backfill); fixed by re-running Build statistics at a quiet time. A row the drift check marks Stale also stays Stale until a rebuild (#3831 closed the re-stamp).
  • #3644: screening history for zero-Study projects.

MembershipScreening

What it counts. For each project member: how many Studies they screened, included and excluded, and how many of their decisions agree or disagree with the final outcome. Example: Reviewer A screened 20, included 12, and agreed with the final decision on 18.

Calculated from scratch. POST …/membership-screening/backfill or /rebuild → MembershipScreeningBackfillService → MembershipScreeningScopeCalculator, one scope per membership (disabled members included). Parity reference: the member's row in the MembershipScreening section of StudyStatsQuery.GetFullProjectStatsAsync.

Kept current by quick updates. A screening decision moves every member's row in the same transaction when the project has at most 100 members and the before-state was captured (ReviewerScreeningPointClassifier via ProjectScreeningStatisticsWriter.Prepare). These moves require existing Fresh rows, so they cannot build a row from nothing.

Marked Stale or fenced by:

  • A screening decision in a project with more than 100 members, or without a before-snapshot: whole family Stale (one write-epoch advance). Example: Reviewer B's decision in a 150-member project marks every member's row Stale.
  • Threshold change, import completion, bulk update: fenced, then Stale (as ProjectScreening).
  • A new member: they have no row until a backfill, so reads that include them fall back to live. The quick update also depends on it. ReviewerScreeningPointClassifier.Classify emits moves for every member, including the new one, whenever a decision changes that member's contribution (for example the Study becomes sufficiently screened, which moves their Available/Unavailable counts). These families are in RequireExistingFreshFamilies, so ProjectStatisticsTransactionCoordinator.CheckRowAdmissionAsync finds no row for the new member and routes the whole commit to the source-only fallback. That marks the touched scopes Stale for ProjectScreening as well as MembershipScreening and ReviewerScreening, and records each family as Stale, so later quick updates are refused until a backfill. Example: Reviewer D joins; the next decision that makes a Study sufficiently screened leaves project, membership and reviewer screening Stale (served live) until the backfills run. This only applies when the MembershipScreening flag is on. It fails safe (Stale, never wrong), but it takes project screening off the fast path.

Never published over stored agreement-measure drift (#3937). The legacy membership aggregation reads each Study's stored AgreementMeasure.NumberScreened, AgreementMeasure.AbsoluteAgreementRatio and Inclusion (not its stored inclusion status), and every whole-Study save re-persists them recalculated from the screenings, with no reviewer move unless it is a screening save. So a rebuild or backfill of this family first compares every Study of the project with what a save would write, and while any differs it publishes nothing (InclusionStatusDrift, scope Stale, reads fall back). Example: an older Study stores NumberScreened 1 but has two screenings. A backfill would count it insufficiently screened, and the next presence or annotation save would rewrite it as sufficiently included without moving any member's row. The inclusion recalculation does not rewrite these fields; only a save of each listed Study does. Read-only check: GET …/reviewer-screening/inclusion-status-drift (agreementDriftedStudies). Prerequisite before this family can be materialised on a project with agreement drift: run the re-persist (POST …/reviewer-screening/re-persist, operator operation re-persist-drift, #3960), which performs exactly that save for every drifted Study (version-guarded, retried on a concurrent save, idempotent, fenced over every project-wide family and leaving them all Stale), check again, then backfill every project-wide family (runbook).

Known gaps. No membership or permission change pushes an invalidation to open pages (reads are always re-authorized, so nothing leaks; clients refresh on their next poll).

ReviewerScreening

What it counts. For one reviewer: Studies they screened, Studies still available to them, Studies no longer available (already sufficiently screened by others), and the project total. Example: Reviewer A has screened 12, 20 are available, 8 are unavailable, 40 in total.

Calculated from scratch. POST …/reviewer-screening/backfill or /rebuild → ReviewerScreeningBackfillService → ReviewerScreeningScopeCalculator → ReviewerScreeningSourceReader. Parity reference: the four counts of StudyRepository.GetInvestigatorScreeningStats (used by GetReviewerStatsForStageAsync and GetReviewerStatsForProjectAsync).

Kept current by quick updates. As MembershipScreening (same writer and conditions). Rebuild contract: reviewer-screening-rebuild.md.

Marked Stale or fenced by. As MembershipScreening. Example: when a Study becomes sufficiently screened, every other reviewer's "available" count drops, so without per-member moves the whole family goes Stale.

Never published over stored-status drift (#3937). The legacy query counts "available" and "unavailable" from each Study's stored inclusion status, and every whole-Study save re-persists the recalculated one. Only screening saves carry a reviewer move; annotation saves (transactional and fold path), reservation, presence, idle-session and risk-of-bias saves do not. So a rebuild or backfill of this family first compares every Study of the project with what a save would persist, and while any differs it publishes nothing: the scope is left Stale with InclusionStatusDrift (status Blocked) and reads fall back live. With no such Study, those saves re-persist exactly the status they read and cannot move the family. Example: an older Study stores Included although its single inclusion is below a threshold of two. Before #3937 a backfill published Reviewer B's "unavailable" count including it, and the next annotation save of that Study made it available to B without moving B's row. Now the backfill refuses until the project's inclusion recalculation has rewritten the status. Read-only check: GET …/reviewer-screening/inclusion-status-drift (runbook). Prerequisite: status drift the recalculation leaves behind (a Study whose stored agreement fields are themselves stale) is cleared by the re-persist (POST …/reviewer-screening/re-persist, operator operation re-persist-drift, #3960) before the backfills of every project-wide family it leaves Stale. ReviewerScreening refuses only on its sufficiency answer (statusDriftedStudies): an Included/Excluded mismatch leaves both answers sufficient.

Known gaps. As MembershipScreening.

StageAnnotation

What it counts. Per stage, separately for included and excluded Studies: how many have annotation sessions, how many still need sessions, how many have enough completed sessions, how many of those have started or finished reconciliation, plus a grid by (sessions started, sessions completed). "Enough" is two sessions, still hard-coded (StudyStats.cs, var minNumberSessions = 2), not the stage's session target. Example: stage "Extraction", 30 included Studies, 18 with two completed sessions, 5 of those reconciled.

Calculated from scratch. POST …/stage-annotation/backfill or /rebuild → StageAnnotationBackfillService → StageAnnotationScopeCalculator, one scope for every stage. It reads the StageAnnotation section of StudyStatsQuery.GetFullProjectStatsAsync plus ReadZeroCandidateStageTalliesAsync. Parity reference: the stage section of the legacy full-statistics calculation. Contract: stage-overview-cutover.md.

Kept current by quick updates. Annotation session save and delete (SubmitAnnotationSessionService, ReviewController.RemoveSession), and reservation claims or releases that create or remove a stage's "reserved but no session yet" tally row (TryAtomicAssignStudyCoreAsync, TryAdmitActivityReviewAsync, TrySaveReservationChangeAsync). All go through ProjectAnnotationStatisticsWriter and AnnotationStatisticsClassifier.

Marked Stale or fenced by:

  • A screening decision that moves a Study between included and excluded: every stage scope Stale. Example: a third reviewer's exclude flips a Study to excluded; all stage rows go Stale.
  • A screening decision that also releases a legacy reservation while screening statistics are on: family Stale.
  • Threshold change, import completion, bulk update: fenced, then Stale.

Never published over stored exclusion drift (#3960). Every stage facet is partitioned by each Study's stored ScreeningInfo.InclusionInfo[] entry for the project's threshold (SufficientlyExcluded, NewStudyFilters.ExcludedStudies), while the quick updates classify the recalculated Study (AnnotationThresholds.ClassifyInclusion). Every whole-Study save re-persists the recalculated flag with no move. So a rebuild or backfill of this family (and of DomainReconciliation, MembershipStageAnnotation and ReviewerAnnotation, which read the same partition) first compares every Study with what a save would write, and while any Study's stored exclusion answer differs it publishes nothing (InclusionStatusDrift, scope Stale, reads fall back). Agreement drift that leaves the stored flag consistent does not refuse these families. Example: an older Study with two include decisions stores Excluded with SufficientlyExcluded true for the threshold. A backfill would count it in every stage's excluded class, and the next presence or risk-of-bias save would move it to the included class without moving any row. Read-only check: GET …/reviewer-screening/inclusion-status-drift (exclusionDriftedStudies). Prerequisite: the re-persist (POST …/reviewer-screening/re-persist), check again, then backfill every project-wide family. The inclusion recalculation rewrites the flag from the stored agreement fields, so it does not clear exclusion drift that comes from stale agreement fields.

Known gaps.

  • Fixed by #3831: a quick update on a sibling stage whose row is still Stale (after a rebuild published another stage and recorded the family Fresh) no longer re-stamps it; the save goes source-only and the row stays Stale until rebuilt (worked example 6).
  • Fixed by #3838 (#3740 items 3–4): the screened-reservation release uses the shared reservation save with a per-attempt operation id, and transient statistics rejections on claims, admissions and review-settings saves are retried a bounded number of times.
  • Older baselines must be rebuilt after #3763 before serving.

MembershipStageAnnotation

What it counts. For each member on each stage: their sessions in progress and completed, how many Studies are still open or full for sessions, and the same for reconciliation sessions. Example: Reviewer A on "Extraction" has 3 sessions in progress and 9 completed.

Calculated from scratch. POST …/membership-stage-annotation/backfill or /rebuild → MembershipStageAnnotationBackfillService → MembershipStageAnnotationScopeCalculator, one scope per member × stage. Parity reference: the member's StageAnnotationStatsMap entry in the MembershipAnnotation section of StudyStatsQuery.GetFullProjectStatsAsync.

What is stored is the reviewer's own count, before the stage's settings are applied. Each row holds seven per-reviewer counts per inclusion class; the composed numbers a screen shows are worked out from them and the stage row when the row is read. One of the seven, reconcileSessionAvailable, counts this reviewer's session rows on Studies with two completed sessions and no reconciliation started. When the stage allows self-reconciliation (Stage.AllowSelfReconciliation; there is no product route to set it, but a stored stage can hold it) the legacy calculation drops that count from everything it returns, so the rebuild reads it directly from the same aggregation, in the same snapshot (ReadReconcileSessionAvailableFacetAsync, #4077). Before

4077 the rebuild and the parity audit got 0 there while quick updates stored the true count: the isolated soak

reported this as membershipStageAnnotation.unexcluded.reconcileSessionAvailable Mismatch (actual 1–5, expected 0) on its two self-reconciliation stages. Screens were not affected, because the stage setting zeroes that count again when the numbers are composed. A row rebuilt on such a stage before #4077 can hold a count that is too low (and can go negative after a later reconciliation start).

After #4077 deploys (one-off). Find the projects with a stored self-reconciliation stage (read-only: db.pmProject.find({ "Stages.AllowSelfReconciliation": true }, { _id: 1 })). For each one with this family materialized, run POST api/admin/project-statistics/{projectId}/membership-stage-annotation/backfill. It republishes only the scopes whose row differs from the corrected recount (the rest answer AlreadyCurrent). Then check GET …/parity?family=membership-stage-annotation reports InParity. Projects with no such stage need nothing.

Kept current by quick updates. Annotation session save and delete move the rows of every member who holds a session on that Study and stage. Reservation changes do not move this family.

Marked Stale or fenced by. Include/exclude flips (every member × stage scope Stale); threshold change, import completion and bulk update (fenced, then Stale). A stage's session target does not affect it while the two-session minimum is hard-coded.

Never published over stored exclusion drift. Every member's per-class counts are partitioned by the stored SufficientlyExcluded flag, so it refuses InclusionStatusDrift exactly as StageAnnotation does (#3960).

Flag combinations. With …MembershipAnnotation on and …Annotation off, transactional saves still move this family; the StageAnnotation and DomainReconciliation scopes the save touched are marked Stale instead of moved (#3840, fixed). Those families are then recorded Stale, so rebuild (backfill) a family after re-enabling its flag: until then its saves fall back to the source-only route and it is read live. Rebuild before re-enabling in every case, because with every annotation-related flag off nothing at all is staled.

ReviewerAnnotation

What it counts. For one reviewer on one stage: included Studies they have in progress, completed, or could still start; Studies unavailable because others have already taken all the slots; excluded Studies in progress or completed; and the total. "Taken all the slots" uses the stage's session target and, when active-reviewer tracking is on, counts reservations as well as sessions. Example: target 2; a Study with two other reviewers' sessions is unavailable to Reviewer C.

Calculated from scratch. POST …/reviewer-annotation/backfill or /rebuild → ReviewerAnnotationBackfillService → ReviewerAnnotationScopeCalculator → ReviewerAnnotationSourceReader, using the durable tracking mode stored on the global control row. A rebuild of a project with fold history publishes the authoritative value minus what is still pending, as the other fold families do. Parity reference: the counts of StudyRepository.GetInvestigatorAnnotationStats.

Kept current by quick updates (#4083). Each Study contributes to a reviewer's seven counters on a stage through four inputs: whether it is excluded (the stored SufficientlyExcluded flag), whether the reviewer holds a candidate session there, whether that session is in progress or completed (reconciliation sessions do not count), and whether the stage is sufficiently allocated in the durable tracking mode (candidate sessions alone untracked, plus reservations tracked, against the stage's effective session target). A save moves the difference between the Study's contribution after and before, for every member on every stage (ReviewerAnnotationPointClassifier). Every member with no session on the stage gets the same move, which is non-zero only when the Study crosses the exclusion or allocation boundary. Example: target 2; Reviewer A's session is the second on a Study, so A's row moves from available to in progress and every member without a session moves the Study from available to unavailable. A real-Mongo test proves the per-Study contribution equals the live reader for every reviewer in both modes (ReviewerAnnotationContributionParityTests).

  • Fold path: a ReviewerAnnotationPoint transition (fold protocol 6) on the annotation writers' entries, and the moves of a reservation claim derived from its copied pre-image, when the project has at most 100 members. The fold applies the family scope by scope: a member's row that cannot take its move (no row yet, or a row that is not Fresh) is staled alone and never started from zero, and the other members' rows still move. See Async point fold.
  • Transactional path: the annotation writer adds the family's moves as an independently admitted family (the seam QuestionAnswers uses) while its guard and lifecycle are Fresh and the project has at most 100 members. The coordinator admits them against the family's own epoch, existing Fresh rows (a member with no row is staled alone; the others move) and, last, the Project version the members and targets were read from (ProjectStatisticsTransitionRequest.FamilyProjectVersions). A refusal leaves ReviewerAnnotation out alone and applies its declared stales (below); StageAnnotation, MembershipStageAnnotation and DomainReconciliation never fall back because of it, and a not-servable annotation family never stops the reviewer rows moving. A transition that moves no reviewer counter proves the family unchanged on the same Project version. Moves over the point path's caps (an inclusion crossing moves members × stages rows) stale the family as below.

Marked Stale or fenced by:

  • A project with more than 100 members, a fold entry written for a stamp below protocol 6, a transactional save the coordinator cannot admit the family's moves for, or a crossing over the point path's caps: a session save/delete or reservation change that changes whether a Study has all its slots taken (in the durable mode, for a Study unexcluded on some side), or that flips include/exclude, stales the whole family (MarkFamilyStale); otherwise only the acting reviewer's rows. Example: in a 150-member project Reviewer A claims the last slot on a Study; Reviewers B and C lose it from "available", so the family goes Stale.
  • A member added or removed, or a session target changed, between a fold-path save and its fold (the entry's membership or annotation context no longer matches): the fold stales the whole family.
  • A change to the stage's effective session target (StageReviewSettingsController.Put or the legacy ProjectController.UpdateStage): whole family Stale in the Project save's transaction (ProjectStageConfigurationChange, #3728). Example: target 2 → 3 makes some "unavailable" Studies available again, to every member, including one who has never reviewed.
  • Threshold change, import completion, bulk update: fenced, then Stale.

Never published over stored exclusion drift. The reviewer query splits Available from Unavailable by the stored SufficientlyExcluded flag, and presence, idle-session and risk-of-bias saves stale nothing, so it refuses InclusionStatusDrift exactly as StageAnnotation does (#3960).

Known gaps.

  • Changing the active-reviewer tracking setting has no owner that re-marks rows or records the new mode (Phase 0 matrix M15). Review saves are never refused for it (#4286): they follow the server's flag. The statistics fail closed instead (reads answer DurableModeDisagreement, writes stale the families they miss) until the durable setting and the servers agree.
  • #3729: items 1–2 are fixed in code; the issue stays open for its remaining items and parity proof.
  • Stored-tally drift (accepted, follow-up). The reader takes allocation from each Study's stored SessionTallies, while a whole-Study save classifies the before-state from the tallies the loaded Study recalculates from its sessions and reservations. They agree for every Study written through the aggregate since TotalAllocatedSessionCount was added (each save stores the recalculated tallies). They do not agree for a legacy document whose stored tally disagrees with its sessions (for example sessions with no stored tally at all), and in the tracked mode for a legacy tally stored without TotalAllocatedSessionCount: the reader reads the missing field as not allocated, the classifier and the claim (which copies the stored tally's counts) compute candidates plus reservations. The first save of such a Study after a rebuild then moves the rows against the wrong before-state, leaving each member with no session on that stage off by one until a rebuild (ReviewerAnnotationContributionParityTests.ALegacyTally… pins it; one whole-Study save stores the field). Precondition before serving ReviewerAnnotation on a project: its count of Studies with a tally lacking the field is zero (runbook); the production-copy rehearsal measures it for production. StageAnnotation, MembershipStageAnnotation and DomainReconciliation share the stored-tally assumption; unlike stored-exclusion drift (#3960) no scan guards it yet.
  • A member who joined after the last backfill has no row: the first fold move to their scope inserts a Stale placeholder and the next one stales the family until the repair or a backfill republishes it; on the transactional path the family's moves are dropped for that save and it stales as above.
  • Where saves still stale this family (a fold project stamped below protocol 6, or a project with more than 100 members), the scheduled repair leaves it on the live fallback (skipped/unsettled) while reviewers keep annotating and republishes it about an hour after annotation stops (when the repair republishes). Elsewhere saves move it and never advance its marker, so it is repaired under load like the other point-maintained families.

QuestionAnswers

What it counts. For each annotation question: how many Studies have at least one answer, and how many answers there are. Questions with no answers have no row. There is no stage or question-version dimension. Example: Question 1 answered on 14 Studies, 16 answers in total.

Calculated from scratch. POST …/question-answers/backfill or /rebuild → QuestionAnswersBackfillService → QuestionAnswersScopeCalculator → QuestionAnswerTallyQuery. Parity reference: the same pipeline as the legacy manual refresh, StudyRepository.GetAnnotationQuestionAnswerTally. Contract: question-answer-backfill.md. On a project with fold history the rebuild publishes the authoritative tally minus the pending moves, like every other fold family, and the family's bootstrap holds the project fold lease from its scope capture to its checkpoint, so a first publication succeeds while fold-path saves continue (#4080; before it, every fold batch inside the bootstrap moved its boundary and the run was refused PublicationRaceLost).

Kept current by quick updates (#4080). A Study's answer count for a question is its whole contribution to that question's row, so an annotation session save, completion or deletion that changes the count from before to after moves the row by exactly annotations += after − before and studies += [after > 0] − [before > 0] (QuestionAnswerPointMoves), whatever the answer type, the session (candidate or reconciliation) or the sub-question. Example: Reviewer A adds a second answer to Question 1 on a Study that already had one: Question 1 moves by one answer and no Study.

  • Transactional path: the annotation writer adds the moves to the save's own request while …QuestionAnswers is on, the counts are known and at most 100 questions changed. The coordinator admits the family on its own and scope by scope (ProjectStatisticsTransitionRequest.IndependentlyAdmittedFamilies): a question whose row cannot take its move (no row, a non-Fresh row, a family that is fenced, Stale or at another write epoch) is staled alone and never sends the annotation families to the source-only fallback, and a non-servable annotation family never stops the question rows moving. The delta record holds only what was published.
  • Fold path: from fold protocol 5 the entry carries one AnnotationQuestionAnswers transition; the reader overlays its moves, the fold applies them, and the rebuild subtracts them. The rebuild derives them without the Project context (PendingEntryMoves.DeriveQuestionAnswers), so the family still reads no agreement input and an agreement change keeps re-stamping its rows (#4082). The fold applies this family scope by scope (ProjectStatisticsFoldFamilies.ScopeIndependent): a question whose row cannot take its move, or that a protocol-4 scoped intent in the same batch stales, is staled alone. At protocol 4 the entry carries today's scoped intents.
  • A row never starts from zero. A question with no row may already have answers that a scoped stale could not mark (a stale of a missing row is a no-op on both paths, and an older writer or a failed rebuild leaves the same), so its first move is refused: the transactional path stales nothing and leaves it rowless, the fold inserts a published Stale placeholder, and the next backfill (or the scheduled repair) publishes it. On an active project the question set is stable, so this costs one backfill per newly answered question.
  • A count that drops to zero. The row stays, Fresh, with zero counters, and reads exactly as an absent question: the question-designer read covers it and omits its count, as the live tally omits an unanswered question; the parity audit judges it InParity against the absent authoritative answer.

Marked Stale or fenced by:

  • Question deletion: source-visibility fence (typed 503 for the project) over the question and its sub-questions, released Stale when the Project saves (ProjectManagementService.DeleteQuestionAsync, #3178). Example: deleting Question 2 and its sub-question removes their answers from every Study; counts answer "recalculating" until the save completes, then live until a backfill. No question row moves again until that backfill. The release also marks each deleted question's own row Stale in the same transaction (#4125): the backfill rebuilds only the questions the project still has, and its first publication makes the family servable again, so a deleted question's row left Fresh would be served again with its old counts (2026-10-07 soak). No later run republishes it: the question is gone from the declared set and a rebuild of it answers ScopeAbsent.
  • A deleted question's row that nothing staled (a deletion made with the statistics flags off, or one made before #4125): the question-answers backfill retires it. Every run first rebuilds each scope that still has a served Fresh row but whose question the project no longer defines; the rebuild answers ScopeAbsent and returns the row to Stale, and the backfill reports the scope Absent. A question the project still defines keeps its row, including the Fresh all-zero row of one whose answers were all removed, and sub-questions are defined questions like any other. The retirement runs before the version refusals and the bootstrap point (it only stales). A retirement whose rebuild staled the row but ended for another reason (a race, a fence) is reported Stale and the run finishes as a partial failure; one that never started (Contended) stops the run with a 409. The scan pages the family's Fresh rows until an empty page. A run whose retirement is contended stops there and answers 409, so nothing is reported complete while the row is still served.
  • Legacy manual tally refresh (PUT api/projects/{id}/update-annotation-answer-tally): every question fenced, then Stale; no question row moves until a backfill.
  • Not by an agreement-threshold change. The counts read no screening or agreement input, so the recalculation's completion re-stamps the question rows with the new settings and they are served again unchanged (#4082, threshold change). A question row already Stale stays Stale.
  • More than 100 changed questions in one transactional save, or a fold entry whose question transition would encode in more than 8 KiB (more than 129 changed questions; AnnotationQuestionAnswersTransition.MaximumEncodedBytes): the whole family (one family-wide stale, as before). Profiles whose counts were not captured from a Study: the whole family. A protocol-5 reader accepts up to 256 changed questions in one transition (MaximumReadQuestions), so a later binary can raise the writer's budget up to that without a protocol bump.
  • With …QuestionAnswers off when a transactional save begins, or while a fold project is still stamped at protocol 4: the changed questions' rows are staled exactly as before #4080 (scoped stales, family-wide above the caps). Changing an answer's value without changing the number of answers changes no row and stales nothing, whatever the answer type (a reworded or emptied text answer, a flipped checkbox).

Flag combinations. The annotation writer runs whenever Writes, the allowlist and any family it moves or invalidates are on, so …QuestionAnswers alone is enough for saves and deletes to move the changed question rows (#3840, fixed). No new flag: point maintenance rides the family's existing flag, and on the fold path the protocol stamp.

Known gaps. Point-maintained saves name no QuestionAnswers intent, so with point maintenance on the scheduled repair is never held back by answer changes. Saves that still stale the family (the flag off when a transactional save begins, a fold project still stamped at protocol 4, or a save over the caps above) do hold it back: the repair leaves those questions on the live fallback (skipped/unsettled) and republishes them about an hour after the changes stop (when the repair republishes). Question deletion is still two separate writes (#3088). Search removal does not fence this family (#3849): with the rows now point-maintained and Fresh, that is a prerequisite for serving QuestionAnswers in production. A question that has never been answered has no row, so the question-designer read, which requests every defined question, falls back while one exists, and a question first answered after its family was published gets a row only from the next backfill (its first move is refused rather than started from zero). The weekly drift check applies the parity audit's zero-row rule, so a row moved to zero stays Fresh and in parity.

DomainReconciliation

What it counts. Per stage, for included and excluded Studies: how many have enough completed sessions and have not started, have started, or have completed reconciliation. Example: 18 Studies ready, 5 reconciled.

Calculated from scratch. POST …/domain-reconciliation/backfill or /rebuild → DomainReconciliationBackfillService → DomainReconciliationScopeCalculator, which reuses the StageAnnotation calculation in the same snapshot. Parity reference: the reconciliation counters of the legacy stage section. Membership-level reconciliation lives in MembershipStageAnnotation.

Kept current by quick updates. The same annotation session saves and deletes as StageAnnotation (reconciliation sessions use the same route with a reconciliation flag).

Marked Stale or fenced by. As StageAnnotation.

Never published over stored exclusion drift. As StageAnnotation (the same stage calculation and partition; #3960). It is exposed only through Studies with at least two completed sessions at the stage.

Known gaps. As StageAnnotation. No page reads this family directly today.

SearchPopulation

What it counts. For each search linked to the project: how many references its imported file contained (SystematicSearch.NumberOfStudies), not how many Studies survive today. A search shared by two projects counts in both. Example: "Search 1" imported 1,200 references.

Calculated from scratch. POST …/search-population/backfill or /rebuild → SearchPopulationBackfillService → SearchPopulationScopeCalculator, one scope per linked search. Parity reference: the imported count on each search, as the search list showed it before.

Kept current by quick updates. None.

Marked Stale or fenced by. Search import completion or failure: that search's scope fenced, then Stale. While fenced, the search list returns a typed "unavailable" rather than an old number. Example: while "Search 2" finishes importing, its count is withheld. Public search and project deletion answer 503 where deletionLifecycle is on (staging). Where it is off (production's value) they are the legacy immediate deletion, which fences the deleted searches' scopes before the change and leaves them Stale after it. An agreement-threshold change does not stale it: the completion re-stamps its rows with the new settings (#4082).

Known gaps. #3474 (join-predicate test and counter naming, before serving); a forced rebuild of one scope used to strand siblings (fixed for the rebuilt family by #3700).

DerivedSummary

Not stored. Percentages and chart segments are computed at read time from one coherent set of the families above (derived-summaries.md). Its flag, materializedProjectStatisticsDerivedSummaries, is read by no current query, so turning it on changes nothing. The enum value also carries a synthetic test-only family (ProjectStatisticsSyntheticFamily) that no product code uses.

Activity → affected statistics

"Quick" = counters moved in the same transaction. "Stale" = marked Stale in the same transaction. "Fence" = an operation fence, released Stale. "503" = source-visibility fence (typed 503 while running). "—" = no effect on stored counts. Owners and transaction shapes: mutation matrix. For a project in fold mode (only the staging pilot today), "Quick" becomes a pending entry folded shortly after the save and "Stale" becomes an invalidation intent applied by the fold (async point fold).

Activity PS MS / RS SA / DR MSA RA QA SP
Reviewer screens, corrects or reconciles a Study Quick Quick (≤100 members) or Stale Stale if include/exclude flips Stale if flip Quick (≤100 members; transactionally also ≤100 member-stage rows) or Stale if flip or reservation effect — —
Reviewer saves or deletes an annotation session — — Quick Quick Quick (≤100 members; fold protocol 6) or Stale (family if slots change) Quick (changed questions; a question with no or a non-Fresh row is staled alone; the family above 100 of them, or above 16 on the fold path; Stale per question at fold protocol 4) —
Reviewer claims or releases a Study (reservation) — — Quick if a reserved-only row appears/disappears — Quick (≤100 members; fold protocol 6) or Stale (family if slots change) — —
Any whole-Study save that is not a screening save (annotation, reservation, presence, idle session, risk of bias, …) of a Study whose stored inclusion status, exclusion flag or agreement measure differs from the recalculated one — : it re-persists the recalculated agreement fields with no move, so PS is never published while a Study's stored agreement fields would change (#3960) — : it re-persists the recalculated values with no reviewer move, so RS (status) and MS (agreement measure) are never published while such a Study exists (#3937) as its own row for the save itself; the re-persisted exclusion flag moves nothing, so SA/DR are never published while a Study's stored exclusion flag would change (#3960) as SA/DR as SA/DR as its own row (reads no stored screening field) as its own row (reads no Study field)
Manager changes agreement threshold 503, then Stale 503, then Stale 503, then Stale 503, then Stale 503, then Stale 503 (whole project), then served unchanged (re-stamped) 503 (whole project), then served unchanged (re-stamped)
Manager changes a stage's effective session target (review-settings save or the legacy stage update) — — — — Stale (whole family: every member's scope on every stage, members with no sessions included) — —
Manager changes other stage settings (maximum in progress, hide excluded, excluded grouping, target enforcement, idle timeout, mode, selection, additional screening, active, extraction); claims the save revokes are reservation releases (the claims row) — — — — — (read-time only) — —
Manager deletes a question 503 (whole project) 503 503 503 503 503 then Stale 503
Manager creates, edits, copies, reorders or detaches a question — — — — — — —
Manager runs the legacy question-tally refresh — — — — — Fence —
Search import (parse phase) Fence Fence Fence Fence Fence — —
Search import completes or fails Fence Fence Fence Fence Fence — Fence (that search)
Bulk Study update file Fence Fence Fence Fence Fence — —
Member joins, is disabled or changes groups; permissions change — new member has no row — new member has no row new member has no row — —
Search or project deletion with deletionLifecycle off (legacy immediate deletion, DeletionNamespace fence; on, the route answers 503 and nothing changes) Fence, then Stale Fence, then Stale Fence, then Stale Fence, then Stale Fence, then Stale — (not fenced; #3849) Fence (deleted searches; a shared search is only unlinked), then Stale
Active-reviewer tracking setting changes 503 on any server whose setting disagrees with the durable one

"503 (whole project)" means a held source-visibility token blocks every family's stored rows for that project, not only the families the job rewrites.

How to tell whether a number can be trusted

A stored count is shown only when all of the following are true at the same moment (one database snapshot). The checks live in ProjectStatisticsServingGate.

  1. Writes and Serving are on, the family's flag is on, and the project is on the allowlist.
  2. The fleet and the project are both switched to Enabled, and the project is not deleted.
  3. No threshold recalculation or question deletion is running for the project.
  4. The project's catalogue, storage and source versions match the fleet's.
  5. The family is Fresh and has no active fence.
  6. The row being served is Fresh, was built with the same configuration (agreement settings) as the project's statistics control record, and carries the current write epoch. An epoch advance is how a whole family is marked Stale in one step.
  7. The row is not newer than the last committed change (it belongs to this snapshot).
  8. No fence covers that scope.
  9. This server's active-reviewer tracking setting agrees with the durable one.

If any check fails, the page recalculates live (or answers 503 for checks 3 and 9). Separately, every read re-checks the caller's permissions, so a stored count is never shown to someone who could not see the live one.

Reading rows directly. Marking a whole family Stale is one write to the family's publication guard (its lifecycle and write epoch, checks 5 and 6), not a write to each row, so the rows in pmProjectStatisticsCurrent keep State: Fresh (on the fold path too: guards beside the fold). A row's own State is therefore not the serving verdict: apply checks 5 to 8 (the guard, the epoch, the revision and fences) or use the per-family parity read. For the same reason a backfill answers Rebuilt for every scope after a family-wide invalidation, including scopes whose counts did not change; Rebuilt is evidence of a wrong served value only for a scope that passed checks 5 to 8 just before the backfill. ReviewerAnnotation is the family this matters most for: a session-target change stales the whole family by design, and until #4083 so did any save that changed whether a Study had free slots or flipped it between included and excluded (still true above 100 members). Reading the row state alone is what made the isolated soak report reviewer-annotation scopes of a member with no sessions as "Fresh but wrong" on 5 October; real-Mongo reproductions of both reports found no served value that differed from the authoritative one (#4057).

Limits of these checks. They compare the row with the project's stored configuration record, not with the Project itself. That is why a threshold change raises the fence in the same transaction that saves the new threshold (#3841). They also trust that every writer marked the right rows Stale; the gaps below are places where that trust is misplaced.

Checking a family against the live calculation

The parity audit checks that trust directly, for every family (GET api/admin/project-statistics/{id}/parity?family=<key>, runbook). For each scope the family has for the project (the scopes its backfill would publish plus every published Fresh row, at most 256) it compares what a page would be served, through the same checks as above and the fold overlay, with the family's live calculation (the one its backfill publishes), counter by counter and exactly. It changes nothing.

  • InParity: every scope was served and every counter matches.
  • Mismatch: a served scope disagrees (a counter, a row with counts for a scope that no longer exists, or a deletion over one that does). This is the projection serving a wrong number. An all-zero row for a scope that no longer exists agrees, because pages show it exactly as they show an absent scope, except for a current member's reviewer scope, which the leaderboards would list with zeros.
  • Inconclusive: a write raced the audit, a scope was not served (it fell back, so a page would have recalculated it live), or the family has no scope. Neither a pass nor a failure.

Project screening (the default, no family) is the original audit, unchanged. A reviewer's scope is labelled with a per-project pseudonym rather than the investigator id; the pseudonym is an unkeyed hash, so anyone who knows the project's member ids can reverse it. The staging preflight runs it for every family every six hours, and scripts/validate-statistics-evidence.py judges the results per family: each expected family needs an exact pass and no mismatch, and inconclusive, flag-off or HTTP-error samples prove nothing.

Worked examples

1. Threshold change

The manager changes the rule from "two reviewers must agree" to "three".

  1. ScreeningController.PostScreeningSettings saves the new rule, sets the inclusion token and fences all seven screening and annotation families in one transaction (#3841). Every statistics read for the project now answers 503. If another threshold's token is still held, the save is refused (409, StatisticsInclusionRecalculationInProgress) and nothing changes. So is a save while another statistics operation — an import, a bulk Study update, a drift re-persist — owns one of those families (409, FamilyFenced; #4053). Before that fix the second case answered 500. Retry once the other operation has finished.
  2. The project-management consumer picks up the command and resumes the same token.
  3. The consumer rewrites every Study's inclusion data in three passes, then clears the token. The same transaction records the new agreement settings as the project's statistics configuration (#4054) and marks the seven families that read them Stale. Question answers and search population read neither setting (ProjectStatisticsAgreementDependency), so instead of staling them the transaction re-stamps their current rows with the new settings, and they are served again at the same numbers as soon as the token clears (#4082). Only rows stamped with exactly the replaced settings are re-stamped; a row of any other configuration, or one already Stale, stays as it was. Either family is marked Stale as before when its guard was not Fresh, another operation fences it, or it has more than 256 published scopes; the last case records StatisticsScopeCapacityExceeded on the family's lifecycle. The completion's final transaction attempt never re-stamps, and a re-stamp whose versioned replace matches no document stales its family instead, so nothing about the re-stamp can make the completion fail and leave the fence held. The configuration_restamps counter records which of these happened. The settings are adopted only when the recorded ones were written by this code version and differ in nothing but the two agreement settings (ProjectStatisticsConfigurationDigest.IsProjectSettingsChangeOf). Otherwise (a mixed or rolled-back deployment, or a change to the configuration's format or constants in code) it adopts and re-stamps nothing, the fenced families still end Stale, and only the administrative forced rebuild reconciles them. While its job is open, POST api/projects/{id}/update-study-inclusion refuses rather than clearing the token under it.
  4. Pages recalculate the seven Stale families live until each is republished under the new settings: by the hourly stale repair where it is enabled, or by an administrator's ordinary backfill of each Stale family. The repair settles per family (#4081): the completion moves no family's own invalidation marker, so a family the repair had already observed is repaired on its next run even while other families are being written, and one it sees for the first time is observed once and repaired on the run after. It repairs up to 8 families per run by default, so the seven fit in one run. No forced rebuild is needed, and the daily observation records under the new settings straight away. If the consumer fails, the token stays; GET api/admin/project-statistics/{id}/inclusion-recalculation-fence diagnoses it. With the job still open the recovery is to find and redeliver its command (runbook step 2a); with the job closed it is to repeat POST api/projects/{id}/update-study-inclusion under the same threshold. That route's completion adopts the project's settings in the same way, which also reconciles a threshold changed while the project's statistics were off (no fence was raised then, so nothing was adopted).

2. Stage session-target change

The manager raises "Extraction" from 2 to 3 sessions per Study.

  • ReviewerAnnotation depends on the target, so the save marks the whole family Stale in the same transaction. Reviewer progress is recalculated live until a backfill. "Whole family" includes members who have never reviewed: a Study whose two sessions took every slot at target 2 is available to them at 3, so their counts move too (ReviewerAnnotationNoActivityMemberStalenessTests, transactional and fold mode).
  • StageAnnotation, MembershipStageAnnotation and DomainReconciliation still use the hard-coded two-session minimum, so they are correctly left alone. If that minimum is ever replaced by the stage target, these families must join ProjectStageConfigurationChange.TargetDependent.

3. Question deletion

The manager deletes Question 2, which has one sub-question.

  1. A definition-rewrite token is raised and the two questions' QuestionAnswers rows are fenced. Every statistics read for the project answers 503.
  2. One project-wide update removes their answers from every Study.
  3. The Project is saved and the token released in one transaction; QuestionAnswers is Stale until a backfill, and the two deleted questions' rows are marked Stale in that transaction too, so the backfill's republication of the surviving questions never serves them again (#4125). No consumer asks for a deleted question (the designer's counts read the Project's current questions); the family parity audit and the drift check enumerate served Fresh rows, which is where the 2026-10-07 soak saw it.

Annotation session counts do not change: removing answers does not change any session's status. If step 2 succeeds and step 3 fails, the answers are gone but the question remains; the token stays up so no wrong number is served (#3088).

4. Search import

A manager imports a 1,000-reference file into a pilot project.

  1. Before the first Study is saved, the import fences seven families plus the new search's population scope (#3839). While the file is parsed, Studies are saved in batches of 200; live calculations already count them, and pages recalculate live because the stored rows are fenced. If another import or a bulk Study update holds those families, the import is refused with a parse error before anything is saved.
  2. At completion, the import resumes that fence, reveals the search in one transaction, and releases everything as Stale. A failed or cancelled parse deletes its Studies and the saga's failure step releases the fence as Stale.
  3. Pages recalculate live until the backfills run.

5. Annotation save changes answer counts

Reviewer A completes a session that adds an answer to Question 1.

  • StageAnnotation, MembershipStageAnnotation and DomainReconciliation move in the save's transaction.
  • ReviewerAnnotation is marked Stale for Reviewer A, or for everyone if the Study just ran out of free slots.
  • QuestionAnswers moves too: "Question 1: 14 Studies, 16 answers" becomes 15 and 17 (or 14 and 17 if the Study already had an answer), in the same transaction, and stays served (#4080). Before #4080 the save marked Question 1 Stale, so on an active project the family was rarely served; before #3933 the stale targeted a project-level row that does not exist, so Question 1 stayed Fresh with the old count.

6. A Stale sibling scope after a partial backfill (#3831, fixed)

StageAnnotation on a project with two stages, S1 and S2.

  1. A reviewer's decision flips a Study to excluded, so both stage rows and the family go Stale.
  2. An administrator backfills. S1 publishes first, and publication records the whole family as Fresh. S2's publication fails (for example a write conflict that outlasts the rebuild's bounded retry and is refused as 409 RetryExhausted, #3826), so S2's row stays Stale with the old counts.
  3. A reviewer completes a session on S2. The family looks Fresh, so the quick update is admitted by the family gate, but S2's row fails the reader's own row checks (ProjectStatisticsServingGate.EvaluateRow). The save falls back to source-only: the session is saved, S2 stays Stale, the family is recorded Stale.
  4. The page recalculates S2 live until a backfill or the scheduled repair (which looks for Stale rows) republishes it. Before #3831 the update was applied to S2's Stale baseline and stamped Fresh, serving a wrong value that repair skipped.

The same check covers a row the drift check marked Stale (also single-scope ProjectScreening), a Rebuilding row, a tombstone and a row built under a retired digest, version or write epoch; it is the check MembershipScreening and ReviewerScreening already applied. Its cost is the backfill side effect. Reproduced by ProjectStatisticsPointPathServabilityTests.

Known gaps across families

Issue Families Risk today
A save during a backfill leaves the family Stale (details; side effect of the #3831 fix, #3845) all quick-update families Performance only; re-run Build statistics at a quiet time
#3729 reviewer-annotation staleness RA Items 1–2 fixed in code; ordinary activity point-maintained since #4083
Stored-tally drift: a legacy Study whose stored SessionTallies disagree with its sessions (RA known gaps) SA, MSA, DR, RA Accepted; no scan yet (follow-up)
#3727 copied daily snapshots all, when copying is enabled Default off
#3809 superseded rows never reclaimed all Storage growth only
#3704 stranded checkpoint observations history Default off
#3790, #3792 scheduled repair scaling and vanished scopes all Default off
#3826 backfill write conflict: the publication is retried a bounded number of times, then refused as a typed, retryable 409 RetryExhausted (no longer a 500) all Operator repeats the request
#3360 runtime flag toggles not seen by project management all Use static configuration only
#3086 legacy threshold latch never cleared on failure PS and dependants Blocks threshold changes
#3194 benchmarks not in CI — Acceptance evidence

Not a gap but worth knowing: membership and permission changes do not push an invalidation to open pages, and a new member or new stage has no rows until the next backfill (reads fall back live meanwhile). #3777 is a future transport evaluation, not a defect.