You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(purchase): approve and direct-execute map every failure to 409, partial runs mint no revocation token, and approved-row claim races #532
Purchase approve and direct execute map every failure to 409, partial runs mint no revocation token, and the approved state is claimable by more than one path.
approvePurchaseViaSession (internal/api/handler_purchases.go:730) returns 409 for any approve error (:786). ApproveAndExecute (internal/purchase/approvals.go:307) would need to return the row, as RunPlannedPurchaseNow (:331) now does (fix(api): map run-now failures by cause and stamp its audit fields #495).
directExecutePurchase (internal/api/handler_purchases.go:3057) returns 409 for every error (:3090) and stamps ExecutedAt (:3069) before the claim, so a refused direct execute leaves executed_at on a row that never ran.
Partial runs (partially_completed) mint no revocation token and send no executed email. This is also true on main.
Approve and run-now execute while the row is still approved. claimAndExecute (internal/purchase/manager.go:177, SQS path) and the stranded-approved recovery can also claim approved. Pre-existing race; needs a separate check.
Failure scenario
A direct execute refused by a policy check returns 409 with the wrong semantics and leaves executed_at set. A run that ends partially_completed cannot be revoked by token and the approver gets no email.
Approve and direct execute return the matching status class per error, executed_at is only set after a successful claim, partial runs mint a revocation token and send the email, and the approved-row claim race has a test or a documented single claimer.
Plan for #532 (checked against main feec673, 2026-10-06). #386 items 2/3 (runNowError, run-now stamping) are merged and are the pattern to follow; #584/#589/#585/#594 fixed the token-mode error mapping only. #415 is untouched.
Failure modes
[STILL PRESENT] Session approve maps every ApproveAndExecute error to 409: internal/api/handler_purchases.go:851 (approvePurchaseViaSession). ApproveAndExecute (internal/purchase/approvals.go:316) discards the row (_, token, err), so the handler cannot tell a lost claim from a provider failure after money moved.
[STILL PRESENT] directExecutePurchase maps every error to 409 (handler_purchases.go:3179) and stamps ExecutedAt/ExecutedByUserID/PreApprovalSkipReason and saves them (:3158-3167) before the claim, so a 4-eyes denial or lost claim leaves executed_at on a row that never ran. Run-now already stamps after the CAS (approvals.go:~380).
[STILL PRESENT] Partial runs (partially_completed): executeAndFinalize saves the row, but transitionApproveAndExecute returns before rotateApprovalToken (approvals.go:410 vs :419), so no revocation token and no executed email. The row itself exists, so there is no missing-record problem.
[STILL PRESENT, needs a test] approved-row claim: sync approve/run-now execute while the row is "approved"; claimAndRedrive (manager.go:452) claims stale "approved" rows. Window is only a sync run longer than the stale threshold.
Target mapping (follows runNowError, handler_purchases.go:370), applied to session approve, direct execute (token mode in slice 3)
ErrFourEyesDenied -> 403
no row returned and ErrExecutionNotInExpectedStatus/ErrNotFound -> 409 (lost claim)
no row returned, other error -> 500 (not started)
row returned and ErrAuditLoss -> 500
row returned, any other error (provider failure, partial) -> 502 with details {execution_id, status}
PR split (each <= ~400 lines, by money impact)
Approve-session + direct-execute error mapping, ApproveAndExecute returns the row, direct-execute stamps after the claim (this PR, size M).
Partial runs mint a revocation token and send the executed email; response reports partially_completed (size M).
Token-mode ApproveExecution returns the row and maps post-claim failures to 502 (SQS caller adjusted) (size S/M).
approved-row claim race: test the stale-approved reclaim vs an in-flight sync run, or document the single claimer (size S).
Order note: slice 2 builds on the row return from slice 1, so mapping goes first; no money-moved-without-record case exists on main (the row is always saved).
Activity
cristim commentedon Oct 6, 2026
Plan for #532 (checked against main feec673, 2026-10-06). #386 items 2/3 (runNowError, run-now stamping) are merged and are the pattern to follow; #584/#589/#585/#594 fixed the token-mode error mapping only. #415 is untouched.
Failure modes
_, token, err), so the handler cannot tell a lost claim from a provider failure after money moved.Target mapping (follows runNowError, handler_purchases.go:370), applied to session approve, direct execute (token mode in slice 3)
PR split (each <= ~400 lines, by money impact)
Order note: slice 2 builds on the row return from slice 1, so mapping goes first; no money-moved-without-record case exists on main (the row is always saved).