PR: #557: [Story-2170] Wave B app sender — ACP stamps surface_context on invokes by @ankitmaheshwari-com
View PR →

Story-2170: Wave B app sender — the ACP stamps where counsel is standing

A matter's pinned ContextBundle is matter-wide. But counsel is on one page, reading one document, and their question is about that document. Wave B closes that gap: the app tells the broker which surface counsel is viewing, so Mira can say Counsel is viewing: Order_S.17.pdf and the broker can float that document's cards, chunks and assertions to the front of the same pinned bundle.

pramaan-functions already shipped the receiving half. This story is the sender.

The load-bearing discovery: this contract is not in src/gen, and it never will be

The obvious first move — grep surface_context openapi/ src/gen/ — returns nothing, which reads like "the mirror is stale, run pnpm codegen". That is a trap. The field rides a WebSocket message model (router_ws.WebSocketClientMessage), not an HTTP DTO, so make openapi produced no snapshot diff — exactly as pramaan-functions #600 reported. There is nothing to codegen and no mirror to sync.

The brief also credits fn#600 (Story-2152). #600 added SurfaceContext, AgentInvoke.surface_context and the document-first bundle scoping. The WS boundary field the app actually fills arrived in fn#612 (Story-2171). Both are on main, and — checked, not assumed — both are inside the deployed artifacts.

Two casings meet on this wire, and both are load-bearing

LayerModelCasingUnknown key behaviour
Outer envelope WebSocketClientMessage(ApiRequestModel) camelCase → surfaceContext extra="forbid" — rejects the whole launch
Inner block SurfaceContext(_OpenBlock) snake_case → surface_key, params extra="ignore" — silently drops it

Both failure modes are silent, and one is severe. A mis-cased outer key is rejected outright — and per Story-2181 / Bug-2210 a rejected launch renders as endless "thinking". A mis-cased inner key is simply ignored, so document-first scoping quietly never happens. Neither surfaces an error. Hence a test that pins the literal wire keys, not just the happy path.

Deployment ordering, verified before writing a line. Because the envelope is extra="forbid", shipping surfaceContext against a broker that predates fn#612 would have rejected every ACP launch — a total agent outage presenting as infinite thinking. A green deploy is not proof (LI-7). Both lambda zips were downloaded and grepped: pramaan-ws-handler-staging and pramaan-app-api each contain surface_context in router_ws.py. Safe to send.

The matterId trap — Bug-2210, one layer down

The CPC store hands us the live route params, so the lazy move is to snake_case them all: documentId → document_id, matterId → matter_id. That would have been a bug, and a silent one.

The [matterId] route segment is the per-firm matter number (e.g. 13), not the matter UUID the brain keys on. The app's own generated contract says so outright:

deep_link contract v1: /matters/{matter_number}/record/doc/{document_id}
  … the shipped matter.record.doc permalink route, whose [matterId]
    segment is the per-firm matter_number.
                                     — src/gen/zod/citeResolutionSchema.ts

This is precisely the shape of Bug-2210 ("key the Mira rail on the matter UUID, not the route param"). And the broker coerces with UUID(str(raw)), degrading to None on junk — so a wrong id would not error, it would just stop working. So the map is explicit and narrow:

const CONTRACT_PARAMS = { documentId: "document_id" };  // matterId deliberately absent

[documentId] is the document UUID (same contract line), so it maps cleanly. The broker already receives the real matter UUID on the launch envelope; it does not need ours.

One seam, not three

Three rails send launches — the wizard intake rail, the Atlas dock-chat rail, and Mira's converse/command rails. All three inject only a builder into the same agent-agnostic AgentRailConnection. So the stamp goes at the single launch() send site rather than into each builder:

const cpc = useCpcStore.getState();
socket.send(JSON.stringify(
  stampSurfaceContext(this.buildLaunch({ … }), {
    surfaceKey: cpc.active?.key ?? null,
    params: cpc.params,
  }),
));

