Selected reviewer reconciliation questions¶
The minimal usable slice lets a freshly authorized reconciler question candidate reviewers about the current project, study and stage. Every question opens a private one-to-one conversation between the reconciler and exactly one candidate reviewer: selecting several candidates opens one thread per candidate, so no reviewer can read another reviewer's question or reply. A durable conversation retains its two participants, the questions and the replies; each committed post captures a notification for the other participant only, in the same transaction. Saved notification links reopen the private thread after refresh or sign-in, and a notice can be answered from its card without opening the thread (the quick reply, below).
History. The first slice (#3944, on main as notifications restart L6, 28 October 2026) showed every post to every selected reviewer (gap C7). The privacy fix (#3965) was written on the notification stack and re-applied on main by cherry-pick as restart L7 (#4673, owner decision Q1, option B); the L6 and L7 sections of the technical plan hold the numbered acceptance criteria.
Acceptance: authoritative candidate selection, current stage grants and account admission, participant-only reads/replies, blinding-safe labels, retry deduplication, atomic capture and no changes to canonical annotation/reconciliation answers. Routine study claims stay silent. The shortest critical path is domain context/participant admission, persistence and inbox shaping, question/reply controls and security/transaction/browser verification.
One thread per candidate¶
- Identity. A thread's ID is a SHA-256 over the reconciler, project, study, stage, the candidate's session and the client request ID, so one request fans out to distinct threads. Post IDs derive from the thread, so every post, and therefore every notification source (capture is an insert-only upsert unique on recipient, kind and source), is distinct. A reused source ID would silently drop a later notification, which is why no two threads share one.
- Recipients. A reconciler's post notifies that thread's candidate only; a candidate's reply notifies the reconciler only. A candidate reads only their own thread and cannot post into another candidate's.
- Atomic fan-out. All threads of one request (at most 20) and their notices commit in one majority transaction, or every requested thread already exists unchanged and the request replays, or none is written. A retry that changes the text for any candidate, or names a candidate the saved request did not include, is refused whole as a typed conflict; it is never topped up. A retry naming only already-saved candidates replays. The web form starts a new request identity after any answer from the server (a conflict, or "no longer eligible") and on every reload, and keeps the identity only for a send the server never answered.
- Who can be questioned. Only reviewers whose own non-reconciliation session for that study and stage is Completed, and who currently hold stage Review authority. Incomplete sessions and reconciliation sessions are never candidates. The server rechecks this on every request, and a reconciler's follow-up needs the candidate session still completed (the candidate may always answer while their session exists).
- Stable aliases. Reviewers are named by a study-local alias, "Reviewer 7Q" (owner decision, 2 November 2026: never "Candidate 7Q", and no longer L6's running "Reviewer N"): a digit and a letter drawn from a hash of the study and the session, so an alias is fresh for each study and says nothing about order. Draws are taken in the creation order of all the stage's non-reconciliation sessions for the study, whether or not they are offered, and a collision moves the later session to its next draw, so one reviewer reopening or finishing a session never renames another and a session added later never renames an earlier one. The label on a candidate card, a saved thread, its summary and its notices is resolved from the candidate's session at read time, so they always agree; the label stored when the thread opened is only the fallback for a session that has since been removed.
- About one form question. A question may optionally name one question of the stage's form (owner decision Q3); the server refuses any other ID and stores it on every thread of the question. Both participants see the question's current text; one removed from the form reads "A question no longer in the form".
- Who can ask. A Reconcile holder who has reviewed the study themselves, meaning any of their
own non-reconciliation annotation sessions on it, in any stage and in any status (a draft
counts), cannot start a conversation. The rule is deliberately conservative and does not depend on
the stage being reconciled, whereas who can be questioned stays per stage. The candidates and
create endpoints answer
409with the typed problem codeReconcilerReviewedStudy, and the same rule blocks their follow-ups with the same typed refusal. Saved history stays readable. Only a review counts: the reconciler's own reconciliation sessions do not, and neither does a screening decision, which is not an annotation session. The refusal is about that reconciler alone: the candidate may still answer, and another Reconcile holder may still ask.
Exposure record and indication¶
The conversation records, on itself and never on the Study, which candidate session was questioned, its status when first questioned, and when the first question and the candidate's first reply happened. Each of the candidate's own posts also records the status their session had when they posted, so a reply given after the session was reopened can be told apart later. This record is for the asking reconciler: their conversation page shows it under Details ("Questioned during reconciliation", the review's status when first asked and the first question and reply times), and the candidate does not see it. The reconciler also sees a read receipt ("Read 10:22") under each of their messages the candidate has opened (owner decision Q13): the first time the candidate opened its notice or the conversation, or replied after it, written once and never changed by marking notices read or unread. The candidate is told "The reconciler can see when you read this." and sees no receipts.
Every Reconcile holder for the study and stage, not only the asking reconciler, sees a
content-free questioned marker on the candidate: when the candidate was first questioned (by
anyone) and whether any reply exists. It never carries a thread, its text, who asked, or any other
candidate. It is answered by a repository query that projects only the exposure fields, served by
L6's conversation index (0033: project, study and stage first), and it shows as a Questioned
during reconciliation badge above the candidate's column on the reconciliation page, and behind
Details in its Conversations area (PR B; the badge no longer repeats beside the reviewer
checkboxes). Only the asking reconciler sees the thread. Candidate-side visibility is
unchanged: a candidate sees their own thread and nothing about who else was questioned.
Context without an editable link¶
No read-only study view exists for reviewers, so a candidate's context link is the thread itself
(/attention/{id}), which is also the notification deep link, and the project, stage and study
title are shown as text. The reconciler's link goes to their own reconciliation page. Neither is
an editable review session.
Back to the context (#4754, owner decision 4 November 2026). The reconciler's Open study
opens the reconcile page at the question the conversation is about: the API's context path carries
?question=<id> while that question is still in the stage's form, and the page scrolls every
candidate card to its copy of the question and focuses the first (both candidate forms name each
rendered question with data-question-id; a virtualised AF2 row out of range, or a question no
longer in the form, leaves the page as it opens). The reviewer gets My answer: their own
answer to that question, read-only, as text on the conversation page. No read-only review view
exists to reuse, so the API sends the answer (StudyConversationDto.myAnswer, the candidate's own
session's answers to that question with their notes; a lookup answer reads as the named
annotation's answer, never an ID) to the candidate alone; the reconciler already sees every
candidate's answers on the reconcile page.
Lifecycle: closing with the reconciliation (#4754)¶
Owner decision 4 November 2026, option C plus a status line. A conversation is closed once any
reconciliation session of its stage on the study is completed (the rule of
SessionTally.ReconciliationCompleted) or the stage is inactive; otherwise it is in progress
while a reconciliation session exists and not started before one does
(StudyConversation.LifecycleIn). The state is derived from the study and the stage on every read
and post, never stored, so there is no migration or backfill, a reopened reconciliation or stage
opens the conversation again, and switching reconciliationConversations off and on changes
nothing stored.
- While open, the conversation page shows a status line ("Reconciliation in progress").
- Once closed, the composer is replaced by a line saying why ("This study's reconciliation is
complete, so replies are closed." / "This stage has closed, so replies are closed."). The history
and the link stay. A reply, a quick reply and a new question are refused with a typed 409
(
code: ConversationClosed,reason: reconciliationComplete | stageClosed) that the web translates. - Both participants' notices of the conversation read as closed: the resolver sets the view's
workflow state to the closing state, so (like an answered invitation, N4) they leave All, the bell
panel and the unread count and list under Resolved, with no Reply. This uses N2's per-item
resolution rule rather than writing
ResolvedAtUtc, so nothing is written when the context ends.
No reply needed (#4754, Chris, 4 November 2026). On the conversation page and on a conversation
notice's card, either person can choose No reply needed: their own open notices of the
conversation move to Resolved without a message (POST api/study-conversations/{id}/no-reply-needed,
the same admission and participant check as posting; anyone else gets 404). It goes through the
reply's resolution path (InboxNotificationWriter.ResolveManyAsync, transition noReplyNeeded),
is idempotent (the first resolution is kept) and leaves the other person's notices unchanged. It is
offered while the reader has an open notice in a conversation that has not closed
(StudyConversationDto.noReplyNeeded, the card's noReplyNeeded type action); on a closed
conversation the route still answers, but there is nothing left to resolve. Resolve-on-reply is
unchanged.
Reply state in the list (reconcile-page conversations, PR A, 5 November 2026). Each row of
GET api/study-conversations (and each row POST answers) also carries, derived when read and
never stored: replyState (replied when the newest post is the other participant's,
noReplyYet when it is the caller's own, so a follow-up after a reply is waiting again;
StudyConversation.LastPostByCounterpartOf), lastPostAtUtc, questionId (the question while it
is still in the stage's form, the rule of the reconciler's link; null for the whole study or a
removed question, whose about says so), reviewerSessionId (the reconciler only: the session they
chose when asking, so nothing new, even once they have reviewed the study and the candidate picker
refuses them) and lifecycle (as above). They come from the thread and the
context the list already reads for it, so the list makes no further repository read. All are
additive: an older web ignores them, and a newer web reads rows as ListedConversation
(core/services/study-conversation/study-conversation-api.service.ts), which marks them optional because
an older API leaves them out. An
inactive stage's GET api/study-conversations/candidates still answers 409, now with the typed
ConversationClosed body and its reason; a completed reconciliation on an active stage is not
refused there, so an older web keeps its conversation list. The web that uses these fields is PR B
(#4836); #4835 is this API part.
The Conversations area on the reconcile page (PR B, #4836,
5 November 2026). The bottom "Reviewer
questions" section is replaced by a Conversations chip at the top of the page, above the
reviewer columns, with the count of your conversations about the study. It is closed on every
visit, including arrival from a conversation's Open study. Opening it shows a small label, "Your
conversations about this study", and Close (no title row and no reload button), then two-line rows:
the first message; "Reviewer 7Q · {question text or Whole study} · {time of the latest message}",
with a trailing Replied or No reply yet pill. Each row opens the conversation. Ask a
question (since PR D, Ask about the whole study) opens SyRF's message box in the area, with "To"
and an M3 checkbox per reviewer in one row and, until PR D, the About choice (owner decision Q3); the
privacy line appears only in the message box. Who has been questioned sits behind the "i Details"
chip; until PR D the questioned badge also appeared once, above the reviewer's column. When asking is not
allowed (you reviewed the study, its reconciliation is complete, or the stage has closed) the Ask
action is not shown and one line says why; conversations stay listed. The list refreshes itself on
the inbox's live hint, on reconnect, when the tab becomes visible and when the area opens, at most
once every 2 s and only while the page is visible; after a failed first load these retry too, and
only the newest of overlapping reads is applied. Rows from an older API (no reply state) show the
time asked and no pill; an older API's bare 409 from the reviewer list reads as "stage closed" too. A
page-scoped store (ReconcileConversationsStore, provided by the reconcile page alone) holds the
reviewers, the conversations, the refusals and the drafts, and keeps the old question form's
request-identity rules: an unconfirmed send keeps its requestId; any answer from the server, and
every full load, starts a fresh one; a live refresh keeps it. Everything stays behind the
conversations flags (reconciliationConversations, studyAttention, notificationInbox): with any
of them off the page renders no chip, area, column label or badge and sends no
study-conversations request. Asking about one question moved onto the questions in PR D (below).
Landing in the right category tab and page (PR C, #4851,
5 November 2026). A question a reconciler arrives at from Open study may sit on another category
tab (v1 renders only the selected tab), on a later AF2 page, or inside a collapsed answer group. With
the conversations flags on, the page first asks every reviewer column to reveal it through
ReconcileLandingService (shared/annotation/reconcile-landing/, in shared because the shared
forms read it; provided only by the reconcile page, so stage review and preview never see it and
their forms inject nothing). A v1 card selects the tab of the question's category and the
question's first copy opens every collapsed group around it (the reveal validation navigation
uses); an AF2 card selects the category, shows the first unit holding the question, turns to that
unit's page, and its question list brings the row into view (natural page or virtual range,
opening collapsed branches). Each card's reveal is tracked until it shows the question, bounded at
5 s, and the locator waits until no reveal is pending, then lands only on a shown copy: it scrolls
every column and outlines and focuses the first. A question inside repeated units lands on the
first unit's copy. With the flags off the page lands as #4754 shipped it.
Asking about one question (PR D, #4853, design A3b, owner
decisions 5 November 2026). Every question on the reconcile page (the owner chose every question over
"only where the answers differ") shows, in each reviewer's column, a compact Ask text action
after that reviewer's answer: the A3b mock-up's "Ask after the candidates' answer chips", mapped onto
today's page, where each column shows one reviewer's answer. Ask opens SyRF's message box with that
column's reviewer ticked and the others not, and no About choice (the question is the one it opened
under; owner decision Q3 is kept by asking from the question itself): on a desktop or tablet at the
foot of that copy, under the answer; on a phone (under 600 px, where each column is about half the
screen) in the app's full-screen adaptive dialog, the same box and draft. A send that fails (not
confirmed, something changed, the question no longer in the form, the reviewers reloaded) keeps the
draft and says why above the box, in place or in the dialog. After the server's "question not in this
stage's form" refusal, that question offers no Ask until the page's form reloads (an open box keeps its
draft and the reason, and is not sent again). Once you
have asked that reviewer about that question, the copy shows one plain line, "Reviewer 7Q replied ·
3 min ago" or "Reviewer 7Q hasn't replied yet · asked 3 min ago", with Open conversation: the
newest such conversation (from questionId and reviewerSessionId on the list rows); the others
stay listed in the Conversations area. The candidates' answers are untouched. A question repeated in
a column (inside cohorts or outcomes, or with several answers) shows Ask and its line on its first
copy only, found by one page-level pass (ReconcileFirstCopiesService) that re-checks a column only
when its question copies change. One message box is open per page, and each target (the whole study, or a question in a
column) keeps its own draft. When asking is refused or the study is closed, Ask goes everywhere and
the lines stay; a refusal while a question's box held focus puts the line saying why in its place,
focused. The area's action becomes Ask about the whole study and its About choice goes. Who has
been questioned is behind the area's Details chip only: the badge above each column is removed. The
two shared question renderers (v1 app-annotation-question, AF2 syrf-question-field) render the
adornment through the RECONCILE_QUESTION_ADORNMENT token (shared/annotation/reconcile-adornment/),
provided only by the reconcile page and enabled only while the conversations flags are on, so stage
review, the question preview, and the reconcile page with the flags off render nothing extra. Rows
from an older API carry no question or reviewer, so no line shows.
Quick reply from the notification card¶
A conversation notice the reader can still reply to (the same rule as the conversation page's reply form) carries a Reply action. The card is N4's shared notice card in the layout the owner chose on 2 November 2026 (option 1 of the question notice board): the headline says who wrote ("Question from the reconciler", "Reviewer 7Q replied"), the context line names the project, then "Stage · Study", each message is a speech bubble with its author and time (a reply also shows the reader's own last message, clamped to three lines with Show all), and Reply, the last and only filled button, opens the message composer in place: a rounded box with Send inside, the privacy line naming the one reader ("Only the reconciler will see this. It does not change any answers."), and Cancel. Send reads the thread's current version and posts through the conversation's own reply route, so the server checks authority again and notifies only the other participant. A refusal is shown in plain words, and Open conversation stays on the card. Nothing is offered when the reader can no longer reply (the stage is inactive, the candidate's session is gone, the reconciler reviewed the study, or the thread holds 100 posts). The page and the bell panel reply the same way.
Replying answers the notice (owner decision Q5): the post resolves, in its own transaction, every notice its author received in the thread. The card shows the sent reply as the reader's own bubble and "You replied" at once, and stays where it is until the reader leaves Notifications or closes the panel; then the notice lives under Resolved. A new message from the other person is a new notice.
Flag decision¶
reconciliationConversations (feature reconciliation-conversations, Release, default off,
runtime-manageable, API host with a reviewed consumer contract) declares studyAttention as its
dependency, and studyAttention keeps its dependency on notificationInbox. Conversation
endpoints, the reconciliation page's question form and history, the /attention/:id route and the
conversation notices all need the three on, decided from one capture. While any is off the six
endpoints answer 404, saved threads stay hidden, and conversation notices read as "no longer
available". studyAttention alone never opens conversations; it stays the study attention family
switch (study issues and checked PDFs follow in L8 and L9).
Until L7, studyAttention was deployment-managed (pinned) because of gap C7. L7 lifts that pin: a
non-production environment may switch studyAttention at runtime again, and switching
reconciliationConversations on while studyAttention is off is refused with the typed
unsatisfied-dependency answer unless the administrator confirms the dependency.
Rollout order and legacy documents¶
Data is additive. The thread shape changed (Reviewer replaces Reviewers, plus the exposure
record), but no document is converted. A conversation saved by the L6 build, which was only
possible where studyAttention was deployed on (the E2E stack and local stacks; it was pinned off
everywhere else), has a Reviewers list and no single candidate, so it cannot be read as a
one-to-one thread: reads skip it instead of failing, GET answers 404, a reply conflicts, and
its notices read as unavailable. The API logs one warning per process carrying only how many it
skipped, never their content. Nothing deletes it.
Rolling back to the L6 build is safe: that build pins studyAttention to its deployed value, which
is off in every environment, so it never reads a one-to-one thread.
Rollback, and what hiding conversations costs¶
Setting the flag off is the kill switch and takes effect at the next request. It is a deliberate hide: conversations disappear while their flag is off. Weigh these before turning it off where people have live conversations:
- Saved conversations vanish for both parties. The endpoints answer
404, the/attention/:idpage is not found, the reconciliation page shows no question form, history or questioned badge, and their notices read as "no longer available". Nothing is deleted; it all returns unchanged when the flag is turned back on. - Hidden notices leave the unread count and stay in history (N2's rule: the count is unread, unresolved, not dismissed and available at read time), so the bell never counts a notice the recipient cannot open.
- Emails pending while the flag is off are dropped, not deferred. A delivery whose notice resolves
as unavailable is suppressed for good (
NotificationEmailProcessor,NotificationDigestProcessor), so conversation emails that were waiting are not sent when the flag comes back. Nobody can opt in to these categories from the web yet. - A reconciler loses the thread, and a reviewer cannot answer, for as long as it stays off.
Behaviour retained¶
Existing review and reconciliation paths remain intact. Accepted conversations retain history through later role/candidate changes while the flags are on, and every visible detail and reply action rechecks current authority. Email uses the existing category preferences and shared immediate/daily ledger; no separate transport.
Study issue reports and quarantined PDF correction are the remaining ordered slices (L8, L9).
Authority, bounds and presentation¶
The server binds candidates to saved non-reconciliation sessions in the same study and stage that are completed. It checks fresh active accounts, active project membership and current stage Review/Reconcile grants without application-admin shortcuts. Selected session IDs are admitted by the server; clients cannot supply participant identities. Stable request/post IDs deduplicate retries; changed payloads and stale revisions refuse writes. A stage in the canonical review model is refused like a missing one (L6).
History retains the two participants. Later removal of the candidate's session disables that reviewer's replies; current role withdrawal hides protected history and turns related notices unavailable. Inactive stages retain authorized history and disable replies. DTOs contain generic labels ("Reconciler", "Reviewer 7Q"), never names, emails or investigator IDs. Free text is rendered as text.
Initial bounds are 20 selected sessions, 4,000 characters per post, 100 posts per conversation and 100 recent conversations per study/stage/participant query. A full conversation becomes read-only; new questions remain independent. Paging and closed-thread controls are ordered follow-ups.
Validation¶
4754 (context link and lifecycle): the counts are in the PR.¶
Reply state in the list (PR A): the counts are in the PR.
Conversations area on the reconcile page (PR B): the counts are in the PR.
Original slice (#3944): 383 focused API checks, four physical Mongo replica-set transaction checks
and 29 Angular tests passed, with changed-file lint, generated-code validation and Release builds.
The full browser journey passed: two reviewers save conflicting answers, only the selected reviewer
receives the question, a foreign participant is refused, replies reopen after reload and both
parties follow their saved notification links. Canonical ExtractionInfo is byte-for-byte unchanged
by the conversation. The existing E2E_BROWSER_NETNS=1 harness isolates Chromium from sibling
Docker interface changes on this shared host.
Privacy follow-up (#3965) on its own branch: six aggregate tests, 15 physical Mongo replica-set tests and 35 conversation tests in the API unit project passed, with 286 Angular tests in 22 files. The counts for L7 on main (the same tests on main's architecture, plus the quick reply and the lifted pin) are in the PR.

The screenshot is the candidate's view of the saved conversation: the private-thread notice, the project, stage and study as plain text with no study link, the two participants, and the reply form. The questioned indication is only shown to Reconcile holders (beside the candidate on the reconciliation page, and on the asking reconciler's conversation page), never to the candidate, so it does not appear here.