Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

fix(billing): give back the velocity increment when a charge loses its race - #323

Merged
PetrefiedThunder merged 1 commit into
mainfrom
claude/webgl-particle-wave-landing-9xn6qd
Aug 20, 2026
Merged

PetrefiedThunder merged 1 commit into
mainfrom
claude/webgl-particle-wave-landing-9xn6qd

Conversation

@PetrefiedThunder

@PetrefiedThunder PetrefiedThunder commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The velocity monitor records a charge in its own transaction, before the billing transaction takes the wallet lock. When the ledger insert then violates the wallet/operation-key uniqueness constraint, the billing transaction rolls back — but that already-committed increment does not roll back with it. The loser adopts the winner's durable debit and returns it as a successful idempotent replay, so one charge is counted twice against the caller's hourly and daily spend.

That is not a skewed metric. Those counters are what the spend cap and the anomaly auto-freeze are measured against, so the wallet is eventually throttled and then frozen for money it never spent. reverse_velocity_record already says exactly this in its own comment — it simply could never run here, because it is a closure bound to the transaction that rolled back.

Reproduced deterministically, by landing the winner's debit in the window between the loser's duplicate pre-check and its own insert:

hourly_spent / daily_spent after one 3-credit charge
before 6.00000000
after 3.00000000

The fix, in three parts

  • Lift the reversal to a module-level _reverse_velocity_counters so any caller holding a live session can issue it. The in-transaction closure now delegates to it and keeps its session.refresh(wallet).
  • Hoist the period markers out of the transaction into recorded_velocity. The reversal has to know which period the increment landed in; without them it could decrement a period the charge never touched, and under-counting is the worse direction — an over-count throttles a caller who did not spend, an under-count silently raises the cap and delays the auto-freeze.
  • Reverse on both ways out after the increment lands: the IntegrityError path — whether it adopts the winner's debit or re-raises, its own increment was spurious either way, since the winner recorded its own — and any other exception escaping after that point (a conflict from the ledger writer, a database error mid-flush). Both no-op when the monitor never got as far as recording, which is the common case for early validation failures.

Noticed, not fixed here

velocity_monitor.py:126-127 carries the same falsy-zero pattern that #321 just fixed elsewhere:

hourly_limit = wallet.hourly_limit or self._default_hourly_limit
daily_limit = wallet.daily_limit or self._default_daily_limit

Since #321 made daily_limit=0 a meaningful value that now reaches the monitor, a zero-limit wallet gets the default limit for alerting instead of its own. This is alerting and freeze-threshold only — the hard cap in billing_engine is already fixed — but it is the same bug class, and #321 sharpened the edge on it. Left out to keep this change to one behavior; happy to take it next.

The three other findings from the same review remain open and unbundled: the nested connection checkout in authorize_and_reserve, max_calls_per_tool/aggregate_value_cap being read outside the atomic reservation, and create_permit preferring the request-body subject_key_id against its own comment.

Type of Change

  • feat
  • fix
  • docs
  • refactor
  • test
  • chore

Validation

  • pytest -q — new test_losing_an_operation_key_race_does_not_leave_velocity_overcounted, verified to fail without the fix (Decimal('6.00000000') != Decimal('3.00000000')) and pass with it. Full-suite run in progress locally; CI covers the same on this head.
  • ruff check — clean on app/ and the test file
  • python scripts/export_openapi.py --check — n/a, no API surface change
  • python scripts/generate_sim_inventory.py --check — n/a, no simulator change
  • manually tested key endpoint paths — the race is driven through AgentMoney.charge with a shared operation_key, the same path POST /v1/billing/charge uses
  • updated docs/config examples if needed — none apply; the reasoning lives in the helper's docstring and at both call sites
  • no new claim contradicts SECURITY_LIMITATIONS.md

For trust-plane changes, make trust-release-gate output is not pasted here: this corrects a metering counter rather than a permit, receipt, or audit invariant, and no proof artifact changes. Say the word if you want the gate run anyway before merge.

Risk

  • low
  • medium
  • high

Notes on migration, rollout, or rollback concerns: no migration, no schema change, no API change. The only behavior difference is on paths that previously left a counter inflated — a wallet that lost an idempotency race now keeps the velocity it actually spent. Wallets already carrying an inflated counter from a past race are not retroactively corrected; the counters self-heal at the next hourly/daily period reset.

🤖 Generated with Claude Code

https://claude.ai/code/session_017iqdJ2aL7KsafeRz7BFi2m


Generated by Claude Code


Note

Cursor Bugbot is generating a summary for commit d4a9040. Configure here.

…s race

The velocity monitor records a charge in its own transaction, before the
billing transaction takes the wallet lock. When the ledger insert then
violates the wallet/operation-key uniqueness constraint, the billing
transaction rolls back -- but that already-committed increment does not
roll back with it. The loser adopts the winner's durable debit and
returns it as a successful idempotent replay, so one charge is counted
twice against the caller's hourly and daily spend.

Those counters are what the spend cap and the anomaly auto-freeze are
measured against, so this is not a skewed metric: the wallet is
eventually throttled and then frozen for money it never spent. The
existing reverse_velocity_record already says exactly this in its own
comment -- it just could never run here, because it is a closure bound to
the transaction that rolled back.

Three parts:

- Lift the reversal to a module-level _reverse_velocity_counters, so a
  caller with a live session can issue it.
- Hoist the period markers out of the transaction. The reversal needs to
  know which period the increment landed in; without them it could
  decrement a period the charge never touched, and under-counting is the
  worse direction -- it silently raises the cap and delays the freeze.
- Reverse on both ways out after the increment lands: the IntegrityError
  path, whether it adopts the winner's debit or re-raises, and any other
  exception. No-ops when nothing was recorded.

Reproduced deterministically by landing the winner's debit in the window
between the loser's duplicate pre-check and its own insert: one 3-credit
charge left 6.00 recorded against both counters before this change, 3.00
after. The test fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iqdJ2aL7KsafeRz7BFi2m
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 48292278-4c5f-49dd-946c-04c5fc5b71d9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-middleware-web Ready Ready Preview Aug 20, 2026 8:02am

@PetrefiedThunder
PetrefiedThunder marked this pull request as ready for review August 20, 2026 08:05
@PetrefiedThunder
PetrefiedThunder merged commit 29343ac into main Aug 20, 2026
19 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4a9040f41

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +775 to +781
except Exception:
# Same debt on every other way out after the increment landed --
# a conflict raised by the ledger writer, a database error mid
# flush. No-ops when the increment never happened.
await self._reverse_recorded_velocity(
wallet_id=wallet_id,
amount=charge_amount,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify rollback before reversing the counters

If the transaction commits but its acknowledgement or subsequent session cleanup raises—for example, during a database connection failure—the broad handler assumes the debit rolled back and subtracts its velocity in a new transaction. The ledger debit and balance change can therefore remain durable while hourly/daily spend is reduced, allowing later charges to bypass budget and anomaly limits. Before compensating, verify that no matching debit committed (using the operation key where available), or restrict compensation to failures with a known rollback outcome.

AGENTS.md reference: app/services/AGENTS.md:L7-L14

Useful? React with 👍 / 👎.

@PetrefiedThunder
PetrefiedThunder deleted the claude/webgl-particle-wave-landing-9xn6qd branch October 20, 2026 07:35

This branch was successfully deployed

1 active deployment
Preview — d4a9040f Deployed Aug 20, 2026 by vercel[bot]
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants