Skip to content

fix(chargeback): fail explicitly on a budget-less rollup root; bound read-route inputs (#97, #107)#111

Merged
JumpTechCode merged 1 commit into
mainfrom
fix/chargeback-rollup-read-bounds
Jul 20, 2026
Merged

fix(chargeback): fail explicitly on a budget-less rollup root; bound read-route inputs (#97, #107)#111
JumpTechCode merged 1 commit into
mainfrom
fix/chargeback-rollup-read-bounds

Conversation

@JumpTechCode

Copy link
Copy Markdown
Contributor

#97 — silent under-report on a budget-less rollup root

spend_rollup sums a node's own budget ledger rows (INNER join to budget for 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/spend reported zero spend, while /v1/budgets on 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 (403 budget_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 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 alongside _parse_scope.
  • _LabelKey gains min_length=1, so an empty-string label key is rejected on reserve/meter — matching the other command-path bounds.

Tests

  • Integration: the node-without-a-budget rollup now asserts BudgetNotFound (was == []); all other single-node/no-double-count/meter/grace rollup tests unchanged and green.
  • Unit: oversized scope_id → 422; _parse_group_by_query parses/validates and rejects an oversized label key → 422; empty label key on ReserveRequest → 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).

…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
@JumpTechCode
JumpTechCode merged commit a8d51f4 into main Jul 20, 2026
8 checks passed
@JumpTechCode
JumpTechCode deleted the fix/chargeback-rollup-read-bounds branch July 20, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spend_rollup silently returns empty (under-reports) when the root scope node has no budget of its own

1 participant