Story 1660: Broker dock_chat routing (Lane B)

Why this exists (the catastrophe it prevents). The Agent Dock lets counsel ask "what is this page about?" on any workspace surface. Chore-1663 already minted the wire shape for that turn — a kind discriminator (intake | dock_chat) and a DockChatContext surface payload — and the WebSocket boundary already parses both. But the broker between the socket and the brain was deaf to it: clerk_worker/runner.py hardcoded mode:"chat" and always shoved normalize_atlas_input(command["input"]) into the envelope. A dock_chat launch has no wizard input, so that call would crash; and even if it didn't, the brain would receive an intake envelope and run the wizard behavior. The dock turn had a contract and a door, but no hallway. This Story builds the hallway: the broker now routes on kind and forwards the surface context, so a dock_chat launch reaches AgentCore as a real dock_chat invocation.

This is the broker rail for pramaan-functions only (Lane B of EPIC-1646). The Atlas dock_chat behavior is a sibling child in pramaan-agents (#1664); the app WS streaming is #1665. The AgentCore invoke + stream already work — the wizard intake rail proves them. This is the routing seam, not a new transport. A staging-smokeable producer path exists the moment this lands: the broker accepts a dock_chat launch and invokes AgentCore even before the brain's new behavior deploys (the runtime can echo/no-op on kind="dock_chat" until #1664 ships).

The shape of the change

app (sends kind=dock_chat + dockChat ctx)
   |
   v
WebSocketClientMessage  --- already parses kind/dock_chat (Chore-1663)
   |   NEW: forward kind + dock_chat into the queue envelope
   v
AgentCommand (SQS body)  --- NEW: kind + dock_chat fields + launch matrix
   |   NEW: _uses_clerk_rail() routes atlas_dock_chat onto the clerk rail
   v
pramaan-clerk-runs queue --- (the single AgentCore-egress rail)
   |
   v
clerk_worker/runner._invoke_brain
   |   NEW: build agent_invoke.v1 from command["kind"]
   |        intake   -> normalize_atlas_input(input)   (byte-unchanged)
   |        dock_chat-> carry dock_chat ctx, no input
   v
AgentCore (pramaanAgents_atlas)  --- runtime branches on kind

What this ships

1. The envelope carries the discriminator — src/shared/agent_commands.py

The WebSocket boundary already understood kind/dock_chat, but AgentCommand — the JSON that actually travels over SQS to the worker — did not, so both fields were silently dropped before enqueue. They are now first-class envelope fields, and the launch matrix gained a dock_chat branch:

launch + kind="dock_chat":
  agent_key MUST be "atlas_dock_chat"   # pins routing + brain behavior
  no form_id/input                      # no wizard draft
  no matter_id/chat_session_id          # surface-scoped, not matter-scoped
launch + agent_key="atlas_dock_chat" + kind!="dock_chat":  rejected
non-launch command carrying dock_chat:                     rejected

The pairing rule (kind='dock_chat' ⇔ a dock_chat context present) reuses the same validate_kind_dock_chat_pairing helper that agent_invoke.v1 and the WS boundary use, so a malformed launch is rejected identically at all three layers, not just once it reaches the brain. kind defaults to intake and dock_chat is dropped from the serialized body when absent, so every existing intake/matter-chat/lifecycle envelope is byte-unchanged on the wire.

2. Routing on the rail — src/app_api/router_ws.py

The clerk rail is the single AgentCore-egress rail. _uses_clerk_rail now returns true for both atlas_intake_review and atlas_dock_chat; matter-chat keys stay on the legacy agents-runs queue (its own consumer). Because routing, the inference-egress consent gate, and the run.accepted ack all key off this one predicate, a dock_chat launch automatically:

3. Building the envelope from kind — src/clerk_worker/runner.py

The hardcoded mode:"chat"=intake assumption is gone. _invoke_brain reads the launch kind and branches:

kind = command.get("kind") or "intake"   # legacy in-flight commands default
intake   -> input = normalize_atlas_input(command["input"])   # Bug-1319 coercion, byte-unchanged
dock_chat-> dock_chat = command["dock_chat"]; no input         # normalize would crash on None

The runtime branches on kind to pick the behavior. Everything else the worker guarantees is untouched and still wraps the dock_chat path: the in-stream cost cap, the terminal-frame stall watchdog (Bug-1345), the atomic durable run row, the defensive consent re-check (the worker is the single AgentCore caller, so its consent gate is the hard ZDR/residency default-deny floor — ADR-116 D3), and the ADR-124 trace carrier injected across the SQS hop into the AgentCore handoff span.

The decisions that matter

Route on an explicit, paired key — not magic derivation

The app sends both kind="dock_chat" and agentKey="atlas_dock_chat", and the matrix enforces the pairing. This keeps agent_key the single thing routing, span-naming, the ack, and disconnect already key off — no new "derive the key from the kind" branch to drift — and it matches the agent_invoke.v1 contract test exactly (envelope agent_key=atlas_dock_chat).

Surface context is orientation; the brain re-resolves authority

The DockChatContext rides verbatim to the brain, but its available_surfaces/granted_permissions/current_context blocks are orientation only (RFC-APB-002). The broker stamps real identity into meta from the verified session; the client blocks never become a grant.

Proving it at the real boundary

A real system-boundary bug this caught (twice). Admitting a new agent_key is not a code-only change — agents.agent_run.agent_key carries a hand-written CHECK (agent_key IN (...)) constraint (plus mirrors on chat_session and agent_approval_request). A fake-session unit test would pass while the clerk worker's claim INSERT violated the CHECK on the first real dock_chat run. Worse: agent_run_scope_present (0092) demands every run be matter+chat OR form scoped — and a dock_chat run is surface-scoped, carrying none of those. Both were found only by running the migration against a real Postgres and attempting the actual INSERT. Migration 0126 widens all four constraints: the three agent_key sets gain atlas_dock_chat, and agent_run_scope_present admits a run anchored only by agent_key='atlas_dock_chat'. Verified live: the scopeless dock_chat INSERT now passes both CHECKs (failing only on a fabricated firm FK), a bogus key is still rejected by agent_run_agent_key_valid, and a scopeless matter-chat row is still rejected by agent_run_scope_present (regression intact). This is the "done = system boundary, not component boundary" rule: a green pytest was necessary, not sufficient.
LaunchRailBackend outcome
kind=dock_chat, atlas_dock_chat, consent onclerkenqueued + run.accepted; worker builds a dock_chat agent_invoke.v1 and invokes AgentCore
kind=dock_chat, consent absent/revokedclerkblocked at edge — terminal-failed truth + consent-required frame, nothing egresses (F4)
kind=dock_chat with no dockChat context—422 — WS boundary matrix rejects it
kind=intake (wizard) / matter-chatclerk / legacybyte-unchanged — existing behavior preserved

Verification

Cross-repo close gate (ADR-110, not in this PR): a real dock_chat turn on staging showing a NEW service.name=atlas AgentCore invocation + a streamed answer requires the brain behavior (#1664) and app streaming (#1665). This PR delivers the broker producer path that makes that turn reachable.