fix(chargeback): fail explicitly on a budget-less rollup root; bound read-route inputs (#97, #107)#111
Merged
Conversation
…read-route inputs (#97, #107) #97: spend_rollup sums a node's OWN budget ledger (INNER join), which already aggregates everything beneath it (ADR 0033) — but only when the node carries a budget. A budget-less root accrues no ledger rows of its own, so the aggregate came back empty and /v1/spend silently reported zero spend while /v1/budgets listed the subtree's real committed spend. It now raises BudgetNotFound (403, already documented on the read routes) instead of a misleading [] — a cheap keyed existence check distinguishes "no budget at this node" from "budget exists, zero spend" (still []). Rolling the whole subtree up instead would double-count spend shared across ancestor nodes (the very thing the single-node design avoids), so the explicit precondition is the safe correct fix. #107 (read-route bounds): scope_id and the group_by label key were taken from unbounded query values straight into a WHERE / the rollup grouping; both are now bounded (MAX_STR_LEN, MAX_LABEL_KEY_LEN) with a 422, via a new _parse_group_by_query helper. _LabelKey gains min_length=1 so an empty-string label key is rejected on reserve/meter, matching the other command-path bounds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BjJ4QScUKbrMzF1jujL5Bg
This was referenced Jul 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#97 — silent under-report on a budget-less rollup root
spend_rollupsums a node's own budget ledger rows (INNER join tobudgetfor that single node), which by ADR 0033 already aggregates everything beneath it — but only when the node carries a budget. Budgets are optional per node, so a budget-less root (e.g. an org whose teams have budgets but the org does not) accrues no ledger rows of its own: the aggregate came back empty and/v1/spendreported zero spend, while/v1/budgetson the same credential correctly listed the teams' committed spend. The two reads silently disagreed.Fix: a cheap keyed existence check; when the root carries no own budget, raise
BudgetNotFound(403budget_not_found, already documented on the read routes) instead of a misleading[]. This keeps[]unambiguous ("budget exists, zero spend") and makes the budget-less case explicit.Why not roll up the subtree? The ledger writes one row per (call, governing node), so summing every budget in the subtree would double-count spend shared across ancestor nodes — the exact thing the single-node design avoids (pinned by
test_single_node_aggregation_does_not_double_count). A correct subtree rollup would have to sum only the topmost budget on each path; that's a real chunk of conditional SQL whose subtle failure mode is mis-stated money. The explicit precondition is the safe, correct fix. (A "frontier" rollup that returns real numbers is a reasonable future enhancement if desired.)#107 — read-route input bounds
scope_id(from?scope=<kind>:<id>) and thegroup_bylabel key were taken from unbounded query values straight into aWHERE/ the rollup grouping. Both are now bounded (MAX_STR_LEN,MAX_LABEL_KEY_LEN) with a 422, via a new_parse_group_by_queryhelper alongside_parse_scope._LabelKeygainsmin_length=1, so an empty-string label key is rejected on reserve/meter — matching the other command-path bounds.Tests
BudgetNotFound(was== []); all other single-node/no-double-count/meter/grace rollup tests unchanged and green.scope_id→ 422;_parse_group_by_queryparses/validates and rejects an oversized label key → 422; empty label key onReserveRequest→ validation error.Local gate: ruff, mypy --strict, import-linter, full unit suite (490), chargeback integration suite (27) — all green.
Closes #97. Advances #107 (read-route-bounds item).