Skip to content

fix(purchase): approve and direct-execute map every failure to 409, partial runs mint no revocation token, and approved-row claim races #532

Open

Description

@cristim

Summary

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.

Location (origin/main 496d9d7)

  • 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.

Context

Related: #260, #415, #386.

Acceptance

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.

Activity

  1. cristim commented on Oct 6, 2026

    @cristim
    MemberAuthor

    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).
    • [PARTLY FIXED] Token approve (approveViaToken, :760): tokenActionError maps invalid/expired/wrong-status to 403/410/409 (fix(api): answer 4xx for bad approval tokens and a missing revoke execution #584/fix(api): validate the emailed approval token on the delayed-approval path #589/fix(purchase): refuse mismatched client discriminators when execute omits the recommendation id #585). Post-claim execution failures fall through as a raw 500, not 502 with the row status, because ApproveExecution returns no row.
    • [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)

    1. Approve-session + direct-execute error mapping, ApproveAndExecute returns the row, direct-execute stamps after the claim (this PR, size M).
    2. Partial runs mint a revocation token and send the executed email; response reports partially_completed (size M).
    3. Token-mode ApproveExecution returns the row and maps post-claim failures to 502 (SQS caller adjusted) (size S/M).
    4. 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions