Skip to content

fix(exchange): refuse a quote with no PaymentDue instead of treating it as $0 - #2076

Merged
cristim merged 2 commits into
mainfrom
fix/1964-exchange-cap-missing-paymentdue
Sep 8, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/1964-exchange-cap-missing-paymentdue

Conversation

@cristim

@cristim cristim commented Sep 8, 2026

Copy link
Copy Markdown
Member

What

Closes #1964. Refs #1448 (finding A09-004), which stays open as a multi-item tracker.

In the auto-exchange path the per-exchange spend cap was evaluated only when a quote carried a PaymentDue. An absent one left PaymentDueUSD nil, so the cap check was skipped, the exchange was recorded as costing "0", it contributed nothing to the daily-cap total, and it proceeded to execution. The last guard, the cap handed to Execute, was defeated by the same nil on the fresh re-quote, so every layer of the cap stack collapsed on one missing field.

resolvePaymentDue becomes requirePaymentDue and returns an error, propagated by both pre-accept checks.

The bypass is fully closed, not narrowed

An AWS quote that is otherwise valid but carries no PaymentDue is now refused in three places: at the sweep, where the recommendation is skipped with no record, no token and no Execute; at checkInitialQuote; and at checkReQuote, immediately before AcceptReservedInstancesExchangeQuote. That call has a single production call site, inside executeWithAPI, so the sweep, the execute endpoint, the approval path and the sanity tool all pass through both checks.

The daily-cap SQL sums payment_due over completed and processing rows, and every writer of those rows now carries a real amount rather than a fabricated "0".

A genuine zero still works

This is the load-bearing distinction, so it is worth stating precisely. AWS sends an explicit "0.000000" for a zero-cost exchange, which parses to a non-nil zero and keeps working. Only an absent field yields nil, and only nil is refused. A whitespace-only value fails the parse and errors the quote call, which is also fail-closed.

A positive control drives the real parser with an explicit "0.000000" and passes both before and after this change. Two of the six mutations below attack this distinction from each side.

Verification

Four refusal tests were observed failing on the pre-fix code with the exact symptoms of the defect: Execute called when it must not be, a pending record written, the skip list empty, and the ledger recording "0" where "30.000000" was due.

An independent reviewer reproduced that, then ran six mutations. All were killed:

Mutation Killed by
Sweep substitutes zero instead of skipping the sweep refusal test
checkReQuote substitutes zero on error the re-quote refusal test
checkInitialQuote substitutes zero on error the initial-quote refusal test
acceptedAmountFromQuote returns "0" the ledger test
Parser maps an absent value to non-nil zero both fail-loud refusal tests
Parser maps an explicit "0.000000" to nil the positive control

The last two matter most: they show the parse layer itself is covered, not just its consumers.

Check Result
go test -count=1 -race ./exchange/ in pkg/ ok
go vet, gofmt -l clean
gocyclo -over 10 on both touched files clean
go build ./... from the root exit 0
Exchange-adjacent tests in internal/api, internal/server, providers/aws/ladder ok

Lint could not be reproduced locally. The pinned golangci-lint fails to typecheck against the host toolchain on this machine. The fix tree and an origin/main archive produce byte-identical lint output under both available runners, so nothing lint-visible changed, but CI is the arbiter.

No live AWS reproduction is possible: an unpriced quote cannot be provoked on a real account. The fake-EC2 tests drive the real executeWithAPI with the real SDK output type, which is the closest available evidence.

Second commit

Adversarial review found that handlerAcceptedAmount in internal/api still returned "0" for an empty amount, with a comment explaining it as a zero-cost exchange where AWS returned nil. This PR disproves that premise, and the branch is now unreachable, so it was left asserting something untrue that a future reader would take as evidence. It now mirrors its pkg/exchange sibling and returns the caller's fallback.

cristim and others added 2 commits September 8, 2026 08:38
…it as $0

An ExchangeQuoteSummary whose PaymentDueUSD is nil means the AWS response
carried no PaymentDue at all; a zero-cost exchange arrives as an explicit
"0.000000" and parses to a non-nil zero. Every cap layer collapsed the two:
getValidatedQuote skipped the per-exchange cap, processRecommendation
recorded the exchange as costing "0" (so the daily cap added nothing and a
manual pending record carried "0"), and resolvePaymentDue substituted a
zero inside Execute so both the initial and the pre-accept re-quote checks
passed. An unpriced exchange could reach AcceptReservedInstancesExchangeQuote
with no effective ceiling.

Fail closed at every consumer:
- getValidatedQuote skips the recommendation with "quote reported no
  PaymentDue" before the cap compare; processRecommendation no longer
  defaults the amount to "0".
- requirePaymentDue replaces resolvePaymentDue; checkInitialQuote and
  checkReQuote return its error before Accept is called.
- acceptedAmountFromQuote falls back to the initial quoted amount, never
  "0", when a fresh quote carries no amount.

Regression tests drive RunAutoExchange (auto and manual) and the real
executeWithAPI with an unpriced quote and assert nothing executes and
nothing is recorded; an explicit zero still proceeds.

Closes #1964
Refs #1448 (A09-004)

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC
…tedAmount

The helper returned "0" when a fresh Execute quote carried no payment
amount, and its comment explained that as a zero-cost exchange where AWS
returned nil. This commit's own change disproves that premise: a genuine
zero arrives as an explicit "0.000000" and parses to a non-nil zero, while
an absent PaymentDue is now refused by checkInitialQuote and checkReQuote
before Accept runs.

Execute can therefore no longer return successfully with an empty amount,
so the branch is unreachable and its comment asserts something untrue. A
future reader would take it as evidence that an empty amount is normal.

Now mirrors exchange.acceptedAmountFromQuote and returns the caller's
fallback instead of fabricating a figure.

Found by adversarial review of the parent commit.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 51 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 33566702-d4f7-42eb-ae56-b0ca46680c8c

📥 Commits

Reviewing files that changed from the base of the PR and between 8e44c0f and 72bc374.

📒 Files selected for processing (5)
  • internal/api/handler_ri_exchange.go
  • pkg/exchange/auto.go
  • pkg/exchange/auto_test.go
  • pkg/exchange/exchange.go
  • pkg/exchange/fail_loud_test.go

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

@cristim cristim added type/bug Defect severity/high Significant harm priority/p1 Next up; this sprint urgency/this-sprint Within the current sprint impact/few Limited audience effort/xs Trivial / one-liner triaged Item has been triaged labels Sep 8, 2026
@cristim
cristim merged commit eac9a62 into main Sep 8, 2026
27 checks passed
@cristim
cristim deleted the fix/1964-exchange-cap-missing-paymentdue branch September 8, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/few Limited audience 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.

fix(exchange): a quote with no PaymentDue skips the per-exchange cap instead of failing

1 participant