fix(review): the Paper rail never shows the previous proposal's data, and says loading or failed instead of claiming emptiness - #2705
Conversation
The six selector values left this composable with no way to say which proposal they belonged to, or whether the read that produced them had landed. A consumer therefore could not tell proposal A's settled values from A's values with B's read still running, nor a pending read from a proven absence. railEvidence answers both in one object: the state of the batch for the ACTIVE key (idle, loading, failed with the outcome kept, settled), the key the values belong to, and the two rail values themselves, withheld in every state but settled. Values and state cannot be passed apart. The bare isLoading boolean is replaced by one record holding the last batch's key and status, so loading is derived from the same fact the snapshot reads and the two cannot disagree. Every write site keeps its previous meaning: fast path settled, new batch loading, failure and success terminal, no proposal clears the record. Read semantics are unchanged: the #2460 keying, the automatic refresh, the same-action retry and waitForCoreBatch all behave as before. Refs #1940
…d evidence Two residuals recorded with PR #2662, same root and one fix. Loading identity: PaperReviewView passed the bare selector reads to ReviewRightRail, so while proposal B's batch was in flight the rail showed proposal A's confidence number, A's confidence-source sentence and A's similar-past rows under B's header. The rail's evidence props and authorMeta now come from the keyed snapshot, which withholds the values unless the batch settled for the active key. Settled state: the hoisted card sentences from #2662 asserted emptiness whether the read was pending, had failed, or had genuinely found nothing. ReviewRightRail forwards evidenceState to both cards. ReviewAuthorCard reserves the confidence-source sentence for a settled read and otherwise states that the read is running or that it failed; ReviewSimilarPast does the same for its empty sentence, and the "(none found)" disclosure label, which is the same claim in miniature, is now reserved for the settled empty result too. The disclosure region opens onto a matching line in each state rather than onto nothing, so the count and disclosure semantics from #2662 are unchanged. idle states nothing at all. Copy in review.* for en, it and es. The rail's evidenceState default is loading, the conservative one: a caller that does not know the state gets cards that withhold their claims rather than cards that assert emptiness. The KNOWN GAP comments in both cards are replaced by what the code now does. Out of scope and unchanged: ReviewMain still receives the bare confidence read, so its badge can show the previous proposal's number while the new batch loads. Same root, different component, recorded on the issue. Refs #1940
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Review record (alpha product-trust lane; one fresh-context read-only reviewer subagent since Codex credits are exhausted, SC-9; round 1 at head Verdict at round 1: SHIP on the merge-blocking bar, with one MEDIUM taken as a round-2 fix because it is a regression from the changed lines that contradicts this PR's purpose:
Clean lenses corroborated by the reviewer against the tree at the head: the four Unverified by the read-only reviewer, run by the lane before merge: the spec, typecheck and eslint claims at the head (the worker's report gives 7 files / 309 tests, 16 files / 402 tests, typecheck and eslint clean); the finding above is confirmed by the red-first spec in round 2. Round count: 2 (this round's fix changes logic, so one read-only verification pass scoped to the fix diff follows). Merge gate remaining: the fix head's |
…rency test Round 2 review finding. railEvidence asked "does this record cover the active key" with selectorKeysEqual, which treats a revision identity of 'rev-Y' and one of null as different keys. The watcher decides whether to start a batch with proposalRevisionMoved, which is deliberately asymmetric: 'rev-Y' to null is not a move, because a revision identity can only reach null by the proposal leaving PendingReview. On exactly that transition the two disagreed, no record was ever written for the new key, and the snapshot reported loading with no end. Both now go through one predicate, selectorKeyStillCovers: same proposal id, same capture reference, and a revision that either matches or did not move. The watcher's no-op condition is expressed through it too, so the two cannot drift apart again. Which transitions start a batch is unchanged: for two present proposal ids the new condition is exactly the previous three flags, and when either id is absent both forms proceed. The settled snapshot now names the key the values were READ under rather than the active one, since after a decision retires the revision those are no longer identical and the read is the honest answer. selectorKeysEqual is unchanged and still the right test where the exact read identity matters: the settled-cache fast path and the publication guards gate Apply on evidence taken for the exact revision identity being applied, which is the #2460 contract. Refs #1940
|
Verification pass scoped to the round-2 fix diff ( What it checked: Triage at the two-round ceiling (no third round):
Not run by the reviewer, covered by the worker's run at |
Summary
The Paper review right rail used to show the previous proposal's evidence under the new proposal's header, and its cards claimed emptiness whether the read behind them was pending, had failed, or had genuinely found nothing. Both are the two residuals recorded with PR #2662's release on 2026-09-05, and both come from the same missing fact: the rail received selector values with no way to say which proposal they belonged to or whether the read that produced them had landed.
usePaperReviewSelectorsnow exposes one keyed snapshot,railEvidence. It carries the state of the batch for the ACTIVE key (idle, loading, failed with the outcome kept, settled), the key the values belong to, and the two rail values, withheld in every state but settled.PaperReviewViewpasses that snapshot toReviewRightRail, which forwards the state to both cards. So a switch from proposal A to proposal B shows B's loading state, never A's confidence number, A's confidence-source sentence or A's similar-past rows, and the cards say what is true of a pending or failed read instead of asserting an absence they cannot know.Mechanism: a keyed snapshot returned by the composable, not gating in the view. The composable is the only place that knows both which key a value was read for and how that read ended, so gating there makes the mistake unrepresentable at the boundary rather than merely unlikely: a caller cannot pass a value without the state that says whether it may be believed. Gating in the view would have needed the view to reconstruct the key comparison the composable already does, on every consumer, forever.
Read semantics are unchanged. The #2460 keying, the automatic refresh, the same-action retry and
waitForCoreBatchbehave exactly as before; the bareisLoadingboolean is replaced by one record holding the last batch's key and status, andloadingis derived from it so the two views of the same batch cannot disagree.Refs #1940 (the two 2026-09-05 residuals). D-9 (waves, request-edit fields, defer durations) stays a maintainer ruling and is not touched; the parked P-key residual on
issue-1940/provenance-shortcutstays separate. This does not close the issue.Refs PR #2662 (the hoisted honest-empty sentences this makes truthful in every state).
Changes
Commit 1,
usePaperReviewSelectors.tsand its spec: thePaperReviewEvidenceStatusandPaperReviewRailEvidencetypes, the exportedSelectorKey, thebatchRecordthat replacesisLoading, and therailEvidencecomputed. Five write sites keep their previous meaning: fast path settled, new batch loading, failure and success terminal, no active proposal clears the record. A record for another key reads as loading, which is also the safe default if a future edit ever leaves a gap where no batch was started.Commit 2, the rail seam and its specs:
PaperReviewView.vue(rail wiring only):breakdown,similarPast,similarPastApplyRateand the newevidenceStateall come fromselectors.railEvidence, andauthorMeta(which carries a confidence number into the rail) reads the snapshot instead of the bare breakdown.ReviewRightRail.vue: one newevidenceStateprop, forwarded verbatim to both cards. Its default isloading, the conservative one, so a caller that does not know the state gets cards that withhold their claims.ReviewAuthorCard.vue: the confidence-source sentence is reserved for a settled read; loading and failed each get their own sentence in a newpaper-review-author-confidence-stateline; idle states nothing. Bars on screen already show where the number came from, so no state line is added to them.ReviewSimilarPast.vue: the same three states for the empty sentence, in a newpaper-review-similar-past-stateline. The "(none found)" disclosure label is the same claim in miniature and is now reserved for the settled empty result too. The region opens onto a matching line in each state rather than onto nothing, so the disclosure and count semantics from fix(review): tell the truth before the similar-past and confidence disclosures open #2662 are unchanged.locales/{en,it,es}/review.ts:author.confidenceLoading,author.confidenceFailed,similarPast.{loading,loadingDetail,failed,failedDetail}. The failure copy is a plain statement that the read failed and the values are unknown, because the rail has no retry affordance for the page-load batch;evidenceUnavailableis the Apply-time refresh and keeps its own prop.Test plan
Red-first. The new assertions were run against the unmodified source (the eight source files stashed, the four spec files kept): 14 failed | 265 passed (279), 4 files. The failures were, per file:
usePaperReviewSelectors.spec.ts, all five new cases:railEvidencedid not exist. The load-bearing one is "withholds the previous proposal values while the new key is still loading", which asserts in the same test that the bare reads STILL hold proposal A's#PAST-Arow and A's0.84overall while B loads. That is the residual itself, and it is why the gate has to exist.ReviewAuthorCard.spec.ts, four cases: with a loading or failed state the card still renderedpaper-review-author-confidence-sourcesaying "No model confidence reported" (and the deterministic sentence), and rendered it foridletoo.ReviewSimilarPast.spec.ts, three cases: with a loading or failed state the card still renderedpaper-review-similar-past-emptysaying "No comparable past decisions." and the "(none found)" label, and rendered them foridletoo.PaperReviewView.spec.ts, two cases: after switching to proposal B mid-flight the rail still contained A's0.84 model-reported average, A's similar-past row title and A's confidence-source sentence; with B's read rejected it claimed emptiness instead of saying the read failed.The third view case, "keeps the settled empty sentences from #2662 exactly as they were", passed before and after by design: it is the regression guard on #2662's shipped behaviour, not a red-first assertion.
Verified after the fix, on the exact pushed head (which includes a merge of
origin/mainatf74514fd1, disjoint boards-list work):npx vitest --run --maxWorkers=2 src/tests/composables/usePaperReviewSelectors.spec.ts src/tests/views/paper/review/PaperReviewView.spec.ts src/tests/views/paper/review/PaperReviewView.language.spec.ts src/tests/i18n/catalogs.spec.ts src/tests/views/paper/review/ReviewAuthorCard.spec.ts src/tests/views/paper/review/ReviewSimilarPast.spec.ts src/tests/views/paper/review/ReviewWhyNow.spec.ts— 7 files, 309 tests passed, Test Files matching the seven paths named.npx vitest --run --maxWorkers=2 src/tests/views/paper/review— 16 files, 402 tests passed (the whole Paper review directory, run before the merge).npm run typecheck— clean, before and after the merge.npx eslinton all twelve changed files — clean, zero warnings.git diff --check origin/main...HEAD— clean.NOT verified: the Spanish and Italian wording is not native-reviewed (OUTSTANDING_TASKS #1770); the catalog spec proves structural parity, not tone. No screen-reader run and no real-browser pass: the state lines are plain text with no live region, so a reviewer whose focus is elsewhere is not announced when a state changes. The required E2E was not run locally; it drives the similar-past disclosure and asserts the region is visible once opened, which now holds in all three states because each renders a detail line, but that is reasoned from the source, not observed.
npm run buildwas not run.Boundaries and risks
Owned and changed:
usePaperReviewSelectors.ts,PaperReviewView.vue(the rail wiring only),ReviewRightRail.vue(the prop pass-through),ReviewAuthorCard.vue,ReviewSimilarPast.vue, the three review catalogs and the four specs. The rail is mounted directly inPaperReviewView, so nothing flows throughReviewMain.vueand it is untouched.useReviewProposals.ts, the receipt recorders, the diff pane and the sticky seam are untouched.ReviewWhyNow.spec.tsmounts the rail and was NOT edited: the new prop is optional there and its populated fixture renders no state line, so it is an unmodified regression gate on this change.Risks. The rail's
evidenceStatedefault isloadingrather thansettled, so a future caller that forgets the prop gets withheld claims instead of false ones; the trade is that such a caller would show the in-flight sentence indefinitely, which is visible rather than silent.ReviewMainstill receives the bare confidence read, so its badge can show the previous proposal's number for the same in-flight window — same root, a different component, outside this slice's file boundary and recorded on the issue. The failure state is reachable only through a rejected page-load batch; a reviewer sees the failure sentence with no retry button, because the rail has none for that batch, and the recovery today is leaving the proposal and returning.Human-action file
OUTSTANDING_TASKS.md: unchanged by this PR. Open items it already carries that touch this work are D-9 (a) to (c) for the rest of #1940 and #1770 for the untranslated-tone review.Round 2
One MEDIUM from the fresh-context review, fixed here in
688bedc58.The finding:
railEvidenceasked "does this record cover the active key" withselectorKeysEqual, which treats a revision identity ofrev-Yand one ofnullas different keys, while the watcher decides whether to start a batch withproposalRevisionMoved, which is deliberately asymmetric —rev-Yto null is not a move, because a revision identity can only reach null by the proposal leaving PendingReview. On exactly that transition the two disagreed: no batch starts, no record is ever written for the new key, and the snapshot reportedloadingwith no end. That is a regression from this PR's own lines, since the pre-PR rail rendered the settled values there.Proven red first at the composable level before fixing: a proposal with
latestRevisionId: 'rev-1'settles, the rejected DTO replaces it with both revision fields null, andrailEvidence.statuscame backloading. Exact failure:AssertionError: expected 'loading' to be 'settled'. It now returnssettledwith the values intact and issues no new read.The fix is one shared predicate,
selectorKeyStillCovers(same proposal id, same capture reference, and a revision that either matches or did not move), used byrailEvidenceAND by the watcher's no-op condition, so the two cannot drift apart again. Which transitions start a batch is unchanged: for two present proposal ids the new condition is exactly the previous three flags, and when either id is absent both forms proceed. The settled snapshot now names the key the values were READ under rather than the active one, because after a decision retires the revision those are no longer identical.The fast path was checked as asked.
ensureCoreBatch'sselectorKeysEqual(settledCoreKey, key)carries the same asymmetry and is deliberately left alone: it is pre-existing and untouched by this PR, the watcher never calls it on this transition, and its only reachable caller there iswaitForCoreBatchfrom an Apply-class action, where an exact mismatch costs one redundant re-read rather than producing a stuck state. Exact matching is also the correct test in that position and in the publication guards, which gate Apply on evidence taken for the exact revision identity being applied (the #2460 contract). The helper's doc says so, so the next reader does not "fix" it.Recorded, not fixed: if a decision lands while the batch is still in flight, the in-flight batch's publication guard still compares exactly, returns
superseded, publishes nothing and leaves the record atloadingfor a key that no longer arrives, so the rail's two cards can hold their in-flight line until the reviewer selects another proposal. It is an honest line rather than a false claim, it needs a decision inside the read window (Approve and Apply cannot reach it, since the #2460 barrier settles the batch first), and closing it means changing publication semantics, which is outside this round's one-finding scope.The view-level case added alongside is a GUARD, not a red-first case: it passed before this fix too. Measured on the pre-fix head, the reject issues a second evidence batch (one read before the decision, two after), so that flow was repopulated by a fresh read rather than by the record it already held. Nothing in the flow asks for that restart, which is why the snapshot must not depend on it; the case now fails the day it stops happening.
Proof at
688bedc58: the same seven named specs — 7 files, 311 tests passed; the whole Paper review directory — 16 files, 403 passed;npm run typecheckclean;npx eslinton the three changed files clean;git diff --checkclean.