Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6e1beb5
fix(config): refuse a SQLite DATABASE_URL in production-like environm…
claude Aug 18, 2026
9579e4e
test(races): cover the four guarded writes that shipped without one
claude Aug 18, 2026
11cd596
fix(velocity): reverse a rejected charge only in the period it was re…
claude Aug 18, 2026
74891a1
fix(billing): refuse an unknown wallet and a non-finite units count
claude Aug 18, 2026
9e8489c
test(config): skip the forced-interleave tests when the row lock is real
claude Aug 18, 2026
bfa80f0
chore(sdk): bump the source to 0.5.0 and stop conflating it with the tag
claude Aug 18, 2026
67ec427
docs(changelog): record the metering and velocity hardening
claude Aug 18, 2026
0619e24
test(config): pin the SQLite refusal to the startup path, not just th…
claude Aug 18, 2026
58b9e91
fix(billing): refuse non-positive units — a negative charge minted cr…
claude Aug 19, 2026
78de9b4
fix(billing): constrain dry-run units; make the version-drift asserti…
claude Aug 19, 2026
1076a25
test(billing): pin that an unauthenticated dry run reveals nothing
claude Aug 19, 2026
728db51
test(billing): compare a real dry-run session with an unknown one
claude Aug 19, 2026
edb4245
fix(billing): a dry-run session must not confirm another tenant's exists
claude Aug 19, 2026
a7808ea
fix(billing): a commit that committed nothing must not answer 200
claude Aug 19, 2026
b59457a
fix(billing): a revert that lost the session must not report success
claude Aug 19, 2026
ee02252
fix(billing): end_session must claim the session, not read then delete
claude Aug 19, 2026
87aa7c0
fix(billing): a simulation must not resurrect a claimed session
claude Aug 19, 2026
37c857c
test(billing): prove the two claims that were argued, not tested
claude Aug 19, 2026
cae74b1
Merge remote-tracking branch 'origin/main' into claude/ip-prosecution…
claude Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,84 @@ The next release consolidates the accumulated trust-plane and public-product
work as `v1.3.0`. Create that tag only from the exact commit that passes the
full release gate; do not backfill a final `v1.2.0` tag.

### 🔒 Production refuses a SQLite `DATABASE_URL` at boot

- **The premise behind eleven of the twelve audit findings no longer holds.**
Those findings were live because `SELECT ... FOR UPDATE` is a silent no-op on
SQLite and nothing refused SQLite in production. `validate_trust_mode_config`
now refuses a SQLite `DATABASE_URL` — file *and* in-memory spellings — in
production-like environments, and refuses an empty one, since wallets,
permits, receipts, and the ledger are relational and `get_engine()` returns
`None` without it.
- This is a **separate control** from the `STATE_BACKEND` guard added in #304.
That one governs the key/value durable-state store; this one governs the ORM
engine the money and permit paths actually write through. The gap between
them was reachable: `STATE_BACKEND=redis` with a `REDIS_URL` satisfies the
state check completely while `DATABASE_URL` stays SQLite, which is precisely
the posture `tests/test_trust_mode_guardrails.py::test_a_redis_state_backend_does_not_excuse_a_sqlite_database_url`
now pins.
- Local development is untouched: SQLite remains the standard value outside
production-like environments, and a regression test asserts it stays
accepted there.

### 🔒 A dry-run session no longer confirms another tenant's session exists

- **`403` where a `404` was due.** Every `/v1/billing/dry-run/session`
endpoint looked the session up first and checked wallet access second, so a
caller holding any valid wallet-scoped key got `404` for an invented session
id and `403` for a real one — enough to tell them apart with no access at
all. The `403` body also carried the session's *owning* `wallet_id`,
disclosing another tenant's wallet id outright.
- A session the caller may not see now answers exactly as one that does not
exist, on all five endpoints that accept a session id (`GET`, `DELETE`,
`commit`, `revert`, and `dry-run/charge`). Authorized callers are
unaffected — a regression test pins that the owner still reads its own
session.
- Session ids are UUID4 and cannot be enumerated, so exploiting this needed an
id learned elsewhere (a log, a trace, a shared URL).

### 🧾 Metering and velocity hardening

