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>
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