Skip to content

feat(api): scope-aware SQL totals for the History summary cards - #1200

Merged
cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-675
Oct 25, 2026
Merged

cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-675

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Root cause

getHistory built summary with summarizePurchaseHistory(all), which only sees the page: at most limit purchase_history rows, 100 terminal executions and 1000 actionable ones. With more rows than a cap, the cards under-report, and truncated only says so.

Fix

  • One store method. HistorySummaryAggregate (internal/config/store_postgres_history_summary.go) runs one grouped COUNT/SUM per source and returns groups, not totals:
    • purchase_history, grouped by (account_id, cloud_account_id, provider, revoked). Upfront is NULL when any row in the group has an unknown cost, the same way sumKnownCosts handles it.
    • purchase_executions, grouped by (status, cloud_account_id, created_by_user_id, ordered [provider, cloud_account_id] of each recommendation).
    • There's one group per account/provider/status combination, so the result size doesn't grow with row count.
  • Shared SQL predicates.
    • purchaseHistoryFilterWhere is now the only WHERE builder for both GetPurchaseHistoryFiltered and the aggregate.
    • historyExecutionsPredicate is shared with GetExecutionsByStatuses.
    • On the handler side, historyFilters.purchaseHistoryFilter() builds the store filter for both the row fetch and the aggregate.
  • Same scope check, same code. The row path applies the account scope in Go: allowed.Allows(accountID, historyRowAccountName(...)), plus the ambient-row ownership gate. That logic now lives in historyScope.allows. It is resolved once per request and applied to the listed rows and to every aggregate group.
    • Execution groups are rebuilt into a PurchaseExecution and passed through the same matchesExecutionProviderAndAccount and executionToHistoryRow the list uses.
    • Group totals are only added up after the scope check, so a scoped user never gets counts from another tenant. Nothing is returned for groups outside the scope.
    • I did not rewrite the scope check in SQL. That would be a second copy that could drift from the Go one.
  • Same bucketing rules. summarizePurchaseHistory now loops over addHistoryRowToSummary(row, n), and the aggregate calls that same function for each group. Its rules are unchanged:
    • revoked rows have no dollars
    • audit-gap and partially completed rows count as completed, with no dollars
    • canceled rows count only toward total_purchases
    • pending/notified/scheduled count as pending
    • approved/running/paused count as in progress
  • Concurrency.
    • Both aggregate queries run in one REPEATABLE READ, READ ONLY transaction. 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.
    • The stale-approval sweep is now 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.
  • Response. 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 and summary_complete: false, instead of failing the whole History load. The openapi HistorySummary schema also lists the per-bucket counters it was missing.
  • Cost. One grouped scan per table. The default date window is unchanged and there is no new index. An unfiltered view scans all of purchase_history, which is bounded by the number of purchases ever made.
  • Interface. Implemented in internal/mocks/stores.go, internal/analytics/collector_test.go and internal/server/test_helpers_test.go. With no expectation set, the shared mock returns 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/null because 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_TotalsCoverAllInScopeRows against a git archive origin/main copy with only the new test file added.

  • Seed: two tenants. Each has 11 purchases (2 revoked, 3 legacy rows with no cloud_account_id), 105 canceled executions (over the 100 terminal cap), and pending/notified/scheduled, approved/running/paused, failed, expired, audit-gap completed, partially_completed, clean-completed (excluded), canceled and web-bulk rows. There are also ambient executions owned by the scoped user and by another user. The request uses limit=5.
  • On main:
    • scoped user total_purchases expected 130, got 61
    • admin expected 260, got 120
    • total_completed, upfront and savings also wrong
    • summary_complete missing
  • On this branch it passes. The scoped user gets exactly the manual sum over tenant-A rows plus their own ambient row, the admin gets the sum over all rows, and a scoped user filtering on tenant B gets zero.

Drift 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 legacy account_id filters, including rows exactly on and 1µs outside the date edges.
  • TestIntegration_HistorySummaryAggregate_MatchesRowFetch (config): aggregate groups == uncapped GetPurchaseHistoryFiltered / GetExecutionsByStatuses rows 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.
  • Unit (mock):
    • the aggregate gets the scope applied
    • the aggregate's purchase filter equals the row fetch's filter
    • the summary falls back when the aggregate fails
    • on Lambda the sweep runs after the aggregate
    • no listed execution status can add dollars, which is why rebuilt execution groups are safe
    • a group of n rows equals n single rows

