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

fix(billing,registry): treat zero as a value on money controls, not as absence - #321

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

PetrefiedThunder merged 2 commits into
mainfrom
claude/webgl-particle-wave-landing-9xn6qd

Conversation

@PetrefiedThunder

@PetrefiedThunder PetrefiedThunder commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A fresh max-effort review of the money path (billing_engine, refund_reconciliation, permits, service_registry) surfaced two falsy-zero defects on spend controls plus a schema-registration gap. Each is confirmed by a test that fails without the change.

daily_limit = 0 meant "unlimited" instead of "spend nothing." The cap is Optional, where None means "no cap", so a truthiness test conflated the absence of a limit with the strictest limit there is — the value an operator sets to halt a runaway agent. This had two instances, and the second is the one that mattered:

Site Before Effect
routers/billing.py:338 Decimal(...) if request.daily_limit else None a requested 0 became None at the API boundary
billing_engine.py:374 if wallet.daily_limit and ... a stored 0 skipped the only hard daily-spend check

Fixing only the engine would have changed nothing, because the zero never reached it — the new test is what proved that. The HTTP schema declares ge=0, so zero is documented input, and app/db/converters.py already drew the distinction correctly with is not None.

auto_refill_threshold = 0 meant "alert below 100." The column is non-Optional defaulting to 100, so or Decimal("100") could only ever fire on exactly zero — the one value a wallet sets to mean "never alert, never refill" — and silently restored the default it had opted out of, inserting a LOW_BALANCE alert on every subsequent charge.

register_local dropped the input schema when an output model was supplied. The callable fallback ran only when both schemas were absent, so a handler with plain typed arguments returning a model advertised inputSchema: {} — and every client that obeyed that contract called it with no arguments and hit a TypeError. Each side now falls back independently.

Proposed and withdrawn: a credits_per_unit > 0 floor

The first commit added one, on the theory that a zero price makes a metered tool free by accident. The existing suite proved that wrong and it was reverted in d93c38b. test_discovery_annotations_carry_governance_contract registers a tool at 0.0 specifically to assert the manifest reports economicAction: false, so a free tool is a deliberate, tested product state. test_public_manifest_stays_strict_json_for_malformed_prices then registers nan, inf, -inf, and 0.0 on purpose: this codebase hardens prices where they are rendered, not where they are registered, and raising at registration prevented those cases from being set up at all. Three tests failed; the tests were right. Recorded here rather than silently dropped, since the reasoning is the useful part.

Reported, not fixed here

Each needs its own change, so none are bundled:

  • Nested connection checkout in authorize_and_reserve (permits.py:474) — holds a pooled connection and a permit row lock while verify_signature checks out a second connection from the same 15-connection pool. Enough concurrent governed invokes and every request stalls for pool_timeout and fails. Invisible under SQLite tests.
  • max_calls_per_tool / aggregate_value_cap read outside the atomic reservation (permits.py:685) — absent from the guarded UPDATE's WHERE, so two simultaneous invokes both pass a one-call permit. Contradicts the module header, which states no cap decision may come from a number read in an earlier statement.
  • Velocity counters not reversed on the IntegrityError replay path (billing_engine.py:699) — reverse_velocity_record lives inside the transaction that rolled back, so a raced retry leaves the wallet permanently over-counted, which eventually auto-freezes it.
  • create_permit prefers the request-body subject_key_id (permits.py:324) over the authenticated one — the exact opposite of the comment above it. Either the code or the comment is wrong; which one depends on whether issuing a permit for another agent's key is a supported flow.

Type of Change

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

