# Server-side screen grid + clean repaint on attach

## Status

accepted (2026-06-30) · companion to `V0.19.0-P6-SCREEN-GRID-DESIGN.md` · relates to [ADR-0008](0008-live-activity-buffer-pty-digest.md) (render-vs-content split) and [ADR-0001](0001-clean-room-fork-from-claude-skill-owl.md) (clean-room provenance).

## Context

A cold attach streams the entire retained **raw-byte ring** as its initial batch (`become_controller`, broker.rs:402-416). For an alt-screen TUI (Claude Code) that ring is a mid-stream rendering protocol; replayed raw into a fresh terminal it corrupts the screen + scrollback (field bugs #6/#7/#8). We need a server-side render model that interprets the byte stream into an authoritative current screen and synthesizes one clean repaint at attach.

Two questions this ADR settles:
1. Does a server-side PTY-byte interpreter **contradict ADR-0008**, which retired the PTY-byte parser?
2. May we take a **third-party VT parser** dependency under ADR-0001 (clean-room everything except the sister project's stable wire/schema formats)?

## Decision

<!-- [doc->REQ-BROKER-SCREEN-GRID] the server-side render grid + clean repaint on attach:
     a clean-room `ScreenGrid` (vte::Perform) interprets the byte stream into an
     authoritative current screen; `become_controller`/`add_viewer` emit one synthesized
     repaint instead of the raw ring. Companion design: V0.19.0-P6-SCREEN-GRID-DESIGN.md. -->

**1. The render grid does NOT reverse ADR-0008.** ADR-0008's governing split (2026-06-12 amendment) is: **render surfaces read the PTY; content surfaces read the logs.** It retired the PTY-byte parser only for **content** — the digest, which now projects from normalized session logs — because extracting *semantic turn content* from a repaint-heavy byte stream is fragile and harness-specific. The screen grid is a **render** surface: it reconstructs the *visible screen*, not conversation content. It is the render-reads-PTY side ADR-0008 explicitly preserved, and it resolves the exact corruption ADR-0008 named ("alt-screen PTY = rendering protocol; repaints corrupt it"). The content parser stays retired. This ADR records the distinction so a future reader does not misread #6 as un-retiring the digest parser.

**2. Take a vetted VT-parser dependency (`vte`), clean-room the grid.** The byte→action layer is `vte` (the alacritty terminal parser state machine): small, single-purpose, battle-tested against real-world escape sequences. The `ScreenGrid` (cells, cursor, main/alt buffer, modes) implementing `vte::Perform` is clean-room. Rationale under ADR-0001: ADR-0001 defaults to clean-room, but its intent is to avoid copying the sister project's evolving logic and to keep provenance clean — a **universal, standardized, spec-fragile-at-the-edges VT state machine** is neither sister-derived nor a place where re-implementation adds value; a hand-rolled parser's edge-sequence bugs would produce precisely the rendering artifacts #6 exists to eliminate. The dependency is narrow (parsing only), auditable, and the screen semantics we own remain clean-room.

**Scope:** current screen only (viewport + cursor + alt/main). Scrollback stays the raw ring (history). The grid drives only the attach repaint. Wire format unchanged (`AttachRecord::Output`); only the initial-batch *content* changes. See the design doc for architecture, hazard integration, and the build plan.

**Amendment (2026-07-06, REMOTE-TRUTH E-2):** the repaint also carries the child's **last-set window title** (OSC 0/2, captured on the grid, cleared by RIS) — a cold attach previously painted a perfect screen under the attacher's stale title because `vte`'s default drops OSC.

**Amendment (2026-07-21, RC-RENDER-TRUTH — the resize GEOMETRY EPOCH):** a resize
transition is **atomic from the grid's perspective** — every byte is parsed at the
geometry it was **emitted** under.

<!-- [doc->REQ-RC-RESIZE-GEOMETRY-EPOCH] the resize barrier + geometry epoch on the
     dispatch_resize / set_size_and_notify path: hold the grid parse across the
     ordered surface+grid transition, tag held output with the geometry it was
     emitted under, replay it at that geometry, and roll back on a failed surface
     resize. Includes the SHRINK counter-example that rejects the reorder. -->

*What was wrong.* `dispatch_resize` resized the **surface** and the **grid** under two
separate `recover_log` acquisitions, so the drain interleaved between them by
construction. On ConPTY the resize itself emits a full repaint **asynchronously**; the
drain parsed that repaint into the grid at the **old** geometry, and the subsequent
top-left-preserving `grid.resize` then faithfully preserved an already-miswrapped
model. The cold-attach repaint re-emitted it: right-margin fragments, left-shifted
rows, and — at word scale — stale glyphs surviving inside nominal whitespace
(`isnsettled`, `fulllyacaptured`), because later differential output and `EL` address
the **correct** geometry and so never clear cells the mis-widthed parse contaminated.

*The fix that was REJECTED, recorded so it is not proposed again.* "Set the grid
geometry **before** invoking the surface resize" is **insufficient**. It is safe on a
**grow** and wrong on a **SHRINK**: bytes already in flight at the old **wider**
geometry are then parsed at the new narrow width and wrap early — the same class of
corruption, arrived at from the other side. Neither pure ordering is correct, because
the defect was never the **order**; it was that the grid had **no notion of the
geometry its input was emitted under**.

*The contract.* A resize now runs as a barrier with an explicit **geometry epoch**:

1. **close the barrier** — output keeps ringing and fanning out to controller and
   viewers at full speed (delivery is never gated, hazard 7.12 intact), but is held
   back from `grid.advance` and tagged with the geometry it was emitted under;
2. **settle the drain** (bounded) so the epoch split is a clean cut rather than a
   guess about bytes still in the OS pipe — the same quiescence *witness* the W3
   exit-waiter settles on, deliberately reused rather than adding a second
   quiescence authority over this surface;
3. **issue the surface resize** — output from here belongs to the target epoch;
4. **commit**: replay each held segment at its **own** geometry, resizing the grid
   between epochs, then bump the epoch and push the `size` frame;
   **or roll back** on a failed surface resize — grid geometry, stored size and epoch
   untouched, held output replayed at the old geometry, and no `size` frame (a refused
   resize must not tell the viewers the letterbox moved).

*Binding constraint on any future repair here:* the barrier must restore **one
geometry authority for glyph placement AND erasure**. Whitespace special-casing or an
extra clear would satisfy a blank-cell assertion and leave the defect in place.

**Amendment II (2026-07-21, RC-RENDER-TRUTH second reopen — the PRESENTATION
barrier):** the geometry-epoch barrier above shipped scoped to the **grid parse
only** — `OutputLog::append` deliberately kept ringing **and fanning out every chunk
to the controller and the viewers** during a transition (its own doc said so). That
scope is the defect: a **live attached terminal** receives old- and new-geometry
differentials across a viewport it has **already resized**, and renders exactly the
corruption the cold-attach fix was built to end. The field stayed red on a
fully-fixed node because the cold-attach repaint model is definitionally blind to
the live delivery path. Requirement-scope defect, not an implementation bug: the
gate held against the documented design.

<!-- [doc->REQ-RC-RESIZE-PRESENTATION-BARRIER] the presentation barrier over live
     delivery: during a resize transition the ring keeps recording but controller
     and viewer delivery is suppressed; commit lands the grid at the target
     geometry and pushes ONE synthesized sync frame (repaint ++ deferred non-grid
     bytes) to every attached sink under the same OutputLog serialization before
     raw fan-out resumes; the cursor-of-record advances past suppressed frames
     as-if-written; resume never raw-replays across a transition boundary. -->

*The contract.* During a transition, **presentation is barriered along with the
parse**:

1. **append still assigns `seq` and rings every chunk** — the ring stays the
   scrollback/resume authority for raw history — but the controller handoff and
   the viewer fan-out are **suppressed** (a skip, never a block or a sleep: the
   drain still runs at full PTY speed, hazard 7.12 intact). The suppressed window
   does not count toward the controller's Full-eviction deadline — no handoff was
   attempted.
2. **commit** replays the held segments into the grid at their emitting geometry
   (the Amendment-I machinery, unchanged), lands the grid at the target geometry,
   then — under the **same** `OutputLog` lock acquisition, before any raw frame
   can interleave — pushes the viewers their `size` frame **first** (the letterbox
   must move before content arrives at the new geometry) and then **one
   synthesized sync frame to every attached sink**, controller included:
   `render_repaint()` at the target geometry, concatenated with the **deferred
   non-grid bytes** (below). Mixed-geometry raw bytes are **never** replayed to a
   client.
3. the controller's sync frame carries a **watermark `seq` = the highest
   suppressed seq**, so the writer advances the cursor-of-record past the whole
   suppressed range on one successful write — suppressed frames are advanced past
   **as-if-written**, because the repaint supersedes them (the same shape the
   cold-attach `repaint_initial` watermark already uses).
4. **abort** (failed surface resize) takes the same uniform path minus the `size`
   frame: held bytes replay at the old geometry, sync frame at the old geometry,
   cursor advanced. One mechanism on both exits.
5. **resume never crosses a transition boundary raw**: the log records the
   presentation floor (the `seq` after the last commit/abort); a resume-from-floor
   asking below it is served the cold-attach shape (sync repaint + skip to live)
   instead of a raw replay of suppressed corruption. At-least-once is preserved in
   effect — the repaint supersedes the skipped range.

*The wire contract* (second-reopen build finding, doyle wire-flag ruling
2026-07-21): the sync frame's watermark `seq` is a FORWARD JUMP on the wire, and
every strict consumer — the `serve_attach` controller path keeps reject-gap
deliberately (exactly-once resume, B2) — would fatal it as an `output gap`,
re-fetch from its frozen floor, meet the below-floor repaint at the same jump,
and land in the irrecoverable-behind ladder: a marked truncation on every
resize under a live controller. The cold-attach repaint always carried the same
supersession assertion *implicitly* (the client infers it from its own
`from_seq == 0`); the sync frame moves it onto the wire where it belongs:
`OutputEvent` gains an additive `sync: bool` (serde-default `false`, never
serialized when false — the `resume_seq` additive-field shape), set by the
broker on the commit AND abort sync frames and on every `repaint_initial`
batch. A consumer accepts a **flagged forward jump** and baselines on it, on
both dedup paths — B2-sound, not a B2 exception: the supersession is explicit
and broker-authored, nothing is silently skipped. **Forward-only**: a flagged
frame at or below the cursor dedup-drops exactly as today (a backward baseline
would re-accept replayed frames). An **unflagged** jump keeps the strict
reject-gap byte-for-byte; `baseline_next_output` stays untouched as the
old-broker × new-client cold-attach compat path. N-1 matrix, published: new
broker × old client — a resize during the mixed-version window ends the attach
with a marked truncation (honest termination where pre-fix was silent
corruption; the release notes carry "update CLI and broker together"); old
broker × new client — no flag ever arrives, every strict path byte-identical.
Rejected shapes, recorded so none is re-proposed: unconditional
baseline-on-jump (kills B2); empty-frame delivery through the suppressed window
(contradicts the suppression contract above and fills the very queues the skip
protects); rebasing post-transition seqs (breaks log ordering and every resume
floor); cold-reattach-on-second-gap (silently re-rules
REQ-HAZARD-CONTROLLER-IRRECOVERABLE-BEHIND, which stands as-is).

*Non-grid byte disposition* (every class the sync frame would otherwise silently
drop, ruled explicitly): sequences the grid **tracks** (title, alt screen, cursor
visibility, DECSTBM, pen, cursor) are superseded by the repaint by construction.
Stateful sequences the parser currently **ignores** (bracketed paste, the mouse
family, focus reporting, DECCKM, DECAWM, keypad, DECSCUSR, OSC color set/reset,
charset designation), one-shot events (BEL, OSC 52 clipboard, OSC 9/777
notifications) and child→client **queries** (DA1/DA2, DECRQM, XTWINOPS, OSC 10/11
`?`) are **deferred verbatim in emission order** and flushed inside the sync frame —
a dropped mode toggle is an indefinite client/child divergence and a dropped query
can hang a waiting child, while a deferred one is bounded-window latency. Cell-scoped
decorations the grid model does not carry (OSC 8 hyperlinks, DCS graphics) are
**dropped as a balanced class** — their content is superseded by the repaint, and an
unbalanced deferred hyperlink open would decorate unrelated post-repaint text; this
matches the repaint's existing fidelity boundary (a cold attach already renders
without them). DSR is in **no** class here: the drain strips and answers it before
`append` (REQ-DSR-SINGLE-CPR); mid-transition its CPR reports the pre-transition
cursor — an accepted bounded residual. **Classification runs in the one parser
authority** — the `ScreenGrid` `vte::Perform` impl in a capture mode during held-byte
replay, re-encoding unconsumed sequences from the callback parameters. Never a
second scanner over raw bytes: two parsers are two truths about sequence boundaries.

*What this closes as a side effect:* the Amendment-I accepted residual — a cold
attach during a transition renders the pre-transition screen with **no visibility
bound** on a quiet child — now ends at commit for every attached sink, because the
commit-time sync frame *is* the seeded "push a fresh repaint to transition-era
attachers" follow-up. The attach itself still renders the pre-transition screen
(the refusal to put attach latency behind the settle cap stands); the stale window
is now bounded by the transition, not by the child's next output.

*Binding constraints carried forward:* one geometry authority for placement AND
erasure — no whitespace special-casing, no extra clears; a fix that passes by
clearing harder fails the gate. The regression must drive the **real live
controller writer** and judge the client-side stream against an independent
terminal authority at the client's target geometry — the cold-attach oracle is
insufficient by construction.

## Consequences

- New first-party dependency `vte`; folded into the release Cargo.lock first-party-name discipline (a version pin + audit at cut).
- The grid is the **OS-neutral render-replay layer** CONTEXT §terminal anticipated (forkpty raw-pipe vs ConPTY repaint-on-resize both feed one grid); it slots behind `SessionSurface`/`OutputStream`.
- `become_controller`/`add_viewer` emit a synthesized clean repaint as the initial batch; live frames after attach are unchanged raw bytes. Cross-node attach (#4) and any client are agnostic.
- Must preserve the attach/serve hazard invariants (REQ-HAZARD-INJECT-CONTROL-COEXIST 7.12, CONTROLLER-WRITER-REORDER 7.21, VIEWER-ISOLATION 7.7 + skip-to-live, the wedge family) — the grid is on the output path only, touches neither the input/effect journal nor the QUIC deadlines.
- Unlocks the follow-up sticky ID-marker redo (#8/#14 marker-half) as a grid-backed per-frame overlay (separate REQ).
- Composes with #12 (REQ-RC-WIN-VT-OUTPUT): the repaint is ANSI bytes; on Win10 conhost it renders only once VT-output-processing is enabled client-side (#12).
