Skip to content

fix(api): map run-now failures by cause and stamp its audit fields - #495

Merged
cristim merged 2 commits into
mainfrom
fix/run-now-status-and-error-mapping
Oct 5, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/run-now-status-and-error-mapping

Conversation

@cristim

@cristim cristim commented Oct 5, 2026

Copy link
Copy Markdown
Member

What

Item 3 of #386 (from the #370 review): runPlannedPurchase ("Run now") in internal/api/handler_purchases.go.

  • Error mapping. Every RunPlannedPurchaseNow error came back as 409 "cannot be started", including a provider failure or a partial completion where money had already moved. Now:
    • 4-eyes refusal: 403
    • lost claim (ErrExecutionNotInExpectedStatus) or missing row (ErrNotFound): 409
    • store error before the claim: generic 500
    • purchase ran but its final status could not be saved (ErrAuditLoss): 500
    • execution failure (failed / partially completed): 502, with the row's finalized status and execution_id in the JSON body
  • Real status. The success response reports the claimed row's status, not a hardcoded "completed". The executed email now uses the post-run row, not the pre-run snapshot.
  • Audit stamp. Run-now now stamps executed_at, executed_by_user_id (the session user's UUID) and pre_approval_skip_reason = "run-now", the same columns direct-execute fills.

How

  • purchase.Manager.RunPlannedPurchaseNow returns (*config.PurchaseExecution, revocationToken, error). The row is non-nil once the CAS is won, including when execution fails, and nil when the run never started. The handler uses that to tell "never started" from "ran and failed". ApproveAndExecute's signature does not change.
  • Every 4-eyes refusal in checkDifferentApprover now wraps a new purchase.ErrFourEyesDenied sentinel ("approval declined"), so most messages read the same as before. A failure to evaluate the policy (config or store error) is not wrapped and still maps to 500.
  • The audit stamp is written inside transitionApproveAndExecute after the CAS is won, in the same save as approved_by. It is deliberately not a pre-claim save like directExecutePurchase does: a run-now row lives long and the scheduler can claim it concurrently, so saving the stale pre-claim snapshot could set a running row back to pending and re-arm it.

Verification

New Postgres testcontainers integration tests (internal/api/handler_purchases_run_now_integration_test.go) use a real store, the real manager and a stub provider, and call the handler.

Against origin/main (ed44785), with the test file copied in:

--- FAIL: TestRunNow_StampsExecutedAuditFieldsAndReturnsRowStatus   (run-now must stamp executed_at)
--- FAIL: TestRunNow_PartialFailureIsServerErrorWithRowStatus       (expected: 502, actual: 409)
--- FAIL: TestRunNow_FourEyesDenialIsForbiddenAndLeavesRowUntouched (expected: 403, actual: 409)
--- PASS: TestRunNow_LostClaimIsConflict

With the fix: all 4 pass. As a mutation check, I dropped updated.ExecutedAt = &now: the stamp and partial-failure tests failed again, and passed once I put the line back.

Also run with GOTOOLCHAIN=go1.26.6 GOWORK=off:

  • go build ./...
  • go vet ./... and go vet -tags integration ./...
  • unit tests for internal/{api,purchase,server,testutil,config}: 3995 passed
  • -tags integration for internal/api and internal/purchase: all pass
  • golangci-lint run ./internal/...: no issues

There is also a new table test, TestRunNowError, for the mapping branches. All of this is fixture-based; no real purchase was made.

Deliberately left

Refs #386

Every RunPlannedPurchaseNow error used to come back as a 409 "cannot be
started", including a provider failure or a partial completion where money
had already moved. The handler now maps a 4-eyes refusal to 403, a lost
claim or missing row to 409, a store error before the claim to 500, an
unsaved final status to 500, and an execution failure to 502 carrying the
row's finalized status (failed / partially_completed).

To tell "never started" from "ran and failed", RunPlannedPurchaseNow now
returns the claimed row (nil when the CAS was never won), and every 4-eyes
refusal wraps the new purchase.ErrFourEyesDenied sentinel. The success
response reports that row's status instead of a hardcoded "completed", and
the executed email uses the post-run row instead of the pre-run snapshot.

Run-now also stamps executed_at, executed_by_user_id and
pre_approval_skip_reason = "run-now" like direct-execute does, but after
the CAS is won and in the same save as approved_by: a pre-claim save of
the stale row could revert a concurrent scheduler claim.

Refs #386
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 12 billable files and costs up to $3.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 5 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 58 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 2205d2a7-4cb3-4f50-b1e0-41b2ab0fc56c
📥 Commits

Reviewing files that changed from the base of the PR and between 4403023 and 8cb5c78.

📒 Files selected for processing (12)
  • internal/api/execution_scope_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_run_now_integration_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/handler_test.go
  • internal/api/mocks_test.go
  • internal/api/types.go
  • internal/config/types.go
  • internal/purchase/approvals.go
  • internal/purchase/approvals_test.go
  • internal/server/interfaces.go
  • internal/testutil/mocks.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/bug Defect triaged Item has been triaged labels Oct 5, 2026
runNowError checked ErrNotFound / ErrExecutionNotInExpectedStatus before
final == nil, so a plan deleted mid-run (or an audit-loss wrapping
ErrNotFound) after the claim was reported as "cannot be started" (409)
although the row was already failed or approved. Apply the 409 only when
no claim was won; otherwise fall through to 502/500 with the real status.

Also reword the executed_by_user_id / pre_approval_skip_reason docs: the
four-eyes check runs on run-now rows and ApprovedBy is stamped.

Refs #386
@cristim
cristim merged commit a442002 into main Oct 5, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant