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."
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.
Three parts, all borrowed from how a car already keeps you off the shoulder:
ops.platform_wallet): a single durable row holding the
last-read USD balance. A platform fact โ our tank, not any firm's โ so it carries no
firm_id and no RLS, exactly like a global config row.#pramaan-alerts.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.
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.
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.
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.
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.
| Left out | Why |
|---|---|
| Block mid-run | Pre-flight only. In-flight runs finish; only the next run is gated (mirrors the credit gate's run-open semantics). |
| Entangle with per-firm rails | This is a platform breaker. It shares no state with consent, credits, or the per-firm perimeter. |
| EventBridge schedule / secret / CloudWatch alarm | Terraform-owned in pramaan-infra (a coordinated sibling task). This repo owns the poll function, the gauge, and the gate. |
| Own the floor config table | The 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. |
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.