Skip to content

fix(review): a malformed deep-link target no longer freezes the queue refresh, and refused pins stop logging as API errors - #2658

Merged
Chris0Jeky merged 4 commits into
mainfrom
issue-2214/pin-leg-truth
Sep 5, 2026
Merged

fix(review): a malformed deep-link target no longer freezes the queue refresh, and refused pins stop logging as API errors#2658
Chris0Jeky merged 4 commits into
mainfrom
issue-2214/pin-leg-truth

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Summary

Refs #2214. This closes residual items 7 and 8 of the 2026-09-03 reconciliation comment. Item 2 stays open for a later slice that needs both review views.

The background review-queue poll is a composite read: it reads the list, then re-reads a hash-pinned proposal by id when the list page omits it. Both legs were already handled for 403 and 404, which mark only the pin unavailable and let the list answer land. Every other by-id failure went to the queue-level failure branch, which returns before proposals.value = next.

Item 8. A #proposal-<id> whose id is not a GUID is answered 400 by the by-id route. GetProposal(Guid id) sits under [ApiController], so model binding refuses a non-GUID route value before the handler runs, and nothing client-side validates the hash. That 400 took the queue-level branch, so a list read that had already succeeded was thrown away. It was also silent: isTransientQueueRefreshFailure counts only a missing response, 408, 429 and 5xx, so the 400 reset the consecutive-failure counter instead of climbing to the degraded threshold and queueRefreshStale never rose. The pin leg re-fires on every tick while the target sits outside the list, and a malformed id is never in the list, so the queue refresh froze with no indication for as long as the bad link stayed in the URL.

Item 7. The shared response interceptor in api/http.ts logs any error status a call has not listed in expectedStatuses as API Error:. The background pin read turns 400, 403 and 404 into an explicit pin-unavailable outcome, so each handled result was reported as a failure on every tick.

Predicate chosen: status === 400, on the background pin leg only. 405 and 410 are deliberately excluded. Only a malformed id is provably unusable: a 405 would be a routing defect affecting the whole surface rather than one target, and 404 already carries the gone meaning here. Neither status is emitted by this route, so giving them pin-level meaning would be guessing. The explicit openProposalFromHash path is untouched: its non-404 failures still toast, and it does not pass expectedStatuses.

Unchanged: the 403 branch, teardown and abort handling, supersession guards, the 8-second request deadline, the three-failure degraded threshold, and the recovery signal from #2630. A 5xx or a no-response on the pin leg is still transient and still keeps the current queue; a 401 keeps its existing handling.

Changes

04ff09f08 fix(review): a malformed deep-link target marks only the pin unavailable

  • src/composables/useReviewProposals.ts: new module-level isMalformedTargetError predicate (400 only) with the reasoning above; the pin-leg catch takes the existing pinUnavailable = true branch for it, alongside 403 and 404. The consequences are the shipped ones: the fresh list lands, unavailableProposalId is set so the already-shipped Legacy and Paper unavailable-pin states render, recordQueueRefreshSuccess runs so the failure counter is not touched and no degraded state rises, and onQueueReplacedByPoll is not fired.
  • src/tests/composables/useReviewProposals.spec.ts: a dedicated red-first case, plus 400 added to the existing it.each that already pinned 403 and 404 through the recovery half.

d68e1248d fix(review): a refused background pin read stops logging as an API error

  • src/api/automationApi.ts: AutomationReadOptions gains expectedStatuses?: number[]. Both readers already forward the options object verbatim, and only when one was supplied, so no other caller's http.get arity or arguments move.
  • src/composables/useReviewProposals.ts: the background pin read passes expectedStatuses: [400, 403, 404]. This is the proposalDeepReviewApi.getProvenanceMetadata pattern.
  • src/tests/api/automationApi.spec.ts: the option is forwarded when given, and the key is absent when it is not.
  • src/tests/composables/useReviewProposals.spec.ts: the exact-options assertion at the pin call site now includes it, and the explicit deep-link read is pinned as not carrying it.

2d6dc3b95 test(review): pin the Legacy unavailable panel for a malformed 400 target

  • src/tests/views/ReviewView.spec.ts: the reviewer-visible half. Mounts with the target in the queue so the explicit read never fires, then the next background read drops it and its by-id re-read answers 400.

PaperReviewView.vue and its spec are held by #2629 and are untouched here.

Test plan

