Skip to content

fix(purchase): gate sweep/SQS execution on approval+AutoPurchase, fail-loud on delay-config error, safe reaper retry (adversarial-review follow-ups) - #1456

Merged
cristim merged 1 commit into
mainfrom
fix/purchase-approval-gate-failopen
Jul 17, 2026
Merged

fix(purchase): gate sweep/SQS execution on approval+AutoPurchase, fail-loud on delay-config error, safe reaper retry (adversarial-review follow-ups)#1456
cristim merged 1 commit into
mainfrom
fix/purchase-approval-gate-failopen

Conversation

@cristim

@cristim cristim commented Jul 17, 2026

Copy link
Copy Markdown
Member

Summary

  • F1 (HIGH - approval bypass): ProcessScheduledPurchases cron and handleExecutePurchase SQS path now gate on executableByScheduler: pending/notified rows with AutoPurchase=false or source=web are skipped/rejected without touching the cloud. Gate is fail-closed: a plan-fetch error is treated as ineligible, not as a pass.
  • F2 (HIGH - dead auto-create): GetExecutionByPlanAndDate now wraps ErrNotFound on zero rows; getOrCreateExecution branches on errors.Is(ErrNotFound) so the create branch is reliably reachable. Pre-fix the branch was dead code against the real store (tests only passed because mocks returned (nil,nil)).
  • F3 (MED-HIGH - fail-open money): Both approveViaToken and approvePurchaseViaSession now return 500 when GetGlobalConfig fails rather than executing without the configured free-cancel window. Pre-fix: if cfgErr == nil && delay > 0 silently fell through on transient config errors.
  • F4 (MED - reaper unsafe retry): reapOne only appends "; safe to retry" when allRecsSafeToRedrive returns true. Azure savings-plans lack server-side idempotency and must not be encouraged to retry without review.

Test plan

  • All four defects confirmed present on origin/main before implementing fixes
  • Regression tests added for F1 (5 new manager tests + 2 SQS message tests), F2 (pgxmock store test + ErrNotFound branch test in notifications), F3 (2 handler tests for token and session paths), F4 (2 reaper tests for Azure SP and empty recs)
  • Existing tests updated to reflect new gate: AutoPurchase: true set on plans that should execute; GetExecutionByPlanAndDate mock behavior unchanged (defensive switch handles both (nil,nil) and ErrNotFound)
  • 2633 tests pass across internal/purchase, internal/config, internal/api
  • go vet clean; gocyclo -over 10 (non-test files only, per pre-commit hook) clean; processOneExecution and checkAutoExecuteGate extracted to keep affected functions under cyclomatic limit of 10

Summary by CodeRabbit

  • Bug Fixes
    • Scheduled purchases now run only when automatic purchasing is enabled; web-originated purchases are skipped.
    • Approval requests stop safely when configuration cannot be read, preventing unintended immediate execution.
    • Purchase creation correctly handles missing prior executions while still surfacing other storage errors.
    • Retry guidance in failed purchase messages now reflects whether retrying is safe.
    • Multi-account and claim-race outcomes are reported more accurately.

… delay-config error, safe reaper retry

F1 (HIGH - approval bypass): add executableByScheduler gate to both
ProcessScheduledPurchases cron sweep and handleExecutePurchase SQS path;
pending/notified rows with AutoPurchase=false or source=web are skipped/rejected,
requiring explicit token-link approval. Gate is fail-closed: a plan-fetch error
counts as ineligible.

F2 (HIGH - dead auto-create): GetExecutionByPlanAndDate now wraps ErrNotFound
on zero rows; getOrCreateExecution branches on errors.Is(ErrNotFound) and
defensively treats (nil, nil) as not-found so the create branch is reliably
reachable. Pre-fix the create branch was dead code against the real store.

F3 (MED-HIGH - fail-open money): both approveViaToken and approvePurchaseViaSession
now return 500 when GetGlobalConfig fails instead of silently executing without
the configured free-cancel window. Pre-fix: if cfgErr == nil only gated the delay;
an error meant immediate execute.

