Story-2858

Per-identity spend/rate caps for bound identities

This story is RFD-AGT-013 T2f-a (D23, adversary finding A-10). A legitimate bound identity can still run away — a compromised phone, a client loop — and the existing guards don't bound it: the per-run cost_cap caps one run, and the agents.agent_circuit breaker is per-FIRM, so one runaway phone could burn under both. These caps bound the blast radius of legitimate channel traffic per binding. It branches off the Story-2847 schema-seed with zero new migrations — counting rides the seeded attempt ledger, inbound dedupe rows, and delivery records.

The two axes

CapCounted fromSemantics
Rate — channel_rate_cap_max_messages per channel_rate_cap_window_seconds (default 30/hour) business.channel_inbound_messages (accepted/in-flight rows for the binding) + business.channel_refusal_ledger (this gate's own rate_limited/spend_capped rows for the subject) The current message's inbound row already exists at check time, so messages 1..max pass and max+1 refuses. Counting refusals too means hammering after refusal keeps the sender over cap instead of resetting it.
Spend — channel_spend_cap_max_cents per channel_spend_cap_window_seconds (default $10/day) business.channel_delivery_records.run_id → agents.agent_run.total_cost_cents, DISTINCT run ids A new run's cost is unknowable pre-enqueue, so prior-window terminal cost ≥ cap refuses the NEXT run. DISTINCT linkage means a two-chunk answer never double counts.

Where it sits — and what it deliberately is not

The cap check (src/domains/channel/caps.py, check_identity_caps) is called by the channel-ingress consumer AFTER resolve_bindings and BEFORE run enqueue, on the firm-scoped session (the consume_enrollment_code re-scope pattern — agents.agent_run has no staff read path, so the firm scope is what makes the spend join RLS-honest). That consumer does not exist on main yet (T2a-4/T2e-a build it), so this PR ships the gate as a service with DB proofs at the exact call shape the consumer will use; the refusal seam it composes (dispatch_whatsapp_text + the static_refusal/rate_limited slots the schema-seed reserved) is the same one Story-2850's zero-amp shield will use — noted as the one coordination assumption.

The refusal: static, counted, then silent

Over cap never starts a run and never calls a model. The refusal body is one constant (build_static_cap_refusal() in the D5 formatter — the same zero-LLM, zero-variance shape as the static ack and the T2a-4 refusal), delivered as a static_refusal delivery record with run_id NULL. Every over-cap attempt writes an attempt-ledger row (counted — it feeds the rate window), but the message itself goes out at most once per cap window (the CC-22 one-message-then-silence pattern, bounded by the attempt ledger — RFD risk 17): the first over-cap attempt in the window sends it, repeats are ledger-counted silence (zero amplification, CC-41/CC-49). The consumer then stamps the inbound row refused — the R5 honest terminal state — and does not enqueue.

Config, not magic numbers

All four thresholds are get_settings() fields with documented defaults and bounds (CHANNEL_RATE_CAP_MAX_MESSAGES, CHANNEL_RATE_CAP_WINDOW_SECONDS, CHANNEL_SPEND_CAP_MAX_CENTS, CHANNEL_SPEND_CAP_WINDOW_SECONDS). Defaults sit far above human converse pace and far below runaway-loop damage (~$0.50/run makes $10/day ≈ 20 runs); ops can tighten per environment without a deploy.

How to verify

# Decision boundaries + static-refusal shape
uv run pytest tests/unit/test_channel_caps.py

# DB proofs: under-cap passes; over-cap static+counted; repeat = counted
# silence; window rollover resets; spend DISTINCT per run; cross-binding
# isolation — all on the pramaan_app role with RLS binding
make test-db
bash scripts/run-with-test-db-env.sh uv run pytest \
  tests/integration/test_channel_caps.py

# Red-first: both axes were observed failing on planted negatives
# (rate branch disabled: 2 unit + 2 integration red; spend branch disabled:
# 1 unit + 1 integration red), then reverted to green.

Scope boundaries