Atlas learns to point at a component, not paint the rows

On a matters query the Dock brain can now choose a render component — it emits a selection, never the data.
Story-1796 · RFD-AGT-004 §6/§7.6 (D12) · branch issue-1796-agents-render-selection · 2026-06-27

Since Story-1741 the Dock brain can answer a data question from an authorized read. This Story adds a sixth job: on a "show my matters as a table" ask the brain can answer with a rich app component instead of prose. It does this by emitting a RenderSelection — {component_id, query_ref, view_fields} — that names a catalog component and the read whose result to render, and nothing else. The broker authors the RenderDataFrame (functions#1793): it re-executes the bound read under the caller's RLS (B3 re-auth at bind) and stamps the rows. This is a NEW render intent sibling to the existing answer / navigate / set_view / propose_operation channels — not a rewrite.

One boundary defines the whole Story: the brain emits a SELECTION ONLY — no rows, no counts, no SQL. The brain sees only a selection; it cannot vouch for authority, so it never carries data. Capability-gating is re-used, not re-implemented (RFD-AGT-002 / pramaan-docs ADR-1703): a component is offered only when the surface declares it AND the role permits it. An unknown / unpermitted / undeclared component deterministically falls back to the existing text answer — no model retry, never a blank or invalid selection.
Contract custody (no ad-hoc mirror): the canonical render-selection shape lives in the shared contract package agent_contracts.render.RenderSelection — the agents-repo mirror of the functions#1793 contract, alongside agent_invoke / agent_event_stream / enums. The brain ships as a self-contained AgentCore CodeZip and cannot import the repo root at runtime, so it restates the shape locally and a parity test fences the restatement against the canonical field shape — the same custody discipline runtime_axes uses for the enums, so the brain copy can't drift from #1793.

The render turn shape

// counsel on the matters page: "show my open matters as a table"
//   surface declares component "matters_table"; caller holds matters:read
{ next_message_md: "Here's your open matters as a table.",
  render_selection: { component_id:"matters_table",
                      query_ref:"open_matters",        // the read to render — opaque ref
                      view_fields:["title","status"] },   // row fields to surface (no rows!)
  is_terminal: true }              // → broker binds the RenderDataFrame, renders to the app

What to review

AreaWhereWhat it does
Shared contract custodyagent_contracts/render.py newThe canonical RenderSelection for the agents repo — the mirror of the functions#1793 contract (component_id/query_ref non-empty, view_fields list, extra="forbid"), exported from agent_contracts alongside the invoke / event-stream / enum contracts.
Brain restatementagents/atlas/models.pyRenderSelection restated for the self-contained CodeZip (cannot import the repo root at runtime), fenced by a parity test against the canonical shape. AtlasDockOutput gains a flattened render_component_id/render_query_ref/render_view_fields channel; AtlasDockTurn gains render_selection (terminal).
The render-component catalogagents/atlas/dock_render.py newThe brain's catalog — ONE component today (matters_table, matters:read), a flat definition not a registry (YAGNI until a second lands). available_render_component_ids is the capability gate: offered only when the surface declares it (current_context['render_components']) AND the role permits it. view_fields are NOT brain-validated — the broker projects them server-side (functions#1793 _project).
The Dock brainagents/atlas/dock_chat.py_assemble_render_turn builds the selection (blank query_ref → deterministic text fallback); render_component_id joins the one-action-per-turn guard; the output-validator _dock_render_offered DETERMINISTICALLY strips an un-offered component (a simple allow-set membership check) and keeps the text answer — no ModelRetry. Deps carry the offered-component allow-set.
The Dock promptagents/atlas/prompts/dock_chat.pySixth job: SELECT a component, give a query_ref, never emit rows/counts/SQL. Renders AVAILABLE RENDER COMPONENTS; empty ⇒ answer in text.
Runtime wiringagents/atlas/runtime.py_dock_turn_event puts render_selection on the agent_event_stream.v1 wire (typed dict, SELECTION ONLY) so the broker can bind it.
Closed capability universeagents/atlas/command_vocabulary.py · evals/surface_operator_*render is registered as the 6th IntentClass and a SurfaceFamily, with en/hi/Hinglish phrasings + success/refusal twins (cross-firm render is the red-team adversary). It now inherits the §8 refusal matrix, 100% refusal coverage, bilingual parity, and red-team independence like every other intent — no ungated escape hatch.
Evals + offline gatesagents/atlas/evals/dock_*.py · tests/test_dock_*.pyLive cases: valid render on a matters query, text fallback on an unsupported component, permission refusal. A render-fallback gate fails any non-render case that emits a selection. A parity test imports the canonical contract and fails on any field-shape drift. Offline + FunctionModel wiring tests run in CI without a key.

The capability gate (re-used, not re-invented)

Two conditions, both enforced in code:

How to verify

# 1. offline gates + the real PromptedOutput wiring (no key — runs in CI)
cd agents/atlas && pytest tests/test_dock_chat.py tests/test_dock_evals.py -q   # passed

# 2. repo gate — full atlas suite + lint
cd agents/atlas && pytest tests -q && ruff check .                            # 507 passed, clean

# 3. golden Dock-evals LIVE against the real model (needs OPENROUTER_API_KEY)
cd agents/atlas && python -m evals.dock_run

The FunctionModel tests prove the chain without an API key: the flat DTO is validated, the output-function assembles a render-selection turn (terminal, no rows), an invented component deterministically falls back to the text answer (no failure, no retry), the parity test fences the brain's RenderSelection against the canonical contract, and the selection rides the wire as a typed dict with no data key. The LIVE Dock-evals (DeepSeek v3.2 via OpenRouter) assert the render / text-fallback / permission-refusal behaviours against the real model where the key is injected.

Out of scope (separate tickets): the broker's render-frame emission wiring (functions), the app render-substrate (#1794, merged), and the component .tsx (003). This Story is the brain side only — it emits the selection.