Story 1797: the broker emits the render frame on the turn it already owns

Why this exists (the catastrophe it prevents). Story 1793 built the render primitive — bind_render_frame authors RLS-scoped data and re-checks the rendered entity id at bind — but nothing called it. A primitive with no caller is a door welded shut: the brain can select a component, the app can consume a render frame (#1794), but the two never meet because the producer glue in the middle is missing. Story 1797 is that glue, and the trap it must avoid is the obvious one: do NOT open a new REST route to ship render data. Render is an answer about this turn; the broker already streams that turn to the browser. A new route would mean a second auth surface, a second OpenAPI contract, and a second place for a cross-firm leak to hide. Instead the frame rides the rail the broker already enforces.

Producer glue only (RFD-AGT-004 §6 D12/D14). Reuses the merged bind_render_frame (#1793) and the dock_chat round-trip loop (Bug-1758). No app render changes (#1794), no brain selection logic (agents ticket), no new component. This is a broker→app PUSH on the existing turn — no REST route, no app-api.json change.

The decisions that matter

1. query_ref is the join, and the broker owns both ends of it

A render selection names query_ref — an opaque ref to the read whose RLS-scoped result is rendered. For the broker to author that data it must re-run the right read, so it needs to map query_ref → read_intent. The broker owns both ends: as it executes each server read in the round-trip (_execute_tool_intents), it stamps a deterministic query_ref (query_ref_for_read — a SHA-256 of tool + params, no wall clock / randomness so a redriven run recomputes the same ref) onto the DockToolResult it feeds back, and records reads_by_ref[query_ref] = intent. The brain echoes that ref verbatim on its render selection; the broker resolves it back to the intent and re-runs it under RLS. The brain never supplies the read params, so it can never widen a read past what it originally asked for.

2. The selection is flattened, never a nested object

Per the flat-DTO doctrine (DeepSeek drops deep shapes — pramaan-agents Bug-1357), the brain emits scalars on the terminal turn: render_component_id, render_query_ref, render_view_fields (a list or a comma-delimited string), and an optional render_resource_id for a single-entity card. extract_render_selection reads those off the turn into a RenderSelection — selection only, never row data. Absent component/query_ref → no render (the turn is a plain text answer).

2b. Producer failures fail CLOSED — explicit denied frame, never a silent widen or drop

Two ways the broker can fail to honor a selection, and in a legal product both must be visible and fail closed, never a quiet fallback: (a) a malformed render_resource_id — a single-entity card requested with an unusable id — must NOT silently widen into a whole-list render (the caller asked for ONE row; widening over-exposes); (b) an unresolvable query_ref — the brain named a read the broker never ran this turn — must NOT be silently dropped. Both emit an explicit render.data frame with state='denied' and empty data (the app already renders the honest "no longer available" state, #1794) and both are durably audited via emit_render_denied_frame — the same agent_session.executed / decision=denied trail as the B3 / plane-guard denials inside bind_render_frame.

3. The frame rides the existing rail, between turn.completed and run.done

On a terminal dock_chat turn, after pushing turn.completed, the broker calls _emit_render_frame: it resolves the read by query_ref, opens one RLS-scoped session (the same _set_scope the round-trip uses), calls bind_render_frame, and pushes the result as a render.data frame — before the next event (run.done). So the app sees the thread item, then the frame that binds to it, then the run settles. The frame type is a WS rail frame on the agent_event stream, not a mounted route, so make openapi shows no drift.

The terminal turn carries…broker does…frame pushed
no render fieldsnothing (no-op)— (plain text answer)
a selection, query_ref resolvable, firm-scopedre-runs the read under RLS, authors datarender.data, state=ready
a selection naming a CROSS-FIRM render_resource_idB3 denies at bind, audits decision=deniedrender.data, state=denied, empty data
a query_ref the broker never ran this runfail closed: denies + audits (no bind)render.data, state=denied, empty data
a malformed single-entity render_resource_idfail closed: denies + audits (no widen to list)render.data, state=denied, empty data

4. No new auth model, no manual span

The bind reuses execute_server_tool_intent — authority re-resolved from the verified session, the read writes its own agent_session.executed audit row, and the B3 denial is durably audited. _emit_render_frame runs inside the worker's SQS CONSUMER span; the bind's read + audit ride the executor's auto-instrumented DB spans. No manual span is opened (a plumbing span here is a PR reject — render.py doctrine, pramaan-docs ADR-124). The work is bounded (DR-1): one bind and one authority resolution, and only when a render is actually present.

5. What was deliberately NOT built

No REST route, no OpenAPI surface, no new auth path — the frame is a push on the turn the broker already enforces. No app reduceFrame / component (#1794). No brain emission logic (agents ticket). Multi-render-per-turn is out: v1 is one render item per turn, and render_resource_id parsing fails safe to a list render rather than growing a tenancy gate the RLS read already provides.

Verification

An integration test drives the real RLS database through the full worker: a non-terminal turn emits a query_matters intent (the broker executes it under firm A's RLS and stamps the deterministic query_ref), then a terminal turn carries the flattened render selection echoing that ref. The broker binds and pushes a render.data frame, asserted firm-scoped — firm A's matter is authored, a sibling firm B matter is never in the data — with renderMarker.state == 'ready', the view_fields projection narrowing rows to the asked columns plus id, and the frame landing between turn.completed and run.done on the rail.

A second test renders a CROSS-FIRM render_resource_id under firm A: the frame comes back state='denied' with empty data (firm B's row never on the wire), and the render_bind denial audit row is asserted to persist in a fresh session after the run's transaction committed — the denial is durable, not lost to a rollback. The dock_chat round-trip, agent_tools, and runner unit suites stay green; no OpenAPI drift — this Story ships no route.

One sentence to remember: the brain points at a read with query_ref on the turn the broker already streams, and the broker re-runs that read under RLS and pushes the authored frame on the same rail — so render data reaches the client without a new route, a new auth surface, or a brain-authored row.