F4 (MED - reaper unsafe retry): reapOne only appends "; safe to retry" when
allRecsSafeToRedrive returns true. Azure savings-plans have no server-side
idempotency key and must not be retried without review.

Regression tests added for all four defects; existing tests updated to reflect
the new AutoPurchase gate (set AutoPurchase=true on plans that should execute,
update ErrNotFound mocks). gocyclo kept under 10 by extracting processOneExecution
and checkAutoExecuteGate helpers.
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/l Weeks type/security Security finding labels Jul 17, 2026
@cristim

cristim commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 17, 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 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae485980-ff2c-482d-9611-ad396eb876a0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Purchase approval now fails closed on global-config errors. Scheduled and message-driven execution paths enforce source and AutoPurchase eligibility. Not-found lookup errors are classified for execution creation, and reaper retry messaging now depends on recommendation safety.

Changes

Purchase execution behavior

Layer / File(s) Summary
Fail-closed purchase approval
internal/api/handler_purchases.go, internal/api/handler_purchases_test.go
Token-based and session-based approval return errors when global configuration cannot be read, and tests verify no execution calls occur.
Not-found execution lookup handling
internal/config/store_postgres.go, internal/config/store_postgres_pgxmock_test.go, internal/purchase/notifications.go, internal/purchase/notifications_test.go
Execution lookup errors wrap ErrNotFound, allowing creation of new executions only for classified not-found results while preserving other failures.
Scheduled execution eligibility and accounting
internal/purchase/manager.go, internal/purchase/manager_test.go, internal/purchase/money_path_regression_test.go
Scheduled processing skips web-source or non-auto-purchasable executions, counts gate failures, claims eligible rows, and updates results incrementally.
Execute-message eligibility gate
internal/purchase/messages.go, internal/purchase/messages_test.go, internal/purchase/coverage_extra_test.go
execute_purchase messages apply the same eligibility checks before claim and execution, with regression fixtures updated for AutoPurchase plans.
Recommendation-aware reaper errors
internal/purchase/reaper.go, internal/purchase/reaper_test.go
The “safe to retry” suffix is persisted only for executions whose recommendations are safe to redrive.

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

Sequence Diagram(s)

sequenceDiagram
  participant Scheduler as ProcessScheduledPurchases
  participant Processor as processOneExecution
  participant Gate as executableByScheduler
  participant PlanStore as GetPurchasePlan
  participant Executor as claimAndExecute
  Scheduler->>Processor: process due execution
  Processor->>Gate: check source and AutoPurchase
  Gate->>PlanStore: load purchase plan
  PlanStore-->>Gate: eligibility result
  Gate-->>Processor: eligible or rejected
  Processor->>Executor: claim and execute eligible row
  Executor-->>Processor: execution result
  Processor-->>Scheduler: update ProcessResult
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: execution gating, fail-loud config handling, and safer reaper retry behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/purchase-approval-gate-failopen

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

@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 492-514: Update executableByScheduler to reject the persisted
"cudly-web" source value before fetching the plan, preserving the fail-closed
approval gate for web-originated executions; then update the related regression
fixtures to use and verify "cudly-web".
🪄 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: 96c78f17-ef66-445e-ac76-29bfd2bd1f86

📥 Commits

Reviewing files that changed from the base of the PR and between bc4d3ae and 4205c60.

📒 Files selected for processing (14)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go
  • internal/purchase/messages.go
  • internal/purchase/messages_test.go
  • internal/purchase/money_path_regression_test.go
  • internal/purchase/notifications.go
  • internal/purchase/notifications_test.go
  • internal/purchase/reaper.go
  • internal/purchase/reaper_test.go