- **A rejected charge no longer decrements a velocity period it never
contributed to.** The hourly and daily counters roll over independently, and
a rollover between the increment and its reversal left the reversal taking
credits off the *new* period's total. Each counter is now reversed under the
period marker it was recorded against, separately, so a rolled daily window
cannot suppress an hourly reversal that is still owed. The direction is the
point: an over-count throttles a caller who did not spend and heals at the
next rollover, while an under-count silently raises the effective spend cap
and delays the anomaly auto-freeze.
- **`/v1/billing/charge` answers 404 for an unknown wallet** instead of letting
`WalletNotFoundError` escape as a 500 — the six other endpoints in that
router already did.
- **Negative and zero `units` are refused at the billing engine.** A negative
`units` made `charge_amount` negative, and the guarded debit then read
`balance >= charge_amount` as trivially true and applied
`balance - charge_amount` — *raising* the balance. Reproduced against the
unfixed engine: a wallet at 100 charged `units=-5` ended at 110, with a
ledger entry recording `action="debit", amount=+10`, so the audit trail
agreed it was a charge. The router refused it through `gt=0`; the governed
MCP path and the SDK do not pass through the router.
- **The dry-run endpoint validates `units` too.** `POST /v1/billing/dry-run/charge`
has two branches: with a session id it calls `ShadowLedger.simulate_charge`
directly and never reaches `BillingEngine.charge`, so the engine guard did
not cover it; without one it reached the engine and the guard escaped as a
500. `SimulatedChargeRequest.units` now carries the same `gt=0` and
`allow_inf_nan=False` constraints as the real charge, refusing both branches
with a 422 before either is chosen.
- **Non-finite `units` are refused**, at the query boundary
(`allow_inf_nan=False`) and again in `BillingEngine.charge`, which the
governed MCP path and the SDK reach without passing the router. `gt=0` does
not exclude an infinite float, and one reaching a write would put a
non-finite amount in the ledger that no reconciliation can remove.
- **Four guarded writes from the previous release gained the regression tests
they shipped without**: the `refund_charge` credit, `_apply_refund`, the
operator refund's permit release, and the Stripe fiat clawback. Each was
confirmed to fail against the read-modify-write it replaced.
- Tests that force a concurrent writer into an open transaction are now skipped
when `DATABASE_URL` names a real-locking engine. They depend on the lock
being a no-op; against a real one they would hang rather than fail.

### 🔌 Standard `/mcp` endpoint now served by the official MCP SDK

- **The opt-in `POST /mcp` endpoint no longer hand-rolls JSON-RPC.** The
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,16 +659,18 @@ become stale; the CI badge and release gates are the source of truth.
| [`migrations/`](migrations/) | Alembic history; use migrations for production schema changes |
| [`scripts/`](scripts/) | Reproducible demos, release gates, OpenAPI export, and operator helpers |
| [`tests/`](tests/) | Product, negative-path, concurrency, and proof-surface tests |
| [`b2a_sdk/`](b2a_sdk/) | Python trust SDK 0.4.0 source and release build |
| [`b2a_sdk/`](b2a_sdk/) | Python trust SDK 0.5.0 source and release build |
| [`framework_integrations/`](framework_integrations/) | Source examples for agent frameworks; not published packages |
| [`site/`](site/) | Static marketing and discovery pointer site |

### Python SDK 0.4.0
### Python SDK 0.5.0

HTTP and MCP are the canonical integration surfaces. CI builds and smoke-tests
Python SDK 0.4.0 wheels and sdists on Python 3.10 through 3.12. Pushing the
matching `python-sdk-v0.4.0` tag attaches those artifacts to a GitHub release;
the package is not published to PyPI. The typed `AgentMiddlewareClient` covers
Python SDK 0.5.0 wheels and sdists on Python 3.10 through 3.12. Pushing the
matching `python-sdk-v0.5.0` tag attaches those artifacts to a GitHub release;
the package is not published to PyPI. The newest tag today is
`python-sdk-v0.4.0` — the source here is ahead of it, and the wheel a release
build produces carries the source version, not the tag's. The typed `AgentMiddlewareClient` covers
tool discovery, permit creation, governed invocation, receipt verification, and
evidence retrieval, and exposes idempotency conflicts and delivery uncertainty
as explicit errors. For repository development (installs from this repo, not
Expand Down
18 changes: 18 additions & 0 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ You set `ENVIRONMENT=production` but used SQLite or left `STATE_BACKEND=sqlite`.
- Switch to `ENVIRONMENT=local` for local development, **or**
- Set `STATE_BACKEND=postgres` and provide a `DATABASE_URL` with `postgresql+asyncpg://`.

### `TrustModeGuardrailError: DATABASE_URL must not be SQLite in production-like environments`
You set `ENVIRONMENT=production` (or another production-like value) with a
SQLite `DATABASE_URL`. SQLAlchemy silently drops `SELECT ... FOR UPDATE` on
SQLite, so concurrent charges, budget reservations, and velocity counters lose
the serialization the money and permit paths depend on — two callers can each
debit a balance only one of them fits inside. Either:
- Switch to `ENVIRONMENT=local` for local development, **or**
- Point `DATABASE_URL` at PostgreSQL (`postgresql+asyncpg://...`).

This is a different check from `STATE_BACKEND` below: that one governs the
key/value state store, this one governs the ORM engine. Satisfying one does
not satisfy the other.

### `TrustModeGuardrailError: DATABASE_URL must be set in production-like environments`
Comment thread
PetrefiedThunder marked this conversation as resolved.
Wallets, permits, receipts, and the ledger are relational. Without
`DATABASE_URL` the engine is never created and the trust plane has nowhere
durable to record what it authorized. Set it to your PostgreSQL DSN.

### `RuntimeError: ENABLE_PROOF_SURFACES must be false in production`
Set `ENABLE_PROOF_SURFACES=false`. Proof surfaces are for local demos only.

Expand Down
13 changes: 13 additions & 0 deletions app/core/db_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ def is_postgres_url(url: str) -> bool:
)


