Skip to content

Recover a full detail superseded by a status-only read (#2960) - #3004

Merged
Chris0Jeky merged 1 commit into
mainfrom
issue-2960/superseded-detail-path
Sep 11, 2026
Merged

Chris0Jeky merged 1 commit into
mainfrom
issue-2960/superseded-detail-path

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

What

Closes #2960.

A status-only triage read takes per-item read authority, but it can only PATCH an existing
detailById row - it never carries a body. So when it superseded the first, uncached
fetchDetail for an id, the older body was correctly dropped and no newer body ever replaced it:
only a terminal status starts a fresh full-detail read, so the id stayed uncached through every
subsequent nonterminal tick.

The fix is a store-local handoff, not a new ordering rule:

  • fetchDetail records the id in supersededDetailIds when its read-authority branch drops a
    body and detailById holds nothing for that id. Checked at resolution time, so a supersession
    by another detail read - which caches its own newer body - never marks.
  • A nonterminal tick re-issues the full GET once, under that tick's own readAuthority, via
    readTriage (same ten-second deadline, transport cancellation and late-result rejection as every
    other ordinary read). Because it is the current read for the id, neither an older status nor an
    older full body can land on top of it.
  • It writes only detailById (syncSummary: false, no upsertSummary): list membership, order and
    row fields stay with the status patch. Only terminal hydration writes a row, unchanged.
  • A failed body read does not relabel a successful status observation as delayed/retrying; the
    mark stays set and the watch's existing backoff carries the retry.
  • Any cache write clears the mark (cacheDetail), which bounds the recovery to one read per gap;
    resetForLogout clears the set with the other per-item maps.

useInboxOrchestrator.selectItemById is not touched: its existing superseded branch assumes a
newer read owns the id and will deliver a body. This change makes that assumption true at the store
level rather than adding a second recovery path in the composable.

Per the issue, this is not described or treated as a proven user-visible outage: the reviewer of
#2959 found no ordinary UI path to a stranded visible panel (Legacy triage starts from cached detail
and retains it; leaving the view stops the watches; Paper editing consumes the returned body
directly). This closes the store contract gap.

Tests

The reproduction probe from the issue, turned into a regression, plus three ordering guards - all in
src/tests/store/captureStore.polling.spec.ts:

  • re-reads the full body a nonterminal status observation superseded while nothing was cached -
    the probe. Asserts the older body is still dropped as superseded, the cache is empty, then the
    next tick issues exactly one replacement GET, and later nonterminal ticks stay status-only.
  • does not re-read a full body when the superseded detail was already cached - no duplicate GET.
  • lets a newer foreground body win over the replacement read it raced - an older replacement body
    cannot overwrite newer state, and the settled body stops further replacement reads.
  • retries a failed replacement read on the next tick without reporting the status check as delayed.

Verified failing before the fix (probe: expected ... 2 times, but got 1; cache still undefined)
and passing after.

