fix(review): pair proposal headlines to operations by sequence, not by array index - #2609
Conversation
The proposal DTO carried its operations in whatever order they arrived in while presentation.operationHeadlines was always built in Sequence order, so any consumer pairing the two arrays by index could render headline n against a different operation. Two reachable orderings produced that mismatch: - MapToDto mapped proposal.Operations directly. On the create path that is the in-memory AddOperation order, which is the request's array order, with no database read involved. On the read path it is an EF Include with no ORDER BY, which today happens to be served from the (ProposalId, Sequence) index but is not guaranteed to be. - BuildEffectiveProposalDto replaced Operations with the revision's parsed set, which keeps the payload's array order. A reviewer editing the revision JSON can leave that array disagreeing with the operations' own sequence fields. Both now sort by Sequence, so operations and headlines share one contract. OrderBy is a stable sort, so operations sharing a Sequence keep their relative source order. Nothing depended on the previous order: the executor, the diff, the contract validator, capture triage and the similar-decision reader all sort by Sequence themselves, and card history uses the index only to number its own rows. BuildPresentation is unchanged, the operation vocabulary is unchanged, and Apply is unaffected because it re-parses the pinned revision and sorts before dispatch. Refs #2563
…ines ReviewProposalCard paired presentation.operationHeadlines[i] with operations[i]. The headlines are built server-side in Sequence order, so the pairing was only correct when the wire array happened to be sequence-ordered too. The visible harm is the enrichment path: a "Move card." headline paired with a non-move operation fails the actionType/targetType gate and silently loses its destination, and two moves paired in the wrong order name each other's columns. Sorting a copy by sequence before pairing mirrors what storedOperationsFallback in this same component and the Paper review surface already do. The backend now emits sequence order as well; this stays as the local defence that keeps the Legacy card correct against an older backend. The #2541 enrichment gate itself is unchanged. Refs #2563
|
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, review-and-ship round 1). Reviewer: one fresh-context independent reviewer subagent (read-only), input = merge-base..head diff at 0800e31 plus the worktree at that head. Verdict: SHIP, no CRITICAL or HIGH. What the reviewer refuted and could not break: the DTO sort cannot move what Apply executes (the executor re-parses the pinned revision payload and re-sorts by Sequence before dispatch); the order contract is total (one ProposalDto constructor site, one Operations replacement site, both sorted); pairing holds under duplicate sequences because OrderBy is stable and every consumer re-sorts the same list; saved revisions cannot carry null, negative or duplicate sequences (ProposalRevisionService and ProposalOperationStructureValidator), so only array order can disagree, which is what this PR normalizes; an independent consumer audit found no order-dependent consumer left unsorted; the frontend sort copies before sorting and the #2541 enrichment gate is untouched. Triage of the three LOWs (none blocks; no fix commit):
Merge gate: ci-required green at the exact head, aged three minutes, then merge commit. |
Summary
presentation.operationHeadlinesis built server-side inSequenceorder, but the proposal DTO'soperationsarray was not, andReviewProposalCard.vuepaired them by index. When the two orders disagreed, the reviewer read headline n against a different operation than the one it describes.The visible harm is on the enrichment path added by #2541. A
"Move card."headline paired with a non-move operation fails theactionType/targetTypegate and silently loses its destination, and two card moves paired in the wrong order name each other's columns. Both are shown in the new component spec.This fixes it on both sides: the backend now emits one order contract, and the Legacy review card sorts defensively so it stays correct against an older backend.
Closes #2563
Changes
Backend contract (
AutomationProposalService.cs)Two reachable orderings produced the mismatch, and both now sort by
Sequence:MapToDtomappedproposal.Operationsdirectly. On the create path that is the in-memoryAddOperationorder, which is the request's array order with no database read involved, so a client that posts operations out of sequence order got a mispaired response deterministically. On the read path it is an EFIncludewith noORDER BY.BuildEffectiveProposalDtoreplacedOperationswith the revision's parsed set, which keeps the payload's array order. A reviewer editing the revision JSON can leave that array disagreeing with the operations' ownsequencefields.Frontend defence (
ReviewProposalCard.vue)getOperationHeadlinessorts a copy of the operations bysequencebefore pairing. This mirrors the sort thatstoredOperationsFallbackin the same component and the Paper review surface already apply; it does not import fromPaperReviewView.vue. The #2541 enrichment gate itself is untouched.Consumer audit before changing the DTO order
Nothing depended on the previous order.
AutomationExecutorServicesorts bySequencebefore dispatch,GetProposalDiffAsync,ProposalOperationContractValidator,CaptureTriageServiceandSimilarDecisionServiceall sort themselves, andCardHistoryServiceuses the array index only to number its own rows (its serials now follow sequence order, which is an improvement). On the client,PaperReviewView.vue,ReviewAppliedDecisionRecord.vueandeditablePayloadalready sort by sequence; the remaining readers use.lengthor iterate order-independently.useBatchApproveProposals'sreviewFingerprintstringifies the array, so a stable order makes that fingerprint less prone to spurious mismatches, not more.Test plan
Verified, all from the worktree root unless noted.
dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~AutomationProposalServiceTests"— 151 passed, 0 failed.dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~AutomationProposalsApiTests|FullyQualifiedName~ProposalRevisionApiTests"— 94 passed, 0 failed.dotnet test backend/tests/Taskdeck.Architecture.Tests/Taskdeck.Architecture.Tests.csproj -c Release -m:1— 28 passed, 1 skipped, 0 failed.dotnet test backend/Taskdeck.sln -c Release -m:1— 8850 passed, 34 skipped, 0 failed across all six assemblies (Domain 1603, Application 4171, Api 2835 with 4 skipped, Cli 206, Architecture 28 with 1 skipped, Integration 7 with 29 skipped). No pre-existing failures appeared.cd frontend/taskdeck-web; npx vitest --run --maxWorkers=2 src/tests/components/review src/tests/views/ReviewView.spec.ts src/tests/composables/useProposalDisplayNames.spec.ts— 12 files, 184 passed, 0 failed.npm run typecheck,npx eslint src/components/review/ReviewProposalCard.vue src/tests/components/review/ReviewProposalCard.diff.spec.ts,npm run build,git diff --check— all clean.Red before green. Each test was written and run against the unfixed code first:
GetProposalByIdAsync_ShouldOrderOperationsBySequence_WhenStoredOutOfOrderfailed with{2, 1, 0} differs at index 0.GetProposalByIdAsync_ShouldOrderRevisedOperationsBySequence_WhenPayloadIsOutOfOrderfailed with{1, 0} differs at index 0.GetProposal_ShouldReturnOperationsInSequenceOrder_WhenStoredOutOfSequenceOrderfailed on the create response with{2, 1, 0} differs at index 0."Move card."where"Move card to “Done”."was expected, which is the enrichment loss described above.Mutation checks, each restored afterwards:
MapToDtosort: the Application test failed with{2, 1, 0}and the API test failed with{2, 1, 0}.BuildEffectiveProposalDtosort: the revision test failed with{1, 0}.ReviewProposalCardsort: both new component cases failed.NOT verified:
Includefrom the(ProposalId, Sequence)index, so the persisted read already returned sequence order. It is kept as a contract lock and the test's doc comment says so; the deterministic wire reproduction is the create response.Boundaries and risks
AutomationExecutorServicere-parses the pinned revision itself and sorts bySequencebefore dispatch, so sorting the response array cannot change what Apply executes.BuildPresentationis untouched, and so is the operation vocabulary and Apply dispatch.PaperReviewView.vueis not edited. It already sorts before indexing and is held by fix(review): bound the post-revision truth refresh and keep it retryable #2576.Sequenceis only validated as non-negative, so duplicates are possible.OrderByin LINQ andArray.prototype.sortin modern V8 are both stable, so operations sharing aSequencekeep their relative source order rather than being shuffled. Ordering within a tie is therefore unchanged, not newly defined.CardHistoryServicenumbers its pending rows by array index, so those serials now follow sequence order. No test asserted the previous order.2803355and is behindorigin/mainby seven documentation-only commits with no overlap in the changed files. fix(api): bring batch approve to batch execute's input and commit-boundary parity #2597, which edits the batch-approve region of the same service file, had not landed at the time of this run, so no merge oforigin/mainwas performed.