Skip to content

fix(review): the #2214 residual pass, and the three open items it confirmed - #2710

Merged
Chris0Jeky merged 2 commits into
mainfrom
issue-2214/residual-pass
Sep 5, 2026
Merged

fix(review): the #2214 residual pass, and the three open items it confirmed#2710
Chris0Jeky merged 2 commits into
mainfrom
issue-2214/residual-pass

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

The #2214 residual pass the 2026-09-04 coordinator note asked for, plus the composable- and
view-scoped open items that need no ruling.

Summary

Every residual ever recorded on #2214 — the issue body's items 1-5, the "Fixed in the PR's final
round" trail, and all 24 comments — was checked against main at a8a3f3496, together with the
three #2599 rows the coordinator folded into this pass.

37 residuals: 23 shipped, 1 superseded, 13 open. This PR fixes 3 of the 13 open; the other 10
have a row saying why they stay out. Nothing was struck on a PR body's word — every "shipped" row
names a line or a spec on main.

Attribution correction for the record: the pin-leg-truth work (a 400 on the pin leg as
pinUnavailable, expectedStatuses on the background pin read) merged as PR #2658
(issue-2214/pin-leg-truth), not #2668; the refusal-retraction sentence and the recovery
retirement rule merged as PR #2702 (issue-2638/recovery-retire).

Residual table (condensed)

Full table with per-row evidence: posted separately by the coordinator.

Shipped, verified against main (23)

Residual Shipped by Evidence on main
Deep-link re-authorisation (item 1) #2357 useReviewProposals.ts:1174-1249; spec useReviewProposals.spec.ts:1362
Restart polling after permission recovery (item 2), incl. the copy detail #2298 useReviewProposals.ts:1340-1344, called at :787-789
Stale/degraded queue disclosure (item 3) #2388 :60 threshold, raised :883-894; LegacyReviewView.vue:379-388, PaperReviewView.vue:2753-2763
Per-poll deadline (item 5) #2388 :53 REVIEW_QUEUE_REQUEST_DEADLINE_MS = 8_000; awaitQueueRefreshRequest :1014-1039 on both legs
Legacy live region under the loading skeleton #2593 LegacyReviewView.vue:234-236; spec ReviewView.spec.ts:1852
Board-scope leg of the superseded-read guard on the success path shipped useReviewProposals.spec.ts:1678 (success), :1925 (403)
Expected-status log noise on the pin read #2658 useReviewProposals.ts:1194-1198 expectedStatuses: [400, 403, 404]
Malformed-hash retry / wedge #2658 isMalformedTargetError :182-185; pin-leg branch :1230-1239
Legacy unavailable-pin presentation #2593 LegacyReviewView.vue:429-448
Recovery announcement in both skins #2630 queueRefreshRecoveredKind :329; LegacyReviewView.vue:344-358 + Paper twin
Persistent disclosure of repeated non-transient list failures #2694 isRefusedQueueRefreshFailure :221-226; queueRefreshRefused raised :859-867
Consecutive-counter reset after an intervening non-transient failure #2445 recordQueueRefreshFailure :876-882
Scrolled Paper warning visibility (the warning itself) #2630 .paper-review-deep__queue-stale--pinned { position: sticky } PaperReviewView.vue:3157-3158
Paper degraded copy localization #2630 review.queue.degraded.body in en/it/es; PaperReviewView.vue:2762/:2978, LegacyReviewView.vue:387
Filtered-to-empty ARIA co-render coverage #2630 PaperReviewView.spec.ts:4217-4254
The STATUS #2357 sentence recorded as a correction docs/STATUS.md:221
LIST-read 400 freezing the poll silently #2694 isRefusedQueueRefreshFailure :221-226
Unavailable-panel copy for a malformed id #2694 unavailableProposalMalformed :288; LegacyReviewView.vue:439-440
Explicit path's generic toast for a malformed hash #2694 openProposalFromHash :718-727
Recovery sentence's overclaim on a list-success/pin-fail tick #2702 QueueRecoveryKind :254; raiseQueueRecovery('refused', …) :947
#2638: recovery retired by any successful load #2702 backgroundQueueReadCount :374-386, retirement :991-1004
#2599 (3): Paper does not pass the rail's suppression props #2593 PaperReviewView.vue:2730-2731
Item-2 copy detail: re-arm the poll in the success branch #2298 useReviewProposals.ts:786-789

Superseded (1)

Residual Disposition
The identity-guard comment claiming every decision path assigns a new array Reworded at :1110-1116 to "EVERY single-proposal decision path (… in useReviewActions)", which is true and scoped. upsertProposal (:620-635) still writes in place and is still not named, but no false statement remains and its only caller is still openProposalFromHash (:696)

Open (13) — 3 fixed here

Residual Disposition
Item 4, count-neutral queue replacement announcements fixed here
Explicit list read's 403 toasting beside the durable revoked panel fixed here
Hash watcher marking a pin unavailable under a revoked queue fixed here
Paper polled-403 announcement (Codex round 5) open — PaperReviewView.vue:2980-2986 renders the revoked title as a plain <h2> with no role, no aria-live and no focus move, and the only status region in that branch is v-if'd off while revoked. Needs a new always-mounted region per skin (the #2630 pattern) plus a focus decision: its own slice
LOW "double announcement" (PR #2694 round 2) open — see below
LOW "empty-queue wording" (PR #2694 round 2) open — see below
Badge lag after a failed entry load (badgeSyncArmed) open — LegacyReviewView.vue:244-252, PaperReviewView.vue:2538-2547; the catch never assigns proposals, so the first poll success is consumed arming the flag. Workload-badge wiring in both skins, not a live region
No spec pins the badgeSyncArmed first-assignment skip open — pairs with the row above
Aborted-poll logging open — src/api/http.ts:132 logs in the error.request branch, which expectedStatuses (:87-93) does not guard. src/api/** is not owned by this claim
Canonical STATUS correction open (docs) — docs/STATUS.md:428 still says the Paper stale copy's localization "is still open as #2214 item 4", false since #2630 and mis-numbered. Docs are outside this claim
#2638 decision-rail sticky offset inert open — D-15, explicitly excluded
#2599 (1) re-announcement of an unchanged count on an explicit reload open — see "#2599" below
#2599 (2) focus after the unavailable-pin return control open — focus management in both skins, outside "the live regions and the announcement wiring only"

Changes per item

1. Item 4 — announce a count-neutral queue replacement, in both skins

Both live regions rendered a sentence derived from the pending count alone. A live region only
speaks when its text changes, so a poll that removed one pending proposal and added another in the
same response produced a byte-identical "3 proposals awaiting review.", mutated nothing, and
announced nothing. The queue moved under the reviewer in silence — the false-negative class #2194
exists to remove.

  • useReviewProposals gains awaitingProposalIds (the ORDERED ids of the proposals both skins count
    as awaiting review) and queueAnnouncementKey (that identity as one primitive). One predicate now
    owns the count AND what it stands for: Legacy read the number off summaryCards' pending-review
    card and Paper recomputed the same filter inline, so neither skin had any notion of WHICH proposals
    the number was about. Both views now take awaitingCount from awaitingProposalIds.length; the
    rendered value is identical.
  • Each skin wraps its sentence in a node keyed on that identity
    (LegacyReviewView.vue, ReviewQueueRail.vue). Re-keying replaces the node inside a live region
    that itself stays mounted, and a node addition is what aria-live's default
    aria-relevant="additions text" announces. The sentence and its count are unchanged. A
    byte-identical queue keeps its key and stays silent; the loading/revoked gate from #2593 still
    withholds the whole thing and its specs are untouched.
  • Blanking the text for a frame and restoring it was rejected: it recreates the "inserted together
    with its text" shape that #2593 and #2630 both call unreliably announced, and it would need a
    timer that every existing fake-timer spec would have to know about.
  • ReviewQueueRail takes the key as an optional prop (default '') rather than deriving it: its
    items prop is the whole visible queue, not the awaiting set the count is about, and a rail-local
    derivation is exactly how the two skins drift (#1124 / ADR-0038).

2. The explicit list read's 403 no longer toasts beside the revoked panel

useReviewProposals.ts:808-810 raised the durable revoked panel and the generic
review.toast.loadProposalsFailed — two reports for one fact, the shape #2694 removed on the pin
leg, and the weaker of the two: the toast names neither the revocation nor its remedy and is gone
seconds later, contradicted by a panel that stays. Only the 403 branch loses its toast; every other
explicit failure keeps it, and the 'failed' outcome every action composable depends on is
unchanged. The LIST-leg authority path (recordQueueAccessRevoked) is untouched.

3. The hash watcher no longer marks a pin unavailable under a revoked queue

The route-hash watcher had no queueAccessRevoked guard, while the explicit load's own
openProposalFromHash call site has had one since #2694. A #proposal- link followed while the
revoked panel is up — a stale rail row, a bookmark, the back button — still asked the by-id route
about a target inside a board the server had refused wholesale, and wrote that refusal into
unavailableProposalId as a second, narrower and wrong account of the same fact. Latent rather than
visible today only because the revoked panel is the first branch of both skins' empty chains: the
state was written, and the next surface to consume it would read a lifecycle claim ("applied,
archived, or removed") about a proposal that was none of those things.

4. The two LOWs from PR #2694's round 2 — stated exactly, both left open

"Double announcement." Recorded text: "a refusal rising while the degraded warning is visible
announces the sentence twice (the visible region's text change plus the new sr-only region); the
alternative strips aria-live from the visible warning, which #2630's specs pin."
On main: with
queueRefreshStale already true the visible panel (LegacyReviewView.vue:379-388,
PaperReviewView.vue:2971-2979) is mounted with aria-live="polite" aria-atomic="true" showing
review.queue.degraded.body; when queueRefreshRefused rises its text changes to
review.queue.refused.body (spoken) and the always-mounted sr-only region
(LegacyReviewView.vue:365-371) goes from '' to the same sentence (spoken again).
Not fixed, because no variant is a correct one-liner: gating the sr-only region on
!queueRefreshStale re-announces spuriously when stale later clears while refused still stands;
latching on whether the panel was already mounted needs new state; and moving the announcement wholly
into the sr-only region means stripping role/aria-live/aria-atomic from the visible panel,
which #2630 deliberately pinned (PaperReviewView.spec.ts:4237-4239, ReviewView.spec.ts:377-379)
and which would silence the degraded ONSET. The issue comment says a deliberate choice is needed; it
still is.

"Empty-queue wording." Recorded text: "the refused body's 'these are the last proposals it
confirmed' reads oddly beside an empty queue on the boardId-400 entry path (the initial load 400s
too); the disclosure is true (the confirmed set is empty) but the two sentences read as
contradictory."
On main the one string is review.queue.refused.body = "This review queue has
stopped updating. The server is refusing the refresh rather than failing temporarily, so these are
the last proposals it confirmed. Reload the page, or check the board filter in the address bar."
Not fixed: the recorded remedy is "a count-aware variant of review.queue.refused.body" — a new
sibling key authored in en, it AND es, a count predicate branched in both skins, and view specs in
both. Not a one-liner, and it needs Italian and Spanish copy this lane cannot native-review. It is
also copy for the REFUSAL disclosure, not for the awaiting-count announcement this PR changes, so the
two need not land together.

No locale catalog changed in this PR. The awaiting-count sentence keeps its exact wording in all
three catalogs.

#2599

Audited as instructed. Item 3 is already shipped (PaperReviewView.vue:2730-2731, PR #2593).
Item 2 (focus after the return control) is focus management in both skins and stays out. Item 1 is
not a one-line consequence of the identity key, so it stays out too, and this PR carries no
Refs #2599:

  • the POSITIVE half comes for free — an explicit reload that actually replaced the queue now re-keys
    and announces once;
  • the NEGATIVE half (no repeat when the queue did not change) needs the announcement node to stop
    unmounting during a reload, i.e. replacing the !proposalsLoading term with "no read has landed
    for the CURRENT board scope yet". That means a new load-scope signal in the composable, a gate
    change in both skins and a third rail prop, and it interacts with the board-filter change (where
    the count on screen genuinely stops being real mid-load) and with a failed entry load. It changes
    what #2593 pinned and wants its own review lens.

Test plan

Red first. All seven new assertions were run against the unmodified source before any fix:

FAIL useReviewProposals.spec.ts > explicit list read refused with 403 (#2214, round 2)
     > reports the revocation once, without the generic load-failure toast
     AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
FAIL useReviewProposals.spec.ts > explicit list read refused with 403 (#2214, round 2)
     > does not let a later hash change mark a pin unavailable under the revoked panel
     AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
FAIL useReviewProposals.spec.ts > queue announcement identity (#2214 item 4)
     > changes on a count-neutral replacement and not on a byte-identical queue
     TypeError: Cannot read properties of undefined (reading 'value')
FAIL useReviewProposals.spec.ts > queue announcement identity (#2214 item 4)
     > tracks exactly the proposals the awaiting count is made of
     TypeError: Cannot read properties of undefined (reading 'value')
FAIL ReviewView.spec.ts > re-announces the awaiting count when a poll swaps the queue
     without changing its size (#2214 item 4)
     Error: Unable to get [data-testid="review-queue-announcement"] within: <div class="td-review" …>
FAIL PaperReviewView.spec.ts > re-announces the rail count when a poll swaps the queue
     without changing its size (#2214 item 4)
     Error: Unable to get [data-testid="paper-review-queue-announcement"] within: <div class="paper paper-review-deep" …>
FAIL ReviewQueueRail.spec.ts > replaces the announcement node when the queue identity changes
     under an unchanged count (#2214 item 4)
     Error: Unable to get [data-testid="paper-review-queue-announcement"] within: <aside class="paper-review-rail" …>

Test Files  4 failed (4)
     Tests  7 failed | 459 passed (466)

One further rail spec — "keeps the whole announcement withheld while the count is unspeakable, key
or no key"
— was green on arrival and is a pin, not a fix: it stops the re-announcement from
ever speaking under the #2593 loading/revoked gate.

The count-neutral view specs assert the real mechanism, not a proxy: the announcement node's DOM
element is .not.toBe() the previous one after a swap, .toBe() it after a byte-identical poll, and
the live region's own element is .toBe() itself throughout (the region is never remounted).

Green at the head, all run in this worktree:

cd frontend/taskdeck-web
npx vitest --run --maxWorkers=2 \
  src/tests/composables/useReviewProposals.spec.ts \
  src/tests/views/ReviewView.spec.ts \
  src/tests/views/paper/review/PaperReviewView.spec.ts \
  src/tests/views/paper/review/ReviewQueueRail.spec.ts \
  src/tests/i18n/catalogs.spec.ts \
  src/tests/views/paper/review/PaperReviewView.language.spec.ts
  -> Test Files  6 passed (6)    Tests  492 passed (492)

npm run typecheck            -> clean
npm run build                -> built in 1.67s (no new warnings)
npx eslint <the 8 changed files>  -> clean
git diff --check             -> clean

useReviewProposals.spec.ts alone was also run immediately after the composable fix: 167 passed.

Not verified

  • No Playwright and no real screen-reader pass. The specs prove the DOM mechanism — that the
    keyed node is genuinely replaced inside a region that stays mounted — not that a given AT speaks
    it. That a node addition inside a polite live region is announced is aria-live's documented
    default (aria-relevant="additions text"), reasoned from the spec, never observed here.
  • No E2E is owed: no test id was changed. Two were added
    (review-queue-announcement, paper-review-queue-announcement); every existing one, including
    review-queue-live and paper-review-queue-live and their text contracts, is untouched.
  • No browser run of the two skins side by side.
  • The audit's "shipped" rows were verified by reading the named line or spec on main; they were not
    each re-exercised at runtime.
  • Nothing in docs/ or OUTSTANDING_TASKS.md was touched, so the two open docs rows above
    (docs/STATUS.md:428, and the #2214 residual list itself) are left for the coordinator.

Boundaries

Owned and changed: src/composables/useReviewProposals.ts; src/views/LegacyReviewView.vue;
src/views/paper/PaperReviewView.vue; src/views/paper/review/ReviewQueueRail.vue; and the four
specs src/tests/composables/useReviewProposals.spec.ts, src/tests/views/ReviewView.spec.ts,
src/tests/views/paper/review/PaperReviewView.spec.ts,
src/tests/views/paper/review/ReviewQueueRail.spec.ts.

Not touched: usePaperReviewSelectors.ts and the rail cards (#2705's files), the receipt
recorders, the diff pane, the sticky seam (D-15), src/api/**, the locale catalogs, docs/**,
OUTSTANDING_TASKS.md.

Refs #2214

The explicit list read's 403 raised the durable revoked panel AND a generic
"Failed to load proposals" toast: two reports for one fact, the shape #2694
removed on the pin leg. The panel names the revocation and its remedy and
stays; the toast named neither and was gone seconds later. Only the 403 branch
loses its toast, and the 'failed' outcome every action composable depends on is
unchanged.

The route-hash watcher had no queueAccessRevoked guard, while the explicit
load's own openProposalFromHash call site has had one since #2694, so a
#proposal- link followed under the revoked panel still asked the by-id route
about a target inside a board the server had refused wholesale and wrote its
refusal into unavailableProposalId as a second, narrower and wrong account of
the same fact. Latent rather than visible today only because the revoked panel
is the first branch of both skins' empty chains.

Adds awaitingProposalIds and queueAnnouncementKey: the ordered ids of the
proposals both skins count as awaiting review, and that identity as one
primitive. One predicate for the count and for what it stands for, so the skins
cannot drift (#1124 / ADR-0038) and a count-neutral queue replacement has
something to key an announcement on (#2214 item 4).

Refs #2214
Both queue live regions derived their sentence from the pending count alone, so
a poll that removed one pending proposal and added another in the same response
rendered a byte-identical "3 proposals awaiting review.": no DOM mutation,
nothing announced, the queue moved under the reviewer in silence. That is #2214
item 4, and the false-negative class #2194 exists to remove.

The sentence and its count are unchanged. Each skin now wraps it in a node keyed
on the composable's queueAnnouncementKey (the ordered awaiting ids), so a
changed queue replaces that node inside a live region that itself stays mounted
-- the node addition aria-live's default aria-relevant="additions text"
announces -- while a byte-identical queue keeps its key and stays silent.
Blanking the text for a frame and restoring it was rejected: it recreates the
"inserted together with its text" shape #2593 and #2630 both call unreliably
announced.

ReviewQueueRail takes the key as an optional prop rather than deriving it: its
items prop is the whole visible queue, not the awaiting set the count is about,
and a rail-local derivation is exactly how the two skins drift. Both views now
take awaitingCount from the same composable predicate instead of recomputing it
(Paper) or reading it off summaryCards (Legacy); the rendered value is
identical.

Refs #2214
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane; one fresh-context read-only reviewer subagent since Codex credits are exhausted, SC-9; round 1 at head 5b88373fb, inputs: the clean worktree at the head and the merge-base diff, 8 files).

Verdict at round 1: SHIP; no CRITICAL or HIGH, and no realistic path from the changed lines to a lost report, a lost deep link, a spurious per-poll announcement or a skin drift. Triage, one pass:

  • MEDIUM, declined as a commit here, handled by the lane's STATUS block: shipped reality changed with no docs/STATUS.md line, and STATUS lines 428 and 430 still say the Paper stale copy's localization "is still open as #2214 item 4" (false since #2630, mis-numbered even then). Lane PRs do not edit STATUS; the eighteenth block, already drafted, records this PR and retires that clause in its corrections line.
  • MEDIUM, tracked on #2214: a second explicit 403 while the queue is already revoked is now silent, including on the remedy the panel prescribes (pick another board from the header, header Refresh): recordQueueAccessRevoked() is idempotent, so nothing on screen changes and no toast confirms the attempt failed as it did on main. The panel stays truthful; the gap is feedback on a user-initiated retry. Needs its own copy (a "still refused" sentence in en, it and es, not the generic failure toast), so an issue row rather than a round-2 one-liner.
  • LOW, tracked on #2214: the hash-watcher guard's "nothing is lost" claim (a later successful load clears the flag and re-runs the hash lookup) holds by reading (:835, :872, :883-885) but is not pinned by a spec; the new spec asserts only the negative.
  • LOW, tracked on #2214: the code comments assert what an assistive technology speaks on a re-inserted node with identical text; only DOM node identity is proven (the mechanism is real: Vue re-keys and remounts the node, the region is never re-created), and role="status" carries implicit aria-atomic, which the comments do not mention. No screen-reader pass was run, as the PR body states.
  • LOW, tracked on #2214: awaitingProposalIds reads visibleProposals, which keeps a deferred hash target visible, so navigating between two deferred deep links swaps one awaiting id for another and speaks the count for a move the reviewer made; niche (deferred rows are reachable only by deep link). The reviewer refuted the likelier version: the backend orders by CreatedAt DESC, Id and the pin reinsertion uses the same rule as upsertProposal, so polls cannot shuffle the key.

Clean lenses corroborated against the tree at the head: the keyed node is the direct child of a live element that is never v-if'd in either skin, and Vue's compiler keeps the user key so a change unmounts and remounts the node; the awaiting predicate is byte-for-byte the one both skins used before (no status-set drift); the rail's announcementKey prop is optional with a default, PaperReviewView is the only mount passing it and the membership-filter specs keep today's behaviour; the try wraps only the list read, isForbiddenError is 403-only, 401 still toasts, no third consumer of the composable relied on the toast, the failed outcome is unchanged; recordQueueAccessRevoked still suspends the poll and the resume path is still reached and specced; the new specs drive real fake-timer polls, assert node identity (same node on an identical poll, a different node after the swap, the same region throughout) and cannot pass on main except the one pin the worker labelled; ADR-0038 parity (same gates, same key, same test-id shape, same region attributes).

Unverified by the read-only reviewer, run by the worker at the head (six named specs 6 files / 492 tests; typecheck, build, eslint, diff-check clean) and by CI's frontend unit job. Round count: 1. Merge gate remaining: CI green at 5b88373fb and the three-minute age.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant