Story 2895: HTTP launch beyond mira-converse — all four app rail builders

T1a (Story-2840) shipped POST /app/agents/{agent_key} with a static mira-converse mapping only: router_agents.py rejected every key except "mira", and MiraAgentLaunchRequest required matterId + message with a hardcoded kind="converse". Three of the four app rail builders — wizard intake, dock chat, mira command — had no HTTP launch path, so T1b (Story-2842, app rail rewire) could not migrate without breaking live surfaces. This Story extends the adapter so all four builders launch over HTTP, keeping the frozen broker contract clean: server-derived firm/user (RFD-AGT-013 D4), a static key mapping (no generic registry), per-builder request DTOs with explicit validation, and unknown keys still rejected with the typed 404.

The four rails

RouteRequest DTOEnqueued envelope
POST /app/agents/atlas_intake_review AtlasIntakeReviewLaunchRequest: formId + structured input (AtlasIntakeInput) + optional history/message. No matterId — no matter exists at intake time. agent_key="atlas_intake_review", kind="intake", form_id + input, no matter/chat session.
POST /app/agents/atlas_dock_chat AtlasDockChatLaunchRequest: the dockChat context envelope (DockChatContext — surface key + counsel message + history; snake_case inner block, the same shape the app sends on the WS rail). agent_key="atlas_dock_chat", kind="dock_chat", dock_chat context verbatim, no matter/form/input.
POST /app/agents/mira (converse) MiraAgentLaunchRequest, intent="converse" (default) — today's shape, byte-compatible: the four pre-existing integration cases stay green. agent_key="mira_matter_chat", kind="converse" — unchanged.
POST /app/agents/mira (command) MiraAgentLaunchRequest, intent="command" + command + scope. No message, no frame. agent_key="mira_matter_chat", kind="mira_command", matter + resolved chat session, input={matter_id, scope, command}.

The mira command decision: WIRED, not refused

The WS broker rejects the command route even today (pramaan.websocket.mira_command_unsupported, router_ws.py). The HTTP endpoint deliberately does not inherit that refusal — the command route is wired end to end:

What changed, by file

SurfaceChange
src/app_api/router_agents.py Four static routes (mira, intake, dock, typed-404 catch-all) sharing one launch tail: firm context, socket attach, prepare_launch (run-id idempotency, input ceiling, rate limit, circuit breaker, consent, blocked-run truth), clerk-rail enqueue.
src/app_api/agent_launch_dtos.py Shared base (requestId/socketId/surfaceContext) + per-builder DTOs; Mira DTO gains the converse/command intent matrix; AgentLaunchResponse.chatSessionId becomes nullable (intake and dock have no chat session).
src/app_api/agent_launch_core.py attach_launch_socket — the Story-2896 attach seam as one helper every route shares.
src/shared/agent_commands.py mira_command launch validator branch (additive).
src/clerk_worker/runner.py _load_matter_frame (shared by both stampers), _stamp_command_input, and the intent/agent_run_id routing pair on the command invoke.
openapi/app-api.json Regenerated: three concrete paths added, catch-all retained.

Guards preserved (same as the WS launch path)

Every rail rides the shared prepare_launch: run-id idempotency (agents.agent_run_key), input ceiling, per-firm/user rate limit, circuit breaker, egress consent, and blocked-run truth writes. Identity/firm/matter are server-derived only — no firm_id/user_id in any request body.

Proof (tests)

LayerCoverage
Integration (tests/integration/test_app_agents_launch.py) Per rail: intake happy path + deduped relaunch; dock happy path + deduped relaunch + consent-blocked terminal truth (the atlas_dock_chat scope-CHECK path); mira command happy path (envelope shape, no client frame, default session resolved) + deduped relaunch + cross-firm session refusal; unknown key → typed 404. The four pre-existing mira-converse cases stay green unchanged.
Unit (tests/unit/test_router_agents.py, test_agent_launch_core.py, test_agent_commands.py) Per-route envelope mapping; intent-matrix 422s; the attach_launch_socket seam (explicit vs derived vs none); the AgentCommand mira_command matrix.
Worker unit (tests/unit/clerk_worker/test_runner_mira_command.py) The command invoke carries intent="command" + agent_run_id; the broker stamps the matter's latest frame + base version into the input and the landing receives the same stamped input; empty-first-run degrades to the well-formed empty frame.

What is not changing