Comment on lines +492 to +514
// executableByScheduler reports whether a pending or notified execution may be
// auto-executed by the cron sweep or an SQS execute_purchase message, without
// an explicit human approval action (fail closed on money paths).
//
// Rules:
// - source="web" rows must wait for the token-link approval path; the
// scheduler and SQS paths must never bypass that gate.
// - All other pending/notified rows require the owning plan to have
// AutoPurchase=true. A plan-fetch error is propagated so the caller can
// fail closed rather than defaulting to "execute".
//
// "approved" rows are handled by the session/token approval paths and
// RecoverStrandedApprovals; this helper is only called for pending/notified.
func (m *Manager) executableByScheduler(ctx context.Context, exec *config.PurchaseExecution) (bool, error) {
if exec.Source == "web" {
return false, nil
}
plan, err := m.config.GetPurchasePlan(ctx, exec.PlanID)
if err != nil {
return false, fmt.Errorf("failed to fetch plan %s for AutoPurchase gate: %w", exec.PlanID, err)
}
return plan.AutoPurchase, nil
}

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.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Gate the persisted cudly-web source value.

Line 506 checks "web", but PurchaseExecution.Source is documented as "cudly-web". Such rows can therefore pass this shared scheduler/SQS gate and execute without approval when AutoPurchase=true. Handle the persisted value and update the regression fixtures accordingly.

