Skip to content

fix(exchange): interactive AWS/Azure RI exchange endpoints persist no ri_exchange_history row, so their spend bypasses the daily-cap accounting #145

Closed

Description

@cristim

Summary

The interactive RI-exchange endpoints execute a real, billed exchange and then persist nothing. No ri_exchange_history row is written. Because GetRIExchangeDailySpend computes the daily spend cap by summing exactly that table, an interactive exchange also contributes zero to the daily-cap accounting that the automated exchange path relies on.

This applies to both interactive endpoints, AWS and Azure. It is not Azure-specific — the split is interactive vs automated, not provider vs provider. That framing matters for the fix, and it is the opposite of what I expected when I started tracing.

Open question, and the reason this is labelled status/needs-info: I could not determine from the code alone whether this is a deliberate scope boundary — the interactive flows are session-based and return the exchange/session ID straight to the caller, so an argument exists that the ledger is meant to track only automated runs — or a genuine omission. The priority below hinges on that answer and should be re-triaged once a human resolves it. I have deliberately not guessed. If the omission is unintentional, this is materially more serious than the p2 I have assigned.

Evidence

Azure interactive path — internal/api/handler_ri_exchange.go:1002 executeAzureExchange:

result, err := client.ExecuteExchange(ctx, preview.SessionID)   // line 1037 — money moves here
if err != nil { ... }

logging.Infof("azure ri-exchange executed: subscription=%s session=%s status=%s", ...)  // line 1043

return &AzureExecuteExchangeResponse{ ... }, nil                 // line 1045

After the exchange is executed and billed, the only trace is a log line. Then it returns.

AWS interactive path — same file, the handler executing at line 1595:

exchangeID, quote, err := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{ ... })
if err != nil { ... }

return &ExchangeExecuteResponse{ ExchangeID: exchangeID, Quote: quote }, nil

Also no persistence. And it cannot persist: ExchangeExecuteRequest (pkg/exchange/exchange.go:86) carries no store, and func ExecuteExchange(ctx context.Context, req ExchangeExecuteRequest) (pkg/exchange/exchange.go:286) takes no store either.

Confirming the absence mechanically: grep -nE "\.store\.|Store\.|Save[A-Za-z]*\(|Insert|Persist" internal/api/handler_ri_exchange.go returns no matches at all.

The only writer is the automated path. SaveRIExchangeRecord (internal/config/store_postgres.go:2502) is reached solely through the adapter at internal/server/handler_ri_exchange.go:291, called only from pkg/exchange/auto.go (lines 390, 456, 629) inside RunAutoExchange.

Why this is more than a missing audit row

GetRIExchangeDailySpend (internal/config/store_postgres.go:2779) is the daily cap:

SELECT COALESCE(SUM(payment_due), 0)::text
FROM ri_exchange_history
WHERE status IN ('completed', 'processing')
  AND COALESCE(completed_at, updated_at) >= date_trunc('day', $1::timestamptz AT TIME ZONE 'UTC')
  ...

It sums the ledger and nothing else. The automated path treats that write as load-bearing — pkg/exchange/auto.go:417:

// maxLedgerAttempts is the number of times processAutoExchange retries a
// SaveRIExchangeRecord write after money has moved. Persistent failure halts
// the run to prevent subsequent exchanges from bypassing the daily cap
// (GetRIExchangeDailySpend sums the ledger rows that this write would create).
const maxLedgerAttempts = 3

So the automated path retries the ledger write three times and halts the entire run if it cannot land, explicitly to stop later exchanges bypassing the daily cap. The interactive paths skip that write entirely and never consult the cap at all (GetRIExchangeDailySpend has no caller in internal/api/).

Consequences, in order of severity:

  1. Interactive exchange spend is invisible to the daily cap. An operator can execute interactive exchanges all day; every subsequent automated run still sees full daily headroom, because the cap sums a table those exchanges never wrote to. The guardrail the automated path halts itself to protect is silently bypassed from the side.
  2. No audit row for a completed money movement. Nothing in ri_exchange_history records that the exchange happened, who approved it, or what was paid.
  3. No audit-gap signal either. The sibling purchase_history path treats a lost audit row as a defect in its own right and surfaces it — recordHistoryAuditGap (internal/purchase/execution.go:686) stamps a history_write_failed marker and the API reports IsAuditGap=true (internal/api/handler_history.go:416). The interactive exchange paths have no equivalent, so the absence is not merely unrecorded, it is unreported.

