Residual findings from the fresh-context adversarial review of PR #2463 (#2302, capture write generation). None were merge-blocking under the CRITICAL/HIGH bar; tracked here rather than fixed in a review-round cascade.
The guard PR #2463 shipped is a monotonic capture-write generation: recordCaptureWrite() bumps a counter, stores it per item in latestDetailWriteGenerationById, and bumps latestListWriteGeneration when the write also touches the summary cache. Readers snapshot at request start and drop their response if it moved.
MEDIUM 1 - batchTriage is the one capture mutation that never bumps the generation
frontend/taskdeck-web/src/store/captureStore.ts, batchTriage.
For the list cache this is masked, because its own fetchItems() bumps latestListLoadRequestId and pollBatchTriageCompletion's isCurrent() also checks that. It is NOT masked for the per-item detail cache: pollTriageCompletion's tick guards only on detailWriteGeneration(itemId).
Path: useInboxOrchestrator starts pollTriageCompletion(X) for the open item, and nothing stops it before a batch action runs. A batch ignore including X succeeds, batchTriage's refreshTerminalDetails writes the fresh Ignored detail, and a poll GET issued before the batch resolved lands afterwards. detailWriteGeneration('X') is unchanged, so cacheDetail restores the pre-batch Triaging / no-disposition detail — the exact #2302 defect class, on the one path the PR left uncovered.
Suggested fix: call recordCaptureWrite(id, true) for every result.results entry with success === true, immediately after the POST resolves and before the fetchItems()/refreshTerminalDetails() reconciliation. Cover with a deterministic regression in the style of the existing describe('capture write generation') block.
MEDIUM 2 - a mid-flight write silently discards an explicit, newer list load with no retry
frontend/taskdeck-web/src/store/captureStore.ts, fetchItems.
Dropping the response is right for the background poll, which retries in about 3 seconds, but fetchItems is also the only user-facing list load (useInboxOrchestrator loadInbox) and nothing re-issues it.
Path (UNVERIFIED - would need a spec interleaving keepItem with a scoped fetchItems): user clicks Keep, then switches board scope to B while the keep POST is in flight. loadInbox() issues the scoped list, keepItem resolves mid-flight and bumps the generation, and the scoped response is dropped. The Inbox then shows board A's rows while the scope selector reads B, until something else triggers a load.
Suggested fix: either apply loadedItems and re-upsert the summaries of items whose detail generation is newer than the snapshot, or restrict the list-generation drop to background readers and leave fetchItems on its existing latestListLoadRequestId ordering.
LOW 1 - triageItem bumps the list generation unconditionally
recordCaptureWrite(itemId, true) in triageItem is unconditional, but the summary write below it happens only when existingSummary or optimisticDetail is present. With neither (triage started for an item absent from both caches) the bump falsely invalidates any in-flight fetchItems, amplifying MEDIUM 2. Fix: compute syncSummary the way keepItem/archiveItem do, or move the bump after the optimistic block.
LOW 2 - a dropped final tick can produce a false batch-poll timeout warning
If the tick that would have completed the batch is discarded by a generation change and the 60s deadline lands before the next one, isComplete() is still false and the user gets the persistent BATCH_TRIAGE_POLL_TIMEOUT_MESSAGE toast for work that actually finished. Cosmetic - the cached state itself is correct.
Explicitly refuted by the same review (do not re-raise)
- peekDetail needs no guard; it writes neither cache.
- refreshTerminalDetails is double-guarded (shouldCache: isCurrent for the list generation, plus fetchDetail's own per-item snapshot).
- ignoreItem/cancelItem/triageItem bump before calling fetchDetail, and fetchDetail snapshots after the bump, so no mutation invalidates its own follow-up read.
- Continuous invalidation cannot starve either poller: pollTriageCompletion caps at 450 ticks, pollBatchTriageCompletion at its 60s deadline timer.
- latestDetailWriteGenerationById growth is bounded by distinct captures acted on in one session, far below the likewise-unpruned detailById. Not worth a change.
Refs #2302, PR #2463, and #2301 (the still-open poll-vs-poll race on the same seam).
Residual findings from the fresh-context adversarial review of PR #2463 (#2302, capture write generation). None were merge-blocking under the CRITICAL/HIGH bar; tracked here rather than fixed in a review-round cascade.
The guard PR #2463 shipped is a monotonic capture-write generation: recordCaptureWrite() bumps a counter, stores it per item in latestDetailWriteGenerationById, and bumps latestListWriteGeneration when the write also touches the summary cache. Readers snapshot at request start and drop their response if it moved.
MEDIUM 1 - batchTriage is the one capture mutation that never bumps the generation
frontend/taskdeck-web/src/store/captureStore.ts, batchTriage.
For the list cache this is masked, because its own fetchItems() bumps latestListLoadRequestId and pollBatchTriageCompletion's isCurrent() also checks that. It is NOT masked for the per-item detail cache: pollTriageCompletion's tick guards only on detailWriteGeneration(itemId).
Path: useInboxOrchestrator starts pollTriageCompletion(X) for the open item, and nothing stops it before a batch action runs. A batch ignore including X succeeds, batchTriage's refreshTerminalDetails writes the fresh Ignored detail, and a poll GET issued before the batch resolved lands afterwards. detailWriteGeneration('X') is unchanged, so cacheDetail restores the pre-batch Triaging / no-disposition detail — the exact #2302 defect class, on the one path the PR left uncovered.
Suggested fix: call recordCaptureWrite(id, true) for every result.results entry with success === true, immediately after the POST resolves and before the fetchItems()/refreshTerminalDetails() reconciliation. Cover with a deterministic regression in the style of the existing describe('capture write generation') block.
MEDIUM 2 - a mid-flight write silently discards an explicit, newer list load with no retry
frontend/taskdeck-web/src/store/captureStore.ts, fetchItems.
Dropping the response is right for the background poll, which retries in about 3 seconds, but fetchItems is also the only user-facing list load (useInboxOrchestrator loadInbox) and nothing re-issues it.
Path (UNVERIFIED - would need a spec interleaving keepItem with a scoped fetchItems): user clicks Keep, then switches board scope to B while the keep POST is in flight. loadInbox() issues the scoped list, keepItem resolves mid-flight and bumps the generation, and the scoped response is dropped. The Inbox then shows board A's rows while the scope selector reads B, until something else triggers a load.
Suggested fix: either apply loadedItems and re-upsert the summaries of items whose detail generation is newer than the snapshot, or restrict the list-generation drop to background readers and leave fetchItems on its existing latestListLoadRequestId ordering.
LOW 1 - triageItem bumps the list generation unconditionally
recordCaptureWrite(itemId, true) in triageItem is unconditional, but the summary write below it happens only when existingSummary or optimisticDetail is present. With neither (triage started for an item absent from both caches) the bump falsely invalidates any in-flight fetchItems, amplifying MEDIUM 2. Fix: compute syncSummary the way keepItem/archiveItem do, or move the bump after the optimistic block.
LOW 2 - a dropped final tick can produce a false batch-poll timeout warning
If the tick that would have completed the batch is discarded by a generation change and the 60s deadline lands before the next one, isComplete() is still false and the user gets the persistent BATCH_TRIAGE_POLL_TIMEOUT_MESSAGE toast for work that actually finished. Cosmetic - the cached state itself is correct.
Explicitly refuted by the same review (do not re-raise)
Refs #2302, PR #2463, and #2301 (the still-open poll-vs-poll race on the same seam).