Repository navigation
feat(exchange): record an audit trail for direct-API RI exchange executes - #1092
Conversation
…utes Neither POST /api/ri-exchange/execute (AWS) nor the Azure execute endpoint wrote any record of an exchange executed directly through the API; only the scheduled approve/reject flow persisted to ri_exchange_history. Operators had no after-the-fact trail of who exchanged what, when, for how much. Both handlers now write an ri_exchange_direct_executions row (migration 000131): inserted as "submitted" after every gate and guardrail and before the #1642 submit claim, then finalized once with the provider outcome on a detached, bounded context. A failed insert refuses the exchange and takes no claim. Outcomes are completed, failed (provider client fault or a refused duplicate submit) or unknown (an error that does not prove the exchange was rejected). Finalize is a compare-and-set on status, so a row is never rewritten. A separate table rather than ri_exchange_history: that table's account_id CHECK only admits 12-digit AWS accounts, and its processing/completed rows are the approval flow's daily-spend ledger and reaper input. Closes LeanerCloud#82 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 14
✨ Finishing Touches
Comment |
|
Independent review 1 (claude-fleet, cold): APPROVE f5b3e52 Findings (none blocking; low or info):
Migration numbering and order:
Up-down symmetry:
Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (first reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE f5b3e52 Independent adversarial review of #1092 (closes #82) at head Findings (none blocking):
Up/down and locking: up only creates a new table plus two indexes, with no backfill and no lock on existing tables; Attacked:
|
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
Root cause
Exchanges executed directly through the API left no record. Neither
executeExchange(AWS,POST /api/ri-exchange/execute) norexecuteAzureExchange(POST /api/ri-exchange/azure-instances/exchange) wrote anything; only the scheduled approve/reject flow persisted, tori_exchange_history. On currentmainboth handlers went straight from the #1642 submit claim to the provider call, and from there back to the response.Closes #82
Decision: separate table, not
ri_exchange_historyThe issue asks us to decide this first. A new table,
ri_exchange_direct_executions(migration 000131), is used becauseri_exchange_historyis not a passive log:account_idhasCHECK (account_id ~ '^\d{12}$'), so an Azure exchange (subscription GUID) can't be stored there at all;processing/completedrows are the approval flow's daily-spend ledger (riExchangeDailySpendQuery), and itsprocessingrows are swept byGetStaleProcessingExchanges. Putting direct executes there would quietly change the approval flow's cap and reaper behavior. That might be wanted later, but it's a policy decision, not an audit fix.What gets recorded
Per the issue's list: provider,
cloud_account_id, Azure subscription (provider_account_id), AWSregion,source_ids(plussourcesJSON with Azure's per-source quantity, since a partial exchange of a reservation matters),targetsJSON (AWS offering + count, with the legacy singleton recorded as a one-element list; Azure SKU/location/term/quantity), the caller's cap and currency, Azure's pre-execute re-quote (quoted_net_payable), the AWS exchange ID or Azure session ID (provider_reference), net payable + currency as reported, Azure's operation status, the error text, the actor (executed_by_user_id,executed_by= email or the admin-API-key actor,executed_by_api_key_id), andcreated_at/completed_atfrom the DB clock. There are no FKs, so the trail outlives deleted users, keys and accounts.Flow and statuses
beginDirectExchangeinserts the row assubmitted, then takes the #1642 claim. If the insert fails, the exchange is refused and no claim is taken, so an honest retry isn't blocked for 15 minutes. If the claim is refused (a duplicate inside the window, or a claim-store error), the row is finalizedfailed("not submitted to the provider").finalizeDirectExchangerecords the outcome once, onctxutil.DetachedWithTimeout(ctx, riExchangeTerminalSaveTimeout), the same detached, bounded pattern as the approval flow's terminal write (fix(ri-exchange): run ExecuteExchange outside the request context #790). The Lambda budget is unchanged: 4m execution + 30s terminal write. A finalize failure is logged and does not turn a committed exchange into an error response; the row stayssubmitted, which is the "outcome never recorded" signal.completed: the provider reported success.failed: the handler mapped the error to a 4xx (provider client fault), or the request was refused before reaching the provider.unknown: any other error (timeout, 5xx, transport), which doesn't prove the exchange was rejected. This is deliberately pessimistic. AWSpkg/exchange.ExecuteExchangereturns untyped errors for its own pre-Accept guardrail refusals (e.g. "paymentDue … exceeds max"), and the handler can't tell those from a post-Accept failure without string matching, so they're recorded asunknown, with the quoted amount and the raw error text.FinalizeRIExchangeDirectExecutionis a compare-and-set (WHERE id = $1 AND status = 'submitted'). A second finalize, whether concurrent or later, updates nothing and returnsErrRIExchangeDirectExecutionNotSubmitted. Table CHECKs enforce(status = 'submitted') = (completed_at IS NULL), the four statuses, the providers, non-empty source and target arrays, and a non-empty actor.Amounts are canonicalized from the parsed
big.Ratbefore the NUMERIC cast.ParseDecimalRataccepts things like1/3and0x10that Postgres would reject, and without this an odd-but-valid cap would make the audit insert fail and refuse the exchange.Concurrency
failed.store.FinalizeRIExchangeDirectExecutionruns concurrently. The test asserts it blocks on the row lock (it doesn't answer within 300ms), then, after commit, returnsErrRIExchangeDirectExecutionNotSubmitted, and the row keeps the first outcome.AND status = 'submitted'from the UPDATE failsa_finalized_row_is_never_rewritten,a_finalize_racing_an_uncommitted_finalize_keeps_the_first_outcomeand the pgxmock CAS test.Verification
All evidence is local unit, mock, pgxmock or real-Postgres (testcontainers) evidence. No real AWS or Azure calls were made: provider calls are injected (
executeExchangeFn,mockAzureExchangeOpsClient). Tests ran withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid.Fail-before / pass-after (handler level). I reverted only
internal/api/handler_ri_exchange.gotoorigin/main, keeping the new tests and helpers, and ran:"[]" should have 1 item(s), but has 0: no audit row written)Commands run on the pushed SHA:
The migration number was checked against
origin/main(highest 000130) and every open PR's files immediately before pushing. No other PR adds a migration.Real-cloud gap. Nothing here exercised a live AWS
AcceptReservedInstancesExchangeQuoteor Azure exchange LRO. Thefailed/unknownclassification relies on the existingmapAWSExchangeError/mapAzureExchangeErrormapping, which this PR doesn't change.Out of scope / follow-ups
ri_exchange_direct_executions(the existingGET /api/ri-exchange/historyreadsri_exchange_historyonly). Exposing it needs a scoped read endpoint (allowed_accounts filtering like test(api): pin the submit dedupe as a predicate, not a 1000-row page (closes #266) #1030) plus a frontend view, so it's left for a follow-up. Until then the trail is queryable in SQL; theidx_ri_exchange_direct_executions_unresolvedpartial index coverssubmitted/unknownrows.handler_ri_exchange.gois already ~2850 lines, well over the 500-line guideline. The new logic lives ininternal/api/ri_exchange_audit.gorather than growing it further.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/many,effort/m,type/feat,triaged.🤖 Generated with Claude Code