def is_sqlite_url(url: str) -> bool:
"""True when ``url`` names SQLite, in-memory spellings included.

This is the question ``sqlite_path_from_url`` deliberately does not
answer. That helper returns "" for an in-memory URL because it is looking
for a *durable file*; a caller asking "is this SQLite?" for safety reasons
needs the opposite treatment, since ``sqlite+aiosqlite:///:memory:`` is
every bit as unsuitable for a production trust plane as a file is -- more
so, since it also loses everything on restart.
"""
return (url or "").strip().lower().startswith(("sqlite://", "sqlite+"))


def sqlite_path_from_url(url: str) -> str:
"""Return the **durable** SQLite file path in ``url``, or "" otherwise.

Expand Down
35 changes: 35 additions & 0 deletions app/core/trust_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from app.core.db_urls import is_sqlite_url

if TYPE_CHECKING:
from app.core.config import Settings

Expand Down Expand Up @@ -82,6 +84,7 @@ def validate_trust_mode_config(
enable_public_mcp_endpoint: bool = False,
redis_url: str = "",
public_url: str = "",
database_url: str = "",
) -> None:
"""Refuse unsafe deploy postures in production-like environments.

Expand Down Expand Up @@ -147,6 +150,37 @@ def validate_trust_mode_config(
"in production-like environments (anonymous verification must "
"use shared rate-limit state)"
)
configured_database_url = (database_url or "").strip()
if not configured_database_url:
violations.append(
"DATABASE_URL must be set in production-like environments "
"(wallets, permits, receipts, and the ledger are relational; "
"without it get_engine() returns None and the trust plane has "
"nowhere durable to record what it authorized)"
)
elif is_sqlite_url(configured_database_url):
# STATE_BACKEND already refuses SQLite (see
# app/core/durable_state.py), but that guard covers the key/value
# state store, not the ORM engine this URL builds. A deployment
# setting STATE_BACKEND=redis with REDIS_URL satisfies it while
# DATABASE_URL stays SQLite -- and the money and permit paths run
# against the ORM engine, not the state store. Every
# ``SELECT ... FOR UPDATE`` guarding a balance, a budget, or a
# counter is silently dropped by SQLAlchemy on SQLite, so the
# serialization those paths are written to rely on is simply
# absent. Refuse at boot rather than let concurrent writers
# discover it against real money.
violations.append(
"DATABASE_URL must not be SQLite in production-like "
"environments: SQLAlchemy silently drops SELECT ... FOR UPDATE "
"on SQLite, so concurrent charges, budget reservations, and "
"velocity counters lose the serialization the money and permit "
"paths depend on. Use PostgreSQL "
"(postgresql+asyncpg://...). This is a separate control from "
"STATE_BACKEND, which governs the key/value state store rather "
"than the ORM engine"
)

if enable_public_mcp_endpoint and not (public_url or "").strip():
violations.append(
"PUBLIC_URL is required when ENABLE_PUBLIC_MCP_ENDPOINT is true "
Expand All @@ -172,6 +206,7 @@ def validate_trust_mode_guardrails(settings: Settings) -> None:
enable_public_mcp_endpoint=settings.ENABLE_PUBLIC_MCP_ENDPOINT,
redis_url=settings.REDIS_URL,
public_url=settings.PUBLIC_URL,
database_url=settings.DATABASE_URL,
)


Expand Down
Loading