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:
- Mutation ownership matrix: every source write, its owner and its transaction shape.
- Calculation and consumer catalogue: exact formulas.
- Technical plan: the design, including Lifecycle and the event and invalidation contract.
- Programme status: what is merged, deployed and accepted.
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 runProjectScreeningBackfillServiceor 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 areFencedand owned by the run until it releases themStale. 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 aTransientTransactionError, 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
AdmitAsyncand the standalone source-visibilityBeginAsyncretry 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
materializedProjectStatisticsWritesand 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 answersNotRequested, 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
materializedProjectStatisticsPageskill 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¶
- The save. The writer's own Study write also rewrites the Study's whole
PendingStatisticsset with one more entry (StudyPendingStatisticsSet.Append; a bare$pushis 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 nopmProjectStatistics*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. - The fold.
ProjectStatisticsFoldWorkerin 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'sAudit.VersionandStatisticsFoldSequence. - 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). - 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 answerFoldCoverageIncompleteuntil gate (b) passes and the production rollout is separately approved (ProjectStatisticsFoldAdministration.IsCoverageSufficient). Elsewhere, complete coverage (slice 6) is enough; the staging-onlyAllowPartialCoveragesetting is no longer needed. - Disable sets
Disabling; saves take the quick-update path from then on, and the worker finalizesDisabledonce 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
ScreeningInfofield 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 theSufficientlyExcludedpartition. Each family's rebuild and backfill refuseInclusionStatusDrift(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 operationre-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
AlreadyCurrentcheck 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'sStatefield inpmProjectStatisticsCurrentmust also check the guard'sLifecycleand the row'sAppliedWriteEpoch(how to tell); without them it reports a family-staled row as "Fresh and rebuilt" (soak runs 3 and 4, drillpmRestart).
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
materializedProjectStatisticsScreeningon 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 (allfalse) apply and no family is on in production. - Page consumers in staging:
materializedProjectStatisticsProjectOverview,…Pages,…SignalR,…Exportsand…Historyaretruein both staging values files;…StageOverviewisfalse, and the other consumer flags are unset (defaultfalse). 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.Classifyemits 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 inRequireExistingFreshFamilies, soProjectStatisticsTransactionCoordinator.CheckRowAdmissionAsyncfinds 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
ReviewerAnnotationPointtransition (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.Putor the legacyProjectController.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 sinceTotalAllocatedSessionCountwas 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 withoutTotalAllocatedSessionCount: 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
…QuestionAnswersis 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
AnnotationQuestionAnswerstransition; 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
InParityagainst 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 answersScopeAbsent. - 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
ScopeAbsentand returns the row to Stale, and the backfill reports the scopeAbsent. 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 reportedStaleand 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
…QuestionAnswersoff 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.
- Writes and Serving are on, the family's flag is on, and the project is on the allowlist.
- The fleet and the project are both switched to Enabled, and the project is not deleted.
- No threshold recalculation or question deletion is running for the project.
- The project's catalogue, storage and source versions match the fleet's.
- The family is Fresh and has no active fence.
- 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.
- The row is not newer than the last committed change (it belongs to this snapshot).
- No fence covers that scope.
- 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".
ScreeningController.PostScreeningSettingssaves 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.- The project-management consumer picks up the command and resumes the same token.
- 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 recordsStatisticsScopeCapacityExceededon 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. Theconfiguration_restampscounter 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-inclusionrefuses rather than clearing the token under it. - 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
backfillof 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-fencediagnoses 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 repeatPOST api/projects/{id}/update-study-inclusionunder 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.
- A definition-rewrite token is raised and the two questions' QuestionAnswers rows are fenced. Every statistics read for the project answers 503.
- One project-wide update removes their answers from every Study.
- 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.
- 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.
- 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.
- 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.
- A reviewer's decision flips a Study to excluded, so both stage rows and the family go Stale.
- 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. - 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. - 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.