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.
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.
| Layer | Model | Casing | Unknown 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.
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.
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.
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,
}),
));
query/cancel/resume
go out through a separate query() method and stay clean.
AgentCommand treats surface_context as a launch-only field and
rejects it on non-launch commands — so this separation is not stylistic, it is
required. There is a test for it.
memory_pack and
context_bundle already behave.
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.
| File | Role |
|---|---|
src/lib/agents/surface-context.ts | new — pure builder + stamper; owns both casings and the matterId exclusion |
src/lib/agents/atlas-rail-store.ts | stamps at the one launch() send site (all three rails) |
src/lib/agents/use-ask-mira.ts | new — CCB → Mira ACP seam; nullable when no matter context |
document-counsel-bar.tsx | Explain / What-this-means go live; wiredNext badge removed |
document-reading-surface.tsx | builds the quoted prompt from the live selection |
messages/{en,hi}.json | explainPrompt / impactPrompt; wiredNext dropped from both locales |
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).
"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.
10 file(s), +454 / -28 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +3 | -2 |
| messages/hi.json | Config | +3 | -2 |
| src/components/documents/viewer/document-counsel-bar.stories.tsx | Component | +17 | -3 |
| src/components/documents/viewer/document-counsel-bar.tsx | Component | +36 | -14 |
| src/components/documents/viewer/document-reading-surface.tsx | Component | +26 | -1 |
| src/lib/agents/__tests__/atlas-rail-store.test.ts | Library | +93 | -0 |
| src/lib/agents/atlas-rail-store.ts | Library | +18 | -6 |
| src/lib/agents/surface-context.test.ts | Library | +100 | -0 |
| src/lib/agents/surface-context.ts | Library | +94 | -0 |
| src/lib/agents/use-ask-mira.ts | Library | +64 | -0 |