Lighting up the Counsel Control Bar

Story-2168 shipped the CCB's Explain and What this means for us verbs deliberately dimmed, with a comment: present-but-dimmed ("wired next") until the Wave B app sender lands. This is that sender, so the verbs go live and the wiredNext badge is deleted rather than left to lie.

The CCB lives inside the document reading surface; Mira's rail lives in the layout-mounted Agent Dock. They never see each other's React tree — but they share the module-scoped rail registry, so useAskMira() reaches the same live connection the Dock already primed, by matter UUID. No prop drilling, no second socket, no duplicated chat state.

The selected passage rides as the quoted anchor of a counsel turn (clamped to 1,200 chars — the document is already in Mira's bundle, so the quote is an anchor, not the payload). The surface stamp attaches automatically at the launch seam; the CCB never knows about it.

useAskMira() returns null off a matter shell, and the verb then renders dimmed rather than as a button that silently does nothing — the Story-2181 discipline: a dead affordance never dresses up as a live one.

What ships

FileRole
src/lib/agents/surface-context.tsnew — pure builder + stamper; owns both casings and the matterId exclusion
src/lib/agents/atlas-rail-store.tsstamps at the one launch() send site (all three rails)
src/lib/agents/use-ask-mira.tsnew — CCB → Mira ACP seam; nullable when no matter context
document-counsel-bar.tsxExplain / What-this-means go live; wiredNext badge removed
document-reading-surface.tsxbuilds the quoted prompt from the live selection
messages/{en,hi}.jsonexplainPrompt / impactPrompt; wiredNext dropped from both locales

Verification

Unit tests prove the shape we intended. They cannot prove the deployed Pydantic model accepts it. So the exact message the sender now emits was validated against the real broker models (WebSocketClientMessage, AgentCommand, _surface_document_id):

1. camelCase `surfaceContext` ACCEPTED by extra='forbid' envelope; inner snake_case parsed.
2. Broker `_surface_document_id` extracts the document UUID -> doc-first scoping arms.
3. Non-document surface -> no document_id -> bundle unchanged.
4. Absent surfaceContext -> None (fail-open, unchanged).
5. `query` + surface_context REJECTED -> confirms we must stamp launches only.
6. A mis-cased outer key is REJECTED outright (extra='forbid') -> casing is load-bearing.
7. All 40 app surface keys accepted by the broker's surface_key validator.

Check 7 matters because a surface_key failing ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ would 400 the launch; all 40 registry keys pass.

Local: pnpm lint (0 errors), tsc --noEmit clean, pnpm test — 1082 passed / 175 files, pnpm build clean, pnpm check:routes — registry fresh (40 surfaces).

Outstanding — the live run. The issue's first acceptance box asks for broker-log evidence from a real Ask Mira on the Order_S.17 page on localapp. That has not been performed in this change; it needs an authenticated browser session against a seeded matter. Per this repo's own doctrine — "done" requires a real end-to-end run against real data … not a green Storybook or a passing fixture test — this story is not claimed complete on that box. Everything upstream of the browser is proven: the wire shape validates against the deployed models, and the deployed lambdas contain the receiving field.

Files changed

10 file(s), +454 / -28 lines

Path Role + -
messages/en.jsonConfig+3-2
messages/hi.jsonConfig+3-2
src/components/documents/viewer/document-counsel-bar.stories.tsxComponent+17-3
src/components/documents/viewer/document-counsel-bar.tsxComponent+36-14
src/components/documents/viewer/document-reading-surface.tsxComponent+26-1
src/lib/agents/__tests__/atlas-rail-store.test.tsLibrary+93-0
src/lib/agents/atlas-rail-store.tsLibrary+18-6
src/lib/agents/surface-context.test.tsLibrary+100-0
src/lib/agents/surface-context.tsLibrary+94-0
src/lib/agents/use-ask-mira.tsLibrary+64-0