Mutation probes. Each one was applied by hand, run, and reverted.

  • Removing SET TRANSACTION ... REPEATABLE READ: race test fails ("counted 0 times").
  • Skipping the scope check on execution groups: 3 tests fail.
  • Shifting the SQL date bound by a day: 3 tests fail.
  • Dropping the purchase_history WHERE from the aggregate: 3 tests fail.
go build ./...
go vet ./internal/api/ ./internal/config/ ./internal/mocks/ ./internal/analytics/ ./internal/server/
go vet -tags integration ./internal/api/ ./internal/config/
go test ./internal/api/ ./internal/config/ ./internal/mocks/ ./internal/analytics/ ./internal/server/ -count=1
go test -tags integration ./internal/config/ -run 'TestIntegration_HistorySummaryAggregate|TestIntegration_GetExecutionsByStatuses|History|PurchaseHistory' -count=1
go test -tags integration ./internal/api/ -run 'TestIntegration_HistorySummary_|History' -count=1
golangci-lint v2.10.1 run --allow-parallel-runners --build-tags integration ./internal/{api,config,mocks,analytics,server}/...   # 0 issues
gocyclo -over 10 -ignore "_test\.go" <changed non-test files>   # silent
bash scripts/gosec-hook.sh <changed files>                       # exit 0
git-secrets --scan (fresh temp repo, isolated HOME, repo patterns) # clean; scripts/test-git-secrets-allowlist.sh 42 PASS

Out of scope / notes

  • Pre-existing scope behavior, kept as is. For purchase_history rows the Go scope check compares the row's external account_id to 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.
  • fix(history): pending approvals can fall off the first page because filters are applied after the executions cap; add paging and push provider/account/date filters into the executions SQL #632 is not done here. It covers pushing the provider/account/scope predicates into the executions row SQL, and paging. The executions row fetch still filters in Go after the cap.
  • Separate reads. The row list and the aggregate are still two reads. A row committed between them, or a sweep fired by a concurrent request, can make the cards differ from the list by that row until the next load.
  • JSON keys. The execution grouping reads the lowercase JSON keys provider / cloud_account_id, which is what json.Marshal writes for RecommendationRecord. encoding/json matches 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 in frontend/src/history.ts renderTruncationNotices and frontend/src/types.ts:

  • Add summary_complete?: boolean to HistoryResponse.
  • When 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".
  • Keep the current wording when summary_complete is 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

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

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/feat New capability labels Oct 20, 2026
@cristim

cristim commented Oct 21, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE abec6cd

Findings (none blocking):

  • [non-blocking, pre-existing] frontend/src/history.ts:270 and frontend/src/api/history.ts:22 vs internal/api/handler_history.go:739 (parseHistoryFilters) - plan_id is sent but never parsed, so "View plan history" lists every purchase.
    • Before this PR, the plan view's cards summed only the page. Now they show tenant-wide all-time totals, which makes the bug more visible.
    • This is not this PR's bug, but it is worth a known-issues entry or a follow-up issue.
  • [non-blocking, test gap] internal/config/store_postgres_history_summary.go:85 - The NULL-upfront rule (CASE WHEN COUNT(upfront_cost)=COUNT(*)) is pinned only by the config test. The api-level integration seed has no unknown-cost rows, so probe P2b survives there.
  • [non-blocking] internal/config/store_postgres.go:2116 vs store_postgres_history_summary.go:96-101 - If one execution row's recommendations JSON will not decode, GetExecutionsByStatuses errors and the handler drops that whole class from the list.
    • The aggregate still counts the row and reports summary_complete=true.
    • The same holds whenever a class listing fails. This is an edge case, and the handler logs it.
  • [nit] store_postgres_history_summary.go:132-137 - Begin followed by SET TRANSACTION ... works, and the race probe shows the snapshot holds. BeginTx(pgx.TxOptions{IsoLevel: RepeatableRead, AccessMode: ReadOnly}) would do it in one call, if s.db exposes it.
  • [non-blocking] The cost claim holds for the UI only, because the frontend sends a 7-day window by default (history.ts:228).
    • A no-date API call runs the jsonb projection over every purchase_executions row in the listed statuses.
    • The purchase_history half was already a full scan.

