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
| Cap | Counted from | Semantics |
|---|---|---|
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.
- Not customer billing gates. D23's payment_status clause was struck (Codex review): customer per-run blocking was deliberately deleted from the worker and is a separate billing decision. These are platform safety caps only.
- Not backoff/anomaly/alert transport. Refused-sender backoff, burst detection, and alert email transport are T2f-b's split of D23 — not this ticket.
- Not the unregistered-sender refusal. That is T2a-4's zero-amp shield; this gate only ever runs on resolved, active bindings.
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
- No new migrations (schema-seed merge-order policy), no OpenAPI churn (no route/DTO changes), no new workflows.
- No consumer wiring — the channel-ingress consumer is unbuilt future work (T2a-4/T2e-a); this PR ships the gate it must call and proves it at that call shape.
- The entity comment vocabulary gains
spend_capped(documented, string column, no schema change).