Repository navigation
feat(api): scope-aware SQL totals for the History summary cards - #1200
Conversation
The History summary was summed over the capped, filtered page, so the cards under-reported once a fetch hit its row cap. Add one store method, HistorySummaryAggregate, that returns counts and dollar sums over every matching purchase_history and purchase_executions row, grouped by the columns the handler's provider, account and account-scope checks read. The purchase_history WHERE clause now comes from one builder (purchaseHistoryFilterWhere) shared with GetPurchaseHistoryFiltered, and the executions predicate is one constant shared with GetExecutionsByStatuses. The handler applies the same Go checks it runs on listed rows (matchesExecutionProviderAndAccount, executionToHistoryRow and the account scope) to each group, and both the list and the totals bucket rows through addHistoryRowToSummary, so the scope predicate and the summary rules cannot drift. Both halves are read in one REPEATABLE READ snapshot, and the stale-approval sweep is deferred until after the aggregate. HistoryResponse gains summary_complete, false only when the aggregate fails and the summary falls back to the page. Closes LeanerCloud#675 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review 2 (claude-fleet, cold): APPROVE abec6cd Findings (none blocking):
Against #675: the summary now comes from grouped SQL, and the groups go through the same code the list uses:
Attacked:
Verified locally, on head and merged exports with no cloud credentials, under the heavy lock, using the repo's testcontainers harness:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: APPROVE abec6cd Reviewed against origin/main f5ad8c6. Findings
Checked and found correct
Tests (merged tree)
Attacked (mutation probes, each in a scratch copy and reverted)
|
|
Local verification at
|
1 similar comment
|
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 |
Bring the branch up to date with main (7cde88e), which includes LeanerCloud#1200's HistorySummaryAggregate mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LeanerCloud#1200 added HistorySummaryAggregate with the old panic on a mistyped .Return, which ends the whole test binary. Route it through wrongReturnType like the other sites so a registered test fails under the method's name instead. Refs LeanerCloud#353 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The history-summary integration tests from LeanerCloud#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 (LeanerCloud#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>
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>
Bring the branch up to date with main. Conflicted files: - internal/api/handler_history.go: main (LeanerCloud#1200) replaced summarizePurchaseHistory(all) in the getHistory response with the scope-aware SQL summary plus SummaryComplete; the branch added ActionableExecutionsLimit to the same literal. Kept both: main's Summary/SummaryComplete and the branch's ActionableExecutionsLimit. - internal/api/types.go: both sides rewrote the Truncated doc comment. Combined them: main's note that Summary still counts rows beyond the caps while SummaryComplete is true, and the branch's note that a failed execution class listing also sets Truncated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch is stacked on fix/issue-686 (LeanerCloud#1276). That branch was brought up to date with origin/main in ee67abd, so merging it here brings in main and the LeanerCloud#686 conflict resolutions in one step. Conflicted files: - internal/api/main_nonet_test.go, internal/api/main_integration_test.go: both sides added a teardown hook list for the shared Postgres server (this branch: afterAllTests; main LeanerCloud#1400: afterTests). Took main's files unchanged; this branch only needed the hook, which main now provides. - internal/api/handler_history.go, getHistory: main (LeanerCloud#1200) loads the account scope once (loadHistoryScope), filters rows with scope.filter, defers the stale-approval sweep until after the summary aggregate, and returns the scope-aware SQL summary with SummaryComplete. This branch pages the stream (fetchHistoryPage) and lists the actionable class only on the first page. Combined: the page is fetched first, the scope is loaded once and applied to the actionable rows, staleExecs is declared outside the Cursor == nil branch so the sweep stays deferred (and does not run on cursor pages), and the response carries main's Summary/SummaryComplete plus this branch's paging fields. The summary is now whole-scope on every page. - internal/api/handler_history.go, fetchPurchaseHistory: main replaced the GetAllPurchaseHistory/GetPurchaseHistoryFiltered split with the shared historyFilters.purchaseHistoryFilter; this branch reads one keyset batch through ListPurchaseHistoryPage. Kept both: purchaseHistoryFilter is kept (the summary uses it) and the batch read now starts from it with Limit set to the batch size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main (LeanerCloud#1200) made purchaseHistoryFilterWhere the one purchase_history predicate builder and historyExecutionsPredicate the one executions predicate, shared by the History row fetch and the summary aggregate so the totals count exactly the rows the list can show. This branch's page queries carried their own copies (purchaseHistoryPageConds and an inline status/clean-completed predicate). Use the shared ones instead, so the paged list and the whole-scope summary cannot drift apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main (LeanerCloud#1200) added purchaseHistoryRowStatus so listed purchase_history rows and the summary's aggregate groups get the same Status. The paged stream set the literal "completed"; use the constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The History summary tests came in from main (LeanerCloud#1200) and mock the old purchase_history reads (GetAllPurchaseHistory, GetPurchaseHistoryFiltered). This branch reads purchases through ListPurchaseHistoryPage, so the filter-equality test panicked on a nil row filter. Mock the paged read instead; the assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bring the branch up to date with main. Conflicted files: - internal/analytics/collector_test.go: main kept adding stubs to the hand-rolled analytics mockConfigStore (HistorySummaryAggregate from LeanerCloud#1200, ClaimCollectionInvocation from LeanerCloud#1099, the ReplaceRecommendations removal from LeanerCloud#1209, and the pgx import they need). This branch replaces that hand-rolled mock with one that embeds mocks.MockConfigStore, which already carries all of those methods on main. Kept the branch's version; main's stub edits are covered by the shared mock and drop out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bring the route-coverage guard and spec backfill up to date with main. Conflicted files: - internal/api/openapi.yaml, GET /api/recommendations parameters: keep this branch's account_ids, min_savings_usd and min_savings_pct and main's limit and cursor (LeanerCloud#974). The AccountID ($ref account_id) entry main still carried stays dropped, since the handler never reads account_id. - internal/api/openapi.yaml, GET /api/recommendations 400: keep main's detailed description (LeanerCloud#974), which already covers the filters this branch documents. - internal/api/openapi.yaml, HistorySummary: keep main's schema-level description from LeanerCloud#1200 (audit-gap and canceled rows), add a sentence on revoked rows, and keep this branch's per-field descriptions for total_pending, total_in_progress and total_revoked, which match addHistoryRowToSummary on main. - internal/api/openapi_403_test.go: main (LeanerCloud#1403) replaced the per-route file scan with one permissionGatedHandlers index built by scanHandlerFileForPermission. Keep main's structure and move this branch's whole-line comment skip into scanHandlerFileForPermission, so a requirePermission mention in a comment still doesn't mark a handler as gated. The branch's first-match specPathsForRoute and the route table length check merged cleanly on top. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd e2e findings (#1270) * chore(test): fix the 2026-07-28 review's mock, CI-doc, known-issues and e2e findings - internal/mocks: a mistyped .Return value no longer panics when the test registered itself with the mock's Test method; it fails that test (Errorf + FailNow) under the method's name. 82 of the 83 type-assertion sites in MockConfigStore, MockAuthStore and the Secrets Manager, SES and SNS mocks go through testReporter (GetPendingExecutionsTx is left alone: open PR #1217 deletes it). Without a registered test the mock still panics, now naming the method. - frontend-build.yml: drop the paths filter so `Build frontend` reports on every PR (a path-filtered required check blocks every PR outside its paths); a fail-closed first step skips the build when nothing under frontend/ changed. docs/ci-required-checks.md explains why. - known-issues.md: the #731 entry named a role the ARM template does not define and a stale branch; correct the name and move it to Resolved, pointing re-deploys at the #1545 revoke-first steps. - tests/e2e: TestVersionEndpoint asserts version, git_sha and build_time instead of any non-empty JSON object. Refs #353 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(mocks): fail the test on a mistyped HistorySummaryAggregate return #1200 added HistorySummaryAggregate with the old panic on a mistyped .Return, which ends the whole test binary. Route it through wrongReturnType like the other sites so a registered test fails under the method's name instead. Refs #353 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…_cursor) (#1277) * fix(history): flag a failed execution class listing as truncated and expose the actionable cap fetchExecutionsByClass logged a listing error and skipped the class, so a failed actionable query hid every pending approval while the response kept truncated=false. The class fetch now reports failure and fetchExecutionsAsHistory raises the incomplete-results signal (including on the no-executions early return). HistoryResponse gains actionable_executions_limit (config.MaxListLimit) next to executions_limit (terminal cap, DefaultListLimit), so the UI banner can quote the cap that was actually hit instead of always quoting the terminal cap. The banner text itself lives in frontend/ and is left to the UI owner. Refs #686 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): page the History view with a keyset cursor (has_more, next_cursor) GET /api/history listed at most `limit` purchase_history rows and 100 terminal executions, and rows past those caps could not be reached: there was no way to ask for the next page. Completed purchases and terminal executions are now one stream, newest first, ordered by (timestamp DESC, source DESC, row id DESC), which is unique. Each response lists `limit` rows of it and, when more rows the caller may see follow, sets has_more and next_cursor; passing next_cursor back as `cursor` returns the next page. The cursor is a keyset position, not an offset: the account scope and the execution filters run in Go after the SQL read, so an offset would count rows the caller never sees, and every row written ahead of it would shift the page. With the keyset, a later page resumes strictly after the last row shown, so rows written between page loads never repeat or skip a row, and a page costs the same at any depth. `offset` is rejected with 400 instead of ignored, so a client written against an offset API cannot loop on the first page. Actionable executions (pending approvals, in-flight purchases) are not paged: the first page lists the whole class as before, and cursor pages leave it out, so paging can never push a pending approval off the first page. The #686 signals are kept: a failed execution listing or a full actionable class still sets truncated. has_more is judged on rows the caller may see, so other tenants' rows never set it, and the cursor only ever names a row the caller was shown. The scope is applied again on every page. Store: ListPurchaseHistoryPage (GetPurchaseHistoryFiltered's predicate plus the keyset, returning each row's id) and ListHistoryExecutionsPage (the terminal statuses, clean completed rows excluded, scheduled_date bounds pushed into SQL). No migration: the existing timestamp and scheduled_date indexes serve both. Closes #674 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): build the History page queries from the shared predicates Main (#1200) made purchaseHistoryFilterWhere the one purchase_history predicate builder and historyExecutionsPredicate the one executions predicate, shared by the History row fetch and the summary aggregate so the totals count exactly the rows the list can show. This branch's page queries carried their own copies (purchaseHistoryPageConds and an inline status/clean-completed predicate). Use the shared ones instead, so the paged list and the whole-scope summary cannot drift apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): give paged purchase rows the shared History row status Main (#1200) added purchaseHistoryRowStatus so listed purchase_history rows and the summary's aggregate groups get the same Status. The paged stream set the literal "completed"; use the constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): mock the paged purchase read in the History summary tests The History summary tests came in from main (#1200) and mock the old purchase_history reads (GetAllPurchaseHistory, GetPurchaseHistoryFiltered). This branch reads purchases through ListPurchaseHistoryPage, so the filter-equality test panicked on a nil row filter. Mock the paged read instead; the assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
SavePurchaseHistory now fills cloud_account_id from the registered account with the same (provider, external_id). The History summary (LeanerCloud#1200) and History page (LeanerCloud#1277) fixtures seed "legacy" rows by saving with a nil CloudAccountID next to a registered account, so after the merge those rows came back linked and the drift guards no longer exercised the unlinked, external-id-only shape they were written for. Clear the column after the save so the fixtures still seed legacy rows, and require that exactly one row was cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1244) * fix(history): set cloud_account_id on every purchase_history insert The direct-execute, ambient (host-account) and legacy single-account paths reach SavePurchaseHistory with no CloudAccountID, so their rows were stored with cloud_account_id NULL even after the 000139 backfill. SavePurchaseHistory now resolves a missing cloud_account_id in the INSERT from (provider, account_id) with the backfill's rules: lower-cased provider, exact account id, never an empty id. The lookup locks the account FOR KEY SHARE so a concurrent account delete makes the row NULL instead of failing the insert on the foreign key. A row that matches no registered account is still stored (dropping it would be an audit gap, #621) and logged as a warning. The function moves to its own file out of store_postgres.go. The marketplace constraint test's "missing account" rows now clear the resolved account to keep a legacy unlinked row, and a new case covers a host-account row the save links to its registered account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(history): cover cloud_account_id resolution and account-delete races Real-Postgres tests for #1235: rows shaped like each write path get their account, an explicit id wins, rows that match nothing are kept with NULL, the dedupe path does not rewrite a row, and a save racing an account delete stores NULL instead of failing on the foreign key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(history): keep legacy History fixture rows unlinked SavePurchaseHistory now fills cloud_account_id from the registered account with the same (provider, external_id). The History summary (#1200) and History page (#1277) fixtures seed "legacy" rows by saving with a nil CloudAccountID next to a registered account, so after the merge those rows came back linked and the drift guards no longer exercised the unlinked, external-id-only shape they were written for. Clear the column after the save so the fixtures still seed legacy rows, and require that exactly one row was cleared. 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>
…HECKs, write only canceled_by (#1253) * fix(db): contract the canceled rename: normalize, narrow the status CHECKs, 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 #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): drop the legacy canceled spelling from the history summary test The test came in from main (#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> * fix(deps): bump golang.org/x/net to v0.62.0 (GO-2026-6860, GO-2026-6866) govulncheck in the Docker image build now fails on these two advisories, both fixed in x/net v0.62.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(migrations): target renumbered canceled spelling migration * fix(db): renumber the canceled contract migration to 000158 000153 now belongs to marketplace_listing_settlements on main (000154-000157 are in flight in #1239 and #1241). Pure renames of the up, down and test files, plus the identifier and comment number fixes. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): slim the 000158 canceled contract Only normalize 'cancelled' to 'canceled' on purchase_executions and ri_exchange_history and narrow both status CHECKs. The mirror trigger, the cancelled_by drain and the down backfill are cut: this release keeps reading COALESCE(canceled_by, cancelled_by), so nothing reads canceled_by alone yet. The row-level updated_at triggers (000128 and 000009) are disabled around the normalization so the plan-health and retention clock is not restamped. The down only re-widens the CHECKs. The old test is removed; the new 000158 test follows. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(db): cover the 000158 canceled contract migration Seeds legacy 'cancelled' rows in both tables with a 90-day-old updated_at, then asserts the normalization, that updated_at is unchanged on each table (each assertion fails without its trigger disable), that both triggers are enabled again, the narrowed CHECKs (23514 on the legacy spelling), and that the down restores the widened CHECK text exactly. Also runs the write statements of the build live today (the SavePurchaseExecutionTx upsert, SetCancelledBy, the transitionExecutionStatus dual write and CancelAllPendingExchanges) against the narrowed schema: no 23514, and the actor reads back via COALESCE(canceled_by, cancelled_by). Written relative to the version below 158, so it follows the tree as 154-157 land. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): keep tolerant canceled reads The PR removed every tolerant read of the legacy spelling, which is only safe once 000158 has run and no older build can be rolled back to. Keep them until the follow-up: LegacyStatusCanceled and the lists and switches that use it, COALESCE(canceled_by, cancelled_by) in every execution projection, and status IN ('cancelled', 'canceled') in the cleanup SQL. Their tests (the legacy-spelling fixtures) are restored. The PR's write changes stay: SavePurchaseExecution writes canceled_by = COALESCE(purchase_executions.canceled_by, $17) and no longer writes cancelled_by, and the cancel transition and SetCancelledBy write canceled_by only. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(config): pin cancel actor reads and writes with pgxmock Two tests. The projections test asserts all ten purchase_executions projections read COALESCE(canceled_by, cancelled_by), so a legacy actor on cancelled_by still scans back. The writes test rejects any INSERT or UPDATE that names the legacy column in its write part (RETURNING reads are exempt) and pins SavePurchaseExecution's COALESCE(purchase_executions.canceled_by, $17), so a stale-copy save cannot erase an existing actor. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(config): run the new code against the pre-contract schema Migrates to the version below 000158 and seeds what an older build wrote: the legacy status spelling and an actor only in cancelled_by. Covers GetExecutionByID, the History page and summary, CountLiveExecutionsForAccount, holdsCapacity, the ramp-step occupied check, SavePurchaseExecution on a legacy-actor row (the actor is carried into canceled_by and a stale save cannot erase it), CleanupOldExecutions, and the handler's cancel-race recovery (cancelOrRecoverExecution). The build and the migration roll out in either order, so both orders must read correctly. Against the original #1253 strict reads the config test fails on the actor scan, the live count, holdsCapacity, the save and the cleanup, and the api test fails with a 409. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: drop legacy-spelling rows from DB-backed fixtures, fix lint On a fully migrated schema 000158 forbids the legacy status spelling, so integration fixtures that INSERT it cannot run. Revert the DB-backed fixtures to the PR's versions (history summary, ladder resolve, ramp baseline, ramp step, ladder reconcile, submit dedupe, the db retention test, the retention writers case), and have the account-delete tests iterate TerminalExecutionStatuses minus the legacy spelling. The unit tests keep their legacy fixtures, and the legacy rows are covered at the pre-000158 schema by the new integration tests. Also reword comments and mark the SQL constants that name the legacy column so the misspell linter passes, and drop a stray blank line. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(known-issues): record the two ramp reads without the legacy cancel actor ListPlanCommittedTx and ListExecutionsForPlanStepTx select canceled_by alone. Pre-existing and decision-neutral (both callers branch on status); the drain in #1747 fixes the rows at the source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): renumber the canceled contract migration to 000159 #1762 took 000158. Rename the up, down and test files and update the test names, identifiers and comments that name this migration. The tests still resolve the version just below it dynamically. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): renumber the canceled contract migration to 000155 Migration numbers are assigned at merge time: main's highest is now 000154 (first_dispatch_at). Rename the up, down and test files and update the test names, identifiers and comments naming this migration. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: derive the pre-contract cutoff from the migration file The config and api legacy-canceled tests hardcoded the migration number as the cutoff for "the schema just below the contract". Read it from the file ending _contract_canceled_spelling.up.sql instead and fail loudly if it is missing, so a renumber needs no test edit. Checked in a scratch copy with the migration renamed to 000170 and a 000169 present: derived 169, where the old hardcoded cutoff would have picked 154. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(config): update the pgxmock Save arity after #1762 #1762 added first_dispatch_at as the 30th SavePurchaseExecutionTx argument. The Save SQL on the merged tree still has canceled_by = COALESCE(purchase_executions.canceled_by, $17), and #1762 added no cancelled_by writer or bare reader. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(config): pin that a stale save cannot replace the stored cancel actor A stale copy carrying a different non-nil actor B must not replace the actor A already in canceled_by. Swapping the save to COALESCE($17, purchase_executions.canceled_by) in a scratch copy now fails with expected concurrent@example.com, actual stale-copy@example.com. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(known-issues): use the CONTRIBUTING audit line format Add the resolved count to the audit status of the cancel-actor entry. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): renumber the canceled contract migration to 000157 after #1241 #1241 took 000155 and 000156. Rename the up, down and test files and update the test names, identifiers and comments naming this migration. Refs #47 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Buddy <buddy@users.noreply.github.com> Co-authored-by: Cristian Magherusan-Stanciu <cristi.magherusan@gmail.com>
Root cause
getHistorybuiltsummarywithsummarizePurchaseHistory(all), which only sees the page: at mostlimitpurchase_history rows, 100 terminal executions and 1000 actionable ones. With more rows than a cap, the cards under-report, andtruncatedonly says so.Fix
HistorySummaryAggregate(internal/config/store_postgres_history_summary.go) runs one grouped COUNT/SUM per source and returns groups, not totals:(account_id, cloud_account_id, provider, revoked). Upfront is NULL when any row in the group has an unknown cost, the same waysumKnownCostshandles it.(status, cloud_account_id, created_by_user_id, ordered [provider, cloud_account_id] of each recommendation).purchaseHistoryFilterWhereis now the only WHERE builder for bothGetPurchaseHistoryFilteredand the aggregate.historyExecutionsPredicateis shared withGetExecutionsByStatuses.historyFilters.purchaseHistoryFilter()builds the store filter for both the row fetch and the aggregate.allowed.Allows(accountID, historyRowAccountName(...)), plus the ambient-row ownership gate. That logic now lives inhistoryScope.allows. It is resolved once per request and applied to the listed rows and to every aggregate group.PurchaseExecutionand passed through the samematchesExecutionProviderAndAccountandexecutionToHistoryRowthe list uses.summarizePurchaseHistorynow loops overaddHistoryRowToSummary(row, n), and the aggregate calls that same function for each group. Its rules are unchanged:total_purchasesREPEATABLE READ, READ ONLYtransaction. Without it, a purchase moving from a running execution to a purchase_history row mid-read gets counted 0 or 2 times; there's a race test for this.deferred. On Lambda it used to run synchronously before the summary, so the cards would have counted as expired the rows the list still showed as pending.HistoryResponse.summary_complete(also in openapi.yaml) is true when the summary covers every matching row. If the aggregate query fails, the summary falls back to the page with a warning log andsummary_complete: false, instead of failing the whole History load. The openapiHistorySummaryschema also lists the per-bucket counters it was missing.ErrHistorySummaryAggregateNotStubbed, so older handler tests keep checking the page summary through the fallback.Closes #675
Verification
All on macOS against real Postgres via testcontainers (local integration evidence). Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is broken. There's no real-cloud or deployed-stack coverage; the change is DB and handler only.Fail before, pass after. I ran
TestIntegration_HistorySummary_TotalsCoverAllInScopeRowsagainst agit archive origin/maincopy with only the new test file added.limit=5.total_purchasesexpected 130, got 61total_completed, upfront and savings also wrongsummary_completemissingDrift guards.
TestIntegration_HistorySummary_MatchesListedRowsUnderTheCap(api): with every in-window row listed, summary ==summarizePurchaseHistory(listed rows)for scoped and admin viewers across date, provider aws/azure, account A/B and legacyaccount_idfilters, including rows exactly on and 1µs outside the date edges.TestIntegration_HistorySummaryAggregate_MatchesRowFetch(config): aggregate groups == uncappedGetPurchaseHistoryFiltered/GetExecutionsByStatusesrows grouped in Go, across 7 filter combinations.TestIntegration_HistorySummaryAggregate_DateBoundsInclusive: the SQL date bound is inclusive at both ends, the same as the Go check, and JSON-null recommendations project to an empty list.TestIntegration_HistorySummaryAggregate_SnapshotUnderConcurrentCompletion: a writer moves one purchase between a running execution and a purchase_history row while 1500 aggregates run, and each must count it exactly once.Mutation probes. Each one was applied by hand, run, and reverted.
SET TRANSACTION ... REPEATABLE READ: race test fails ("counted 0 times").Out of scope / notes
account_idto the scope entries, so a scope that lists only an account UUID matches those rows only through the unique-display-name path. The aggregate copies this behavior instead of changing it. Open PR fix(auth): bind group account scope entries to account IDs #1116 (bind group scope entries to account IDs) is the place to change it.provider/cloud_account_id, which is whatjson.Marshalwrites forRecommendationRecord.encoding/jsonmatches keys case-insensitively, so hand-written JSONB with other casing would match the list but not the aggregate.Frontend follow-up for the UI owner
No change under frontend/ in this PR. The cards already read
summary.*, so they show the full totals with no UI change. Suggested follow-up infrontend/src/history.tsrenderTruncationNoticesandfrontend/src/types.ts:summary_complete?: booleantoHistoryResponse.truncated && summary_complete, change the banner to say only that older rows aren't listed, and that the summary cards still cover all matching purchases. Drop "and totals may be incomplete".summary_completeis false.Maintainers: please mirror the issue's triage labels on this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/many,effort/m,type/feat,triaged.🤖 Generated with Claude Code