Story 1793: the broker authors the render data, and re-checks RLS at bind
Why this exists (the catastrophe it prevents). Atlas wants to render a firm's data into a UI component — a matters table, a client card. The naive design lets the brain put the rows on the wire: "render these matters", with the rows attached. That is a tenant-isolation hole the size of a truck. The brain is an LLM; its row payload is untrusted, can be stale, and — if a prompt is poisoned or a session confused — can name a matter from another firm. Trusting brain-emitted rows means a cross-firm leak is one hallucination away. Story 1793 closes that door two ways: the brain emits selection only (no rows ever), and the broker re-resolves the rendered entity id under the caller's RLS at bind time (B3) before a single row is rendered.
reduceFrame/components/.tsx), no new component types, no new wire kinds beyond the render-data frame. Builds on Story 1783's surface-generic broker read substrate.
The decisions that matter
1. The brain contract carries selection, never data
RenderSelection is exactly {component_id, query_ref, view_fields} — the component to bind, an opaque ref to the read whose result is rendered, and which fields to show. It is an ApiRequestModel (extra="forbid"), so a smuggled data field fails validation at the boundary: there is no structural path for a brain-authored row to ride the selection onto the wire. The broker authors the data; the brain only points at it.
2. The broker re-runs the read through the existing executor — no new auth model
bind_render_frame does not invent an authorization path. It calls the same execute_server_tool_intent that every server-plane tool uses (Story 1651): the read (query_matters / query_clients) runs under the caller's RLS session, the caller's authority is re-resolved from the verified session (never client hints), and the read writes its own agent_session.executed audit row. The render frame is a thin author-step on top of an RLS-scoped result — not a parallel data path. A non-executed outcome (a denied read, or a write-proposal tool) is never rendered.
3. B3 — the rendered entity id is re-resolved under RLS at bind
When a render targets a single entity (resource_id supplied), the broker checks that id is present in the freshly-read, RLS-scoped result. Because the read ran under the caller's firm RLS, a cross-firm/unauthorized/deleted id is simply absent from the result — so the broker denies the render, audits the denial, and never returns the row. RLS is re-resolved here on every bind; it is not a guarantee the brain (which sees only a selection) could ever vouch for.
The denial must NOT raise. The whole request runs inside one session.begin() transaction (AppDbDep); an exception out of the handler rolls the transaction back — and the just-written denial audit row with it. So the deny path returns a frame with empty data and render_marker.state == 'denied' instead of raising. The audit row then COMMITS with the request and is durable. This mirrors the executor, whose own denials return allowed=False rather than raising.
3b. Broker-INTERNAL primitive — cross-lane wiring deferred
This module ships the frame-authoring + re-auth primitive (bind_render_frame), not a transport. The render frame is a broker→app PUSH on the dock_chat turn, NOT a REST route — and the turn-emission wiring is a cross-lane (broker↔app rail) integration that needs the app's frame-consume shape, owned by the render-substrate ticket. That wiring will call this function. So there is deliberately no mounted route and no OpenAPI change; a ponytail: note on bind_render_frame records the deferral.
| Render references… | present in caller's RLS read? | outcome |
|---|---|---|
| a matter/client in the caller's firm | yes | rendered (data narrowed to that bound row) |
| a cross-firm / unauthorized id | no | denied at bind, audited decision=denied, row never returned |
| no specific id (a list render) | — | the whole RLS-scoped result is rendered — cross-firm rows can't appear, the read ran under RLS |
4. The render frame carries provenance, not just rows
RenderDataFrame = {component_id, query_ref, data, render_marker}. data is the RLS-scoped rows projected to view_fields (always keeping id for identity). render_marker is the integrity/provenance stamp: {render_catalog_version, schema_version, state, locale, row_count, row_id_digest, query_ref, ts} — a SHA-256 digest over the rendered row ids lets the app detect drift without the broker shipping the ids twice, and state is ready/empty. The frame is a broker→app push payload, not a mounted route DTO — so there is no OpenAPI snapshot change.
5. What was deliberately NOT built
No app-side reduceFrame, no component registry, no .tsx — that is the app render-substrate ticket. The row-list extraction takes the first top-level list in a read result (both registered reads have exactly one: matters / items); a future read with two top-level lists passes an explicit key rather than guessing. No speculative framework for "all surfaces" — the binder rides READ_HANDLERS, which Story 1783 already made surface-generic.
Verification
An integration test drives the real RLS database through the full reused executor loop, not a fixture. Two firms are seeded, each with a matter. Bound under firm A's session, the render frame carries firm A's matter only — firm B's is never in the broker-authored data (RLS scoping). A render referencing firm B's matter id returns a denied frame (empty data, state=denied); firm A's own matter id binds to exactly that one row. The render_marker is asserted present and populated (versions, row_count, non-empty digest, echoed query_ref, state), and view_fields projection keeps only the asked fields plus id.
The B3 denial is bound inside a committing transaction (the same one-session.begin() shape a real request uses), and the denial audit row is then asserted to persist in a fresh session AFTER that transaction committed — proving the denial is durable and not lost to a rollback-on-raise (the bug the return-not-raise fix closes). A pure test proves the brain contract rejects a smuggled data payload at the boundary. The agent_tools and audit-route-coverage suites stay green; no OpenAPI drift — this Story ships no route.
One sentence to remember: the brain points at data with a selection, the broker authors the rows from an RLS-scoped read, and a cross-firm id is denied at bind — so no brain-authored or cross-tenant row can ever reach the wire.