Against #675: the summary now comes from grouped SQL, and the groups go through the same code the list uses: historyScope.allows, matchesExecutionProviderAndAccount, executionToHistoryRow and addHistoryRowToSummary.

  • The group keys cover every field those functions read.
    • historyRowAccountName reads CloudAccountID, AccountID and Provider.
    • accountMatchesFilters and executionProvider read exec.CloudAccountID and the rec provider/cloud_account_id.
    • The ambient gate reads CreatedByUserID.
  • Execution groups can never add dollars. Completed and partially_completed set IsAuditGap, and every other status lands in a non-dollar bucket.
  • The JSON nil, "" and absent cloud_account_id cases are equivalent through collapseRecommendationAccount.
  • The legacy account_id is folded into AccountIDs before purchaseHistoryFilter(), so the no-filter fast path matches too (GetAllPurchaseHistory has no WHERE).

Attacked:

  • Deferred sweep: staleExecs is bound when the defer is set, and the error paths behave as before.
  • Blast radius:
    • All 4 StoreInterface implementers are updated: mocks, server, the analytics test mocks and postgres.
    • filterPurchaseHistoryByAllowedAccounts is also called by handler_inventory.go:48,191, and its behaviour there is unchanged.
  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp:
    • P1, scope skipped on execution groups: FAILS (scoped 249 vs 130; tenant-B filter 118 vs 0).
    • P2, the NULL-upfront CASE dropped: the config test FAILS. P2b at the api level survives (the gap above).
    • P3, <= changed to < on the end bound: FAILS.
    • P4, the sweep not deferred: FAILS (the Lambda ordering unit test).
    • P5, purchaseGroupRow CloudAccountID=nil: survives. This is effectively an equivalent mutant, because name resolution falls back to provider plus external ID.
    • P6, the execution date bound dropped: FAILS.
    • P7, READ ONLY without REPEATABLE READ: FAILS ("counted 0 times").
    • P8, scope skipped on purchase groups: FAILS.
  • Merge: git merge-tree --write-tree origin/main <head> is clean.
    • Main changed interfaces.go, the mocks and the test mocks (RI exchange direct-exec methods), plus unrelated text in types.go and openapi.yaml.
    • handler_history.go and store_postgres.go are unchanged on main.
    • Built and tested on a merged export.

Verified locally, on head and merged exports with no cloud credentials, under the heavy lock, using the repo's testcontainers harness:

  • Build and static checks:
    • go build ./...: OK.
    • go vet on api, config, mocks, analytics and server, plus -tags integration on api and config: OK.
    • gofmt -l on the 12 changed files: clean.
  • Unit tests: go test -count=1 on the 5 packages: ok.
  • Integration tests:
    • config -run 'TestIntegration_HistorySummaryAggregate|TestIntegration_GetExecutionsByStatuses|PurchaseHistory': PASS.
    • api -run 'TestIntegration_HistorySummary_|History': PASS.
  • Fail-before: the new api integration test on base FAILS as the PR says: scoped total_purchases 130 vs 61, admin 260 vs 120, and summary_complete is missing.
  • No migration and no frontend changes.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE abec6cd

Reviewed against origin/main f5ad8c6. git merge-tree is clean (tree e9f2c22e). The merged tree builds, and go vet passes with and without -tags integration, on Go 1.26.10.