Checks run (worktree C:/wt/td-2960, head 969984d7115c8ce032ed1a319d0ab84bd1a912e0, base e84ccfeda)

  • npx vitest --run --maxWorkers=2 src/tests/store/captureStore.spec.ts src/tests/store/captureStore.polling.spec.ts src/tests/store/captureStore.integration.spec.ts src/tests/store/captureStore.demo.spec.ts src/tests/composables/useInboxOrchestrator.spec.ts - 224 passed (220 before + 4 new).
  • npx vitest --run --maxWorkers=2 over the Inbox/detail consumer specs (InboxDetailPanel.degraded, primaryActionGuards, degradedMode, InboxView, views/paper/inbox/*, PaperInboxView) - 379 passed in 11 files.
  • npm run typecheck (vue-tsc -b) - clean.
  • npm run build - clean.
  • npx eslint src/store/captureStore.ts src/tests/store/captureStore.polling.spec.ts - clean.
  • node scripts/check-docs-governance.mjs, node scripts/check-doc-links.mjs - both pass (one sentence added to docs/product/INBOX_TRIAGE_POLLING.md, Last Updated bumped).

NOT verified

  • No backend tests run: no backend file is touched and the status/detail wire contract is unchanged.
  • No Playwright/browser journey run (tests/e2e/inbox-triage-polling.spec.ts needs a running
    stack); the recovery is exercised at the store contract level only.
  • No live-server run: the replacement GET is proved against the mocked captureApi, not a real
    GET /api/capture/items/{id} under a real network.
  • Hosted CI at this exact head is pending.
  • Request-volume impact under many simultaneously watched, never-opened captures is reasoned about
    (at most one extra GET per gap, cleared by any cache write) but not measured.

A status read takes per-item read authority but can only patch an existing
`detailById` row. When it superseded the first, uncached `fetchDetail` for an
id, the older body was correctly dropped and no newer body ever arrived: only
a TERMINAL status started a fresh full-detail read, so the id stayed uncached
through every nonterminal tick.

`fetchDetail` now records the id when its read-authority branch drops a body
and nothing is cached, and a nonterminal tick re-issues the full GET once under
that tick's own `readAuthority`, through `readTriage` for the same deadline,
cancellation and late-result rejection as every other ordinary read. The
recovery is therefore the newest read for the id, so neither an older status
nor an older full body can land on top of it. It writes only `detailById`
(`syncSummary: false`, no `upsertSummary`), leaving list membership, order and
row fields to the status patch, and it does not relabel a successful status
observation when the body read fails - the mark stays set and the watch's own
backoff carries the retry. Any cache write clears the mark, which bounds the
recovery to one read per gap; `resetForLogout` clears the set with the other
per-item maps.

Tests: the reproduction probe turned into a regression, plus already-cached
supersession issuing no extra GET, a newer foreground body beating the
replacement read it races, and a failed replacement retrying silently on the
next tick.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T18:02:50.648732Z 969984d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review at exact head 969984d7115c8ce032ed1a319d0ab84bd1a912e0 against base e84ccfedaeb608e1fd5bb53cb984add2ff3f633c: CLEAN, no CRITICAL/HIGH findings.

Verified by the reviewer from source (read-only):

  • Ordering: readAuthority is a symbol identity compared at settlement; the path from the tick's isCurrent() check into replaceSupersededDetail and fetchDetail's authority re-set is fully synchronous, no flush: 'sync' watcher touches capture reads, and ticks are serialized by triageRunning, so the replacement can never land on newer foreground or status state.
  • Terminal arm: terminal and nonterminal are exclusive branches of one tick and the next tick cannot start while the replacement is awaited; a terminal body from the replacement is cached detail-only and reconciled by the next status patch.
  • Volume: the mark is set only in the authority branch (not by quiet batch reconciliation), so the worst case is one extra item GET per backoff tick per watched, uncached, marked item, cleared by any cache write.
  • Reset and cross-user: the set is cleared alongside the other per-account maps in resetForLogout; a mark is inert without a live watch and logout stops all watches; a read crossing logout reports epoch and cannot re-mark.
  • Tests: the probe fails on base and passes with the fix, call counts bound the extra read, the raced-replacement case interleaves real settlement order with a deferred promise, and the cached case asserts no duplicate GET.
  • The one added doc sentence is accurate and changes no existing contract wording.

Non-blocking notes, recorded here (law 2c):

  • LOW: the hash deep-link peekDetail + cacheDetail path sits outside the authority mechanism (pre-existing for terminal hydration); a peeked body cached while the replacement is in flight could be overwritten by the older replacement body, self-healed by the next tick.
  • LOW: the deadline/cancellation it.each covers status and terminal detail reads; the replacement read inherits the same 10 s deadline from readTriage but has no case of its own.
  • LOW: the doc's coverage inventory sentence was not extended with the new supersession-recovery cases.

Merge gate: hosted checks at this head plus the aging floor; merging with a merge commit once green.

@Chris0Jeky
Chris0Jeky merged commit 391c780 into main Sep 11, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2960/superseded-detail-path branch September 11, 2026 18:32
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 11, 2026
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.

Preserve a full-detail path when status polling supersedes an uncached read

1 participant