Validation

  • pytest -q — two new cases in tests/test_billing.py, each verified to fail without its fix (the zero-daily-limit case failed with 200 != 402 before the router fix; the schema case failed with the fix reverted). tests/test_tool_interface_authority.py is 33/33 after the withdrawal above. A full-suite run is in progress and CI covers the same on this head; two tests/test_railway_preflight.py failures seen in an earlier local run were artifacts of the working tree changing mid-run — that file is 77/77 standalone.
  • ruff check — clean on app/ and the test file
  • python scripts/export_openapi.py --check — n/a, no API surface change (behavior only, within the schema's existing ge=0)
  • python scripts/generate_sim_inventory.py --check — n/a, no simulator change
  • manually tested key endpoint paths — exercised through the HTTP layer: POST /v1/billing/wallets/agent with daily_limit=0, then POST /v1/billing/charge, asserting 402 and an undebited balance
  • updated docs/config examples if needed — none apply; comments at each site record why the distinction is load-bearing
  • no new claim contradicts SECURITY_LIMITATIONS.md

Risk

  • low
  • medium
  • high

Notes on migration, rollout, or rollback concerns: no migration and no schema change. Behavior changes only for values that were already documented as valid and were being silently discarded — a wallet created with daily_limit=0 now actually blocks spending, and auto_refill_threshold=0 now actually silences low-balance alerts. Existing wallets with None limits are unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_017iqdJ2aL7KsafeRz7BFi2m

…s absence

A fresh review of the money path turned up three falsy-zero defects on
spend controls, plus two registration gaps. All five are confirmed by
tests that fail without these changes.

daily_limit=0 meant "unlimited" instead of "spend nothing". The cap is
Optional, where None means "no cap", so a truthiness test conflated the
absence of a limit with the strictest limit there is -- the value an
operator sets to halt a runaway agent. This had two instances, and the
second is the one that mattered: routers/billing.py converted a
requested 0 to None at the API boundary, so the zero never reached the
engine at all and fixing only the engine would have changed nothing.
The HTTP schema declares ge=0, so zero is documented input, and
app/db/converters.py already drew the distinction correctly with
`is not None`.

auto_refill_threshold=0 meant "alert below 100". The column is
non-Optional defaulting to 100, so `or Decimal("100")` could only ever
fire on exactly zero -- the one value a wallet sets to mean "never alert,
never refill" -- and restored the default it had opted out of, alerting
on every charge below it.

register_local accepted a non-positive credits_per_unit. That number is
multiplied by units and debited, so zero makes a metered tool free and a
negative one pays the caller on every invoke. It is now refused at
registration rather than discovered in a ledger.

register_local also dropped the input schema whenever an output model was
supplied: the callable fallback ran only when both schemas were absent,
so a handler with plain typed arguments returning a model advertised
"takes no arguments", and every client that obeyed that contract hit a
TypeError. Each side now falls back independently.

Tests run: pytest tests/test_billing.py (new cases verified to fail
without each fix), ruff check on app/ and the test file.

Not addressed here, and reported separately because each needs its own
change: the nested connection checkout in authorize_and_reserve that can
exhaust the pool under concurrent governed invokes; max_calls_per_tool
and aggregate_value_cap being read outside the atomic reservation UPDATE;
the velocity counters that are not reversed on the IntegrityError replay
path; and create_permit preferring the request-body subject_key_id over
the authenticated one, which contradicts the comment directly above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iqdJ2aL7KsafeRz7BFi2m
@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 2:47am

@coderabbitai

coderabbitai Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a5dec385-0b24-41fc-b8c8-71a7d3d474d2

📥 Commits

Reviewing files that changed from the base of the PR and between d04e909 and d93c38b.

📒 Files selected for processing (4)
  • app/routers/billing.py
  • app/services/billing_engine.py
  • app/services/service_registry.py
  • tests/test_billing.py

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Zero-value daily spending limits are now enforced correctly instead of being treated as unlimited.
    • Zero-value low-balance alert thresholds are preserved instead of defaulting to 100 credits.
    • Local services now correctly derive missing input or output schemas when the other schema is provided.
  • Tests

    • Added coverage for zero spending limits and independent schema derivation.

Walkthrough

The billing changes preserve explicit zero values for daily limits and low-balance thresholds. The service registry derives missing schemas independently. Regression tests cover both behaviors.

Changes

Billing zero-value handling

Layer / File(s) Summary
Billing limit handling and regression coverage
app/routers/billing.py, app/services/billing_engine.py, tests/test_billing.py
Wallet creation and enforcement preserve daily_limit=0. Low-balance alerts preserve an explicit zero threshold. Tests verify rejected charges and unchanged balances.

Local service schema derivation

Layer / File(s) Summary
Independent schema extraction and validation
app/services/service_registry.py, tests/test_billing.py
register_local derives each missing schema while preserving supplied models. Tests cover a derived input schema with a declared output model.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/webgl-particle-wave-landing-9xn6qd

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

…rice

I added a credits_per_unit > 0 guard on the theory that a zero price
makes a metered tool free by accident. The existing suite proves the
opposite: test_discovery_annotations_carry_governance_contract registers
a tool at 0.0 specifically to assert the manifest reports
economicAction: false, so a free tool is a deliberate, tested product
state -- not a mistake to refuse.

test_public_manifest_stays_strict_json_for_malformed_prices makes the
second half of the argument: it registers nan, inf, -inf and 0.0 on
purpose and asserts the public manifest still serialises as strict JSON.
The design hardens prices where they are rendered, not where they are
registered, and raising at registration prevented those cases from being
set up at all.

Three tests failed for these reasons. Reverting the guard rather than
editing them: the tests were right.

The independent input/output schema fallback in the same function is kept
-- nothing objected to it, and supplying only an output model still must
not erase a handler's input contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iqdJ2aL7KsafeRz7BFi2m
@PetrefiedThunder
PetrefiedThunder marked this pull request as ready for review August 20, 2026 07:54
@PetrefiedThunder
PetrefiedThunder merged commit 8164c8c into main Aug 20, 2026
17 of 18 checks passed

This branch was successfully deployed

1 active deployment
Preview — d93c38be 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