Verified, from frontend/taskdeck-web:

Red-first, before the source change:

  • npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts -t "malformed deep-link target answers 400" — 1 failed, 128 skipped. AssertionError: expected [ { id: 'p-existing', ...(16) } ] not to be [ { id: 'p-existing', ...(16) } ] at expect(rp.proposals.value).not.toBe(queueBeforePoll). The queue that had just been read was discarded and the pre-poll array was still the rendered authority: the freeze, exactly.
  • npm run typecheck with the new automationApi.spec.ts cases and no type change — src/tests/api/automationApi.spec.ts(91,7): error TS2353: Object literal may only specify known properties, and 'expectedStatuses' does not exist in type 'AutomationReadOptions'.
  • The view case was re-run against a temporarily reverted predicate to confirm it exercises the change: 1 failed, expected false to be true at expect(unavailable.exists()).toBe(true) — no panel, and the dropped row still on screen. The probe was reverted before the commit; the committed source diff contains no probe.

Green, at head 2d6dc3b95:

  • npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/api/automationApi.spec.ts src/tests/views/ReviewView.spec.ts — 3 files, 201 passed.
  • npx vitest --run --maxWorkers=2 src/tests/composables/useReviewProposals.spec.ts src/tests/composables/useReviewActions.spec.ts src/tests/views/ReviewView.spec.ts src/tests/views/ReviewView.coverage.spec.ts src/tests/api — 37 files, 531 passed.
  • npx vitest --run --maxWorkers=2 src/tests/views/paper/review as a read-only regression run — 14 files, 346 passed. No file under it was edited.
  • npm run typecheck — clean.
  • npx eslint over the five changed files — clean, exit 0.
  • npm run build — exit 0, no error lines.
  • git diff --check origin/main...HEAD — clean.

NOT verified:

  • Playwright E2E. Not run; this change has no new flow and no running stack was used.
  • The real backend's 400 for a non-GUID id. Reasoned from [ApiController] plus [HttpGet("{id}")] GetProposal(Guid id) in backend/src/Taskdeck.Api/Controllers/AutomationProposalsController.cs, not observed against a running API. If that route ever answered a malformed id with something other than 400, this slice would not cover it, and the freeze would return for that status.
  • The Paper skin's rendering of this specific 400 pin. The composable outcome is shared and the Paper specs pass unchanged, but no Paper assertion names the 400 case, because PaperReviewView.spec.ts is held by fix: guard deferred review receipts by decision locus #2629.
  • No backend test project was run; nothing under backend/ changed.

Boundaries and risks

  • expectedStatuses is per-request config, so the suppression is scoped to this one call site. The list leg, the explicit deep-link read, and every other automation call keep their normal error logging.
  • The risk this accepts: a future validation on GET /automation/proposals/{id} that returns 400 for a reason other than a malformed id would be read as "this pin is unavailable" rather than logged. Today the route takes no query or body, so a 400 there can only be model binding.
  • The it.each name for the 403/404 case now reads 400, 403 and 404. No other spec depends on that name.
  • Docs: none. Shipped behaviour of a bug fix within an already-documented seam, no sequencing change, no competing-approach decision.
  • The worktree holds only gitignored dist/ and node_modules/, both regenerable. Nothing was copied out; nothing needs to be.

The background queue refresh reads the list, then re-authorizes a hash-pinned
proposal the list omitted. A by-id read that answered 400 fell to the
queue-level failure branch, which returns before the list answer is assigned.
The list read had already succeeded, so a bad deep link discarded a good queue.

It did so silently. A 400 is not transient, so the consecutive-failure counter
reset instead of climbing to the degraded threshold, and no warning ever rose.
The pin leg re-fires on every tick while the target sits outside the list, so
the freeze lasted as long as the malformed id stayed in the URL.

A 400 on this route is a model-binding failure: GetProposal(Guid id) under
[ApiController] never reaches its handler for a non-GUID id, and nothing
client-side validates the hash. That is a permanent fact about the requested
target, the same class as a 403 or a 404, so it takes the same pinUnavailable
outcome: the list lands, the pin is marked unavailable, the failure counter is
untouched and no degraded state rises.

The predicate is 400 alone. Only a malformed id is provably unusable; 405 would
be a whole-surface routing defect and 404 already covers gone. The explicit
openProposalFromHash path is unchanged, and every other pin-leg failure shape
keeps its current behaviour.

