Skip to content

v0.2.0 — spend caps that hold across processes

Choose a tag to compare

@premsreelathasugeendran premsreelathasugeendran released this 29 Jul 12:55

Spend caps now hold across processes. Until this release they did not, and there was no way to make them.

The README admitted it: two guard processes each enforcing a $50/day cap let $100 through. That is the flagship guarantee failing in the shape most real deployments actually take, and documenting a hole is not the same as fixing it.

npm run demo:cross-process

Two real OS processes, one $50/day cap, $100 of payments attempted:

1. Per-instance limiter (the default)
   worker-A: 5 executed, 0 denied  →  spent $50.00
   worker-B: 5 executed, 0 denied  →  spent $50.00
   TOTAL SPENT: $100.00  against a $50.00 cap
   ❌ OVER the cap by $50.00 — two processes are two budgets.

2. Shared FileSpendLimiter (one budget, atomic reserve)
   worker-A: 5 executed, 0 denied  →  spent $50.00
   worker-B: 0 executed, 5 denied  →  spent $0.00
   TOTAL SPENT: $50.00  ✅ The cap held.

It runs in CI on Linux, Windows and macOS, and fails the build on any overspend.

Why a shared database would not have fixed this

This is the part worth reading, because getting it wrong is the obvious mistake.

SpendHistory.totalsSince() was a read-only query, so the guard could only ever check-then-act: read totals → execute → append. Two processes both read $0, both pass the $50 check, both spend. Pointing that interface at Postgres changes where the rows live, not the gap between the read and the append — the race survives untouched.

So the budget check moved inside the mutating call:

reserve(windows, amount)  →  execute  →  commit

SpendLimiter.reserve() evaluates every rolling window and records the reservation as one atomic step. It is the same fix maxUses needed when it moved inside claim() — the identical bug, one layer up.

Added

  • SpendLimiter — atomic reserve / commit / release. Ships MemorySpendLimiter (default, single process) and FileSpendLimiter (several processes, one box).
  • @vaduno/postgresPostgresSpendLimiter and PostgresConsumeStore, for caps and mandates that hold across multiple instances. Verified by both conformance suites against a real Postgres 16 in CI — 40 tests, no mocks, because the property being tested lives in the database. pg is an optional peer dependency; @vaduno/guard still has zero runtime dependencies.
  • A SpendLimiter conformance suite (23 cases per limiter) run against two independent handles on one backing store, plus guard.releaseSpend(intentId).
  • policyWindows(policy) — rolling windows derived in one place, so the advisory pre-check and the authoritative reserve can never disagree about a cap.

Changed — read this before upgrading

A failed execution now keeps its spend counted. Previously a thrown executor freed the entire reserved amount. But a timeout after the charge landed is indistinguishable from a clean failure, so an executor failing post-charge could be retried past any cap: N timeouts, N real charges, none of them counted — and with no mandate configured, nothing else bounded it.

The amount now stays reserved: counted against caps, but reclaimable. Call guard.releaseSpend(intentId) when you can prove the rail did not charge. It cannot un-count a successful execution, so a mistaken call is safe.

Everything else is additive. history still works as an advisory fast-fail, and code that does not pass limiter behaves exactly as before — single-process, as documented.

Two bugs this work exposed

A latent Windows bug, shipping since 0.1.0. FileMutex treated only EEXIST as lock contention. On Windows a file unlinked while a handle remains open sits in a delete-pending state, and an O_EXCL create against it is refused with EPERM — ordinary contention wearing a different errno, which propagated as a thrown acquire. On this path a thrown acquire is a denied payment. FileConsumeStore has had this since 0.1.0; its tests simply never contended hard enough. The limiter suite's 10 parallel reserves across two handles did.

The release gate was blind to new packages. It iterated a hardcoded list, so @vaduno/postgres would have shipped completely ungated while the gate printed "all packages look publishable" — the same failure as checking a README exists without reading it. Now discovered from the filesystem. It then immediately caught an unqualified "no keys" claim in the README written for that very package.

On testing concurrency

The conformance suites exist because these interfaces are satisfied by implementations that double-spend, and this project has shipped that bug twice.

Measured, against a deliberately naive check-then-act limiter: it passes all 19 sequential cases and fails only the 4 concurrent ones. If you write a store and test it sequentially, you will believe it works.

Both suites are exported. If you implement SpendLimiter or ConsumeStore for Redis, DynamoDB, MySQL or SQLite, run them — your harness supplies two independent handles on one backing store, which is what exercises the cross-process contract. A single handle is exactly how both original bugs hid.

Still true, and worth reading before you use this

Zero users, never run in production, no professional security audit. @vaduno/stripe has never run against Stripe in any mode. In-process, the guard can be routed around by a compromised runtime. Caps don't prevent prompt injection — they bound the loss.

docs/SECURITY-MODEL.md states the non-guarantees next to the guarantees. If a claim anywhere contradicts that file, that file is right.

Criticism of the concurrency and the cryptography is actively wanted — that is where the real bugs have been. Vulnerabilities: private advisory, not a public issue.

Tests 309 → 357, plus 40 more against real Postgres in CI.


npm install @vaduno/guard

@vaduno/guard · @vaduno/postgres · @vaduno/transparency · @vaduno/revocation · @vaduno/x402 · @vaduno/stripe