Story 1717: Dispatcher per-session intent budget + trailing-edge debounce (Transport-A)
Why this exists (the catastrophe it prevents). Counsel can drive the visible page by voice. One spoken sentence — "show Meridian… no, Acme… actually the clients that are open" — is not one action: it is a rapid stream of view-reshape intents, and on the Matters board each one is a Transport-A search-param refetch. Left ungoverned, a single sentence becomes a refetch storm: many round-trips, real spend, and only the last value ever mattered. A long agent loop is the same failure with no human in the loop at all. Story-1717 puts two governors in the dispatcher so the storm collapses to its meaningful tail before any authority work runs — the cost-governance half of RFD-AGT-002 D-9 on the functions side (the sibling agents ticket owns the cached stable prefix).
POST /app/agents/session-context/resolve (src/app_api/router_agent_session.py). It does not touch the brain, the cache prefix, or the app client.The two governors
1. Trailing-edge debounce — only the final value applies
Inside one turn, repeated client-plane view intents of the same tool collapse to the last occurrence. Earlier ones are not silently dropped — they are returned accepted=false with a disclosure (RFD §10: best-effort is allowed only with disclosure). Navigation (navigate_to) and every server-plane intent are excluded: dropping a destination or a write is never safe.
set_filter(client="Meridian") -> debounced ("superseded by a later 'set_filter' …")
set_filter(client="Acme") -> debounced
set_filter(client="Globex") -> applied (the final value)
Debounce is per tool, so two different view dimensions in one turn (set_filter and set_view) both survive — only repeats of the same tool coalesce.
2. Per-session intent budget — a sliding window caps dispatch
A session is one user talking to one agent (RFD-AGT-002 D-12), so the budget key is per-user + per-agent, derived server-side from the authenticated caller — never client-supplied:
session_key = f"{user_id}:{agent_key}"
The budget rides the existing in-process SlidingWindowRateLimiter (max_requests intents per window_seconds, both from settings). Once a session's window is spent, the remaining intents in the turn are returned accepted=false with a budget disclosure — they are not dispatched. Two governors compose in a deliberate order: debounce first (a superseded view intent never charges the budget — it was never going to run), then budget the survivors.
| Turn | Dispatcher verdict | Disclosed via |
|---|---|---|
3× set_filter (same tool) | first two debounced, last applied | ResolvedIntent.reason |
| 3× authorized server intent, budget = 2 | first two pass, third over budget | ResolvedIntent.reason |
2× navigate_to (distinct surfaces) | both kept — navigation never debounces | — |
The decisions that matter
No new contract — disclosure rides the existing reason field
A governed intent is marked accepted=false with a human-readable reason on the existing ResolvedIntent shape. So there is no DTO change, no new route, and no OpenAPI drift — the consumer already renders reason for denied intents. The governance runs in the dispatcher before authority re-resolution; an ungoverned intent falls through to the unchanged authz resolver.
ADR-130 O(1): the governor issues zero queries
The whole governor is pure in-memory bookkeeping — no database query at all — so the route's query count stays bounded and constant no matter how many intents a turn carries. This is the explicit acceptance bar (DR-1: query count must not scale with the work), and it is tested at the system boundary: a turn with 1 intent and a turn with 60 produce the same db_query_count (here, zero) read off the WS-7 completion log line.
Best-effort cost guard, never a correctness gate
On a warm Lambda the sliding window persists across invocations on the same instance — exactly where a burst lands — and a cold start simply restarts the window. The budget is a cost guard; authority is still re-resolved per intent downstream, so a reset window can never grant a forbidden action, only allow one more refetch. The governor emits one ADR-124-compliant structured log line (agent_dispatch.governed) with governance counts only — never message text, params, or firm_id, and never a hand-rolled span (it is covered by the route span, mirroring agent_rails/budget.py).
Where it lives
The governor is src/shared/agent_rails/dispatch_governor.py (dispatch_session_key, govern_turn_intents, DispatchVerdict, IntentDisposition), exported from the agent_rails package alongside the Auto-batch budget governor. Two settings knobs (AGENT_INTENT_BUDGET_MAX, AGENT_INTENT_BUDGET_WINDOW_SECONDS) tune the window; nothing reads os.environ directly.
Verification
Governor unit tests (tests/unit/agent_rails/test_dispatch_governor.py) prove: trailing-edge debounce keeps only the last same-tool view intent and discloses the rest; debounce is per-tool; navigation and server intents never debounce; the per-session budget caps dispatch and discloses overflow; sessions do not share a window; and a superseded intent never charges the budget. Endpoint tests (tests/unit/test_router_agent_session.py) drive the real router through a TestClient: debounce and budget disclosures surface on the live response, and the O(1)-query bound holds across 1 vs 60 intents. Lint (ruff) clean, OpenAPI snapshots unchanged (no contract change).
One sentence to remember: a voice burst collapses to its last value and a per-session window caps the rest — disclosed, not silent — and because the governor touches no database, one turn stays one bounded request no matter how many intents it carries.