Story 1739: Atlas Answer — dock_chat server-plane tool routing + query_matters client_id

Why this exists (the catastrophe it prevents). Open the Atlas Dock on the Matters page and ask "how many matters for the Codex screenshot client?" Today Atlas answers "I cannot see the data on this page." It can orient (name the page, offer to navigate) but it cannot answer a data question — even though the firm's matters are sitting in Postgres behind an RLS-scoped read tool that already exists (Story-1651's query_matters). The gap (DEF-4, EPIC-1646 UAT blocker) is that the dock_chat brain has no channel to call those server-plane read tools: it was shipped Orient-and-Navigate only. The tempting shortcut — let the brain read firm data directly — is the catastrophe Story-1651 already refused: the brain would become a data-control authority and a forged client tab could decide what runs. This Story is Architecture B, part 1 of 2: the server-plane contract (Ankit, 2026-06-25). It makes the broker/functions side ready so the brain (pramaan-agents, part 2) can wire the answer path without ever becoming the authority.

Scope: pramaan-functions only (pramaan-docs ADR-031, one repo per child). This Story does not change the brain. Part 2 (pramaan-agents) gives the dock_chat turn a server-tool-intent output channel, makes the turn multi-step, and replaces the refusal prompt — consuming the contract this Story ships.

What this ships

1. The granted server-tool catalog — the brain's menu, server-resolved

The brain cannot ask for a tool it does not know it has. So the broker stamps the caller's granted READ-tool catalog into the dock_chat turn it sends the brain. The catalog is derived from the caller's REAL permissions, not the client's orientation blocks. A pure helper does the filtering:

granted_server_tool_catalog(granted)   # pure, no DB:
  -> [ ServerToolCatalogEntry(name, kind="read", required_permission, description) ]
  for every READ tool in SERVER_TOOL_REGISTRY whose required_permission in granted

# matters:read caller -> [query_matters, global_search]
# no matters:read     -> []   (fail-closed: no server data-read tools)

It lands on the wire as a new field on the DockChatContext contract (agent_invoke.v1), so part 2 has a typed, snapshot-tracked surface to read:

class DockChatContext(ContractModel):
    ...
    granted_server_tools: list[ServerToolCatalogEntry] = []   # NEW

The broker stamps it; the client never does. The clerk worker (the broker, "stamps everything the brain echoes") resolves the caller's role → permissions and overwrites any client-sent value before invoking the brain:

# src/clerk_worker/runner.py  (dock_chat turns only; one bounded resolution per run)
granted = await deps.resolve_granted_permissions(firm_id, user_id)
dock_chat["granted_server_tools"] = granted_server_tool_catalog(granted)

The resolver is an injected seam (like resolve_live_connection): the default returns the empty set so the broker loop stays unit-testable without a DB, and the Lambda entrypoint (handler.py) injects the real one — a membership-role lookup (pinned to the run's firm so it can never read another firm's row) through the identity.role_permissions matrix.

2. Routing dock_chat through the EXISTING executor loop

Nothing new is built for execution — that would re-introduce the catastrophe. The answer round-trip rides Story-1651's path verbatim: the brain emits a plane="server" intent for a catalog tool and calls POST /app/agents/tools/execute, which re-resolves authority from the caller's session, runs the read under RLS, and returns the result. The four guardrails (re-resolve authority → leash + fail-closed → read executes / write proposes → audit every call) are unchanged and now exercised by a dock_chat-shaped read.

3. query_matters gains a client_id filter

"Matters for client X" previously relied on free-text q matching the client name — brittle. The filter is now first-class, added once on MatterListQuery and threaded through the one canonical service query so both the agent tool and the Matters page inherit it:

# MatterListQuery:           client_id: UUID | None = None
# MatterService.list_matters: if query.client_id: filters.append(Matter.client_id == client_id)
# handlers.py:                forward "client_id" onto the typed query (extras still forbidden)
# router_matters.py:          ?clientId=<uuid> query param

It is RLS-scoped by construction: the firm_id predicate (DR-8 floor) sits beside it, so a client_id from another firm matches zero rows. It shares the same WHERE across the count and page statements, so the query count stays bounded (DR-1) — no per-row lookup is introduced. Unknown brain params still fail closed: MatterListQuery forbids extras, so a junk key raises a typed validation error rather than silently widening the read.

The decisions that matter

The catalog is orientation; the executor is the guardrail

Stamping a tool into the catalog does not grant it. Authority is re-resolved on every /execute call, so a stale or forged catalog entry buys nothing: a caller who lost a permission between catalog build and tool call is still denied at execution (RFC-APB-002 AGENT2 #1). The catalog only saves the brain from proposing a tool it could never run — and degrades safely: an empty catalog means the brain sees no data-read tools and falls back to navigate-only.

Callergranted_server_tools in the invoke
holds matters:readquery_matters, global_search
no matters:read (or no resolver wired)[] — navigate-only, fail-closed
client sends a forged forged_admin_tooloverwritten — never trusted

Reuse the executor, never rebuild it

The execute boundary, the leash, the audit row, and the RLS read handler all pre-date this Story. The only net-new server surfaces are a typed catalog field, a pure catalog builder, a broker stamping step, and one query filter. Keeping the execution plane singular is the whole point of Architecture B.

ADR-124 observability

No new manual spans. The catalog build is a pure in-memory helper covered by the broker's existing AGENT_INVOKE_BUILD span; the read still rides the /execute route's T1 auto-instrumented span with the same low-cardinality T2 enrichment. No firm data enters a trace.

Verification

Pure unit tests prove the catalog lists only granted READ tools (never write/proposal tools), is empty without matters:read, and carries the description hint (tests/unit/agent_tools/test_registry.py); that the DockChatContext field defaults empty and round-trips (tests/unit/agent_contracts/test_dock_chat.py); and that the broker stamps the server-resolved catalog into the dock_chat envelope, overwriting a forged client value, and emits an empty catalog when no resolver is wired (tests/unit/clerk_worker/test_runner.py). An integration test drives the real /app/agents/tools/execute route against a real DB through the full ASGI pipeline: under a firm RLS session it seeds two clients with matters, asserts query_matters filtered by client_id returns exactly that client's count (never the other's), and exercises the zero-rows first run — a client with no matters returns an empty, well-formed list, the case a fixture would hide (tests/integration/test_app_agent_tools.py). OpenAPI mirrors are regenerated (openapi/app-api.json: ServerToolCatalogEntry, granted_server_tools, the clientId matters-list param).

One sentence to remember: the broker hands the brain a server-resolved menu of read tools and a sharper query_matters, but the executor still re-checks who you are on every call — the brain can finally answer "how many matters for this client" without ever becoming the authority.