To be fair to the current design: the interactive paths are not unguarded. Both enforce a per-call cap — Azure via checkAzureExchangeMoneyGuardrails against maxRat/body.Currency, AWS via MaxPaymentDueUSD (and ExecuteExchange refuses outright without it, pkg/exchange/exchange.go:287), plus requirePermissionConstraints with MaxPurchaseAmount. The gap is specifically in the daily aggregate, not the per-call check.

Suggested direction

Depends entirely on the open question above:

  • If the omission is unintentional: have the interactive handlers write a ri_exchange_history row after a successful execution, with the same after-money-has-moved retry discipline as maxLedgerAttempts, and an audit-gap marker mirroring recordHistoryAuditGap when the write ultimately fails. Note that the Azure case is blocked on LeanerCloud/cloud-commitments-cli#1676 — ri_exchange_history.account_id is VARCHAR(20) with a CHECK (account_id ~ '^\d{12}$'), so an Azure subscription GUID would be rejected on both width and the regex. LeanerCloud/cloud-commitments-cli#1676 must land first, and widening alone is not enough there.
  • If it is deliberate: document it at both handlers and, more importantly, at GetRIExchangeDailySpend, stating that the daily cap covers automated exchanges only and does not bound total exchange spend. Right now nothing says so, and the maxLedgerAttempts comment reads as though the ledger is authoritative for all exchange spend.

Related

Found while auditing sibling columns for LeanerCloud/cloud-commitments-cli#1603.

Activity

  1. cristim commented on Nov 5, 2026

    @cristim
    MemberAuthor

    Owner decision (2026-11-05): Option A. Treat the missing persistence as an omission: interactive exchanges write ri_exchange_history rows and count toward the daily cap, so spend is fail-safe. Next: write the row from the interactive endpoints and cover it with a test.

  2. cristim commented on Nov 9, 2026

    @cristim
    MemberAuthor

    Claimed by claude-ui-fix-1 (coordinator-assigned). Owner decision A (2026-11-05) stands: persist interactive exchanges so their spend counts toward the daily cap.

    First step is to re-check the premise against current main, because this issue is from July and the RI-exchange code has since gained "direct execution" (#1841, #1858, #1831, migration 000163). I will confirm which interactive paths still persist nothing and whether any existing row already feeds GetRIExchangeDailySpend, then plan (two review passes, since this is a money path) before any code. Note the Azure part was blocked on the cli account_id widening; I will check that too.

  3. cristim commented on Nov 9, 2026

    @cristim
    MemberAuthor

    Premise check against main (claude-ui-fix-1, read-only), before any code:

    One part of this issue is obsolete; its consequence still holds.

    • Obsolete: "the interactive endpoints persist nothing". Since the direct-execution work (chore(exchange): persist an audit-trail record for manual/direct-API RI exchange executes (AWS + Azure) #82, feat(exchange): record an audit trail for direct-API RI exchange executes #1092 and later), both interactive paths (POST /api/ri-exchange/execute and the Azure instances exchange) write an audit row before the provider call (beginDirectExchange, internal/api/ri_exchange_audit.go), and settle it afterwards, in ri_exchange_direct_executions (submitted → completed | failed | unknown). The Azure account_id blocker is also resolved (migration 000137).
    • Still true: GetRIExchangeDailySpend / riExchangeDailySpendQuery sum only ri_exchange_history, so that direct spend is invisible to the daily cap. Migration 000131 made the separate table on purpose, before the 2026-11-05 owner decision. The cap check proper (reserveRIExchangeCeiling) runs for the auto and approval paths only.

    Rephrased problem: direct (interactive) AWS and Azure exchanges are recorded in ri_exchange_direct_executions, which the daily-spend ledger query ignores. Make that spend count.

    Proposed fix (instead of writing a second ri_exchange_history row from the handlers, which would duplicate the record and be swept by the stale-processing reaper): make the spend query also count the direct rows (submitted and unresolved-unknown rows as a hold of max_payment_due, completed rows by their paid amount, failed rows not at all), with real-Postgres tests including a regression that fails before and passes after. No migration, no response-shape change.

    Open product questions, with the defaults I will use unless told otherwise:

    1. Should an interactive exchange also be refused when over the cap, or only counted? Default: only counted (a refusal is a new 4xx and a policy change).
    2. Azure rows may be in a non-USD currency while the cap is USD. Default: count the raw number regardless of currency (a conservative overcount).
    3. submitted and unresolved unknown rows hold the caller's max_payment_due until settled. Acceptable overcount?
    4. Refunds (negative net_payable) count as 0.
    5. On deploy, today's completed direct rows and every unresolved submitted/unknown row (any age) start counting at once; run the settle runbook's resolve pass first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions