# V0.19.0 P6 — Server-side screen grid (clean repaint on attach)

**Bug:** field-bug-milestone #6 (REQ-BROKER-SCREEN-GRID). Fixes the PTY-rendering cluster: #6 (scrollback corrupt on rc to a running CC TUI), #7 (residual artifacts as the TUI animates), #8 (resize artifacts). Prerequisite for the follow-up sticky ID-marker redo (#8/#14 marker-half).

**Status:** DESIGN (doyle, 2026-06-30). Operator-ratified decisions folded in. Companion: ADR-0031.

---

## Problem

A cold `spt rc` / attach subscribes with `from_seq = 0` (rc.rs:1109, dispatch.rs:350). `become_controller` (broker.rs:402-416) builds the initial batch = the **entire retained raw-byte ring** (`ring.iter().filter(seq >= from_seq)`) and streams it into the operator's fresh terminal. For a modern alt-screen TUI (Claude Code) that ring is a **rendering protocol mid-stream** — alt-screen enter/exit, absolute cursor moves, repaints, SGR runs, a torn oldest chunk. Dumped raw into a fresh terminal it produces: corrupt scrollback (#6), stray cells (#7), resize debris (#8). This is exactly the failure ADR-0008 named when it moved *content* extraction off the PTY: "the PTY byte stream of an alt-screen TUI is a rendering protocol, not a content log... repaints corrupt [it]."

The raw ring is correct for *history* (byte-exact record) but wrong as an *attach repaint*. We need a server-side **render model** that interprets the byte stream into an authoritative current-screen and synthesizes ONE clean repaint at attach.

---

## Decisions (ratified)

1. **Scope = current screen only.** The grid tracks the visible viewport (cell buffer + cursor + main/alt buffer + relevant modes), NOT scrollback. Scrollback stays the raw ring for history. The grid drives **only** the attach repaint. Scroll-through-session-history-as-a-feature is explicitly out of scope (and not what #6 reports — #6 is replay corruption, not a missing scroll feature).
2. **Engine = `vte` crate + clean-room grid.** Use `vte` (alacritty's byte→action parser state machine — small, battle-tested on real-world escape sequences) for the byte→`Perform` action layer. Clean-room a thin `ScreenGrid { cells, cursor, alt/main, modes }` implementing `vte::Perform`. Per ADR-0001 the grid is clean-room; the VT *parser* is the one vetted dep — a universal, non-harness-specific, spec-fragile-at-the-edges state machine is exactly where a vetted parser earns its place (edge-sequence bugs are precisely the artifacts #6 exists to kill). Recorded in ADR-0031.
3. **ADR-0008 reconciliation (companion ADR-0031).** ADR-0008's governing split is "**render** surfaces read the PTY / **content** surfaces read the logs." It retired the PTY-byte parser ONLY for content (the digest). #6 is a **render** grid — the render-reads-PTY side ADR-0008 explicitly preserves. #6 *completes* that side: render-reads-PTY needs interpretation to give a clean repaint. The content parser stays retired (the digest still projects from normalized logs). Not a reversal.
4. **No wire change.** The attach wire stays `AttachRecord::Output { seq, data_b64 }`. Only the **content of the initial batch** changes — a synthesized clean-screen repaint instead of the raw ring. Live frames after attach still stream raw bytes unchanged (the client's own terminal applies them). Cross-node attach (#4) and any client are agnostic — they render whatever bytes arrive.

---

## Architecture

**Placement — broker-side, alongside the PTY + ring.** The grid lives per session next to `OutputLog` (broker.rs:213). It is fed the same bytes `OutputLog::append` receives, on the session drain path.

**Feed (continuous state, off the hot lock).** As the drain thread hands a chunk to `append`, it also feeds the chunk to the session's `ScreenGrid` (vte advance). The grid update must NOT run under the `Mutex<OutputLog>` lock in a way that adds latency to fan-out (REQ-HAZARD-INJECT-CONTROL-COEXIST 7.12). Two acceptable shapes (todlando's impl choice, whichever keeps append lock-clean):
  - (a) grid owned by the drain thread (single-writer, no shared lock) — the drain advances the grid, then `append`s to the ring; a snapshot is taken under a short separate lock only when a repaint is requested; or
  - (b) grid behind its own lock, advanced after the ring append, never held across a fan-out `try_send`.
The grid is **single-writer** (the drain thread) by construction — mirrors the existing single-producer ring.

**Repaint synthesis at attach.** `become_controller` (broker.rs:402) and `add_viewer` (broker.rs:459), instead of streaming the raw ring as the initial batch, request `grid.render_repaint()` → a synthesized byte sequence that paints the current screen cleanly into a fresh terminal:
  - reset/clear (`ESC[!p` soft-reset or `ESC[2J` + home as appropriate), main/alt-buffer selection matching current state, SGR reset, then the current cell buffer row-by-row with minimal SGR runs, then cursor to its live position and mode (DECTCEM etc.).
  - assigned the **current `next_seq - 1`** (or a repaint pseudo-seq) so the render-cursor dedup (rc.rs:1333) and `delivered_through` (broker.rs:265) stay monotonic and the post-repaint live frames continue in-order.
After the repaint, the controller/viewer receives live frames from the ring floor forward (skip-to-live semantics for viewers unchanged).

**Alt-screen correctness (the #6 core).** If the session is currently in the alt-screen (a full-screen TUI like CC), the repaint paints the alt-screen current state and does NOT dump main-screen scrollback — killing the "raw history above the TUI" corruption. If in the main screen, the repaint paints the visible main screen; scrollback is the client's own terminal history, not our raw-ring dump.

**OS-neutrality (CONTEXT L483).** The grid is the OS-neutral replay layer the raw byte stream cannot be: it consumes the `OutputStream` bytes uniformly whether the backend is forkpty (raw-ordered pipe) or ConPTY (repaint-on-resize). It slots behind the `SessionSurface`/`OutputStream` abstraction (spt-term/surface.rs — designed OS-neutral for exactly this). No forkpty/ConPTY semantics leak into the grid.

---

## Build-time refinement (todlando, counter-38 W8 — for doyle's gate)

**Repaint is scoped to a COLD attach (`from_seq == 0`); a resume-from-floor (`from_seq > 0`) keeps the raw-ring re-fetch.** The build surfaced a concrete interaction the design's one-line CONTROLLER-GAP-RESUME note (below) left implicit: the repaint's collapsed pseudo-seq (`broker.next_seq - 1`) is a *forward jump* from a controller's requested `from_seq`, and the controller's brain-side dedup is a **strict reject-gap** (exactly-once resume, B2) that FATALS a forward jump — so a naive "repaint on every attach" wedged a cold attach to a pre-populated ring (`loopback_attach_to_a_prepopulated_ring`). Resolution, kept deliberately conservative so it does **not** rewire the exactly-once resume machinery:

- **Cold attach (`from_seq == 0`)** — the `spt rc` / `endpoint run --attach` case #6 targets: the broker emits the clean-screen **repaint**, and the serving brain **baselines** its strict cursor on that first frame (`Brain::baseline_next_output`, set by `attach`/`attach_as` iff `from_seq == 0`), then reverts to strict reject-gap for every subsequent LIVE frame. The repaint is the complete current screen, so nothing prior is owed; live exactly-once (a b4 drop still re-fetches) is preserved.
- **Resume-from-floor (`from_seq > 0`)** — a controller recovering a b4 drop-don't-block gap: **unchanged**. Raw-ring replay from the floor + strict reject-gap + `ControllerIrrecoverablyBehind` detection are byte-for-byte as before. The "re-repaint on mid-session resume" idea in the CONTROLLER-GAP-RESUME note is therefore **NOT** taken this pass (it would obsolete the irrecoverable-behind path); it remains a possible future enhancement.

Net: #6 changes ONLY the cold-attach initial batch (its documented scope); the controller resume/exactly-once hazard machinery is untouched. Viewers use the existing snap-above path, which accepts the repaint's forward jump natively.

## Hazard checklist (must-not-rebreak — from the seam map §4)

- **REQ-HAZARD-INJECT-CONTROL-COEXIST (7.12):** repaint bytes delivered OFF the drain thread via the existing controller/viewer writer threads; the grid advance + `render_repaint` snapshot must NOT block the fan-out under the log lock.
- **REQ-HAZARD-CONTROLLER-WRITER-REORDER (7.21):** exactly one live `controller_writer`, ascending seq; the repaint pseudo-seq must preserve monotonicity + epoch-gating (`controller_epoch` broker.rs:233).
- **REQ-HAZARD-VIEWER-ISOLATION (7.7) + REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT:** a viewer repaint is still isolated + evict-not-park; an evicted viewer re-subscribes from the current floor with a fresh repaint, rate-limited (RESUBSCRIBE_INTERVAL).
- **REQ-HAZARD-CONTROLLER-GAP-RESUME:** a controller re-fetch from `delivered_through` still works — a mid-session resume can re-issue a repaint from current grid state (better than the old ring re-fetch when the ring rolled).
- **REQ-HAZARD-ATTACH-WEDGE (7.11) / EFFECT-JOURNAL-PTY-WEDGE (7.14):** the grid is on the OUTPUT path only; it touches neither the input/effect journal nor the QUIC op deadlines. No new `block_on`.
- **Bounded memory:** the grid is a fixed viewport (rows×cols), bounded by definition; resize reallocates. No unbounded growth (unlike deferred scrollback spillover).

---

## Scope boundaries

- **IN:** the server-side current-screen grid + `render_repaint` replacing the raw-ring initial batch at `become_controller`/`add_viewer`. Fixes #6, #7, #8-artifacts.
- **OUT (follow-up):** the sticky ID-marker redo (#8/#14 marker-half) — the grid makes a proper per-frame sticky top-row overlay possible; re-enabling the marker (currently flagged OFF, W2 #14) as a grid-backed sticky overlay is a separate follow-up REQ, not this design.
- **OUT (separate):** #12 (Win10 rc VT-output enable) — client-side console mode, dispatched separately (REQ-RC-WIN-VT-OUTPUT). Note: #12's fix is a PREREQ for the repaint to render on Win10 conhost (the repaint is still ANSI bytes; without VT-output-processing they'd print literally). The two compose.
- **OUT:** scrollback-in-grid / on-disk scrollback spillover (CONTEXT L488-489, deferred).

---

## Build plan

- Activate **REQ-BROKER-SCREEN-GRID** — `doc, impl, unit, int`.
- **Units:** vte→grid application (print/CSI/SGR/cursor/alt-screen enter-exit → cell/cursor state); `render_repaint` produces a clean paint for (a) a main-screen state, (b) an alt-screen TUI state, (c) after a resize; repaint pseudo-seq monotonicity; grid single-writer / no-lock-across-fanout.
- **Int:** a broker session driven with a scripted alt-screen byte sequence (enter alt-screen, paint, scroll, partial repaint) → a fresh attach receives a CLEAN current-screen repaint (assert no raw alt-screen-enter / no torn-chunk debris in the initial batch), then live frames continue in-order. Reuse the dummy-harness/mock-adapter fixture pattern.
- **[twohost]:** NOT needed — this is single-host attach-render (the grid is server-side, the wire is unchanged; #4 already owns the cross-node int). A single-host int suffices.
- **Docs:** ADR-0031 (companion: vte dep rationale + ADR-0008 render-vs-content reconciliation); a note in CONTEXT.md §terminal (the grid is the OS-neutral render-replay layer) if warranted; no public CLI surface change.
- **Gate:** fresh-target clippy --workspace --all-targets + nextest (grid units + the alt-screen int) + traceable; the artifact bugs are inherently hard to unit-assert byte-exact — the int asserts *absence* of the known corruption markers in the initial batch.

---

## Open impl questions for todlando (design-level answered; impl-level his call)

1. Grid feed shape (a) drain-owns-grid vs (b) grid-behind-own-lock — pick whichever keeps `append` fan-out lock-clean.
2. `render_repaint` minimality (full-screen repaint vs diff) — full clean repaint is fine for v1 (attach is infrequent); a diff optimization is deferred.
3. vte version pin + a clippy/deny check on the new dep (first-party lock discipline at release).
