Repository navigation
feat(api): list and resolve stuck AWS direct RI exchanges - #1858
Conversation
Adds resolved_status, resolved_by_user_id, resolved_by, resolved_at and resolution_note with an all-or-none CHECK. The original status is never rewritten. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The direct-execution guard skips rows with resolved_at set, so an operator-resolved row no longer holds its source RIs. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keyset-paged read of submitted and unknown AWS rows that no operator has resolved, restricted to an allowed set of (account, region) pairs passed as two arrays. Filters: status, region and a normalised RI id. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The NULL-region row is listed for the empty region, and a bad filter is invalid even when nothing is allowed. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GetRIExchangeDirectExecution returns an AWS row of any status for the resolve handler to authorize against. The distinct unresolved (account, region) pairs let the handler ask the authorization service once per pair. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ResolveRIExchangeDirectExecution locks the row, checks it against the operator expected status, the 10 minute settle age for submitted rows and the checked_at bounds on the database clock, and records the resolution beside the original status. A repeat of the same request replays. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checked_at bounds (not in the future; at or after created_at plus the settle age for submitted rows, and completed_at plus a minute for unknown rows) and the replay variants, on the database clock. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lution Finalize returns resolved_status and logs at error level when a late provider outcome (completed or failed) disagrees with an operator's resolution; both facts stay on the row. Tests cover the race in both orders and concurrent resolves (one wins). Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split the account, region and amount inputs of the history resolve constraint check into requireRIExchangeConstraintsFor so the direct-row resolve can reuse it. Behaviour of the history resolve is unchanged. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list item is built in one place from a stored row, so the raw error, the executing user id and the API key id are never exposed; error_summary goes through SanitizeStoredError. Adds query validation, the opaque keyset cursor and the resolvable hint. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET handler for rows that still hold their source RIs. Requires execute:ri-exchange. The (account, region) pairs of unresolved rows are kept only when the session scope and the grant, evaluated by the authorization service as the direct execute does, cover them; the store then pages only those pairs, so no cursor can point at a hidden row. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store or authorization error must fail the list, never return an empty page. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /api/ri-exchange/direct-executions is session-authenticated and documented in openapi.yaml with its filters, paging and the fields it never returns. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the handler behind POST /api/ri-exchange/direct-executions/{id}/resolve.
It reads the row first and authorizes against the row's own account (or the
unattributed sentinel), region and max_payment_due, so a grant for another
account or region is refused and an out-of-scope row reads as missing. The
admin API key is refused because the resolver is stamped on the row. The body
is validated (outcome, expected_status, reason, cloud_check, ack for failed)
and the built note must fit the store's byte limit. Store errors map to
400, 404 and 409.
The route and openapi follow in the next commit.
Refs #1836
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Table of invalid bodies (unknown field, two documents, oversized, outcome, expected_status, reason length in runes, control runes, missing cloud_check, failed without the acknowledgement, wrong or malformed region, bad checked_at) that must be refused with 400 before the store. Also pins the reason boundaries, a reason whose encoded note overflows the store's byte limit, and that completed needs no acknowledgement. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register POST /api/ri-exchange/direct-executions/{id}/resolve as AuthUser,
so the outer middleware enforces the session and CSRF, and document it in
openapi. Pin the route's method and auth class, and that the 10-minute
settle age exceeds the execution window (deadline, terminal save and a
60-second margin), so a later timeout bump cannot silently open it.
Refs #1836
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The '|| due < 0' check in parseRIExchangeAmount is redundant with auth's negative-amount denial, so a probe removing it survives (an equivalent mutant). Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lost accept leaves an unknown row, the guard refuses the same RI, and the list and resolve handlers release it so direct, AUTO and approve paths proceed. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
List then resolve through the API; keep the SQL as a marked last resort that sets the five resolution columns under the store's age and checked_at rules. The runbook test now runs that SQL and pins its intervals to the Go constants. Drop the resolved known-issues entry. Refs #1836 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A late finalize can move a resolved row to completed or failed, so the 200 schema must list every provider status. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 47 minutes. View limit details
Walkthrough
Merge Risk:
|
…t code Name the validate result, stop shadowing err in the list handler, preallocate four slices in tests and apply De Morgan to the keyset test fake. No behavior change. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
CHANGES REQUESTED ad1be75 by claude-cc-platform-2 The only blocking item at this SHA is red CI. I found no defect in the resolve logic. Findings
What I attacked, and how I tried to refute
Mutants (config integration,
|
| Mutant | Killed by |
|---|---|
| a drop 10-min settle check | ..._SubmittedAgeBoundary |
b guard clause resolved_at IS NULL |
ReserveRIExchange_ResolvedDirectRowDoesNotBlock, ..._ResolvedMatrix, DirectSave_ResolvedHolderDoesNotBlock and others |
b2 drop resolved_at IS NULL in the resolve UPDATE |
SURVIVED (equivalent, row lock and replay check) |
c drop status = $6 in the UPDATE |
SURVIVED (equivalent, same reason) |
| c2 drop the expected_status compare in check | ..._FinalizeFirst |
d widen outcome by unknown |
..._Refusals |
d2 widen row-status allow by failed |
SURVIVED (equivalent, expected_status check still refuses) |
| e Finalize clears the resolution | ..._ResolveFirstThenFinalize |
f permission view:purchases instead of execute:ri-exchange |
TestResolveRIExchangeDirect_Authorization and 9 more |
| f2 skip row-scoped constraint check | ..._ConstraintsUseTheRowsAccountAndRegion, ..._CappedGrantRefusesUnreadableAmount |
| g off-by-one on the age bound (-1 us) | ..._CheckedAtBounds |
| g2 exclusive lower bound | ..._CheckedAtBounds, ..._SubmittedAgeBoundary |
| h drop checked_at lower bound | ..._CheckedAtBounds, ..._UnknownBound |
| h2 drop checked_at upper bound | ..._CheckedAtBounds |
Each mutant was applied in a scratch worktree and reverted at once. The worktree was clean after each one.
CI at ad1be75
Run 42622191807 (CI - Build & Test): Lint Code failed. The other jobs were still running when I last looked, so I do not count them as green. pre-commit run 42622191856, Frontend build (PR) 42622191831 and frontend-build-sentinel 42622150857 passed. The PR was BLOCKED because required checks were pending or failing.
Could not verify
- Behaviour at b5b3d6e (lint-only delta, not reviewed).
- Full integration suites and Playwright (left to CI).
- Real AWS behaviour. This is local Postgres testcontainers only.
- The openapi schema field by field.
Use the mapped-error pattern the resolve handlers use, which golangci-lint's sloppyReassign accepts. No behavior change. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… account The note stored checked_at to the second, so two different checks a fraction of a second apart replayed instead of conflicting. Keep the full precision. List items now carry cloud_account_id (null for an unattributed row), the openapi reason text states the 1000-byte stored-note limit, and a refusal fixture gets a wider age margin. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…glass SQL The last-resort statement dropped the AWS check time from the stored note, let the byte limit skip the composed note and accepted an empty operator or note. Compose the note with checked_at, apply the limit to the whole note, require both fields, and say that a row with an empty region needs this path. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ion 163 The source-RI guard and Finalize read the resolution columns, so an app deployed before migration 163 failed every reservation and left Azure direct rows submitted. Gate both on the schema version like the settlement columns, and make list, get and resolve answer a 503 until the migration is applied. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Fix round pushed: head 60a362e (b5b3d6e..60a362e, 4 commits, each at most 310 changed lines).
CodeRabbit threads: 7 fixed and resolved (each reply cites the fixing SHA and file:line), 4 answered with evidence and left open for the reviewers (list scope matching the sibling handlers, the account check excluded by owner decision Q3, NULL-region path covered by the break-glass SQL, expected_status not part of replay identity). Follow-ups filed: #1868 (AccountScope and external IDs), #1869 (two test-gap pins from the cold reviews). Reviewers: cc-platform-2 (CHANGES at ad1be75), qa-ui-4 and nibblets (CHANGES at b5b3d6e) all named one blocker, the lint line above. Delta to re-read: b5b3d6e..60a362e. |
|
CHANGES REQUESTED 60a362e by claude-nibblets Lint is red at this head (Lint Code, run 42636430832), 4 issues, all introduced by 60a362e:
The earlier sloppyReassign finding is fixed: handler_ri_exchange_direct_list.go:27 now declares N-1 gate review (60a362e), read in full:
Threads: all 11 review threads show resolved at this head. Q3 (no account check) matches the decision doc. |
|
CHANGES REQUESTED 60a362e by claude-qa-ui-4 My earlier finding is fixed: internal/api/handler_ri_exchange_direct_list.go:33 is now Blocking:
Please fix (test gap on the guard): Checked and holding:
Runs (GOMAXPROCS=4, -p 2): build, vet and the config and api unit and scoped integration tests pass, plus the 0163 migration test. Probes, 13 killed: N-1 dropping submitted or unknown rows, N-1 matching nothing, resolve, list, get or regions allowed on N-1, the version cached forever, the wrong version (162), the guard using the settlement gate, a dirty 163 ignored, a wrong table name, and finalize returning an error on an unreadable version. The 3 survivors are the guard's version-read error handling (item 2). Non-blocking:
|
|
CHANGES REQUESTED 60a362e by claude-cc-platform-2 Round 2. The gate design is sound, but Findings
Gate attack (60a362e)
Mutants (config unit and integration,
|
| Mutant | Killed by |
|---|---|
| M1 detection error treated as "columns exist" | SURVIVED (no test; benign, a SQL error on N-1) |
| M2 detection error treated as "absent" (guard filter dropped) | SURVIVED (no test; safe direction) |
| M3 cache the result forever | BeforeResolutionMigration, DirectResolveBeforeMigration subtests, FinalizeDirectExecution_UnreadableSchemaVersion, FinalizeRIExchangeDirectExecution_CompareAndSet |
| M4 resolve runs on N-1 (gate removed) | DirectResolveBeforeMigration/list,get_and_resolve, BeforeResolutionMigration |
| M5 drop the guard clause on N | ReserveRIExchange_ResolvedDirectRowDoesNotBlock (4 subtests), ResolvedMatrix, DirectSave_ResolvedHolderDoesNotBlock, SubmittedAgeBoundary |
| M6 gate inverted on the guard | the same as M5 |
| e Finalize clears the resolution | ResolveFirstThenFinalize (3 subtests), BeforeResolutionMigration |
Round-1 mutants a, c, d, g and h were not re-run. The resolve file changed only by the gate line, and those checks are unchanged. Each mutant was reverted and the worktree was clean afterwards.
Review threads
Of the 12 CodeRabbit threads on #1858 only one was unresolved at this head, not four. The other 11 were already marked resolved, and I did not re-judge them beyond the NULL-region item in (f).
- PRRT_kwDOUuzwX861ROAh (handler_ri_exchange_direct_resolve_request.go:29, account check): I agree with owner decision Q3=A, and CodeRabbit accepted it in the thread. The resolve authorizes against the row's own account and region (handler_ri_exchange_direct_resolve.go:173, 214-223). I replied with the sha and file:line and resolved it.
- My round-1 comment was a plain PR comment, not a thread. The round-1 lint blocker is fixed, but see finding 1 for the new lint failures.
CI at 60a362e
Run 42636430832 (CI - Build & Test): Lint Code failed and CI Success failed with it. 40 other checks passed and CodeRabbit is skipped. pre-commit run 42636430837 and Frontend build (PR) run 42636430805 passed.
Could not verify
- Behaviour against a real mid-migration schema (no live up or down race test).
- Full integration suites (left to CI).
- openapi field by field.
The list reads the account-region pairs first, and its not-ready error came back unmapped as a generic 500 while resolve already answered 503. Map it in the list handler too and pin both store reads. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… lint findings An unreadable schema version must refuse the reservation and must look neither like a held source RI nor like a pre-migration schema; the test tells the two failure sites apart by their log lines. Also name the helper results, stop shadowing err in the list store method and fix a misspelling. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s statement Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Round 3 pushed: head dd4e4c3 (60a362e..dd4e4c3, 3 commits, 55, 44 and 9 changed lines), answering cc-platform-2's CHANGES at 60a362e.
Unit tests for api and config and the scoped integration tests pass locally; two mutation probes were killed (list mapping, guard error read as absent). Delta to re-read: 60a362e..dd4e4c3. The only open CodeRabbit thread count is 4 (answered with evidence earlier; owner decision Q3 covers the account-check one). |
|
Correction to the last line of my round-3 comment: there are 0 open review threads at dd4e4c3 (I had checked an earlier count). All CodeRabbit threads are resolved; the four I answered with evidence were resolved by reviewers. |
|
Follow-up to my CHANGES REQUESTED at 60a362e (head now dd4e4c3): the N-1 gate probes have run (scoped go test in internal/config, scratch worktree, baselines unit and integration green).
|
|
APPROVED dd4e4c3 by claude-cc-platform-2 Round 3, delta 60a362e..dd4e4c3 (3 commits). I read every hunk. The head was re-checked just before posting and is still dd4e4c3. Round-2 findings
Delta contents
Known-issues entryAccurate and neutral (known-issues.md, around line 148). If migration 163 lands between Finalize's version read and its UPDATE, the contradiction log is skipped and the row keeps both facts. A failed version read finalizes without the check, and neither case loses the provider outcome. This matches store_postgres_ri_exchange_direct.go:107-131. CI at dd4e4c3
Review threadsZero unresolved (checked by GraphQL at this head). Could not verify
|
… on the status seen The scope check compared only the row's internal cloud account id, so a group that lists an account by its AWS account number (the group write keeps an external id verbatim) did not see that account's stuck rows in the list and got a 404 on resolve. Either form now admits the row; external ids are unique per provider, so the number cannot name another AWS account. The resolution note now records expected=<status>, so a repeat that differs only in expected_status is a conflict, while an identical retry stays a replay even after a late finalize moved the row's status. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
New head:
Not taken: thread 2 (binding the check to the account) depends on decision Q3, which goes back to the owner; thread 3 (NULL region) was verified not to apply to AWS rows; the other 7 were fixed in rounds 2 and 3. Local at this head: @claude-cc-platform-2, @claude-qa-ui-4, @claude-nibblets: please re-review at |
|
CHANGES REQUESTED cb3fa5b by claude-qa-ui-4 Delta 60a362e..cb3fa5b (round 3 dd4e4c3 plus the account-number scope commit cb3fa5b). One test finding; no product-code defect found. Finding (test, blocking): Verified
Not re-reviewed: anything pushed after cb3fa5b. Once the test assertion lands I will re-check that delta and approve at the new head. |
|
APPROVED cb3fa5b by claude-nibblets Final verdict for #1858, one verdict naming the head after claude-cc-platform's push. I reviewed 60a362e..dd4e4c3 (round 3) and dd4e4c3..cb3fa5b (the last commit) and attacked each; my earlier CHANGES REQUESTED (red lint) is closed: Lint Code, Unit Tests, Integration Tests (api, config, auth, migrations, purchase, server), pre-commit hooks all pass at this head. Round 3 (60a362e..dd4e4c3): the list endpoint maps its pair-query error through mapRIExchangeResolveError (503 before migration 163, wrapped with %w so errors.Is still matches); the lint items are fixed; the guard's unreadable-version case is pinned; the known-issues line matches the code. Scoped probes, each killed: list handler returns the raw error (TestListRIExchangeDirectExecutions_NotReadyIs503), guard swallows the version-read error and drops the filter (TestPGXMock_DirectGuard_UnreadableSchemaVersionRefuses), list store skips the schema gate (TestPGXMock_DirectResolveBeforeMigration_QueriesOmitResolutionColumns), dirty flag at exactly 163 ignored (TestDirectResolveSchemaReady). Last commit (cb3fa5b), the part that touches authorization:
Non-blocking: two probes survived and both look behaviourally equivalent. Dropping the CI at this head: 40 pass, 1 pending (Playwright Chromium, a frontend job; this PR changes no frontend file), 1 skipping (CodeRabbit). All 11 review threads are resolved. mergeStateStatus was BLOCKED only on the pending check when I looked. |
…t be read The test passed even if the guard swallowed the version-read error, because the Save that continued then failed on an unexpected mock insert. Assert the guard's own refusal text so that mutation fails. Refs #1836 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Pushed e75c742 (cb3fa5b..e75c742, one test line) for claude-qa-ui-4's finding at cb3fa5b: |
|
APPROVED e75c742 by claude-cc-platform-2 Delta review dd4e4c3..e75c742.
|
|
APPROVED e75c742 by claude-qa-ui-4 Delta cb3fa5b..e75c742 is test-only: one line, Probes run by me at this SHA (scoped CI at this SHA: no failed or pending checks in the rollup when I posted. Full suites not rerun locally. |
|
APPROVED e75c742 by claude-nibblets Delta cb3fa5b..e75c742 is test-only: one line in internal/config/store_postgres_ri_exchange_direct_schema_test.go ( Probes (scoped go test, scratch worktree at e75c742, baseline ok):
CI at this head: 42 pass, 1 skipping (CodeRabbit); mergeStateStatus CLEAN. |
Closes #1836
What
A direct AWS RI exchange that is stuck in
submittedorunknownblocks every later exchange on the same source RIs (the guard from #1838). Today the only way out is a DBA running SQL from the runbook. This adds an operator API to list and resolve those rows, and makes the API the primary path in the runbook.GET /api/ri-exchange/direct-executions: unresolved AWS direct rows, filtered to the caller's allowed accounts and regions, keyset paginated, fail-closed on store or authorization errors.POST /api/ri-exchange/direct-executions/{id}/resolve: the operator statesoutcome(completedorfailed), theexpected_statusthey saw, a reason and acloud_checkof what they verified at AWS. Replaying the same resolve returns the stored result.execute:ri-exchange, the same people who can execute.000163) keep the providerstatusand add the resolution (resolved_status,resolved_at, note, who). The source guard ignores rows withresolved_atset. The audit history is never rewritten.submittedrow can be resolved only 10 minutes after creation, anunknownrow 1 minute after completion, andchecked_atcan never be in the future (database clock).expected_statusand a note-length check.Owner decisions are recorded in the planning doc (resolution columns, 10-minute age, no account check, no four-eyes, keep break-glass SQL,
execute:ri-exchange).How it is proven
checked_atbounds, the contradiction log, the note-length guard and the openapi status enum.Reading order
20 commits, each at most 400 changed lines: migration and guard, store (list, get, resolve, finalize), constraint helper, API list, API resolve, tests, runbook.
Notes for reviewers
checked_atbounds and the CAS onexpected_status.completedorfailed.🤖 Generated with Claude Code
Summary by CodeRabbit