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.
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:
- enqueues to
pramaan-clerk-runs(not the dead agents-runs queue); - is consent-gated at the edge (F4: a dock_chat turn never egresses without active inference consent — fail-closed, terminal-failed truth + a consent-required frame);
- gets a v1
run.acceptedframe so the app shows in-flight on the durable rail; - survives a browser disconnect (the abandon-on-disconnect sweep now excludes
atlas_dock_chattoo, so reconnect-refetch can replay it — ADR-110 §4.1).
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
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.| Launch | Rail | Backend outcome |
|---|---|---|
kind=dock_chat, atlas_dock_chat, consent on | clerk | enqueued + run.accepted; worker builds a dock_chat agent_invoke.v1 and invokes AgentCore |
kind=dock_chat, consent absent/revoked | clerk | blocked 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-chat | clerk / legacy | byte-unchanged — existing behavior preserved |
Verification
ruff checkclean; mypy clean on the changed files (pre-existing unrelated noise only).- Targeted + full unit suite green (the two failures are DB-backed firm-support tests needing a live test DB, unrelated to this change).
make openapi— no drift (the WS/SQS envelopes are not OpenAPI surfaces).- Migration 0126 proven against a real Postgres:
downgrade base → upgrade headroundtrip clean,alembic checkreports no drift, and the live CHECK definitions + positive/negative INSERTs confirm the constraint behavior above. - ADR-124: the dock_chat path adds branches inside already-instrumented boundaries (AGENT_QUEUE_ENQUEUE at the edge, AGENT_INVOKE_BUILD/CALL + AGENT_QUEUE_PROCESS_RECORD in the worker) — no new uninstrumented executable boundary.
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.