Skip to content

fix(purchase): gate user-facing Retry on provider re-drive safety (#1668) - #1713

Merged
cristim merged 4 commits into
mainfrom
fix/1668-retry-redrive-safety
Aug 5, 2026
Merged

cristim merged 4 commits into
mainfrom
fix/1668-retry-redrive-safety

Conversation

@cristim

@cristim cristim commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

The defect

A purchase_executions row reaching status="failed" does not prove the commitment never landed. A timeout, a lost response, or a failed post-purchase history write all leave a failed row behind an order the provider actually accepted. Every retry is therefore potentially a re-drive, and only a provider-side duplicate guard makes it safe.

The codebase already knew this. purchase.recIsSafeToRedrive (internal/purchase/manager.go) encoded that Azure savings-plans is unsafe to re-drive (order alias named from time.Now().UnixNano(), no server-side idempotency key) and that an unrecognized provider must be refused. Its wrapper allRecsSafeToRedrive was called from exactly two places, both in the reaper.

It was never called from the user-facing retry path. So an operator clicking Retry on a landed Azure savings-plans row created a successor that, once approved, bought a second savings plan: a multi-year commitment that cannot be canceled.

The fix

Consult the existing predicate on the money path, rather than adding a second notion of re-drive safety.

  • recIsSafeToRedrive becomes recRedriveRefusalReason (the same decisions, expressed as an explanation), and RedriveRefusalReason(exec) is exported over an execution. allRecsSafeToRedrive is now RedriveRefusalReason(exec) == "", so the reaper and the API cannot disagree. The reaper's semantics are unchanged, including empty-recs (refused) and unknown-provider (refused).
  • The retry endpoint gates on it in checkRetryEligibilityGates (renamed from checkRetryRateGates; one call site). The refusal is a 409 naming the provider reason, in the same shape as the existing ops-hint 409, plus a redrive_unsafe: true flag marking it as permanent rather than operator-fixable.
  • The gate runs first, and ?force=true does not override it. force exists to push past the attempt threshold; there is no recovery from a wrongly-overridden savings-plan purchase, and an operator who genuinely wants a second commitment can submit a fresh purchase, which is an explicit buy rather than a retry of one that may already exist.

Every path that can create a retry successor

RetryExecutionID / RetryAttemptN writers and all re-drive sites on main, enumerated rather than assumed:

Path What it does Gate status
internal/api/handler_purchases.go retryPurchase → persistRetryExecution (route POST /api/purchases/retry/{id}, router.go:178) the user-facing Retry button: creates a successor row from a failed row the hole; now gated by checkRetryEligibilityGates
internal/purchase/manager.go RecoverStrandedApprovals → claimAndRedrive automatic in-place re-drive of stranded approved rows already gated by allRecsSafeToRedrive, unchanged
internal/purchase/reaper.go:224 appends "; safe to retry" to the failure note already gated; wording only, initiates no purchase
internal/api/handler_purchases.go persistExecutionAndSuppressions fresh executePurchase submission not a re-drive: a new user-initiated buy, not a replay of an existing row
internal/api/handler_plans.go:462 createPurchaseExecutionsTx creates executions from a plan's ramp schedule not a re-drive: no predecessor execution
internal/api/handler_purchases.go finalizePurchaseStatus, approve/cancel writers (:841, :2279, :2707) mutate an existing row's status do not create a successor
CLI (cmd/), SQS message types (execute_purchase, approve, cancel, send_notification) no retry/re-drive entry point exists n/a

So the answer is "one path", but only after checking all of them. internal/purchase/** and internal/api/handler_purchases.go were grepped for every RetryExecutionID/RetryAttemptN write, every SavePurchaseExecution* call, and every re-drive mention.

Slug coverage was verified rather than assumed. mapSavingsPlansSlug recognizes six savings-plans spellings, while the predicate excludes only savingsplans and savings-plans. The other four (savings-plans-compute, -ec2instance, -sagemaker, -database) map to AWS-only SupportedSavingsPlansType values; Azure's GetServiceClient accepts only ServiceSavingsPlansAll and returns "unsupported service" for the rest, so those spellings can never purchase on Azure. The dispatch axis and the guard axis have the same reach, including case sensitivity (both are exact-match).

Regression test: fails pre-fix, passes post-fix

internal/api/handler_purchases_retry_redrive_test.go drives the real chain the operator drives: the retry HTTP handler, then the real purchase.Manager executing the successor it persisted, counting commitments that reach the provider. It asserts on purchase counts, not statuses: a unit test on RedriveRefusalReason alone stays green either way, because the predicate was already correct and simply unconsulted.

Pre-fix (source reverted to origin/main, new tests kept)

=== RUN   TestRetryOfLandedAzureSavingsPlanPurchasesNothing
--- FAIL: TestRetryOfLandedAzureSavingsPlanPurchasesNothing (0.00s)
    --- FAIL: TestRetryOfLandedAzureSavingsPlanPurchasesNothing/savingsplans (0.00s)
    --- FAIL: TestRetryOfLandedAzureSavingsPlanPurchasesNothing/savings-plans (0.00s)
--- FAIL: TestRetryOfLandedAzureSavingsPlanIsNotForceOverridable (0.00s)
--- FAIL: TestRetryOfUnknownProviderRowIsRefused (0.00s)
--- PASS: TestRetryOfFailedAzureReservationStillPurchasesOnce (0.00s)
FAIL	github.com/LeanerCloud/CUDly/internal/api	1.080s

with the money assertion naming the second purchase, and the purchase log line above it showing the duplicate reaching the "cloud":

[INFO] Purchasing: 1x Standard_D2s_v3 in westeurope (azure/savingsplans)
[INFO] purchase[7036be7e-...]: azure/savingsplans/westeurope/Standard_D2s_v3 PurchaseCommitment succeeded

    Error:      Should be empty, but was [b9492665cde10702f6e919d25a0f6f61a5ee7a5ed6ca26ab423bfcf827e44140]
    Test:       TestRetryOfLandedAzureSavingsPlanPurchasesNothing/savingsplans
    Messages:   retrying a possibly-landed Azure savings-plans row must fire ZERO purchases; any
                token here is a second, non-cancellable savings plan bought by one Retry click (issue LeanerCloud/cloud-commitments-cli#1668)

    Error:      An error is expected but got nil.
    Messages:   the retry must be refused

The over-blocking guard (TestRetryOfFailedAzureReservationStillPurchasesOnce) passes pre-fix, as it must: it exists to catch a fix that is too broad.

Post-fix

$ go test ./internal/api/ -run TestRetryOf -count=1
Go test: 10 passed in 1 packages

Cases covered: both savings-plans spellings; a mixed execution whose unsafe rec is not first (so a head-only check would let it through); an unknown provider; ?force=true; and the over-blocking guard, an azure/compute row of identical shape that must still retry into exactly one purchase, carrying the predecessor's token so Azure's two-step lookup dedupes it.

Gates

Gate Result
go build ./... clean
go vet ./... clean
go test ./... -count=1 6542 passed, 41 packages
gocyclo -over 10 -ignore "_test\.go" . (v0.6.0, as CI runs it) exit 0
golangci-lint run ./... at CI-pinned v2.10.1 exit 0, 0 issues.
gofmt -l internal/ clean

Behaviour change: executions with no recommendations

Recorded explicitly because it is the one behaviour outside the Azure SP case that this PR was at risk of changing, and it went through two revisions.

Retry path: unchanged from main. A failed execution carrying no recommendations stays retryable. An interim revision of this PR refused it (the shared predicate had inherited the reaper's empty-recs condition), which would have been a regression: createPurchaseExecutionsTx (handler_plans.go:452) and getOrCreateExecution (purchase/notifications.go:130) both create rec-less executions, a failed approval email marks them failed, and pre-fix validateAndTotalRecommendations([]) returned nil so they were retryable. Since such a row buys nothing, it cannot double-buy, so refusing it bought no safety while removing a real recovery path. The refusal now fires only where the duplicate hazard exists.

Reaper path: unchanged from main. allRecsSafeToRedrive still refuses the empty case, because there a refusal selects a benign safe-fail (mark the row failed, surface it for a human) rather than a terminal one. Same input, different consequence, so the condition belongs to that caller and not to the shared predicate.

Net: neither caller's behaviour on empty recommendations differs from origin/main. RedriveRefusalReason answers exactly one question, could re-driving these recommendations buy something twice, which is what makes it safe to export to a caller whose refusal is permanent.

Notes for review

  • Scope: this PR touches no frontend file (git diff origin/main -- frontend/ is empty). An interim revision rendered the redrive_unsafe refusal as a terminal state in History; it was reverted. The money risk is closed server-side and does not depend on any client honoring the flag, so what remained was presentation: a Retry button that is always offered and always fails. Fixing that properly needs the re-drive verdict on the History row projection (handler_history.go + frontend), which supersedes rather than extends the interim in-place suppression. Tracked in #1714 (p2).
  • redrive_unsafe has no consumer yet by design; a comment at its write site records fix(frontend/history): Retry button still offered on Azure savings-plans rows the backend now refuses cloud-commitments-platform#161 as the intended one so it does not read as dead.
  • loadAndValidateRetryRequest sits at gocyclo 10. It measures 10 on origin/main too and this PR does not add to it (only the callee was renamed); five other functions in the same file are at the same ceiling. Its gates are a documented ordered sequence whose order is the security boundary, so splitting it belongs in its own review rather than a money-path fix.
  • Out of scope, filed separately: fix(purchase): pre-#1713 retry successors can still buy a second Azure savings plan on approval #1718 (p1) — this gate is at creation time, so an Azure SP successor already sitting in pending/notified/approved from a pre-fix retry will still purchase on approval. Creation-time gating cannot reach those rows; this PR stops the ongoing bleed.

Closes #1668

Summary by CodeRabbit

  • Bug Fixes
    • Unsafe purchase retries now return a clear HTTP 409 response with structured operator guidance.
    • Blocked retries no longer create successor purchases or trigger cloud purchases.
    • Forced retries can no longer bypass safety protections.
    • Safe retries continue to execute once, while eligible failed executions remain retryable.
    • Added safeguards for unsupported providers, mixed recommendations, and Azure savings-plan purchases.
    • Recommendationless failed executions remain eligible for retry without creating purchases.

@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/few Limited audience effort/s Hours type/bug Defect labels Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4091936c-5d2d-46de-b235-a7bab7e06dca

📥 Commits

Reviewing files that changed from the base of the PR and between bba8576 and f47edb4.

📒 Files selected for processing (4)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_retry_redrive_test.go
  • internal/purchase/manager.go
  • internal/purchase/redrive_guard_reach_test.go
📝 Walkthrough

Walkthrough

Retry redrives now use shared provider-safety reasons. Unsafe Azure savings-plan and unknown-provider retries return non-overridable HTTP 409 responses before successor creation. Tests cover refusal, allowed, recommendationless, mixed, and idempotent retry paths.

Changes

Purchase redrive safety

Layer / File(s) Summary
Redrive refusal reasons
internal/purchase/manager.go
RedriveRefusalReason reports unsafe Azure savings-plan and unknown-provider recommendations while preserving eligible AWS, Azure reservation, and GCP paths.
Retry eligibility gate
internal/api/handler_purchases.go
The retry handler applies provider safety checks before existing failure and retry-threshold gates. Unsafe retries return structured HTTP 409 responses that force=true cannot override.
Redrive regression coverage
internal/api/handler_purchases_retry_redrive_test.go, internal/purchase/redrive_guard_reach_test.go
Integration and reachability tests verify refusal responses, zero successor and cloud purchases, recommendationless retries, mixed execution rejection, unknown-provider rejection, and single Azure reservation execution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RetryClient
  participant RetryHandler
  participant PurchaseManager
  participant CloudProvider
  RetryClient->>RetryHandler: submit retry request
  RetryHandler->>PurchaseManager: evaluate RedriveRefusalReason
  PurchaseManager-->>RetryHandler: refusal reason or eligible
  alt redrive unsafe
    RetryHandler-->>RetryClient: HTTP 409 redrive_unsafe
  else redrive eligible
    RetryHandler->>PurchaseManager: create and execute successor
    PurchaseManager->>CloudProvider: purchase with predecessor-derived token
    CloudProvider-->>PurchaseManager: purchase result
    PurchaseManager-->>RetryClient: retry result
  end
Loading

Possibly related issues

  • Issue 1718 — Reuses redrive refusal handling in purchase executors.
  • Issue 1714 — Reuses redrive refusal handling in History retry eligibility.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes gating user-facing purchase retries on provider re-drive safety.
Linked Issues check ✅ Passed The changes meet issue #1668 by blocking unsafe retries, preserving safe retries, preventing force overrides, and adding regression coverage.
Out of Scope Changes check ✅ Passed The implementation, documentation, and regression tests directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1668-retry-redrive-safety

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

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
internal/purchase/manager.go (2)

293-296: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the exported function against a nil exec.

RedriveRefusalReason is now exported and consumed by internal/api. Line 294 dereferences exec directly, so a nil pointer panics inside the retry request path. A fail-closed guard keeps the refusal semantics consistent with the "unknown state" branch below it.

🛡️ Proposed nil guard
 func RedriveRefusalReason(exec *config.PurchaseExecution) string {
+	if exec == nil {
+		return "this execution could not be loaded, so what it did or did not purchase cannot be established"
+	}
 	if len(exec.Recommendations) == 0 {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/purchase/manager.go` around lines 293 - 296, Add a nil check at the
start of the exported RedriveRefusalReason function, returning the same
fail-closed refusal message used for unknown execution state before accessing
exec.Recommendations. Preserve the existing recommendation handling for non-nil
executions.

322-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider reusing the shared Azure savings-plans service constant.

internal/commitmentopts/probe_azure.go already defines azureSpService for this service name. The literals here duplicate that value in a second package. If the canonical spelling changes, this gate silently stops matching. A shared exported constant removes the duplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/purchase/manager.go` around lines 322 - 328, Expose the canonical
Azure savings-plans service name from internal/commitmentopts/probe_azure.go and
update the provider handling in the purchase manager to use that shared constant
instead of duplicating the literal. Preserve the existing GCP and
unknown-provider behavior.
internal/api/handler_purchases_retry_redrive_test.go (1)

128-132: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pin the SavePurchaseHistory expectation count.

store.AssertExpectations runs in cleanup. The two GetExecutionByID and TransitionExecutionStatus expectations use .Once(), so a changed call count fails the test. SavePurchaseHistory has no count, so it passes with one call or with many. Add .Once() so a duplicate history write for a single-rec execution fails here too.

♻️ Proposed change
-	store.On("SavePurchaseHistory", mock.Anything, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil)
+	store.On("SavePurchaseHistory", mock.Anything, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil).Once()

Note that only the safe Azure reservation test reaches this helper, and it purchases one recommendation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases_retry_redrive_test.go` around lines 128 - 132,
Add .Once() to the SavePurchaseHistory mock expectation in the test setup,
keeping the existing expectation arguments and return value unchanged so
duplicate history writes fail assertion cleanup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 1744-1751: Update the retry-error handling to inspect the
`redrive_unsafe` field separately from `ops_hint`, ensuring permanent redrive
refusals are handled distinctly rather than treated as operator-fixable hints.
Preserve the existing `ops_hint` behavior for retry errors without
`redrive_unsafe`.
- Around line 1727-1744: The retry gate in checkRetryEligibilityGates must not
permanently reject failed executions whose Recommendations are empty without a
recovery path. Update the execution creation flows in handler_plans.go and
notifications.go to reject or remediate empty recommendations before creating
failed executions, preserving normal retry behavior for valid executions.

---

Nitpick comments:
In `@internal/api/handler_purchases_retry_redrive_test.go`:
- Around line 128-132: Add .Once() to the SavePurchaseHistory mock expectation
in the test setup, keeping the existing expectation arguments and return value
unchanged so duplicate history writes fail assertion cleanup.

In `@internal/purchase/manager.go`:
- Around line 293-296: Add a nil check at the start of the exported
RedriveRefusalReason function, returning the same fail-closed refusal message
used for unknown execution state before accessing exec.Recommendations. Preserve
the existing recommendation handling for non-nil executions.
- Around line 322-328: Expose the canonical Azure savings-plans service name
from internal/commitmentopts/probe_azure.go and update the provider handling in
the purchase manager to use that shared constant instead of duplicating the
literal. Preserve the existing GCP and unknown-provider behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea1126f0-093e-47cb-bb1c-c1653bbfc89c

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and ab8293f.

📒 Files selected for processing (4)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_retry_redrive_test.go
  • internal/api/handler_purchases_test.go
  • internal/purchase/manager.go

Comment thread internal/api/handler_purchases.go
Comment thread internal/api/handler_purchases.go
@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — independent, money-path

Reviewed the committed diff at ab8293fe via gh pr diff / git show <sha>:<path>, not a worktree. Gates re-run locally from a detached worktree at that exact SHA. The implementer's enumeration and gate results were treated as claims to be re-derived, not as evidence.

Verdict: no blocking findings. The hole is closed, the gate is complete, and it fails closed. Three non-blocking items below, one of which (Finding 1) is a real residual money window that deserves a follow-up issue rather than a change to this PR.


Axis 1 — completeness of the gate (built independently)

I did not use the PR body's table. I grepped ab8293fe for every writer of RetryExecutionID / RetryAttemptN, every non-test SavePurchaseExecution* / CreatePurchaseExecution call, every TransitionExecutionStatus call site, every Status: "pending"/"approved"/"notified" construction, the AsyncMessage type set, and cmd/ + mcp/, then worked backwards to callers. Result:

Path Verdict
retryPurchase → persistRetryExecution (router.go:178) the only successor-creating path. Sole writer of RetryExecutionID/RetryAttemptN (handler_purchases.go:1882, :1890). Now gated.
RecoverStrandedApprovals → claimAndRedrive (manager.go:491) already gated by allRecsSafeToRedrive; unchanged
reaper.go:224 note wording only, initiates nothing
claimAndExecute (manager.go:179, SQS + cron) first execution, not a re-drive; SQS redelivery blocked by the approved/pending/notified → running CAS. See Finding 1.
ApproveAndExecute (approvals.go:332) pending/notified → approved; no predecessor. See Finding 1.
executeForAccount (execution.go:268, :298) per-account fan-out within one execution pass; children seed idempotencyLineageKey(root)+":"+account.ID, so a re-drive reproduces the token. Not a successor.
scheduled_fire.go:81, handler_plans.go:462, getOrCreateExecution (notifications.go:140), executePurchase → persistExecutionAndSuppressions fresh buys / plan-schedule rows; no predecessor execution
pausePlannedPurchase / resumePlannedPurchase / runPlannedPurchase (:271, :295, :320) accept only pending/paused/running as from-status — never failed — and create no successor
revokeViaSession (:1448), finalizePurchaseStatus (:841), approve/cancel writers mutate one existing row
CLI (cmd/), MCP (mcp/), queue message types (execute_purchase/approve/cancel/send_notification) no retry or re-drive entry point exists

My enumeration matches the PR body's. One path, correctly identified, correctly gated.

Axis 2 — fail-closed on unknown input — verified by execution

Wrote a throwaway probe in package purchase (run, then deleted — not proposed for commit):

  • Unknown provider (oci, oraclecloud), differently-cased provider (AZURE, Azure, Gcp), and whitespace-padded provider (" azure", "azure ") all hit the default: arm and refuse. No input reaches "safe" by falling through.
  • RedriveRefusalReason(&PurchaseExecution{}) (no recs) refuses.

Axis 3 — was the predicate weakened? No.

allRecsSafeToRedrive(exec) is now exactly RedriveRefusalReason(exec) == "". Both reaper call sites (reaper.go:224, manager.go:491) are byte-identical to main. Verified by execution that the two behaviours the reaper depends on are preserved: empty-recs → not safe, unknown-provider → not safe; plus pure-AWS → safe and mixed-with-Azure-SP → not safe.

Axis 4 — string matching / guard-vs-dispatch reach — verified by execution, this was the highest-risk axis

The dangerous direction is not "a third spelling exists" but "a spelling the guard passes that the dispatch still routes to the Azure savings-plans client". Manager.mapServiceType has a verbatim pass-through final arm (common.ServiceType(service)), which is exactly how a guard/dispatch reach gap would arise here.

I asserted the invariant directly rather than by inspection: for a candidate set of every mapSavingsPlansSlug/mapServiceSlug key, the literal string value of all 20 common.ServiceType constants, and upper/title/whitespace/underscore/dash-stripped variants of each, plus hand-picked variants (savings_plans, SavingsPlans, savingsPlans, SAVINGSPLANS, savings plans, savingsplans-all, "") —

if mapServiceType(s) == common.ServiceSavingsPlansAll then recRedriveRefusalReason({Provider:"azure", Service:s}) must be non-empty.

Passes with zero gaps. The reason it holds: ServiceSavingsPlansAll == "savingsplans" (pkg/common/types.go:107), which is one of the two literals the guard already excludes, so the pass-through arm cannot manufacture a third route to it. And providers/azure/provider.go:577 newServiceClientForSubscription routes only ServiceSavingsPlansAll to NewSavingsPlansClient; the four per-plan-type slugs map to ServiceSavingsPlansCompute/EC2Instance/SageMaker/Database, which hit default: → "unsupported service". Both axes are exact-match, so a cased or padded spelling fails the dispatch closed rather than slipping past the guard. The PR body's claim here is correct; I re-derived it rather than accepting it.

Axis 5 — does the refusal actually prevent the purchase?

Yes. checkRetryEligibilityGates is called from loadAndValidateRetryRequest, which is the first statement of retryPurchase (:1612) and returns early on error. persistRetryExecution never runs, so no successor row, no suppressions, no tx, no email, no provider call, and nothing for a later sweep to pick up. The test asserts this positively (assert.Empty(t, saved, ...) on the refused path), not just the absence of a purchase.

Axis 6 — test quality — verified by execution, not by reading pasted output

The test drives Handler.retryPurchase (the real handler behind POST /api/purchases/retry/{id}) and, when the retry is allowed, feeds the persisted successor through a real purchase.Manager via ProcessMessage, counting PurchaseCommitment calls at the provider boundary. It asserts on purchase tokens, not statuses.

I reproduced the pre-fix run myself: checked out ab8293fe into a clean worktree, reverted only internal/purchase/manager.go and internal/api/handler_purchases.go to origin/main, kept the new tests, and ran go test ./internal/api/ -run TestRetryOf -count=1:

[FAIL] TestRetryOfLandedAzureSavingsPlanPurchasesNothing/savingsplans
[FAIL] TestRetryOfLandedAzureSavingsPlanPurchasesNothing/savings-plans
[FAIL] TestRetryOfLandedAzureSavingsPlanPurchasesNothing
[FAIL] TestRetryOfLandedAzureSavingsPlanIsNotForceOverridable
[FAIL] TestRetryOfMixedExecutionWithOneAzureSavingsPlanIsRefused
[FAIL] TestRetryOfUnknownProviderRowIsRefused

with Should be empty, but was [<token>] on each — i.e. the money assertion, five distinct duplicate purchases. TestRetryOfFailedAzureReservationStillPurchasesOnce passes pre-fix, which is what an over-blocking guard must do. So the test fails for the right reason and could not have passed with the bug present.

Harness plumbing checked rather than assumed: MockConfigStore.SavePurchaseExecutionTx falls through to SavePurchaseExecution when no Tx expectation is registered (internal/mocks/stores.go:1263) and WithTx runs fn(nil) (:1274), so the captured saved[0] really is the successor the tx wrote.

One honest limit: fanoutProvider.GetServiceClient returns the recorder for any ServiceType, so the test does not exercise the real Azure client switch. Axis 4 is therefore covered by my probe above, not by this test suite.

Axis 7 — proportionality

Good. One predicate renamed from bool to reason-string, one exported wrapper, one call site. No policy framework, no safety-mode enum, no capability table, no config knob. allRecsSafeToRedrive delegating to RedriveRefusalReason means there is one source of truth, not two — which is the specific divergence risk worth guarding against here.

Races

Retry acts only on failed; RecoverStrandedApprovals re-drives only stale approved. Disjoint from-statuses, no interaction. The already-retried guard is read-then-write outside a tx, so two concurrent retries on the same row can both pass it — but that is pre-existing, unchanged by this PR, and this gate makes it safer: for Azure SP both are now refused, and for the safe providers both successors copy the predecessor's lineage key so the provider dedupes. No new race.


Findings

1. Medium — the gate is at creation time only; successors already persisted by a pre-fix retry still execute

This PR stops new unsafe successors from being created. It does not stop ones that already exist. A row with RetryAttemptN > 0 carrying an Azure savings-plans rec, currently sitting in pending / notified / approved, will purchase normally when approved: neither claimAndExecute (internal/purchase/manager.go:179) nor ApproveAndExecute (internal/purchase/approvals.go:332) consults re-drive safety, correctly so, since they must be able to execute a first Azure SP purchase.

Not a blocker — the PR closes the ongoing bleed, and gating the executor is a different change with its own blast radius. But the window is real and the check is cheap and precise, because persistRetryExecution is the only writer of RetryAttemptN:

// in claimAndExecute / executeAndFinalize, before the provider call
if exec.RetryAttemptN > 0 {
    if reason := RedriveRefusalReason(exec); reason != "" {
        // fail the row with `reason` instead of purchasing
    }
}

Suggest filing this as a follow-up issue alongside LeanerCloud/cloud-commitments-platform#161, and running the ops query for existing rows:

SELECT execution_id, status, retry_attempt_n
FROM purchase_executions
WHERE status IN ('pending','notified','approved')
  AND retry_attempt_n > 0
  AND recommendations::text LIKE '%"provider":"azure"%'
  AND (recommendations::text LIKE '%"service":"savingsplans"%'
       OR recommendations::text LIKE '%"service":"savings-plans"%');

2. Low — redrive_unsafe is written but nothing reads it

handler_purchases.go:1750 sets "redrive_unsafe": true, and the new test asserts it, but frontend/src/history.ts:1337 branches only on ops_hint; no consumer of redrive_unsafe exists in frontend/src/. Defensible as a forward contract for LeanerCloud/cloud-commitments-platform#161 (which I confirmed is filed), and the operator does get the right message today via the ops_hint branch. Worth a one-line comment saying LeanerCloud/cloud-commitments-platform#161 is the intended consumer, so it does not read as dead payload.

3. Low — zero-recommendation failed rows are now un-retryable; call this out explicitly

validateAndTotalRecommendations returns (0, 0, nil) for an empty slice, so pre-fix a failed row carrying no recommendations could be retried into an empty successor. It is now refused with "this execution carries no recommendations…". This is the correct, safer behaviour and it aligns the API with the reaper, but it is a behaviour change slightly wider than the issue's literal scope, and it is why handler_purchases_test.go:3249 needed a fixture. The PR body mentions the fixture but frames it as a test detail; it is worth one line in the description saying the empty-recs case is now refused at the API too, so it is not a surprise later.

Nits (no action needed)

  • loadAndValidateRetryRequest measures exactly 10 on gocyclo (verified). It passes, but it is at the ceiling — the next gate added there will redden the -over 10 pre-commit hook. The checkRetryEligibilityGates extraction already anticipates this; noting it for whoever adds gate number four.
  • allRecsSafeToRedrive's doc block still carries the full provider-by-provider policy narrative, which is now also documented on RedriveRefusalReason. Two prose copies of one policy is exactly the divergence the code change was designed to eliminate. Consider trimming the wrapper's comment to a pointer.

Gates — re-run locally at ab8293fe, exit codes checked explicitly

Gate Command Result
build go build ./... exit 0
vet go vet ./... exit 0
tests go test ./... -count=1 exit 0, 29 packages with tests, zero FAIL lines
gocyclo gocyclo -over 10 -ignore "_test\.go" . at CI-pinned v0.6.0 exit 0, no output
lint golangci-lint run --timeout=10m ./... at CI-pinned v2.10.1 (ci.yml:49) exit 0, 0 issues.

Lint was run at v2.10.1 specifically, since a newer local version carries a different bundled ruleset and can return a false clean.

CI on the PR: every substantive check is green — Lint Code, Unit Tests, Integration Tests, E2E Tests, pre-commit, Build Docker Image, Snyk, all three Terraform validations, both IAM/role parity checks, GCP grant scope. Security Scanning is red for the unrelated repo-wide npm advisories tracked in #1712. I pulled the CI Success job log to confirm it is a pure aggregator ("One or more CI jobs failed" → exit 1, 3s, no checks of its own), so its red is a consequence of Security Scanning and not an independent failure — but it is also not masking one, since I re-ran every Go gate locally and they pass.

What I could not verify

  • No live Azure tenant, so the provider-side claim that an Azure savings-plans OrderAlias has no server-side idempotency key is taken from Azure Savings Plan purchase is non-idempotent (double-buy on re-drive) #1079 / the existing code comments, not re-tested. It is the premise of the original issue, not something this PR introduces.
  • Whether any affected rows described in Finding 1 actually exist in production — that needs the query above run against the real database.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Round 2: both CodeRabbit findings addressed (413e390)

Detailed replies are on the two threads. Summary of what changed and why.

1. The gate no longer refuses executions with no recommendations

It did refuse, and not vacuously: allRecsSafeToRedrive on main already had an explicit if len(exec.Recommendations) == 0 { return false }, and my first revision folded that into the shared predicate, exporting it to the retry endpoint.

That was wrong. An execution with no recommendations buys nothing, so it cannot double-buy: refusing it was failing closed against a hazard that is absent, and a refusal here is permanent. createPurchaseExecutionsTx (handler_plans.go:452) and getOrCreateExecution (purchase/notifications.go:130) both create rec-less executions, and a failed approval email marks them failed, so the previous revision would have stranded that entire class with no operator recovery.

"Empty by construction" vs "empty because loading failed" is decidable, and decides in our favour. A recommendations unmarshal failure propagates as an error from GetExecutionByID (store_postgres.go:1656), which the handler turns into a 500 before any gate runs; a rec-less row round-trips cleanly (null in, nil slice out, no error). So empty at the gate always means empty as created. Nothing is being assumed away.

The condition moved back into allRecsSafeToRedrive, which keeps its exact prior semantics, because it was never a statement about provider duplicate risk. It is the reaper sweep's own "nothing to do, hand it to a human" condition, and the safe-fail path it selects there is benign. RedriveRefusalReason now answers exactly one question, could re-driving these recommendations buy something twice, which is what makes it safe to export to a caller whose refusal is permanent. Still one source of truth for re-drive safety.

2. The frontend renders redrive_unsafe as terminal

This one mattered more than its Minor label. redrive_unsafe was being displayed through the ops_hint branch, which words the refusal as operator-fixable and re-enables the Retry button. That is the exact outcome this p0 exists to prevent: the user reads "failed, try again", clicks again, and escalates to support to get it unblocked. A correct backend gate behind a misleading message is a fix that passes tests and fails with a human in front of it.

A redrive_unsafe refusal now converges on the presentation the codebase already uses for non-retryable rows: the Retry button is replaced (not disabled) by the same .history-ops-hint badge renderActionCell renders, carrying the provider's reason, and is not re-enabled. The toast says Cannot retry: rather than Failed to retry:. The badge is built as a DOM node with textContent rather than an HTML string, because the reason interpolates the row's provider and is therefore untrusted.

Known limitation, stated rather than hidden: this suppresses the affordance on the row in place; a later loadHistory() re-render brings the button back, because the History row projection does not yet carry the re-drive verdict. That is LeanerCloud/cloud-commitments-platform#161. The backend refuses either way.

Evidence

Both new tests confirmed failing against the previous head ab8293fea:

=== RUN   TestRetryOfFailedRecommendationlessExecutionIsAllowed
    Error: this purchase cannot be retried safely: this execution carries no recommendations…
--- FAIL: TestRetryOfFailedRecommendationlessExecutionIsAllowed

PASS (18) FAIL (1)
1. redrive_unsafe refusal renders as terminal: no retry affordance left on the row (issue LeanerCloud/cloud-commitments-cli#1668)
   Error: expect(received).toBeNull()
   Received: <button class="btn-link history-retry-btn" data-retry-id="r-1" type="button">↻ Retry</button>

Both paired with a guard against over-correction: an ops_hint-only refusal must still leave the button live, and it passes at both heads.

Gate Result
go build ./... / go vet ./... clean
go test ./... -count=1 6543 passed, 42 packages
internal/purchase (reaper semantics unchanged) 233 passed
golangci-lint run ./... at CI-pinned v2.10.1 exit 0, 0 issues.
gocyclo -over 10 -ignore "_test\.go" exit 0
npx jest history-retry-button 19 passed, 0 failed
npx tsc --noEmit / npx eslint clean

package.json and package-lock.json are untouched (npm ci only, which reads the lockfile).

Security Scanning is red for the unrelated repo-wide npm advisories tracked in #1712.

cristim added a commit that referenced this pull request Aug 3, 2026
…y prose

Drops the frontend change out of this PR and applies three review notes.

The frontend terminal-rendering change is reverted to main. The money risk
is closed server-side: the retry endpoint returns a 409 and initiates no
purchase, and that does not depend on any client honoring it. What remains
is a Retry button that is always offered and always fails, which is a real
defect but a presentation one, and fixing it properly needs the re-drive
verdict on the History row projection (handler_history.go plus the frontend)
rather than the in-place suppression this PR carried. That work is tracked
in #1714 and does not belong in a p0 whose scope is stopping the spend.

Three notes from review:

- The redrive_unsafe detail now says at its write site that #1714 is its
  intended consumer, so the next reader does not have to wonder whether an
  unread field is dead.
- allRecsSafeToRedrive's doc block no longer restates the per-provider
  policy that RedriveRefusalReason owns. It documents only what it adds,
  the sweep's own empty-recommendations condition and why that condition is
  deliberately not part of the shared predicate. The policy table moves to
  RedriveRefusalReason, and gains the unknown-provider case it had always
  implemented but never listed.
- loadAndValidateRetryRequest is left at gocyclo 10. It measures 10 on
  origin/main too, this PR does not add to it, and five other functions in
  the same file sit at the same ceiling, so splitting only this one is an
  unrelated refactor. Its gates are a documented ordered sequence whose
  order is the security boundary, which is worth changing under its own
  review rather than inside a money-path fix.

Behavior is unchanged from the previous head: the empty-recommendations
narrowing stands, and the full Go suite, golangci-lint v2.10.1 and gocyclo
are clean.

Refs #1668
@cristim

cristim commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will perform a full review of the current PR head. This review will include commits that the automatic incremental review did not inspect.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1713

Timestamp: 2026-08-04T08:23:48.531Z
Learning: For the LeanerCloud/CUDly repository, CodeRabbit review quota is shared per developer across the organization and adapts to recent review volume. Space review requests to approximately one per hour. When an automatic review did not run because quota was exhausted, use a full review instead of an incremental review so CodeRabbit inspects the missed commits.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/purchase/manager.go`:
- Around line 322-341: Normalize the Azure service value in
recRedriveRefusalReason before checking for savings plans, accepting supported
casing and separator aliases such as “SavingsPlans” and “savings_plans” and
mapping them to the canonical savings-plans value. Keep the refusal message and
behavior unchanged once normalized, and define the needed canonical value
locally rather than reusing azureSpService.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0fd1ecd9-48de-4818-bf18-efb2207851b6

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and bef5ab3.

📒 Files selected for processing (3)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_retry_redrive_test.go
  • internal/purchase/manager.go

Comment thread internal/purchase/manager.go
@cristim

cristim commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will perform a full review of the current PR head. This review includes commits that the automatic incremental review did not inspect.

✅ Action performed

Full review finished.

@cristim

cristim commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will perform a full review of the current PR head. This review includes commits that the automatic incremental review did not inspect.

✅ Action performed

Full review finished.

@cristim

cristim commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will perform a full review of the current PR head. This review includes commits that the automatic review did not inspect.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/purchase/manager.go (1)

309-316: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the re-drive policy out of internal/purchase/manager.go.

The file is 654 lines, exceeding the 500-line guideline. Move RedriveRefusalReason and its helpers into internal/purchase/redrive.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/purchase/manager.go` around lines 309 - 316, Move
RedriveRefusalReason and all of its re-drive-specific helper functions from
manager.go into a new internal/purchase/redrive.go file, preserving their
package, signatures, behavior, and required imports. Remove the moved
definitions and any now-unused imports from manager.go.

Source: Coding guidelines

internal/api/handler_purchases_retry_redrive_test.go (1)

84-112: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the mock transaction-save delegation. SavePurchaseExecutionTx falls back to SavePurchase when no transaction expectation is registered. Add a short comment above the expectation to preserve this test invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases_retry_redrive_test.go` around lines 84 - 112,
Add a short comment immediately above the SavePurchaseExecution mock expectation
in purchasesFiredByRetry documenting that SavePurchaseExecutionTx delegates to
SavePurchase when no transaction expectation is registered, preserving the test
invariant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/handler_purchases_retry_redrive_test.go`:
- Around line 84-112: Add a short comment immediately above the
SavePurchaseExecution mock expectation in purchasesFiredByRetry documenting that
SavePurchaseExecutionTx delegates to SavePurchase when no transaction
expectation is registered, preserving the test invariant.

In `@internal/purchase/manager.go`:
- Around line 309-316: Move RedriveRefusalReason and all of its
re-drive-specific helper functions from manager.go into a new
internal/purchase/redrive.go file, preserving their package, signatures,
behavior, and required imports. Remove the moved definitions and any now-unused
imports from manager.go.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 03690a19-04a0-452a-8eab-ad9b1ef02e32

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and 9a8049b.

📒 Files selected for processing (4)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_retry_redrive_test.go
  • internal/purchase/manager.go
  • internal/purchase/redrive_guard_reach_test.go

cristim added 3 commits August 5, 2026 10:16
)

A purchase_executions row reaching status="failed" does not prove the
commitment never landed: a timeout, a lost response or a failed
post-purchase write all leave a "failed" row behind an order the provider
actually accepted. Every retry is therefore potentially a re-drive, and
only a provider-side duplicate guard makes it safe.

The codebase already knew this. purchase.recIsSafeToRedrive encoded that
Azure savings-plans is unsafe to re-drive (timestamp-derived order alias,
no server-side idempotency key) and that an unrecognized provider must be
refused. It gated the reaper's automatic re-drive and the reaper's
"safe to retry" failure note, and nothing else. The user-facing Retry
endpoint gated on status, RBAC, already-retried, the ops-hint map and the
attempt threshold, none of which knows anything about provider re-drive
safety, so clicking Retry on a landed Azure savings-plans row created a
successor that bought a SECOND savings plan. A savings plan is a
multi-year commitment that cannot be canceled.

Consult the existing predicate on the money path rather than adding a
second notion of re-drive safety:

- Rework recIsSafeToRedrive into recRedriveRefusalReason (same decisions,
  expressed as an explanation) and export RedriveRefusalReason over an
  execution. allRecsSafeToRedrive becomes RedriveRefusalReason(exec) == "",
  so the reaper and the API cannot disagree and the reaper's semantics,
  including empty-recs and unknown-provider, are unchanged.
- Gate the retry endpoint on it in checkRetryEligibilityGates (renamed
  from checkRetryRateGates, one call site). The refusal is a 409 naming
  the provider reason, in the same shape as the ops-hint 409 the History
  UI already renders, plus a redrive_unsafe flag marking it permanent.
- The gate runs first and ?force=true does not override it. force exists
  to push past the attempt threshold; there is no recovery from a wrongly
  overridden savings-plan purchase, and an operator who genuinely wants a
  second commitment can submit a fresh purchase.

Regression coverage drives the real chain the operator drives, the retry
handler followed by the real purchase.Manager executing the successor, and
asserts on the number of purchases reaching the cloud rather than on
statuses: a unit test on the predicate alone stays green either way,
because the predicate was already correct and simply unconsulted. Covers
both savings-plans spellings, a mixed execution whose unsafe rec is not
first, an unknown provider, ?force=true, and, as the over-blocking guard,
an Azure reservation row that must still retry into exactly one purchase
under the predecessor's token.

Closes #1668
…rminal (#1668)

Two CodeRabbit findings on the re-drive-safety gate.

Empty recommendations no longer refuse. createPurchaseExecutionsTx
(handler_plans.go) and getOrCreateExecution (purchase/notifications.go)
both create executions with no recommendations, and a failed approval
email marks those "failed". The gate refused them along with the
genuinely unsafe rows, and a refusal here is permanent, so that whole
class became unretryable with no operator recovery.

Such an execution buys nothing, so it cannot double-buy: refusing it was
failing closed against a hazard that is absent. The distinction between
"empty as created" and "empty because loading failed" is decidable and
lands on the safe side by itself, so nothing is being papered over: a
recommendations unmarshal failure propagates as an error from
GetExecutionByID (config/store_postgres.go), which the handler turns into
a 500 long before the gate runs. Empty at the gate is always empty as
created.

The condition moves back into allRecsSafeToRedrive, which keeps its
byte-for-byte semantics, because it was never a statement about provider
duplicate risk. It is the reaper sweep's own "nothing to do, hand it to a
human" condition, and the safe-fail path it selects there is benign.
RedriveRefusalReason now answers exactly one question, could re-driving
these recommendations buy something twice, so exporting it to a caller
whose refusal is permanent is safe.

The frontend renders the refusal as terminal. redrive_unsafe was reaching
the retry-error handler and being displayed through the ops_hint branch,
which words a refusal as operator-fixable and re-enables the Retry button.
That is the one outcome this p0 exists to prevent: the user reads "failed,
try again", clicks again, and buys the duplicate the backend just refused.
A redrive_unsafe refusal now replaces the button with the same ops-hint
badge renderActionCell already uses for non-retryable rows, carrying the
provider's reason, and does not re-enable. The badge is built as a DOM
node with textContent rather than an HTML string, since the reason
interpolates the row's provider.

Regression coverage for both, each confirmed failing on the previous head:
a failed recommendationless execution retries into a linked successor, and
a redrive_unsafe refusal leaves no retry affordance in the document while
an ops_hint-only refusal still leaves the button live.

The ops-hint test fixture reverts to its original form: it no longer needs
a recommendation to reach the gate under test.

Refs #1668
…y prose

Drops the frontend change out of this PR and applies three review notes.

The frontend terminal-rendering change is reverted to main. The money risk
is closed server-side: the retry endpoint returns a 409 and initiates no
purchase, and that does not depend on any client honoring it. What remains
is a Retry button that is always offered and always fails, which is a real
defect but a presentation one, and fixing it properly needs the re-drive
verdict on the History row projection (handler_history.go plus the frontend)
rather than the in-place suppression this PR carried. That work is tracked
in #1714 and does not belong in a p0 whose scope is stopping the spend.

Three notes from review:

- The redrive_unsafe detail now says at its write site that #1714 is its
  intended consumer, so the next reader does not have to wonder whether an
  unread field is dead.
- allRecsSafeToRedrive's doc block no longer restates the per-provider
  policy that RedriveRefusalReason owns. It documents only what it adds,
  the sweep's own empty-recommendations condition and why that condition is
  deliberately not part of the shared predicate. The policy table moves to
  RedriveRefusalReason, and gains the unknown-provider case it had always
  implemented but never listed.
- loadAndValidateRetryRequest is left at gocyclo 10. It measures 10 on
  origin/main too, this PR does not add to it, and five other functions in
  the same file sit at the same ceiling, so splitting only this one is an
  unrelated refactor. Its gates are a documented ordered sequence whose
  order is the security boundary, which is worth changing under its own
  review rather than inside a money-path fix.

Behavior is unchanged from the previous head: the empty-recommendations
narrowing stands, and the full Go suite, golangci-lint v2.10.1 and gocyclo
are clean.

Refs #1668
A review finding proposed normalizing rec.Service before the Azure
savings-plans re-drive guard, since exact matching lets variants such as
"SavingsPlans" or "savings_plans" past it.

They do get past the guard, and they cannot reach a purchase, which is the
property that decides whether money moves. mapServiceType is the only step
between rec.Service and the provider's service client, and Azure routes
exactly one ServiceType to its savings-plans client:
common.ServiceSavingsPlansAll, matched exactly in
newServiceClientForSubscription, which normalizes nothing and constructs
NewSavingsPlansClient nowhere else. Everything else lands in default and
returns "unsupported service" without purchasing. So the set of values that
can reach an Azure savings-plans purchase is exactly {"savingsplans",
"savings-plans"}, which is exactly the set the guard refuses.

Normalizing before the guard would widen what the guard treats as a savings
plan while the dispatch axis stayed exact, creating a second normalization
axis to keep in lockstep with the first forever. Two axes of one guard
drifting apart is a shape this repo has already been bitten by, so the
guard stays an exact match against the canonical constant and the coupling
gets a test instead.

The test asserts the biconditional over every key of both slug maps, the
literal value of all 20 ServiceType constants, and case, separator,
whitespace and near-miss mutations: a value dispatches to the Azure
savings-plans client if and only if the guard refuses it. 61 cases, no
gaps. Stated a second time as a closed set, so widening either axis shows
up as a diff to an explicit list and not only as a loop failure.

Confirmed non-vacuous: dropping the "savings-plans" spelling from the guard
fails both tests, naming the resulting hole.

Refs #1668
@cristim
cristim force-pushed the fix/1668-retry-redrive-safety branch from 9a8049b to f47edb4 Compare August 5, 2026 08:21
@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Rebased onto 0c92b7777 (main), which includes #1716's npm audit fixes (brace-expansion GHSA-rgw5-rvv9-x895, fast-uri GHSA-7p8r-x3mc-p8w7) — the previous Security Scanning failures on this PR were purely from the stale base, not from this PR's changes, and a CI re-run alone wouldn't have picked up the new base since re-runs reuse the same merge commit.

Rebase was clean, no conflicts. Verification after rebase (new head f47edb498):

  • go build ./...: clean
  • go test ./internal/purchase/... ./internal/api/...: all pass (2331 tests), including TestRedriveGuardReachMatchesDispatchReach and TestRedriveGuardRefusalSetIsExactlyTheDispatchableSpellings in redrive_guard_reach_test.go — the guard's biconditional (dispatch-to-Azure iff guard refuses) still holds
  • go vet ./...: clean
  • golangci-lint v2.10.1 (CI-pinned, run --timeout=10m from repo root): 0 issues
  • gocyclo -over 10 -ignore "_test\.go" .: 0 findings over threshold

@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Merging on a verdict whose SHA does not match head, with the justification recorded rather than waived.

CodeRabbit's latest verdict is against 9a8049bc5, the pre-rebase head, while the current head is f47edb498. The rebase onto 0c92b7777 was needed only to pick up #1716's npm advisory fixes, which is a change to the base, not to this PR's content.

Verified the rebase preserved the reviewed diff exactly:

diff(merge-base 02702a108 -> 9a8049bc5)  =  700 lines / 35862 bytes
diff(merge-base bba857673 -> f47edb498)  =  700 lines / 35862 bytes
byte-identical; same four files

So the verdict at 9a8049bc5 covers precisely the content at f47edb498. The SHA mismatch is an artifact of moving the base, not a gap in review coverage.

Everything else is clean at head: all checks green, no failing, no pending, zero unresolved threads.

Also on record for this PR beyond CodeRabbit: an independent adversarial review with no blocking findings, which re-derived the guard's completeness by probing every service-slug variant, independently reproduced the pre-fix failure (five tests failing on the money assertion with a recorded purchase token), and confirmed the test drives the real handler into a real purchase.Manager rather than exercising the predicate in isolation.

Two follow-ups are tracked separately and deliberately excluded here: #1714 (History still renders a Retry affordance the backend now refuses — presentational, the money risk is closed server-side) and #1718 (successors created by a pre-fix retry are still armed; creation-time gating cannot reach them, so the executors need the RetryAttemptN > 0 && RedriveRefusalReason != "" conjunction).

@cristim
cristim merged commit 5526aab into main Aug 5, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(purchase): user-facing Retry ignores recIsSafeToRedrive, so retrying a landed Azure savings-plan buys a second one

1 participant