Refs #2214
The shared response interceptor logs any error status a call has not named as
expected under 'API Error:'. The background poll's by-id pin read turns 400,
403 and 404 into an explicit "this pin is unavailable" outcome, so each one was
reported as a failure while being handled as a result, on every tick that the
pinned target sat outside the list page.

AutomationReadOptions gains expectedStatuses and forwards it the way
proposalDeepReviewApi.getProvenanceMetadata already does. The options object
was already passed to http.get verbatim, so no other caller's call shape moves.

Only the background pin read names those statuses. openProposalFromHash is a
read the reviewer asked for and keeps its logging and its toast, which the
spec now pins explicitly rather than by objectContaining.

Refs #2214
…rget

The composable spec proves the outcome; this proves the reviewer sees it. The
view mounts with the target in the queue so the explicit deep-link read never
fires, then the next background read drops it from the list and its by-id
re-read answers 400.

Without the pin-leg fix the queue answer is discarded, so the dropped row is
still on screen and no panel appears. Verified red against that state before
this landed.

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, review-and-ship round 1 at head 2d6dc3b).

Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff plus the worktree at the head. Verdict: SHIP, no CRITICAL or HIGH.

Confirmed clean: isMalformedTargetError is referenced exactly once, inside the try that wraps only the by-id read, so a list-read 400 cannot be swallowed as an unavailable pin; the backend 400 on GET /api/automation/proposals/{id} is model binding on a non-GUID id and nothing else (no ApiBehaviorOptions override; the handler's failure paths are 401, 403 and 404 only; the 400 mapping is reachable only from ValidationError and WipLimitExceeded, neither on this route); expectedStatuses gates only the console log in http.ts, the 401 session interception sits outside that gate and 401 is not listed, the retry interceptor is unaffected, and the option matches the shipped cardsApi precedent; every other getProposal caller keeps its arity and no Paper spec asserts exact pin-read args; the 400 path reaches recordQueueRefreshSuccess like 403 and 404, so the #2630 recovery signal is honest (the list read succeeded); the settled-row notice suppression matches 403 and 404; both skins consume the same unavailableProposalId state; PaperReviewView.vue and every Paper spec untouched; no backend change.

Triage:

  • MEDIUM, tracked on Review queue refresh residuals: deep-link re-auth, poll restart after recovery, stale indicator (from PR #2208) #2214 under item 2 (persistent disclosure of non-transient list-read failures): a 400 on the LIST leg (a non-GUID boardId query reaches GetProposals through normalizeBoardIdQueryParam, which only trims) still freezes the poll silently: the explicit load toasts once, every tick thereafter 400s with no degraded state and the surface shows the ordinary empty state. Pre-existing; this PR fixes the pin leg only. The next slice takes both legs.
  • MEDIUM, tracked on Review queue refresh residuals: deep-link re-auth, poll restart after recovery, stale indicator (from PR #2208) #2214 with the same slice: the unavailable-panel copy ("no longer available ... applied, archived, or removed") is wrong for a malformed id that never named a proposal; a link-shape variant needs both views and PaperReviewView.vue is held by fix: guard deferred review receipts by decision locus #2629.
  • LOW, tracked with the same slice: the explicit path still toasts a generic failure for a malformed hash while the poll converts it to the unavailable panel 15 s later; the asymmetry is pinned on purpose for now.
  • LOW, taken in round 2 (comments only): the LegacyReviewView comment saying the panel is for 403/404, the "confirmed 404" comment on the unavailable ref, and a one-sentence scope warning on isMalformedTargetError.
  • LOW: no STATUS line is owed beyond the coordinator's thirteenth block entry.

Merge gate: ci-required green at the round-2 head, aged three minutes, then merge commit; no further review round because round 2 changes comments only.

Review round 2, comment text only, no logic and no spec change.

The Legacy panel comment still named 403/404 as the whole set; it renders for a
400 target too. The unavailableProposalId comment still said "a confirmed 404";
the ref holds 404, a by-id 403 and a 400, plus the fail-closed wrong-identity
and cross-board cases.

isMalformedTargetError gains the boundary that makes it sound: it reads the
by-id leg only. A 400 from the LIST read means the query was rejected, which
says nothing about a pinned target, so moving the predicate to the outer catch
would silently downgrade a whole-queue failure into a pin-level outcome.

Refs #2214
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