Story 1647: AgentSessionContext provider-bag + typed tool-intent contract
Why this exists (the catastrophe it prevents). Atlas and every future agent need to know what the user is looking at, where they can go, and what they may do — per turn. The naive way is a fixed struct: { agentKey, surface, route, permissions, availableSurfaces, clientTools }. The day someone adds a "deadlines" or "calendar" context, that struct becomes a cross-repo contract rewrite across the app, the brain, and the backend — the exact failure PLAN §1e forbids ("we ship in days, not months; the next idea must be a new registry entry, not a rewrite"). The second catastrophe is trust: if the browser-supplied permissions/clientTools were treated as authority, a tampered tab could invent settings.team.delete_member and the brain would believe it. Story-1647 is the Wave-0 contract that makes context additive and makes client context orientation, never authority.
The three things this ships
1. AgentSessionContext — an open, versioned bag of providers
The one shape that must not be a closed struct. It is { version, providers } where providers is an open map: surface, cpc, kb, permissions, clientTools are simply the first providers. Tomorrow's deadlines provider is a new key — not a type change.
ctx = AgentSessionContext.model_validate(payload) # extra="ignore" on the bag
ctx.surface() # -> SurfaceProvider | None (lazy, typed)
ctx.current_page() # -> CurrentPageContext | None (the "cpc" provider)
ctx.provider("deadlines") # -> raw dict, unknown providers welcome
Forward/back-compat is the whole point: a v2 client that adds a deadlines provider and extra fields inside surface still parses on a v1 consumer (the unknown provider is kept as a raw block; unknown block fields are ignored). A minimal v1 payload still parses on a newer consumer (absent providers return None, never an error). Both directions are tested.
2. AgentToolIntent — what the brain emits but never executes
The brain reasons and emits a typed intent; the named plane owns execution. params is a schema'd object, never a raw string or URL.
class AgentToolIntent:
name: str # registered tool name
plane: "client" | "server" # who executes
params: dict[str, Any] # typed per-tool params
Navigation is the litmus case. There is one generic navigate_to tool; its target is a durable surface_key + typed params, never a raw URL. The NavigateParams contract (and SurfaceEntry.surface_key) reject anything URL-shaped — a slash, a scheme, a colon, whitespace, or mixed case — so a navigation target can never smuggle a URL in through the key.
3. The per-turn endpoint — POST /app/agents/session-context/resolve
The trust boundary. The client builds an AgentSessionContext and submits it with the brain's proposed intents; the backend returns its authoritative view.
The decisions that matter
Client context is orientation; the server re-resolves authority
This is the security spine (RFC-APB-002 reviewer locks AGENT2 #1 / AGENT3 / AGENT4). The handler ignores the client's permissions block and re-derives the caller's real grants from the session:
granted = set(current_user.permissions) # session truth, not the client's block
reachable = [s for s in client_surfaces # the client's availableSurfaces is a hint;
if s.required_permission in granted # the server re-filters per user/firm/turn
or s.required_permission is None]
So a tab that claims settings:write in its permissions provider gets nothing: the settings-team surface is filtered out and any write intent is denied, because the real caller only holds matters:read.
Server-plane tools fail closed against a server-owned map
A server-plane intent is authorized only via SERVER_TOOL_PERMISSIONS — a map the client cannot extend. An unknown tool name (a forged clientTools entry) resolves to nothing and is denied; a known tool is allowed only if the real caller holds its permission.
| Intent | Plane | Backend decision |
|---|---|---|
navigate_to to a reachable surface | client | accepted |
navigate_to to a filtered-out surface | client | denied — not reachable this turn |
navigate_to with a raw URL in params | client | 422 at the boundary (NavigateParams) |
query_matters (needs matters:read) | server | accepted iff caller holds it |
propose_matter_write (needs matters:write) | server | denied for a read-only caller |
forged_admin_tool | server | denied — unknown server tool (fail closed) |
Where the shapes live, and why they are snake_case
The contracts live in src/shared/agent_contracts/session_context.py — the pure, dependency-free package (no sqlalchemy, no boto3, no settings) that pramaan-agents imports as the single source of truth (pramaan-docs ADR-110 §5). Like the rest of that package, the wire is snake_case (this is the agent rail, not the camelCase browser-DTO layer); the existing dev broker route already uses snake_case bodies, so the per-turn endpoint follows suit.
HR-18 boundary + ADR-124 observability
The request envelope is strict (extra="forbid") so junk fails loud at the boundary, while the nested AgentSessionContext stays open. The route declares response_model, canonical responses, a clean operation id (agentsResolveAgentSessionTurn), and ships regenerated OpenAPI snapshots. It is a read-only resolution that writes no business row, so it is classified UNAUDITED in shared/audit/coverage.py. Observability rides the T1 auto-instrumented FastAPI/Lambda span with a T2 agent.key enrichment via the shared set_observability_attributes helper — no hand-rolled manual span (a plumbing span here would be a PR reject; the parse is covered by the caller span).
Verification
Contract tests (tests/unit/agent_contracts/test_session_context.py) prove the provider-bag round-trips both ways (old↔new client), the tool-intent validates plane + typed params, navigation rejects raw/URL-like keys, and the frozen agent_invoke.v1 envelope still parses unchanged (back-compat). Endpoint tests (tests/unit/test_router_agent_session.py) drive the real router through a TestClient: the server returns the caller's real grants (not the client's), permission-filters surfaces, accepts reachable navigation and denies filtered targets, authorizes server tools against the server-owned map, fails closed on unknown tools, and rejects an unknown envelope field with 422. Each response is checked by a full-shape DTO round-trip.
One sentence to remember: context is an open versioned bag so adding a provider is one key, never a rewrite — and whatever the client sends is orientation, because the server always re-resolves who you are and what you may do.