Repository navigation
fix(db): contract the canceled rename: normalize, narrow the status CHECKs, stop using cancelled_by - #1253
fix(db): contract the canceled rename: normalize, narrow the status CHECKs, stop using cancelled_by#1253IAmJSD wants to merge 3 commits into
Conversation
…HECKs, stop using cancelled_by Migration 000144 is the contract half of 000089. Under a table lock it normalizes every legacy 'cancelled' status in purchase_executions and ri_exchange_history, drains legacy-only actors into canceled_by, and narrows both status CHECKs to the 000089 lists minus 'cancelled'. The code stops reading and writing purchase_executions.cancelled_by: reads project canceled_by, the cancel transition and SetCancelledBy write only canceled_by, and SavePurchaseExecution writes canceled_by, keeping an actor already on the row (the value reads saw before). The LegacyStatusCanceled constant and every dual-spelling branch are gone. The cancelled_by column is kept. The previous build still reads COALESCE(canceled_by, cancelled_by) and writes cancelled_by, and it keeps serving during a rolling deploy and after a code-only rollback, so dropping the column in the same release would break every execution read on it. A trigger mirrors any legacy-only actor write into canceled_by until a follow-up migration drops the trigger and column. Refs LeanerCloud#47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Bring the branch up to date with main. Conflicted files: - internal/api/handler_history.go: main (LeanerCloud#1200) moved the per-row summary bucketing out of summarizePurchaseHistory into addHistoryRowToSummary and addNonCompletedRowToSummary so the SQL aggregate and the row list share one set of rules. The branch had dropped config.LegacyStatusCanceled from the canceled case in the old inline switch. Kept main's refactor and applied the branch's change to addNonCompletedRowToSummary: only config.StatusCanceled is matched, since 000144 makes the legacy spelling unstorable and the branch removes the constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry test The test came in from main (LeanerCloud#1200) and seeds executions with LegacyStatusCanceled and queries it as an execution status. This branch removes that constant, and migration 000144 rejects the 'cancelled' status, so the seed would fail the narrowed CHECK. Seed and query only the canonical spelling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged origin/main (1c5787a) into this branch. New head: fb1d654. A trial merge with the current main (c1f1781) is clean and passes Conflict resolution:
Follow-up commit (a semantic conflict that merged without a textual conflict):
Checks on the merged head:
One thing to watch when merging: #1277's new |
…ation 000145 -> 000141 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…000140 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140, 000141, 000143 and 000144 belong to PRs held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253); this PR takes 000140 so it can merge ahead of them, and they will take the next free numbers when decided. Rename only: the SQL is unchanged, and the test's version constant and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…7 -> 000143 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-> 000145 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…148 -> 000144 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Migration renumbering needed before this merges. The reviewed, ready PRs have taken 000140-000145 so they could merge without waiting on owner decisions: #1240 (000140), #1302 (000141), #1268 (000142), #1287 (000143), #1289 (000144), #1116 (000145). This PR's migration(s) (000144) must be renumbered to start at 000146, or the next free number at that time, when it's ready to merge (000144 -> 000146 or the next free number). golang-migrate skips a version below the database's current one, so a lower number merged after those would never apply. Dependency check: none of the six ready migrations touches |
…n 000146 -> 000142 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (LeanerCloud#1239, LeanerCloud#1241, LeanerCloud#1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d of the full list (#1240) * fix(api): add opt-in keyset pagination to GET /api/recommendations The list endpoint returned the whole filtered recommendations cache in a single body, so a tenant with a few thousand cached rows pushed the response past the 6 MB Lambda payload cap and the page failed with 502. Gzip (#775) raised that ceiling but did not bound it. Callers can now send limit (1-1000) and the returned next_cursor to read the list in pages ordered by recommendation ID. The cursor is an opaque base64url encoding of the last ID seen, so a row deleted between requests does not shift later pages. summary and regions still describe the full filtered set on every page, and compute capacity is only decoded for the returned rows. Requests without limit or cursor keep the existing unpaginated response, so the current frontend is unaffected until it adopts the parameters. openapi.yaml documents the parameters and the actual response shape. Refs #777 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): cap recommendation pages by serialized bytes and reject empty paging params A page was bounded only by row count, but the opaque Details blob makes a row's size data-dependent, so 1000 large rows could still pass the 6 MiB Lambda response cap. Pages now also stop once the rows, measured as they appear inside the Lambda response envelope (the body re-encoded as a JSON string, so quotes and backslashes count twice), plus the summary, regions, keys and next_cursor would pass a 4 MiB budget. The budget is checked against uncompressed bytes because the #775 gzip only runs when the client accepts it. A page always holds at least one row, so a row that alone is over the budget is returned on its own page with a logged warning instead of stalling the cursor walk. Legacy unpaginated mode is now selected only when neither limit nor cursor is present. A present but empty ?limit= or ?cursor= returns 400 instead of falling back to the unbounded response. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): reject duplicate recommendation IDs when paging and document 400s - Correct the ordering comment: the paging key is the application recommendation ID built from the natural-key columns, not the cache table's UUID primary key. A paginated request now fails if two rows share an ID, since the strict "after" comparison would skip one of them at a page boundary. - Describe the feature as opt-in in-memory cursor slicing, not a SQL keyset (store-side paging is #981). - TestGetRecommendations_UnpaginatedShapeUnchanged copies the expected IDs before the call so an in-place reorder in legacy mode fails it. - Add TestGetRecommendations_PaginationRejectsDuplicateIDs. - openapi: document the 400 response for bad limit/cursor/filter values, and say next_cursor's absence is the only end-of-list signal. Refs #723 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(db): index recommendations by byte-wise payload id for keyset scans Migration 000142 adds recommendations_scan_key_idx on (COALESCE(payload->>'id', '') COLLATE "C", id). The paginated GET /api/recommendations scan (issue #981) walks the cache in chunks in that order and resumes each chunk after the last (id, primary key) it read; without the index every chunk is a full scan plus a sort. COLLATE "C" is byte order, the same order Go's string comparison uses, so SQL paging keeps the order the API already returns. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(config): read stored recommendations in keyset chunks ListStoredRecommendationsChunk returns at most limit rows matching a RecommendationFilter, ordered by the byte-wise recommendation ID and then the table's UUID primary key, resuming strictly after a given position. The primary key keeps rows that share an ID in a total order, so a chunk boundary inside such a run neither skips nor repeats a row. The MinSavingsPct floor stays in-process (shared with ListStoredRecommendations via decodeStoredRecommendation); rows it drops still advance the position. Integration tests pin the order to Go's sort.Strings on IDs that a linguistic collation orders differently, cover duplicate IDs across every chunk size, the pct floor emptying a whole chunk, the empty table, an EXPLAIN check that recommendations_scan_key_idx serves the query without a Sort node, and walks racing concurrent upserts. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scheduler): scan cached recommendations chunk by chunk ScanRecommendations walks the recommendations cache through ListStoredRecommendationsChunk in byte-wise ID order and applies the same suppressions and account overrides as ListRecommendations to each chunk, handing the survivors to a callback. It holds one chunk at a time instead of the whole filtered set. Suppressions are read once per scan and each (account, provider, service) override is resolved once per scan, not once per chunk. Read failures over-show exactly like ListRecommendations. The freshness read, cold-start kick and stale-while-revalidate kick are pulled out of ListRecommendations into startCacheRead/finishCacheRead so both reads share them unchanged. Tests pin the scan to ListRecommendations' output (sorted by ID) for chunk sizes 1 to 31 with suppressions and overrides active. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(api): page GET /api/recommendations from the chunked scan A paginated request (limit or cursor sent) no longer loads the whole filtered set through ListRecommendations and sorts it in memory. It runs Scheduler.ScanRecommendations, applies the enabled-providers and account scope filters to each chunk (their inputs read once per request), and feeds the survivors to recommendationsPageCollector. The collector totals every row into the summary and keeps only the rows after the cursor up to limit+1 (the page plus one row proving a next page exists), then cuts the page with the existing byte budget. Peak memory is one scan chunk plus one page instead of the whole tenant. Output is unchanged: rows arrive in byte-wise ID order (Go string order), duplicate IDs still fail the request, and the summary still covers the full filtered set. The unpaginated path is untouched. The in-memory sortRecommendationsAfter/buildBudgetedPage are removed; the existing #723 pagination tests now run against the scan path, with buildBudgetedPage kept as a test helper over the collector. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): pin paginated recommendations order, ties and last page - PaginatedRequestDoesNotLoadFullList: the #981 regression; a paged request must use ScanRecommendations, never ListRecommendations. - PagedWalkMatchesLegacyOrder: for an unrestricted and a scoped reader, every chunk size and page size (including the exact-fit last page), the walk returns the legacy rows sorted by Go string order in consecutive pages, next_cursor on all but the last page, and the legacy summary on every page. IDs mix case, "|", "-", "/", spaces and accents. - PagedDuplicateIDs: ties fail every paged request at any chunk/page boundary; a twin removed by the scope filter is not a tie. - Background-collection scope (#627), the one-page memory bound, the out-of-order guard and the empty cache. - An integration test walks a real Postgres cache spanning several scan chunks with the min_savings_pct floor and matches the legacy output. Refs #981 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scheduler): scan with main's variant-aware suppression index Merging main brought #1110 (issue #816), which changed aggregateSuppressions to return the raw rows grouped by 6-tuple (map[suppressionKey][]config.PurchaseSuppression) and moved the per-rec aggregate into applySuppressionIndex, because which rows apply now depends on the rec's variant (AZ, platform, tenancy). ScanRecommendations still held the old map[suppressionKey]*suppressionAgg, so the merged tree did not build. loadSuppressionIndex now returns main's grouped index and the scan passes it to the same applySuppressionIndex as ListRecommendations. Matching is per rec, so applying it chunk by chunk suppresses exactly what the non-scan path does. A new test pins the scan to ListRecommendations at every chunk size on an EC2 fixture with a variant purchase, a fully covered variant, a legacy wildcard row and a rec with unreadable Details, and checks the expected per-variant counts. Stripping variants from the scan's index fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber recommendations_scan_key_idx migration 000142 -> 000140 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140, 000141, 000143 and 000144 belong to PRs held on owner decisions (#1239, #1241, #1253); this PR takes 000140 so it can merge ahead of them, and they will take the next free numbers when decided. Rename only: the SQL is unchanged, and the test's version constant and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cristian Magherusan-Stanciu <cristim@users.noreply.github.com>
…vice (#1302) * fix(purchase,server): skip the $0 purchase confirmation and report a failed lazy DB init on /health An execution with nothing selected never reaches a cloud API, but both the single-account and the per-account fan-out paths still sent a "purchase confirmed" email listing zero commitments. Gate the confirmation on anyRecPurchased / committed, so it goes out only when a real commitment was created (full or partial success). When reinitializeAfterConnect fails after setting app.Config, ensureDB closes the pool and clears app.DB but leaves app.Config, and the config_store health check reported healthy over the closed pool. Snapshot dbConnected with the store pointers under dbMu and report unhealthy while lazy PostgreSQL init has not completed. Audit findings A05-015 and A06-009. A06-019 (commitment_type labels) follows separately. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(analytics): label snapshot commitment types from provider and service commitmentTypeFor matched only a service literally named "SavingsPlans", which purchase_history never stores, so every commitment was stored as "RI": AWS Savings Plans ("savingsplans" / "savings-plans-<type>") and GCP committed-use discounts included. Derive the type from (provider, service) in the pkg/common CommitmentType vocabulary, and widen the savings_snapshots CHECK in migration 000145 to match. The CHECK still admits the legacy 'RI' / 'SavingsPlan' literals so an old replica can write during a rolling deploy; existing rows are relabeled. Audit finding A06-019. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(purchase): make the #339 no-confirmation assertions checkable by the mock guard TestNoUnfailableMockAssertions could not resolve the receiver of two AssertNotCalled sites (a mock returned by a helper). Declare the mock directly in the new test, and in the approve test rely on the missing SendPurchaseConfirmation expectation, which fails the test on any send. Refs #339 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(analytics): restore the pkg/common import after #1254 #1254 (on main) replaced the hand-rolled config-store mock in collector_test.go and dropped the now-unused pkg/common import. This branch's new commitment-type assertion uses common.CommitmentType, so the analytics test build broke after main was merged in although the merge was textually clean. Same assertion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber savings_snapshots_commitment_type_vocabulary migration 000145 -> 000141 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ail-closed plan providers, revocation_in_flight clear (#1268) * fix(config/db): audit findings A04-010/011/012/013 in migrations and config store - 000059 down: only remove ...0005 when it really is Purchaser. 000057 gave that UUID to Standard Users, so the old down deleted Standard Users and stripped it from every member. Refuse with a clear error rather than leave a Purchaser-only user with no group. 000096 down gets the same up-front refusal instead of a bare users_min_one_group violation. - 000027: dedupe savings_snapshots on (tableoid, ctid). ctid is per partition, so matching on it alone deleted unrelated rows of other partitions on a forced re-run. - DerivePlanProviders: accept the "provider:service" keys 000040 wrote, and return an error instead of an empty provider set when no key names a provider, so both plan-account guards fail closed. - MarkPurchaseRevoked clears revocation_in_flight, as 000072 documents; migration 000146 clears it on rows already revoked. Real-Postgres tests for each, using the shared container. Normalizing the plan-services key delimiter is deferred (see the PR). Refs #316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): identify legacy-keyed plan services by their provider field The first #316 commit made DerivePlanProviders fail when no service key had a provider prefix. That refused supported plans: a legacy alias kept under its original key (#1123's singleton-alias edit path, exercised by TestLegacyServiceB1S1*) has a key like "original-key", and so do fixtures keyed "ec2" with Provider "aws". Each entry's provider now comes from its key prefix ("provider/service" or "provider:service"), falling back to the entry's own Provider field, which is what mergeRequestedServices and plan step resolution use. The check fails closed only when no entry identifies a provider either way. Refs #316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(migrations): drop the duplicate TestMain from helpers_test.go main's #1400 added main_integration_test.go with a TestMain that stops the shared PostgreSQL container after the run. This branch added an identical TestMain to helpers_test.go, so after merging main the integration build of the package declared TestMain twice and failed to compile. Keep main's TestMain, which does the same teardown, and restore helpers_test.go to main's version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber purchase_history_clear_revoked_in_flight migration 000146 -> 000142 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ngs rollups (#1287) * fix(analytics): keep unknown usage and commitment unknown in the savings rollups savings_snapshots.total_usage and total_commitment are nullable on purpose: NULL means the provider gave no figure. monthly_savings_summary and provider_savings_summary rolled them up with SUM and AVG, which skip NULL, so "us-east-1 $4000 + eu-west-1 unknown" became a definite $4000. Migration 000147 recreates both views so a timestamp's total is NULL when any of its rows is unknown, and the period average is NULL when any of its timestamps is. A known 0 stays 0. Column names and types, the unique indexes and the CONCURRENTLY refresh path are unchanged; the down migration restores the previous definitions. Integration tests on real Postgres cover unknown, known zero, fully known, unknown-at-one-instant, down/up, and a refresh_savings_materialized_views() call queued behind the migration's locks. Refs #151 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber savings_rollups_propagate_unknown migration 000147 -> 000143 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…nd AWS fee (backend slice of #27) (#1289) * feat(marketplace): add listing settlement columns and guarded poller writes Migration 000148 adds the purchase_history settlement columns that 000087 deferred (listed_at, listing_proceeds_received, listing_fee_paid). listing_price_schedule already exists via 000105 (#525) and is left alone, including on rollback. The store gains the three statements the RI Marketplace status poller needs: the scan of listings to poll and two status-guarded UPDATEs. The guards (same listing id, active or same terminal state, proceeds not yet recorded) decide whether a poll result applies. Concurrent polls therefore record proceeds once, and a cancel recorded by the cancel handler is never revived or turned into a sale. Real-Postgres tests cover the races. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(marketplace): poll RI Marketplace listings for sales and fees PollMarketplaceListings reads each listing due for a poll from AWS (DescribeReservedInstancesListings through the existing listing-finder interface, signed as the purchase's own account like list and cancel). While a listing is active it records its CreateDate as listed_at. When the listing closes, or is canceled after a partial sale, it records the terminal state with the seller's net proceeds and the AWS fee: the instances AWS reports sold, times the active price tier, minus the 12% fee, in whole cents. When instances sold but no single active USD tier exists, the state is recorded and the proceeds are left unset for a later poll instead of guessed. A failure on one listing is counted and the run moves on to the next. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): check poller non-writes through MockConfigStore's call log The vacuous-assertion guard cannot resolve f.store as a receiver. Give the two tests their own *MockConfigStore and use the name-only AssertNotCalled, which reads the call log and fails on any call. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(server): run the marketplace listing poll in the finalize_revocations sweep The finalize_revocations task now also runs the RI Marketplace status poller after the revocation repair. Both reconcile purchase_history with the provider on the same hourly schedule, so no new schedule or infrastructure is needed. The two steps are independent: one failing does not skip the other, and the task fails if either did. The task's result keeps the revocation counts under their existing keys and adds the poll counts under marketplace_listings. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber purchase_history_listing_settlement migration 000148 -> 000144 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): drop the previous listing's settlement when a relist is recorded Repricing an RI on the Marketplace means cancel and relist, since AWS has no price edit. A relisted row kept the earlier listing's listed_at, proceeds and fee, and only RecordMarketplaceListingActive cleared them, which needs a poll to see the new listing active first. A relist that sold out (or was canceled) before the next hourly poll was therefore never settled: the settle guard refuses rows whose proceeds are set, so the row stayed active with the old 0.00 and was polled and skipped every hour, or showed the old listing's settlement as the new one's. UpdatePurchaseHistoryListing and ResolveMarketplaceListingAttempt, the two statements that write a listing id, now null the three columns in the same UPDATE when the id changes. Re-recording the id the row already holds (a cancel or its retry) keeps them. The new real-Postgres test runs the review's exact sequence plus the canceled-within-the-hour and resolved-attempt variants. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(auth): bind group account scope entries to account IDs A group's allowed_accounts could hold an account's display name. The name was not bound to any account, so once that account was deleted or renamed the entry matched whichever account was later created or renamed to the same name. #1032 limited name matching to unique names, which does not help here: the newcomer is the only account carrying the name. Scope entries now match by account identifier only: - AccountScope.Allows never compares an entry with a display name. A caller that knows an account only by name (legacy rows keyed by external ID) still resolves through WithRegisteredAccounts to the one account carrying that name, and that account's ID must be in the scope. - Group create/update bind allowed_accounts at grant time: "*", account IDs and external IDs are kept; a name exactly one registered account carries, inside the actor's own scope, is stored as that account's ID; anything else is a 400. A failed account or scope load refuses the write. - Migration 000135 rewrites existing name entries to IDs when exactly one account carries the name and it predates the group's last write. Other entries are left in place and no longer grant access. Down is a no-op. - The name-only MatchesAccount and AuthContext.CanAccessAccount helpers, which had no production callers, are removed. Closes #1034 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): scope the history-summary fixture by account ID The history-summary integration tests from #1200 scoped their user with the tenant's display name. This branch binds allowed_accounts entries to account IDs and makes a bare name entry match nothing (#1034), so the scoped user saw no tenant A rows and the totals and listed-rows checks failed. Grant the scope by tenant A's account ID, the form group writes now store. The assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber the group scope binding migration to 000149 main now ships migrations up to 000139, so 000135 would sit below versions a deployed database has already applied and golang-migrate would never run it. Open PRs hold 000140-000148, so this takes 000149 and must merge after that chain. The SQL is unchanged; only the file names, header comments, test version and test fixture IDs move. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(db): renumber bind_group_account_names_to_ids migration 000149 -> 000145 golang-migrate only applies versions above the database's current one, so PRs must merge in migration-number order. 000140-000144 had been taken by PRs that are now held on owner decisions (#1239, #1241, #1253). The ready PRs move to 000140-000145 so they can merge ahead of them, and the held ones will take the next free numbers when decided. Rename only: the SQL statements are unchanged, and the test version constants and the comments naming the migration follow the new number. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): register the wire-format group account for grant-time binding TestUserGroupWrites_WireFormat came in from main (#1261) and builds a Handler with no config store. Since #1034, group writes bind every allowed_accounts entry to a registered account and refuse the write when the account list cannot be loaded, so POST /api/groups answered 500. The test now registers its account ID in a store mock, as the binding tests do; the wire format it pins is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): scope the History paging user by account ID, not name TestIntegration_HistoryPaging_ScopedUserNeverPagesIntoAnotherTenant came in from main (#1277) and grants the scoped user tenant A by its display name. Since #1034 an allowed_accounts entry matches by cloud account ID only, so that user saw none of tenant A's rows and the walk returned only its own unscoped execution. The grant now names tenant A's ID, as the other scope tests on this branch do; what the test asserts is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(analytics): scope account_ids fixtures by account ID The account_ids analytics tests that arrived with the main merge (#1448) grant scope by account name ("a", "b", "c"). This branch binds group scope entries to account IDs and no longer matches an entry against a display name (#1034), so those name entries authorize nothing and the scoped cases got 404. List the account IDs in the scope instead, as the other analytics scope tests on this branch already do. The assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(analytics): scope the account_ids DB test by account ID TestAnalyticsAccountIDs_SumsSelectedAccountsOnly_DB (from #1448) grants the scoped user ["a", "b"], which are account names. Scope entries are account IDs on this branch (#1034), so the name entries allowed nothing and the in-scope query returned 404. Use the created accounts' IDs. The foreign-id refusal assertion is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(history): scope the plan_id filter DB test by account ID TestIntegration_HistoryPlanFilter_CrossTenant (from #1445) grants the scoped user tenant A by account name. Scope entries are account IDs on this branch (#1034), so the name allowed nothing and tenant A's own plan came back empty. Grant tenant A's ID, as the other history integration tests on this branch do. The cross-tenant assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api): scope #1464's actionable History tests by account ID #1464 landed on main with tests that scope the user by the display name "dev" and with an executionScope case built on WithRegisteredNames. This branch (#1034) makes name entries grant nothing and replaces that constructor, so after merging main those tests no longer compiled or no longer exercised the scoped path. Scope them by the account's ID, which is what a group stores now, and pin that a name entry contributes only its literal value to the SQL scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth,api): bind account names to the identifier in scope checks Review (rE7) found that an ID-scoped user could pass the purchase gate for an account outside their scope. resolveAccountNamesByID keyed names by both account ID and the free-text external ID in one map, so an editor of in-scope account Z who set Z's external ID to victim Y's ID got Z's name back for Y. AccountScope.Allows then resolved that name to Z, which is in scope, and allowed a purchase in Y. The same map fed the dashboard, ladder and RI reshape filters. Decide those gates on the record's cloud_account_id itself, the ID the purchase resolves its credentials from, and delete the dual-keyed map. Bind the name path in Allows as well, so a name only identifies an account through that account's own external ID and never through a value that is some registered account's ID. Callers that pass a name for an external ID already resolve it within the record's provider or require exactly one owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This is the contract half of the 000089
cancelled->canceledrename, minus the column drop. The column drop can't safely ship in the same release, so it comes in a follow-up (explained below).What was still open on main. 000089 (EXPAND, #1277) widened both status CHECKs, added
canceled_byand deferred everything else. Nothing after it touches these constraints or the column; I checked every migration through 000134 plus the open PRs holding 000135-000143. Main still:config.LegacyStatusCanceledappears in about ten switches and lists, and the cleanup SQL usesstatus IN ('cancelled','canceled').COALESCE(canceled_by, cancelled_by).cancelled_by:SavePurchaseExecutionwrites onlycancelled_by(nevercanceled_by).transitionExecutionStatusandSetCancelledBydual-write both columns.Nothing on main writes a
'cancelled'status topurchase_executionsorri_exchange_history. The remaining double-l literals belong toladder_runs/ladder_tranches/ AWS listing state, which are different tables and out of scope.Changes
Migration 000144
contract_canceled_spelling(up), one transaction:LOCK TABLE purchase_executions, ri_exchange_history IN ACCESS EXCLUSIVE MODE. The CHECK rebuild below takes this lock anyway. Taking it first means no writer can commit between the normalize/drain and the narrowed CHECKs, or between the drain and the trigger.purchase_executions_mirror_legacy_cancel_actor,BEFORE INSERT OR UPDATE ... WHEN (NEW.canceled_by IS NULL AND NEW.cancelled_by IS NOT NULL). It setscanceled_by := cancelled_by, socanceled_byalways equalsCOALESCE(canceled_by, cancelled_by), which is exactly what older builds read.status 'cancelled' -> 'canceled'in both tablescanceled_bypurchase_executions_status_checkandri_exchange_history_status_checkas the 000089 lists minus'cancelled'. The test checks the new constraint text is the old one with exactly that one entry removed.Down: widens both CHECKs back to the exact 000089 definitions, drops the trigger and function, and backfills
cancelled_byfromcanceled_bywhere it is NULL. Rows staycanceled. The widened CHECKs accept both spellings and every build since #1277 reads both, so there is no need to convert data back.Code (no read or write of
purchase_executions.cancelled_byremains):canceled_by.SetCancelledBywrite onlycanceled_by.SavePurchaseExecutioninsertscanceled_byand on conflict setscanceled_by = COALESCE(purchase_executions.canceled_by, $17).COALESCE(canceled_by, cancelled_by), so a save could never change an actor already incanceled_by.$17straight intocanceled_bywould let a save from a stale copy (read before a concurrent cancel) erase that cancel's actor.LegacyStatusCanceledis removed, along with every dual-spelling branch: the history filter/annotation/KPI, cancel-recovery normalization, plan health, ramp settled/terminal/health-scored lists, ladder reconcile/resolve, ramp baseline and schedule clock. The cleanup query is nowstatus = 'canceled'.'cancelled'row can no longer be stored. The 000089 test keeps its//nolint:misspellbecause it still exercises the legacy spelling at version 89.Why
cancelled_byis not dropped here (deploy order)Migrations run at app startup (
ensureDB->RunMigrations). So the first instance of the release that ships 000144 applies it while instances of the previous build (P) are still serving.rollback.ymlalso redeploys an older image without touching the schema. P readsCOALESCE(canceled_by, cancelled_by)in every execution query and writescancelled_by. If this release dropped the column, every execution read on P would fail with "column does not exist" for the rest of the rollout, and so would any code-only rollback. So:cancelled_by'cancelled'(verified on main), so the narrowed CHECKs don't affect it. P's legacy-column writes are mirrored by the trigger, so R can readcanceled_byalone. P's COALESCE reads still work, since the column exists.DROP COLUMN cancelled_byThat follow-up is the rest of #47, which is why this PR says Refs, not Closes. It needs a follow-up issue (or #47 left open) to track it.
Rollback story with the DB at 000144
03c0ac9d) is safe without a schema change:canceled;canceled_by.'cancelled'and the narrowed CHECK rejects it. That needs 000144.down then 000089.down, which is already the requirement for going below feat(api): page the History view with a keyset cursor (has_more, next_cursor) #1277.000144.downalone puts the schema back to the 000089 state and is safe with R or P code running.Failure mode: migrations fail open (
ensureDBlogs and keeps serving). If 000144 failed, R would run on the unnormalized schema: surviving'cancelled'rows and any un-drained legacy-only actors would read as unknown status or missing attribution. Nothing breaks outright, and/healthreports the failed migration. The migration is a few UPDATEs and two CHECK rebuilds on two tables under one lock, so the main risk is lock wait on a busy table.Lock ordering: 000144 locks
purchase_executionsthenri_exchange_history. I found no app transaction that writes both, so I don't expect deadlocks. If one happened, Postgres aborts one side; for the migration that means a rolled-back transaction and the existing dirty-flag recovery.Refs #47
Verification
Run on this branch (
62feb4fc), Go 1.26.9,AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null.go build ./...,go vet ./...,go vet -tags integration ./...: clean.-count=1):./internal/config,./internal/api,./internal/purchase,./internal/mocks,./internal/database/postgres/migrations: all ok.golangci-lint run --allow-parallel-runners(v2.10.1), with and without--build-tags integration, on./internal/config/... ./internal/api/... ./internal/purchase/... ./internal/database/postgres/migrations/... ./cmd/cleanup-lambda/...: 0 issues.gocyclo -over 10 -ignore "_test\.go"on every changed non-test file: nothing over 10.New tests
TestPGXMock_ExecutionCancelActorUsesOnlyCanceledBy(pgxmock, unit). Its query matcher rejects any statement naming the legacy column. It drivesGetExecutionByID,TransitionExecutionStatus(... canceled),SetCancelledByandSavePurchaseExecution, and pins theCOALESCE(purchase_executions.canceled_by, $17)save.store_postgres.goswapped back toHEAD~1, it FAILS (statement still references the legacy cancel actor column).TestMigration_000144_ContractCanceledSpelling(integration, real Postgres 16 via testcontainers). FrompreviousMigrationVersion(t, 144)it seeds legacy-status, drain-only, both-columns and conflicting-actor rows plus a legacyri_exchange_historyrow, then migrates to 144 and asserts:canceled_by, and a legacy write never overrides an existingcanceled_by;TestMigration_000144_ConcurrentLegacyWritersKeepAttribution(integration, race). Four writers insert legacy-only actor rows (the shape an older build's save produces) continuously before, during and afterMigrateToVersion(144). It requires at least 20 inserts after the migration, no writer errors, and zero rows withcancelled_byset andcanceled_byNULL (about 1,260 rows in the run).Mutation probes (each reverted afterwards):
ContractCanceledSpellingFAILS (a legacy-only insert must be mirrored into canceled_by);ConcurrentLegacyWriters...FAILS (every legacy-only actor must be in canceled_by, of 1264 rows)ContractCanceledSpellingFAILS on the drain-only rowpurchase_executionsstatus normalization removedcheck constraint "purchase_executions_status_check" ... is violated by some row (SQLSTATE 23514)Changed integration tests, run narrowly:
TestPersistExecution_RetryAfterRowLeftPendingfailed once in the first combined run on a loaded machine, and the error detail was not captured. It passed on two reruns, alone and together with the other two tests.TestRampRetention_OldCleanupWritersPreserveHeldRowsneeded its expected delete count lowered from 3 to 2 once the legacy fixture row was removed.All of the above is local evidence: unit tests, pgxmock and testcontainers Postgres. Nothing was run against a deployed environment.
Combined-tree verification. This branch was tested together with six other queued branches on a local, never-pushed tree:
queue/platform@77d116c6= origin/maina14dc8f2plus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration, andgocyclo -over 10is silent on the changed non-test files. These ran atb61a9c30, which differs from77d116c6only by one.gitallowedline on fix/issue-1233.-tags integration -count=1):cmd/cleanup-lambda,internal/analytics,internal/auth,internal/database/postgres/migrationsandinternal/purchase.internal/apipasses, except two tests whose Postgres container didn't start, or whose migrations hit a timeout, under machine load. Rerun alone on the same tree, both pass:TestDeleteManagedPlanByExecutionStatusandTestNotifiedRow_RescheduleDoesNotOverwriteConcurrentCancel.internal/configpasses exceptTestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).TESTCONTAINERS_RYUK_DISABLED=trueto avoid a testcontainers reaper race under load.scripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.Out of scope
DROP COLUMN cancelled_byfollow-up, described above. Its migration should also drop the trigger and function and do a final drain.ramp_ledger_holds()(000124) still lists both spellings. That is harmless: the function is IMMUTABLE SQL, and redefining it is churn for no behavior change now that'cancelled'can't be stored.internal/mocks/stores.godefault returns andhandler_ri_exchange_test.goTestApproveRIExchange_AlreadyCancelled.ladder_runs.cancelled_by/ ladder'cancelled'statuses are a separate table and spelling (000080) and are not part of this rename.PurchaseExecution.CancelledBy(JSONcancelled_by, which the frontend consumes) and the method nameSetCancelledByare unchanged.Frontend follow-up for the UI owner
After 000144 the API never returns
cancelledfor purchase executions or RI exchanges, so the dual-spelling branches added for 000089 can go:frontend/src/history.ts: status badge (case 'cancelled'), the status counts (s === 'canceled' || s === 'cancelled') and the two filter predicates around lines 1187 and 1207. The'cancelled'chip key itself is UI-internal and can stay.frontend/src/riexchange.tsaround line 2363:case 'cancelled'in the exchange status badge.None of this is required for correctness. It is dead code once 000144 has run.
Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,impact/internal,type/chore,triaged,urgency/this-quarter,effort/s.🤖 Generated with Claude Code