Findings

  • medium (pre-existing, not introduced) internal/api/handler_history.go:739 (main :729) - parseHistoryFilters never reads plan_id, and historyFilters has no plan field. The frontend still sends it: frontend/src/api/history.ts:22, and viewPlanHistory (frontend/src/history.ts:282, :294) passes { planId }. Failure scenario: "View plan history" lists every in-scope purchase, not just the plan's, and has no date bound. With this PR the cards on that view now total every in-scope purchase ever made and report summary_complete: true, where before they summed one capped page. The list and the totals are wrong in the same way, so they stay consistent with each other. This needs a follow-up issue. It does not block this PR.
  • nit (pre-existing, sec(money): currency is dropped at every layer boundary, so USD-denominated caps are compared against non-USD amounts [platform part] #361) internal/config/store_postgres_history_summary.go:85-86 - upfront_cost and estimated_savings are summed with no currency, as the old page summary did. purchase_history has no currency column, so mixed-currency rows still produce one unlabeled number, now over every matching row.
  • nit (test gap) internal/api/handler_history_summary.go:164 - mutation M3 sets the group CloudAccountID to nil, and no unit or integration test fails. The scope-name lookup then falls back to (provider, account_id). That only differs when a row's cloud_account_id and its external id point at different accounts. Add one fixture for that case.
  • nit (process) - the single commit is 1656 changed lines, over the 400-line cap.

Checked and found correct

  • Scope: historyScope.allows (handler_history_summary.go:63-72) is the same code for listed rows and for aggregate groups. Execution groups go through matchesExecutionProviderAndAccount and executionToHistoryRow before the scope check (:121-136). The groups carry every field those functions read: status, cloud_account_id, created_by_user_id and the provider/cloud_account_id of each rec.
  • Filters: purchaseHistoryFilterWhere and historyExecutionsPredicate are shared with the row SQL. The SQL date bound is inclusive and matches the Go check. An empty filter is equivalent to GetAllPurchaseHistory.
  • Unknown upfront: a group with any NULL upfront_cost gets a NULL sum, which sumKnownCosts treats as unknown, the same as a single unknown row.
  • Execution dollars: every listed execution status is either a non-dollar bucket or an IsAuditGap row, so rebuilt groups never add dollars.

Tests (merged tree)

  • Unit: go test ./internal/{api,config,mocks,analytics,server}/: 5763 passed.
  • Integration (testcontainers Postgres): go test -tags integration ./internal/config/ -run 'TestIntegration_HistorySummaryAggregate|TestIntegration_GetExecutionsByStatuses|History|PurchaseHistory' ok. ./internal/api/ -run 'TestIntegration_HistorySummary_|History' ok.

Attacked (mutation probes, each in a scratch copy and reverted)

  • M1: skip the scope check on purchase groups. FAIL TestGetHistory_SummaryComesFromScopedAggregate (caught).
  • M2: skip the scope check on execution groups. FAIL, same test (caught).
  • M3: drop the group CloudAccountID. Unit and integration tests both pass (survived, see the test-gap nit).
  • M4: make the stale sweep run before the aggregate instead of deferred. FAIL TestGetHistory_LambdaSweepRunsAfterSummaryAggregate (caught).
  • M5: replace the NULL-upfront CASE with a plain SUM. FAIL TestIntegration_HistorySummaryAggregate_MatchesRowFetch (caught).
  • Also read: the snapshot transaction (REPEATABLE READ, READ ONLY, begun before any query), the stale-sweep defer on error paths, API-key sessions (UserID is never empty, so the ownership gate holds), and the mock fallback.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Local verification at abec6cdc96e68901aaae657a706cc6445f275764 merged onto current main (00a3f408; merge-tree 05693d46; macOS):

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass (full suite).
  • Nothing was applied to any cloud.

1 similar comment
@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

Local verification at abec6cdc96e68901aaae657a706cc6445f275764 merged onto current main (00a3f408; merge-tree 05693d46; macOS):

  • go build ./..., go vet -tags integration ./... and go test -race -short ./... pass (full suite).
  • Nothing was applied to any cloud.

@cristim

cristim commented Oct 25, 2026

Copy link
Copy Markdown
Member

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 abec6cdc96e68901aaae657a706cc6445f275764).

@cristim
cristim marked this pull request as ready for review October 25, 2026 11:33
@cristim
cristim merged commit 503eb2e into LeanerCloud:main Oct 25, 2026
24 of 25 checks passed
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
…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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 25, 2026
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>
IAmJSD added a commit that referenced this pull request Oct 25, 2026
…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>
IAmJSD added a commit that referenced this pull request Oct 25, 2026
…_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>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
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>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(api): scope-aware SQL totals for the History summary cards

2 participants