Proposed fix
-	if exec.Source == "web" {
+	if exec.Source == "web" || exec.Source == "cudly-web" {
 		return false, nil
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// executableByScheduler reports whether a pending or notified execution may be
// auto-executed by the cron sweep or an SQS execute_purchase message, without
// an explicit human approval action (fail closed on money paths).
//
// Rules:
// - source="web" rows must wait for the token-link approval path; the
// scheduler and SQS paths must never bypass that gate.
// - All other pending/notified rows require the owning plan to have
// AutoPurchase=true. A plan-fetch error is propagated so the caller can
// fail closed rather than defaulting to "execute".
//
// "approved" rows are handled by the session/token approval paths and
// RecoverStrandedApprovals; this helper is only called for pending/notified.
func (m *Manager) executableByScheduler(ctx context.Context, exec *config.PurchaseExecution) (bool, error) {
if exec.Source == "web" {
return false, nil
}
plan, err := m.config.GetPurchasePlan(ctx, exec.PlanID)
if err != nil {
return false, fmt.Errorf("failed to fetch plan %s for AutoPurchase gate: %w", exec.PlanID, err)
}
return plan.AutoPurchase, nil
}
// executableByScheduler reports whether a pending or notified execution may be
// auto-executed by the cron sweep or an SQS execute_purchase message, without
// an explicit human approval action (fail closed on money paths).
//
// Rules:
// - source="web" rows must wait for the token-link approval path; the
// scheduler and SQS paths must never bypass that gate.
// - All other pending/notified rows require the owning plan to have
// AutoPurchase=true. A plan-fetch error is propagated so the caller can
// fail closed rather than defaulting to "execute".
//
// "approved" rows are handled by the session/token approval paths and
// RecoverStrandedApprovals; this helper is only called for pending/notified.
func (m *Manager) executableByScheduler(ctx context.Context, exec *config.PurchaseExecution) (bool, error) {
if exec.Source == "web" || exec.Source == "cudly-web" {
return false, nil
}
plan, err := m.config.GetPurchasePlan(ctx, exec.PlanID)
if err != nil {
return false, fmt.Errorf("failed to fetch plan %s for AutoPurchase gate: %w", exec.PlanID, err)
}
return plan.AutoPurchase, nil
}
🤖 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 492 - 514, Update
executableByScheduler to reject the persisted "cudly-web" source value before
fetching the plan, preserving the fail-closed approval gate for web-originated
executions; then update the related regression fixtures to use and verify
"cudly-web".

@cristim
cristim merged commit b66e854 into main Jul 17, 2026
19 checks passed
cristim added a commit that referenced this pull request Jul 17, 2026
…uteHeadroom

Fixes golangci-lint errcheck failure on handler_ri_exchange.go:1351 where
`exchange.ParseDecimalRat` error was discarded with `_`. The fix extracts a
`checkCapsAndComputeHeadroom` helper that bundles the three cap-validation
checks (MaxDailyUSD==0, checkDailyCap, MaxPerExchangeUSD==0) and the headroom
calculation, properly returning a reason string on any parse failure (fail
closed). This also brings `executeApprovedExchange` back under gocyclo -over 10
(complexity 8) after the helper reduces it from the 12 that errcheck enforcement
would have caused.

Rebased onto main (after PR #1456 merged; no conflicts in store_postgres.go).
cristim added a commit that referenced this pull request Jul 19, 2026
…uteHeadroom

Fixes golangci-lint errcheck failure on handler_ri_exchange.go:1351 where
`exchange.ParseDecimalRat` error was discarded with `_`. The fix extracts a
`checkCapsAndComputeHeadroom` helper that bundles the three cap-validation
checks (MaxDailyUSD==0, checkDailyCap, MaxPerExchangeUSD==0) and the headroom
calculation, properly returning a reason string on any parse failure (fail
closed). This also brings `executeApprovedExchange` back under gocyclo -over 10
(complexity 8) after the helper reduces it from the 12 that errcheck enforcement
would have caused.

Rebased onto main (after PR #1456 merged; no conflicts in store_postgres.go).
cristim added a commit that referenced this pull request Jul 19, 2026
…d ledger, and headroom (adversarial-review follow-ups) (#1457)

* fix(exchange): enforce daily-cap integrity on manual path, actual-paid ledger, and headroom (adversarial-review follow-ups)

H1: checkDailyCap fails closed on unparseable paymentDue (was treating as $0).
H2: executeApprovedExchange and processAutoExchange bound Execute MaxPaymentDueUSD
    by min(perExchangeCap, dailyCap-dailySpent) so re-quotes cannot breach headroom.
H3: Add CompleteRIExchangeWithPayment to persist the accepted (not pre-execution)
    amount; both auto and manual paths use the fresh quote from Execute.
H4: Retry ledger write 3x after money moves; halt auto run / return 500 on
    persistent failure to prevent cap bypass via missing ledger rows.
M5: GetRIExchangeDailySpend includes processing rows (COALESCE on completed_at/
    updated_at) to close concurrent-approval TOCTOU window.

Each defect has a regression test that fails pre-fix and passes post-fix.

* fix(exchange): handle ParseDecimalRat error, extract checkCapsAndComputeHeadroom

Fixes golangci-lint errcheck failure on handler_ri_exchange.go:1351 where
`exchange.ParseDecimalRat` error was discarded with `_`. The fix extracts a
`checkCapsAndComputeHeadroom` helper that bundles the three cap-validation
checks (MaxDailyUSD==0, checkDailyCap, MaxPerExchangeUSD==0) and the headroom
calculation, properly returning a reason string on any parse failure (fail
closed). This also brings `executeApprovedExchange` back under gocyclo -over 10
(complexity 8) after the helper reduces it from the 12 that errcheck enforcement
would have caused.

Rebased onto main (after PR #1456 merged; no conflicts in store_postgres.go).
cristim added a commit that referenced this pull request Jul 19, 2026
…w-up to #1456) (#1458)

executableByScheduler gated on the bare literal exec.Source == "web", but
web-originated executions are persisted with Source = common.PurchaseSourceWeb
("cudly-web"). The literal never matched, so a pending/notified web row on a
plan with AutoPurchase=true could be auto-executed by the cron sweep and the
SQS execute_purchase path WITHOUT the required token-link human approval -- a
fail-open on a money path (CodeRabbit CRITICAL thread on #1456, left unaddressed).

Fix:
- manager.go: compare against the typed common.PurchaseSourceWeb constant, not
  a stringly-typed literal (repo enum convention).
- manager_test.go / messages_test.go: the prior regression tests set
  Source: "web" (matching the buggy gate) and relied on
  AssertNotCalled("GetPurchasePlan"); the mock's default GetPurchasePlan stub
  does not record the call, so those tests could never detect the bypass. Use
  the real persisted value and assert the invariant via a GetPurchasePlanFn
  sentinel: web rows must short-circuit before the AutoPurchase plan fetch.
  Verified the tests FAIL on the buggy literal and PASS on the fix.
@cristim
cristim deleted the fix/purchase-approval-gate-failopen branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant