Story 1831: the spend perimeter — a turnstile, a parcel scale, and a breaker box in front of the brain

Why this exists (the catastrophe it prevents). Every agent launch egresses to a paid model. The only guard we had was a per-run cost cap of $0.50 — a ceiling on one elevator trip. It does nothing against the two shapes of a denial-of-wallet attack. The straddle: a thousand individually-under-cap sessions whose cumulative spend is a five-figure bill — each trip is cheap, the day is ruinous. The flood: a burst of launches (or one 5 MB payload) that pins spend before any single-run cap can even fire. A per-run cap is a turnstile that resets its counter after every person; the building still floods. This Story builds the perimeter the cap was never meant to be: a turnstile that remembers, a parcel scale, and a breaker that trips for the whole firm.

Functions-only. Three NET-NEW controls at the launch edge, all rejecting before any SQS enqueue or model call, plus a rebuilt circuit-breaker with real cross-session state. The per-run cost_cap, the consent gate, RLS firm authority, and the one-audit-row-per-run contract are all unchanged — this wraps them, it does not touch them.

Satellite view: a guard at the only door that spends money

Agent runs have exactly one entrance that leads to a paid model: the WebSocket $default launch handler (handler_ws_default). It reserves a run id, then enqueues to SQS → the clerk worker → AgentCore. Everything expensive is downstream of that enqueue. So the perimeter sits at the enqueue decision, and a rejected launch never costs a cent.

client ─▶ WS $default launch
              │
              ├─ 1. input ceiling   (parcel scale: too big? reject, no DB)
              ├─ 2. rate limit      (turnstile that remembers: token bucket per firm+user)
              ├─ 3. circuit breaker (breaker box: open for this firm? reject)
              ├─ 4. consent gate    (unchanged)
              │
              ▼ (all clear)
            SQS ─▶ clerk worker ─▶ AgentCore (the only paid step)
                       │
                       ▼ on every terminal run
                  record_spend ──▶ circuit window (cumulative spend feeds the breaker)

The breaker is a loop, not a line: the worker feeds each run's cost back into the same per-firm window the launch edge reads. Spend accumulates across sessions; when it (or launch frequency) sustains a breach, the breaker opens and the next launch bounces at step 3.

Airplane view: why the state lives in Postgres, not memory

A perimeter control has to be cross-session and cross-container — the whole point is to see the straddle that no single session can. Lambda gives us neither for free: there is no Redis in this runtime, and an in-process counter resets on every cold start and is invisible to the other nine warm containers (the existing SlidingWindowRateLimiter is exactly that — per-container, per-session, useless as a perimeter). So the state is two small firm-scoped tables (migration 0134), read and written inside the same transactions the launch edge and the worker already open. RLS scopes them like every other agents.* table; the FK to business.firms cascades; they carry the canonical updated_at trigger.

The decisions that matter

1. A token bucket, refilled lazily, consumed atomically

The rate limit is a classic token bucket keyed (firm_id, user_id) — firm-leading, so one firm's burst drains its own bucket and is invisible to every other firm's. We never run a refill cron; the bucket refills lazily, computing elapsed × rate from updated_at at read time, capped at the burst size. Refill and consume are one atomic statement (a CTE: compute the available balance, and in the same statement insert-or-decrement only when a whole token exists). That atomicity is the point — two concurrent launches cannot both read a stale balance and over-admit.

2. The breaker is rebuilt with real state — not the re-enabled flag

circuit_open existed before this Story as a vestigial enum value: a run status nothing ever set, with no logic behind it. The ticket is explicit that re-enabling that flag is not the deliverable. The breaker is now backed by agents.agent_circuit — a per-firm tumbling window of cumulative spend (cents) and launch frequency (count), plus a circuit_open_until timestamp. Two axes feed it: the launch edge records frequency on every launch; the worker records spend on every terminal run. A sustained breach of either ceiling sets circuit_open_until = now() + cooldown, and while that is in the future every launch for the firm is rejected — finally giving the long-dead circuit_open status a real meaning.

3. Spend is recorded once, at the single terminal choke point

Every terminal path — success, brain-failure, cost-abort — routes through one function, _terminal_update. So record_spend hooks in there exactly once, guarded by the same rowcount == 1 that makes the terminal write idempotent. A duplicate-delivery worker that finds the run already terminal writes nothing and therefore double-counts nothing. One hook covers all callers — the root-cause place, not three sprinkled ones.

4. The input ceiling sizes the raw wire body, before anything is parsed

The oversized-payload check is pure — no DB, the cheapest reject — and it measures the raw request body, not a single field. The DTO already caps message at 32 KB, but a denial-of-wallet payload rides in input or history. Sizing the whole body (default 128 KB) means a huge structured snapshot can't sneak past a small message into an expensive prompt.

5. A rejection is terminal truth, not a silent drop

A refused launch is not a dropped packet: the broker writes a terminal agents.agent_run row (status failed for rate/size limits, circuit_open for a tripped breaker) and pushes a filtered run.failed frame, so reconnect-refetch sees a clean terminal state. This reuses the exact tail the consent gate already used — so all four refusal reasons (size, rate, circuit, consent) now share one reject path (write terminal truth → push frame → log → metric), and the consent surface is byte-for-byte preserved.

Verification

The three acceptance tests run through the real perimeter service against the functions test DB (system boundary, including the zero-rows first-run state where a firm has no throttle row yet), plus a pure unit test for the no-DB input ceiling:

CheckProves
burst throttled, others unaffecteda firm/user exhausts its bucket and the next launch is RateLimited; a different firm's launch is still admitted (firm-leading key)
oversized input rejected pre-modela body over the ceiling returns InputTooLarge with no DB touched; at/under the ceiling is admitted
cumulative-spend breach trips the breakertwo under-cap terminal spends cross the firm window ceiling → the next launch is rejected circuit_open; another firm is unaffected. A frequency-axis breach is covered too.

Full gate green: ruff check clean on all changed files; mypy reports no new errors in the changed modules (local-only); the full pytest suite passes against the isolated Docker test DB; the Alembic down/up roundtrip and alembic check are clean on a fresh volume (migration 0134 creates two firm-scoped, RLS-enforced tables with the canonical updated_at trigger and a cascading firm FK); make openapi produces no drift (the WS launch edge is not an OpenAPI route). The two repo-governance tests that track firm-scoped tables — the support-confidentiality list and the updated_at-trigger census — are updated to register the two new tables.

One sentence to remember: the per-run cost cap is a turnstile that forgets every visitor; Story-1831 adds the perimeter it can't be — a remembering turnstile (token bucket), a parcel scale (input ceiling), and a firm-wide breaker fed by real cross-session spend — so the denial-of-wallet straddle and the flood bounce at the door, before a single model call is made.