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.

This is a contract + boundary ticket for pramaan-functions only. It defines the shapes (shared, pure, importable by pramaan-agents) and the per-turn trust-boundary endpoint that re-resolves authority. It does not wire the WebSocket rail, the brain, or the app client — those are sibling children of the Agentic Interaction Epic (#1646).

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.

IntentPlaneBackend decision
navigate_to to a reachable surfaceclientaccepted
navigate_to to a filtered-out surfaceclientdenied — not reachable this turn
navigate_to with a raw URL in paramsclient422 at the boundary (NavigateParams)
query_matters (needs matters:read)serveraccepted iff caller holds it
propose_matter_write (needs matters:write)serverdenied for a read-only caller
forged_admin_toolserverdenied — 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.