Story 1799: a missed render frame is re-bound on reconnect, never replayed stale
Why this exists (the catastrophe it prevents). Story 1797 made the broker push a render.data frame on the dock_chat turn. But the browser churns its WebSocket mid-run roughly 15% of the time (remount → reconnect on a new connection id), and a frame pushed during the dead window is lost. The terminal frames already survive this — the reconnect-refetch path (_replay_run_truth, pramaan-docs ADR-110 §4.1) replays turn.completed / run.done from persisted truth — but render.data was NOT replayed, so the answer text came back on reconnect with its data card permanently blank. The naive fix is the dangerous one: persist the render rows and replay them. In a legal product that is a stale-data leak — if the firm revoked the user's access, or the matter moved firms, between the original bind and the reconnect, the replayed rows would show data the user is no longer entitled to. The render design is reference-only by construction (the brain emits a SELECTION, never rows); the durable fix must honor that. So on replay the broker does not replay a frame — it RE-BINDS the selection, re-running the read under RLS re-resolved at replay time, and redelivers a frame that is fresh by construction.
bind_render_frame / executor / audit path (#1793) and the reconnect-refetch replay (Bug-1372). No app changes (the client rehydrate re-run is a separate app ticket), no REST route, no app-api.json change, no new auth model.
The decisions that matter
1. Persist the SELECTION reference, never the rows
To re-bind on replay the broker needs to map the selection's query_ref back to the read intent it re-runs — but the round-trip's reads_by_ref map lives only in memory and is gone by reconnect. The fix persists it as a reference, not data: when the terminal step_completed event is appended, its jsonb payload now also carries render_reads (the query_ref → {tool, params} intents the brain may render) and the surface_key. These are the read SELECTION, the same shape the brain echoes — no row data is ever persisted. Because step_completed is an already-allowed event type, this is a payload-only change: no migration, no new event type, no CHECK-constraint edit.
2. Re-bind under RLS re-resolved AT replay time
The live emit and the replay now share one author primitive, _author_and_push_render. It re-resolves the caller's authority on every call — granted permissions (the executor leash) and firm role (record-level RLS visibility) — then re-runs the read through bind_render_frame. So a frame authored at replay reflects the caller's current access, not the data as it stood at the original bind. On reconnect (_replay_render_frame) the broker pulls the persisted turn, reads the flat render_* selection back off it with the same extract_render_selection, resolves the intent from the persisted render_reads, and re-binds — ordered after turn.completed, before run.done, exactly as the live rail.
| On reconnect the persisted turn carries… | broker does… | frame redelivered |
|---|---|---|
| no render selection | nothing (no-op) | — (text answer only) |
| a selection, access UNCHANGED, firm-scoped | re-runs the read under fresh RLS, authors data | render.data, state=ready |
| a selection whose access was REVOKED since bind | fresh RLS hides the row → B3 denies + audits | render.data, state=denied, empty data |
| a binding that failed to persist / unparseable | fail closed: unresolvable ref → denied + audited | render.data, state=denied, empty data |
3. render.data is in the durable-delivery set
_FramePusher._MUST_DELIVER already includes render.data (added with Story 1797): a churned/reconnected live socket re-resolves on 410 Gone rather than dropping the frame as a best-effort delta. The replay path added here is the second half — the case where no live socket existed at all during the dead window, so the client refetches and the broker re-binds. Together they close the durability gap from both sides: live re-target on Gone, and re-bind on explicit reconnect-refetch.
4. No new auth model, no manual span, no migration
The replay 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 on the committing path. The work 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 (render.py doctrine, pramaan-docs ADR-124). It is bounded (DR-1): one bind and one authority resolution, and only when the persisted turn actually carried a render selection.
5. What was deliberately NOT built
No persisted render rows (reference-only by design — the whole point). No app rehydrate re-run (separate app ticket). No new event type or migration (folded into the existing step_completed jsonb). No REST route or OpenAPI surface. Multi-render-per-turn stays out, unchanged from v1.
Verification
Two integration tests drive the real RLS database through the full worker. The first simulates a missed live push: the launch socket 410-Gone's every frame (the render frame never reaches a socket), then a query reconnect on a new connection re-binds and redelivers the render.data frame — asserted state='ready', firm-scoped (firm A's matter present, a sibling firm B matter never on the wire), and ordered between turn.completed and run.done. The second proves RLS-freshness: the launch binds a single-entity render as firm_admin (visible → ready), then the reconnect runs after a role DOWNGRADE to a non-admin with no assignment to that matter — record-level RLS (migration 0077) hides it, so the replay re-bind returns state='denied' with empty data (the stale row is never redelivered) and the denial is durably audited, keyed by the rendered id. The Story 1797 emission tests, the dock_chat round-trip, and the runner unit suite stay green; no OpenAPI drift — this Story ships no route.
One sentence to remember: on reconnect the broker re-binds the persisted SELECTION under RLS re-resolved at replay time and redelivers a fresh frame — so a missed render survives the churn, and access revoked since the original bind comes back denied, never as a stale row.