Repository navigation
sec(money): currency is dropped at every layer boundary, so USD-denominated caps are compared against non-USD amounts #1637
Description
Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.
Cross-cutting sweep from the 2026-07-28 full-repo review. Every instance below was re-verified against origin/main @ 3e9660d06 while writing this issue; where a stated line number had moved, the corrected one is given.
What this issue is (and is not)
This is the class, not another copy of the instances. Four instances already have their own issues:
- #1558
fix(exchange): RI-exchange spend caps are compared against a quote whose CurrencyCode is never checked - #1555
fix(api): Azure revoke refund-divergence guard is opt-in and currency-blind - #1562
fix(azure): Savings Plan purchase hardcodes CurrencyCode USD - #1087
AWS Savings Plans purchase path is USD-only; aws-cn accounts can onboard but can't buy SPs
Those stay open and stay independently fixable. This issue exists because fixing them one at a time will not close the class: the same shape reappears in at least five more places that nobody has filed, and a per-site fix leaves the underlying type design intact so the next money field added reintroduces it.
The shared root cause
Two facts, together:
-
Money crosses every layer boundary as a bare
float64(or a bare decimal string), with the currency dropped at the boundary. The currency is frequently read from the provider response and then either stored in a display-only field or discarded outright.providers/azure/internal/recommendations/converter.go:297-306states the assumption in a comment: "Currency is discarded -- downstreamRecommendationconsumers assume a single-currency view per subscription".common.Recommendationcarries no currency field at all, so there is nowhere for it to go even if a caller wanted to keep it. -
Every spend cap in the system is USD-denominated by naming convention only, with no currency field to compare against.
internal/auth/types.go:65-66:// MaxPurchaseAmount limits the maximum purchase amount MaxPurchaseAmount float64 `json:"max_purchase_amount,omitempty" dynamodbav:"MaxPurchaseAmount"`
Same for
pkg/exchange'sMaxPaymentDueUSD *big.Rat(pkg/exchange/exchange.go:104) andMaxPaymentPerExchangeUSD/MaxPaymentDailyUSD(pkg/exchange/auto.go:74-75). TheUSDis in the identifier, not in the type.
The result is that a guardrail comparison like paymentDue.Cmp(maxPayment) == 1 is a comparison between two numbers whose units are asserted by a variable name. On a non-USD deployment (aws-cn, an MCA subscription billed in EUR, a GBP-billed GCP billing account) that comparison is wrong in whichever direction the exchange rate happens to point: it silently blocks a legal purchase, or it silently authorises one an order of magnitude over the operator's stated ceiling.
The correct behaviour is to fail closed on a non-USD money path, not to compare across currencies. A guardrail that cannot establish the denomination of the number it is checking has not checked anything, and "assume USD" is exactly the fabricated-value pattern the project's no-silent-fallbacks rule exists to prevent. Where the currency genuinely cannot be resolved, the money path must error out and say so, rather than proceed on an assumption.
Instances
Already filed individually (listed for completeness of the class; do not double-fix)
-
pkg/exchangespend caps are compared against a quote whoseCurrencyCodeis never read (filed as #1558)- Where:
pkg/exchange/exchange.go:252(CurrencyCode: sdkaws.ToString(out.CurrencyCode)-- captured intoExchangeQuoteSummary.CurrencyCode,exchange.go:23), thencheckInitialQuoteatpkg/exchange/exchange.go:311-321andcheckReQuoteat:326-338; the auto path atpkg/exchange/auto.go:318-324(per-exchange cap) and:505-535(daily cap). - What:
getQuoteWithAPIstores the AWS-reported currency and no guardrail ever reads it.checkInitialQuoteis a barepaymentDue.Cmp(maxPayment) == 1.checkReQuote's error message even hardcodes the unit --"re-quoted payment %s USD exceeds cap %s USD"-- on a number whose currency was never checked. - Ledger half:
internal/config/store_postgres.go:2572-2577(CompleteRIExchangeWithPaymentwritespayment_due) andGetRIExchangeDailySpendat:2633+SUMs that column with no currency column on the table, so the daily-cap ledger silently mixes denominations across rows.
- Where:
-
Azure revoke:
calcRefundCurrencyis computed but used only in the error message (filed as #1555)- Where:
internal/api/handler_purchases_revoke.go:716-731(azureCalculateRefundreadsBillingRefundAmount.CurrencyCodeintocalcRefundCurrencyat:726-728), consumed at:647, interpolated into the divergence error at:661, and passed toMarkPurchaseRevokedat:765/:773. - What: the TOCTOU divergence guard is
math.Abson two bare floats. The currency is carried alongside for the whole call chain and never compared to anything. The client-suppliedExpectedRefundAmount *float64(:73) carries no currency at all, so there is nothing to compare it to.
- Where:
-
Azure Savings Plan purchase hardcodes
CurrencyCode: "USD"(filed as #1562)- Where:
providers/azure/services/savingsplans/client.go:261and:355-- botharmbillingbenefits.Commitmentliterals carryCurrencyCode: toPtr("USD")withAmounttaken verbatim from the recommendation. - What: Azure requires the commitment currency to match the billing account's currency. Nothing resolves or validates it. Note this is the write side of the same gap that
providers/azure/internal/recommendations/converter.go:297-306creates on the read side by discarding the currency the Consumption API returned.
- Where:
-
AWS Savings Plans purchase path is USD-only (filed as #1087)
- Where:
providers/aws/services/savingsplans/client.go:303(Currencies: []types.CurrencyCode{types.CurrencyCodeUsd}on the offering query, with the comment "Pin to USD so non-USD currency offerings are excluded server-side") and:635(Currency: "USD"). - Note: this one is arguably the correct shape already -- it pins the currency explicitly rather than assuming it -- but it fails silently (an aws-cn account gets an empty offering list) instead of erroring with "this deployment cannot buy SPs in CNY". Keep it under AWS Savings Plans purchase path is USD-only; aws-cn accounts can onboard but can't buy SPs cloud-commitments-go#7; listed here so the class census is complete.
- Where:
Not filed anywhere -- new in this sweep
-
max_payment_due_usdis never asserted to actually be USD- Where:
internal/api/handler_ri_exchange.go:717-718(required-field check),:749(exchange.ParseDecimalRat(body.MaxPaymentDueUSD)),:766(maxPayment, _ := maxRat.Float64()),:791(MaxPaymentDueUSD: maxRat); request field declared at:913. Cap type atinternal/auth/types.go:65-66. - What: the request body field is named
max_payment_due_usdand the handler's own error text calls it a "safety guardrail", but no code path establishes that the exchange being guarded is USD-denominated. The value becomesMaxPurchaseAmount, which has no currency field, and is also forwarded topkg/exchangeasMaxPaymentDueUSD. The name is the only thing carrying the unit, across three layers. - Failure scenario: an aws-cn deployment. The operator sets
max_payment_due_usd: "1000"meaning a thousand dollars. AWS quotesPaymentDue = "5000"in CNY (roughly USD 690).checkInitialQuotecompares 5000 against 1000, refuses, and the operator sees a cap breach that is not one. The reverse case -- a currency weaker per unit than the cap assumes -- authorises an exchange over the real ceiling with no signal. - Secondary defect at the same site:
maxPayment, _ := maxRat.Float64()at:766discards the exactness flag. Bounded (only inexact at absurd magnitudes) but it is the same "drop the metadata at the boundary" move. Keep the cap comparison in*big.Rat.
- Where:
-
GCP
Money.CurrencyCodeis never read- Where:
providers/gcp/services/computeengine/client.go:1045-1067(extractCostImpactFromRecommendation). - What: the function reads
costProj.Cost(agoogle.type.Money), negatesUnits + Nanos/1e9intorec.EstimatedSavings, and never touchesCost.CurrencyCode.recommenderpb.CostProjectionalso carriesDuration, likewise unread -- the value is treated as monthly by assumption. So this single function drops both the unit and the period. - Failure scenario: a GBP-billed billing account. The CUD recommendation is stored with
EstimatedSavingsin pounds, ranked against USD-denominated recommendations from other providers on the same dashboard, and checked against a USD-denominatedMaxPurchaseAmount. TheDurationhalf compounds it: a projection over a 1-year duration is stored as a monthly figure, 12x overstated. - Fix: normalise by
Durationto a monthly run-rate, and drop the recommendation with a log whenCurrencyCodeis absent or is not the deployment's expected billing currency.
- Where:
-
firstNonEmptyCurrencylabels reshape output "USD" by fiat when no RI reports a currency- Where:
internal/api/handler_ri_exchange.go:623-637, consumed at:594(currencyCode := firstNonEmptyCurrency(instances)). - What: the helper walks the
ConvertibleRIlist and returns the first non-emptyCurrencyCode; when none has one it returns the literal"USD". The unknown case is rendered as a definite answer. - Failure scenario: an aws-cn deployment whose RIs come back without
CurrencyCodesees CNY-denominated savings figures rendered and compared as USD on the Reshape page. Bounded and display-side today because the field is populated in practice, which is exactly why it will survive until the day it is not. - Fix: return
("", false)and omit the currency from the response rather than defaulting.
- Where:
-
Azure Advisor:
savingsCurrencyis never read, and an unparsable savings amount becomes a real 0- Where:
providers/azure/recommendations.go:442(rec.EstimatedSavings = extFloat(ext, "annualSavingsAmount") / 12),extFloatat:455-462. - What: Advisor's
extendedPropertiescarries asavingsCurrencykey that nothing reads.extFloatreturns0for a missing key and for astrconv.ParseFloatfailure, so a payload like"1,234.00"(thousands separator) yields a recommendation valued at exactly zero rather than an error. This instance sits at the intersection of this issue and the zero-vs-absent sweep filed alongside it. - Fix: read and validate
savingsCurrency; makeextFloatreturn(float64, error)or*float64so an unparsable amount drops the recommendation with a log instead of shipping a fabricated 0.
- Where:
-
providers/azure/internal/recommendations/converter.godiscards the currency at the read boundary, by design- Where:
providers/azure/internal/recommendations/converter.go:297-306(amountValue), called at:207-209. - What: this is the documented root of the Azure half.
amountValueunwraps Modern's*armconsumption.Amount{Currency, Value}to a bare float and the doc comment states the assumption outright: "Currency is discarded -- downstreamRecommendationconsumers assume a single-currency view per subscription, same as the Legacy path." The siblingamountValuePtrimmediately below (:308-320) shows the codebase already knows how to preserve a signal it cares about -- it keeps the absent-vs-zero distinction -- so the currency drop is a deliberate scope decision, not an oversight. It is the decision this issue is asking to revisit. - Note on provenance: the review's findings file cited this as
internal/recommendations/converter.go:294-303. That path does not exist; the correct path isproviders/azure/internal/recommendations/converter.goand the function is at:297-306on currentmain. Corrected here.
- Where:
Fix direction for the class
Fixing the individual sites without this will not hold. Suggested order:
- Carry the currency to the guardrail.
amountValue,extractCostImpactFromRecommendationand the Advisor path must stop dropping it, andcommon.Recommendationneeds somewhere to put it. This is the load-bearing step: a comparison cannot fail closed on something it never received. - Make every guardrail fail closed when the amount is not USD, or its currency is unknown.
checkInitialQuote/checkReQuote/ the auto-exchange caps / the Azure revoke divergence guard /MaxPurchaseAmountevaluation. PR feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596) #1515 already settled the rule for one call site; this applies the same rule at the rest. - Delete the "USD" defaults (
firstNonEmptyCurrency, the two Azure SP literals) and replace them with an explicit unknown that step 2 then refuses.
Deliberately not in this issue, unless and until CUDly actually transacts in a second currency:
- A
Money{Amount, Currency}type inpkg/common, or a currency field onMaxPurchaseAmountand thepkg/exchangecap fields. The caps are USD by construction (feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596) #1515), so giving them a field to hold "USD" adds a value that is never anything else, threaded through three layers. What the comparison is missing is the denomination of the incoming amount, which is step 1. - A currency column on
ri_exchange_history.payment_due. With step 2 in place no non-USD amount can be written, soGetRIExchangeDailySpendcannot sum across denominations. The column is needed on the day the product supports a second currency, and should be added with that work, where its correctness can be tested against a real non-USD path.
Remedy simplified (2026-08-03): the Money type / cap currency field and the ledger column were removed from the plan, leaving carry-the-currency plus fail-closed-on-non-USD, which is what closes every failure scenario listed above. Both dropped items build multi-currency capability, and this issue's own conclusion (and #1515's) is that a non-USD money path must refuse rather than transact. Multi-currency support is a feature; it deserves its own issue rather than arriving as the fix for a fail-open.
Regression tests must exercise a genuinely non-USD path (a quote with CurrencyCode: "JPY", an EUR-billed MCA subscription fixture), not a USD path with an extra assertion. A test that only ever feeds USD will pass with every one of these defects present.
Related
- fix(exchange): RI-exchange spend caps are compared against a quote whose CurrencyCode is never checked cloud-commitments-go#42, fix(api): Azure revoke refund-divergence guard is opt-in and currency-blind cloud-commitments-platform#96, fix(azure): Savings Plan purchase hardcodes CurrencyCode USD cloud-commitments-go#46, AWS Savings Plans purchase path is USD-only; aws-cn accounts can onboard but can't buy SPs cloud-commitments-go#7 (the individual instances, above).
- fix(exchange): make PurchaseHistoryRecord.MonthlyCost a *float64 to distinguish absent from zero #1081 (
make PurchaseHistoryRecord.MonthlyCost a *float64 to distinguish absent from zero) and the zero-vs-absent sweep filed alongside this one: same root shape (money metadata dropped at a layer boundary), different metadata. - Prior art in this repo: PR feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596) #1515 established that
MaxPurchaseAmounthas no currency field and that a non-USD money path must fail closed. This issue is that conclusion applied across the whole surface rather than at one call site.
Activity
cristim commentedon Sep 27, 2026
Split as part of the monorepo split (cloud-commitments-cli / -go / -mcp / -platform). Nothing in this issue touches
cmd/*.goordocs/cli/, so this repo is not an affected repo; closing rather than scoping down.max_payment_due_usdnever asserted as USD,firstNonEmptyCurrencydefaulting to "USD"), plus the shared root-cause and fix-direction narrative: sec(money): currency is dropped at every layer boundary, so USD-denominated caps are compared against non-USD amounts [platform part] cloud-commitments-platform#361Money.CurrencyCodenever read, Azure AdvisorsavingsCurrencynever read,providers/azure/internal/recommendations/converter.godiscarding currency by design), plus the same shared narrative: sec(money): currency is dropped at every layer boundary, so USD-denominated caps are compared against non-USD amounts [go part] cloud-commitments-go#124The four already-individually-filed instances (#1558, #1555, #1562, #1087) are untouched by this split and stay open in this repo as-is; they are referenced for completeness in both new issues but not duplicated.