Story 2160: the low-fuel light that won't let you start the drive

WHY. Every AI run PRAMAAN executes spends money out of one shared tank โ€” our OpenRouter account balance. Nothing was watching that tank. On a real batch a run died mid-flight when OpenRouter returned a 402 at roughly $33 remaining (pramaan-agents/docs/notes/frame-grading-method.md:321): the wallet ran dry in the middle of work, so the user got a half-finished, failed run instead of a clean "come back later." The catastrophe this Story prevents is a mid-run stall โ€” discovering the tank is empty only once you're already on the highway. The fix is a low-fuel light that refuses to let a new trip begin when the tank is below a line you set, so the only failure a user ever sees is "not now," never "stalled halfway."

๐Ÿ›ฐ๏ธ Satellite โ€” the one sentence

A scheduled poll reads our OpenRouter balance into a one-row gauge, and the clerk worker checks that gauge before it invokes the brain: below a config floor (default $1000), a new run is turned away with a distinct platform_wallet_low reason and an alert โ€” never the mid-run 402.

โœˆ๏ธ Airplane โ€” a fuel gauge, a floor line, and a starter interlock

Three parts, all borrowed from how a car already keeps you off the shoulder:

Crucially this interlock is orthogonal to the per-firm rails. The consent gate and the credit gate ask "may this firm drive?" The wallet guard asks "does the platform have fuel at all?" โ€” a wider question, so it is checked first and shares no state with the firm perimeter.

๐Ÿ”ฌ Microscopic โ€” the four moving parts

1. The gauge โ€” a singleton that can't grow (migration 0161)

The tank has exactly one reading, so the table has exactly one row. A boolean primary key pinned true enforces that: the poll upserts on it, and there is nowhere for a second row to go.

CREATE TABLE ops.platform_wallet (
  id          boolean PRIMARY KEY DEFAULT true CHECK (id = true),  -- singleton
  balance_usd numeric(14,4) NOT NULL,   -- NUMERIC, never float (money invariant D-1)
  checked_at  timestamptz NOT NULL DEFAULT now()
);  -- no firm_id, no RLS: platform-global, not tenant data

The migration grants the app role SELECT/INSERT/UPDATE and revokes DELETE โ€” the gauge is durable. It is additive and reversible (upgrade + hand-written downgrade), chaining off the 0157 head.

2. The sender unit โ€” read the balance, cache it, page on empty

OpenRouter's GET /credits returns total_credits and total_usage; the balance is the difference. That endpoint needs a management key, distinct from the inference key โ€” so the poll reads its own secret (OPENROUTER_MANAGEMENT_API_KEY, provisioned by infra). The math and I/O live in src/shared/billing/platform_wallet.py (testable in isolation); the worker (src/workers/platform_wallet_poll/handler.py) is thin glue:

balance = total_credits - total_usage      # fetch_openrouter_balance
await write_cached_balance(session, balance_usd=balance)   # upsert the gauge
emit_platform_wallet_balance(balance_usd=float(balance))   # CloudWatch โ†’ alarm

If the key is missing or the call fails, the poll writes nothing and returns a non-fatal error โ€” it never fabricates a balance. The schedule, the secret, and the alarm are infra-owned (a sibling task); this repo owns the function.

3. The starter interlock โ€” the pure decision, floor-as-argument

The whole point of RFD-BILL-001 ยง14/F14 is that no commercial number is hardcoded. So the gate function takes the floor as an argument, and this money module contains no $ literal at all. The floor itself is sourced from S1's config โ€” load_wallet_floor reads billing.pricing_config.wallet_floor_usd (the staff-editable row) and falls back to the settings.platform_wallet_floor_usd env default only when that row is un-seeded, so a config read can never fail the gate (the same fallback posture as S1's AI-debit pricing):

def wallet_gate(*, balance_usd, floor_usd):
    if balance_usd is None:      return allow      # unknown โ‰  low โ†’ FAIL-OPEN
    if balance_usd < floor_usd: return block(platform_wallet_low)
    return allow

The fail-open on unknown is the single most important line. If the gauge has never been written (fresh deploy, poll not yet run) the guard must allow โ€” because blocking on "no data" would weld every firm's door shut platform-wide, a far worse outage than the one 402 it guards against. Absence of a reading is not evidence of an empty tank. Poll health (a stale/missing gauge) is alarmed separately by infra, not by refusing runs.

4. The wiring โ€” checked first, distinct reason, brain never called

In process_command the wallet guard sits above the consent and credit gates, gated by a kill switch (platform_wallet_guard_enabled, default on):

if deps.platform_wallet_guard_enabled:
    wallet = await _platform_wallet_gate(deps)   # reads the singleton gauge, no firm scope
    if not wallet.allow:
        emit_platform_wallet_blocked()
        await _block_run_platform_wallet_low(...)   # run_failed + terminal stamp + user frame
        return "blocked"

The block reuses the same failure-truth contract as the credit/consent blocks (a run_failed event, a guarded terminal update, a filtered user frame) so a reconnecting browser sees a clean terminal state. Its stop_reason is the distinct platform_wallet_low โ€” attributable, never confused with a firm's out-of-credits โ€” and the user message is a retryable "temporarily paused," because this is our tank, not their fault. The brain is never invoked, so the 402 can't happen.

What this Story deliberately does NOT do

Left outWhy
Block mid-runPre-flight only. In-flight runs finish; only the next run is gated (mirrors the credit gate's run-open semantics).
Entangle with per-firm railsThis is a platform breaker. It shares no state with consent, credits, or the per-firm perimeter.
EventBridge schedule / secret / CloudWatch alarmTerraform-owned in pramaan-infra (a coordinated sibling task). This repo owns the poll function, the gauge, and the gate.
Own the floor config tableThe authoritative floor is S1's staff-editable billing.pricing_config.wallet_floor_usd row (Story-2159); the gate reads it via load_wallet_floor and only falls back to the settings.platform_wallet_floor_usd env default when that row is un-seeded. This Story consumes the config table, it does not define it.
Recall in one sentence: Story-2160 adds a low-fuel interlock โ€” a scheduled poll caches our OpenRouter balance in a singleton ops.platform_wallet gauge, and the clerk worker refuses to start a new run below a config floor with a distinct platform_wallet_low reason, failing open when the balance is unknown so a mid-run 402 can never strand a user.