{
  "summary": "Root cause is twofold, with one additional deterministic state-desynchronization seam. (1) The broker/rc transport does not filter spaces: live PTY chunks are forwarded byte-for-byte. Cold attach intentionally omits trailing default spaces and wholly blank rows in `ScreenGrid::render_repaint`, but only after emitting `CSI 2 J`, so that omission is semantically correct on a VT-capable terminal. (2) Final PTY output is not ordered before `Exit`: the drain queues output through the controller/viewer writer, while the independent exit waiter writes `KIND_EXIT` directly to the same physical connection. `Exit` can overtake queued final output, and both `serve_attach` and the rc pump terminate immediately on it. Thus a final `EL`, spaces, `?25h`, `?1049l`, or other teardown sequence can be stranded. The repository already acknowledges this race in `crates/spt-daemon/tests/broker.rs:376-403`. (3) rc has no unconditional terminal-mode teardown. With the status row disabled, every final path—detach, child exit, displacement, 30-second reconnect give-up, first-event stall, and fatal error—drops raw/input modes and prints prose, but emits no SGR reset, cursor-show, scroll-region reset, or alternate-screen leave. A child that exits or disconnects without completing its own teardown therefore leaves the operator terminal dirty. Separately, a cold repaint tracks but does not replay DECSTBM scroll margins, so subsequent live raw output can be interpreted against a different scroll region than the server grid. Picker rendering does not share the PTY/grid path: it uses ratatui’s local previous-buffer diff. Its stale-glyph cause is distinct—`cmd_endpoint_purge` writes to stderr while ratatui still owns the alternate screen, invalidating ratatui’s previous-buffer model; the next diff legitimately omits unchanged blank cells. The only commonality is mutation of the same physical terminal outside the renderer’s state model.",
  "files": [
    {
      "path": "crates/spt-term/src/reader.rs",
      "description": "`Drain::spawn` at lines 118-161 reads up to 8192 bytes, scans DSR without removing it, calls `sink(chunk)`, and counts exactly `n`; there is no whitespace/ANSI filtering. This is the PTY-byte origin of the broker path."
    },
    {
      "path": "crates/spt-daemon/src/broker.rs",
      "description": "`OutputLog::append` at lines 903-968 clones the exact chunk into the ring and `output_envelope`, fans it to controller/viewers, then advances `ScreenGrid`. `repaint_initial` at 821-825 substitutes one synthesized repaint only for cold `from_seq == 0`. `become_controller` at 1016-1064 creates the initial batch/writer. `controller_writer` at 1675-1778 serializes queued output. The exit waiter at 3757-3792 independently calls `all_sinks()` and directly `sink.write(&KIND_EXIT)` before removing the session; that direct write is not FIFO-ordered behind the controller/viewer output queues and does not wait for drain EOF. `BrokerConn` cleanup in `handle_conn` at 3465-3500 detaches session subscriptions and FINs/terminal-retires ConnectionBound rc streams."
    },
    {
      "path": "crates/spt-daemon/tests/broker.rs",
      "description": "Lines 376-403 explicitly document and work around the production ordering fact: `EXIT notification ... can arrive BEFORE the OUTPUT frame`; the test continues draining after Exit. The rc production pump does not. This is direct repository evidence for the final-tail loss race and a natural regression-test location."
    },
    {
      "path": "crates/spt-term/src/screen.rs",
      "description": "`Cell::is_default_blank` at 141-146 marks only default-pen spaces trimmable. `erase_display`/`erase_line`/`erase_chars` at 347-416 retain erased cells in the model. `render_repaint` at 577-623 emits matching main/alt selection, optional title, `CSI 0 m`, `CSI 2 J`, home, then uses `rposition` and `continue` at 597-599 to omit trailing default blanks and blank rows. Because ED2 precedes it, those omissions are correct for cold repaint; non-default-background blank cells are emitted. However `scroll_top`/`scroll_bottom` are tracked and DECSTBM is parsed at 790-802, but `render_repaint` never emits `CSI <top>;<bottom> r`, leaving client and grid scroll behavior divergent after attach."
    },
    {
      "path": "crates/spt-daemon/src/msg.rs",
      "description": "`OutputEvent` at 548-558 stores arbitrary PTY bytes as base64. `encode_bytes`, `decode_bytes`, and `output_envelope` at 1164-1184 are byte-preserving; no trim, UTF-8 conversion, or whitespace classification occurs."
    },
    {
      "path": "crates/spt-daemon/src/brain.rs",
      "description": "`Brain::read_event_until` at 686-755 decodes `OutputEvent` bytes unchanged and applies only sequence dedup/gap rules. `attach`/`attach_as` at 958-1008 arm the cold-repaint pseudo-sequence baseline. No character-level filter exists here."
    },
    {
      "path": "crates/spt-daemon/src/attach.rs",
      "description": "`serve_attach` forwards `BrokerEvent::Output` unchanged via base64 at 600-609, but on broker `Exit` at 610-617 immediately sends `AttachRecord::Exit` with FIN and returns. It cannot recover a final output frame that arrives after broker Exit. Net-stream EOF cleanup at 568-593 explicitly releases the generation-owned broker role."
    },
    {
      "path": "crates/spt-net/src/net/attach.rs",
      "description": "`AttachRecord::Output`/`Exit` at lines 103-120 are independent ordered NDJSON records over the stream; output bytes remain base64. The protocol has no `exit_after_seq`/drain watermark that would let the operator defer Exit until the output cursor reaches the final sequence."
    },
    {
      "path": "crates/spt/src/rc.rs",
      "description": "`pump` at 1837-2020 decodes each Output and calls `stdout.write_all(&bytes)` verbatim at 1972-1993, but returns immediately on `AttachRecord::Exit` at 1995. `run_attach_inner` cleanup at 1254-1268 restores only the optional status row (currently disabled) and drops `RawGuard`; `RawGuard::drop` at 841-856 restores crossterm input mode/mouse/Windows console mode, not child-controlled VT state. Final branches then print prose. `reconnect_banner_bytes` at 1719-1744 does ED2+home, while the reconnect window is 30 seconds at 1659-1663; successful reconnect gets a fresh repaint, but give-up has no terminal restoration. `FIRST_EVENT_GRACE` at 1792-1805 is only a never-received-any-event backstop, not an idle/disconnect timer after the viewport becomes live."
    },
    {
      "path": "crates/spt-daemon/src/conn.rs",
      "description": "`BrokerConn::write_gated` at 493-610 applies the broker’s absolute 15-second write bound, poisons the entire physical framed connection on timeout/partial-write risk, aborts/severs it, and causes `handle_conn` EOF cleanup. This correctly avoids reusing a potentially partial length-prefixed frame, but it cannot restore local terminal VT modes; that responsibility remains in rc."
    },
    {
      "path": "crates/spt/src/picker/mod.rs",
      "description": "`setup_terminal`/`restore_terminal` at 95-112 use `ratatui::Terminal<CrosstermBackend<Stdout>>` directly and are unrelated to `spt-term::ScreenGrid`. `event_loop` at 165-178 invokes purge inline while ratatui still owns raw/alternate-screen state, then continues drawing with ratatui’s stale previous Buffer."
    },
    {
      "path": "crates/spt/src/cli.rs",
      "description": "`cmd_endpoint_purge` at 12707-12826 emits multiple `eprintln!` paths, including `PURGED:{id}` at 12825. When called inline by picker, this out-of-band write mutates the same alternate-screen cursor/display without updating ratatui’s diff baseline, producing stale characters where the next diff skips unchanged spaces."
    },
    {
      "path": "docs/adr/0031-server-side-screen-grid-render-repaint.md",
      "description": "Governing contract: the grid is the OS-neutral render-replay layer; cold attach gets one synthesized current-screen repaint while live frames remain raw. It confirms picker/PTY content semantics should not be conflated."
    },
    {
      "path": "docs/KNOWN-HAZARDS.md",
      "description": "Hazards 7.27 and the bounded-write sections describe broker exit/control cleanup and 15-second poisoned-connection retirement. They cover control stamps and transport wedges, but not ordering final output before Exit or restoring the operator’s VT state."
    }
  ],
  "architecture": "## End-to-end byte path\n\n```text\nPTY child\n  -> portable_pty master\n  -> spt_term::Drain::spawn(reader.read)\n  -> broker OutputLog::append\n       -> raw ring VecDeque<(seq, Vec<u8>)>\n       -> controller/viewer writer queue\n       -> ScreenGrid::advance (state projection, only used for cold attach)\n  -> OutputEvent { seq, data_b64 }\n  -> Brain::read_event_until\n  -> serve_attach: AttachRecord::Output { seq, data_b64 } NDJSON\n  -> broker-owned loopback/QUIC net stream\n  -> rc AttachDecoder\n  -> decode_bytes\n  -> stdout.write_all(raw bytes)\n```\n\nThere is no character diff or whitespace filter on the live path. The only content transformation is the cold-attach branch:\n\n```text\nfrom_seq == 0 && next_seq > 0\n  -> ScreenGrid::render_repaint()\n  -> one Output frame at pseudo-seq next_seq - 1\n```\n\n## Byte-level reproductions\n\n### 1. Spaces and erase sequences survive the live path\n\nHave the PTY emit:\n\n```text\nhex: 41 20 20 1b 5b 32 4b 0d\ntext: A␠␠ ESC [ 2 K CR\n```\n\n`Drain::spawn` passes the exact 8-byte slice; `OutputLog::append` stores `bytes.to_vec()`; each envelope base64-encodes and decodes the same bytes; rc writes exactly those 8 bytes. A capture at rc stdout must equal the input. Any mismatch here falsifies the current code reading.\n\nOn a cold attach, the original `CSI 2 K` and the two trailing spaces are consumed into grid state rather than replayed literally. The synthesized frame begins with one of `ESC[?1049h` / `ESC[?1049l`, then `ESC[0m ESC[2J ESC[H`. Default blank rows and trailing default spaces are omitted after that full clear. Therefore a terminal initialized with `XXXXXXXXXXXXXXXX` must become blank except for modeled nonblank/non-default-background cells. If it does not, the failure is VT-output interpretation, not missing whitespace in the renderer.\n\n### 2. Final output can be overtaken by Exit\n\nUse a short-lived PTY child whose final single write is:\n\n```text\nhex: 58 58 58 58 1b 5b 32 4b 1b 5b 3f 32 35 68 1b 5b 3f 31 30 34 39 6c\nmeaning: \"XXXX\" + CSI 2 K + show-cursor + leave-alt-screen\n```\n\nThen exit immediately. The drain thread can enqueue this as `CtrlMsg::Output`, while the waiter thread, after `wait()`, calls `sink.write(KIND_EXIT)` directly. If the direct write acquires the connection gate first, the observable frame order is `Exit` then `Output`. `serve_attach` turns Exit into a FINished attach record and returns; rc returns on that record, so the final output bytes are never rendered. Capture broker IPC frame kinds to falsify/confirm. The existing env-injection integration test already observes this ordering and compensates by continuing to drain after Exit.\n\n### 3. rc omits final VT restoration even without the broker race\n\nFeed rc a final rendered chunk containing:\n\n```text\nESC[?1049h ESC[?25l ESC[31m DIRTY\n```\n\nThen produce any final outcome—operator `Ctrl-B d`, `AttachRecord::Exit`, displacement, first-event stall, reconnect give-up, or fatal read/write error. With `STATUS_ROW_ENABLED == false`, capture bytes written after the pump ends. The current output is only CRLF plus the bracketed status prose. It contains none of:\n\n```text\nESC[0m       reset SGR\nESC[?25h    show cursor\nESC[r       reset scroll margins\nESC[?1049l  leave alternate screen\n```\n\nThat is deterministic and independent of OS transport.\n\n### 4. Cold repaint loses the child’s scroll-region mode\n\nFeed `ScreenGrid`:\n\n```text\nESC[?1049h ESC[2;23r ESC[23;1H bottom\n```\n\nThe grid records `scroll_top=1`, `scroll_bottom=22`, but `render_repaint()` contains no `ESC[2;23r`. Apply the repaint to a clean terminal, then feed a raw LF/IND at the bottom margin. The server model scrolls rows 2–23 while the client uses its default full-screen margin, producing divergent cell placement/stale rows. A unit assertion that repaint contains the tracked DECSTBM sequence is the deterministic seam.\n\n### 5. Picker’s distinct diff desynchronization\n\nWhile picker is on `ConfirmPurge`, `cmd_endpoint_purge(id, true, false)` writes `PURGED:<id>\\n` to stderr inside the active alternate screen. Ratatui’s previous `Buffer` is unchanged. The model removes the row and draws again; ratatui emits only cells differing from its previous buffer, so unchanged spaces are not rewritten over the externally written glyphs. A stateful recording/TestBackend that allows an external display mutation between two `Terminal::draw` calls reproduces this exactly. It shares neither `ScreenGrid` nor rc’s output pump.\n\n## Shared versus distinct causes\n\n- **No common renderer/diff/filter:** picker = ratatui local buffer diff; broker cold attach = `spt-term::ScreenGrid` full synthesized repaint; rc live attach = raw `write_all`.\n- **Picker cause:** out-of-band stderr write invalidates ratatui’s assumed previous screen, so its correct diff omits unchanged blank cells.\n- **Broker/rc cause:** output-vs-Exit is cross-thread/out-of-queue ordering, followed by immediate termination; final VT state restoration is absent client-side. Cold repaint also omits tracked DECSTBM state.\n- **Common abstract failure only:** both allow the physical terminal to be mutated or terminated outside the state owner’s ordered render lifecycle. This is architectural similarity, not shared code.\n\n## Fix suggestions, ordered by source correctness\n\n1. **Make session drain the terminal-event sequencer.** Do not have the exit waiter direct-write Exit through `all_sinks`. Wait until the PTY drain has observed EOF/completion, then enqueue Exit behind every prior Output on each controller/viewer writer queue. The drain/output queue must be FIFO authority for both Output and terminal Exit. A mere mutex is insufficient: the existing physical-connection gate serializes individual writes but does not impose producer order.\n2. **Add an explicit final-output watermark if protocol-level defense is desired.** Extend Exit additively with `after_seq`/final sequence. `serve_attach` and rc should not terminate until the render cursor has reached it. This guards future cross-thread reorder, but it is secondary to fixing broker source ordering.\n3. **Unconditionally restore the operator terminal in rc via RAII.** Separate OS raw/console-mode restoration from VT display restoration. Before printing any final prose, emit/reset through a display guard: SGR reset, full scroll-region reset, cursor show, and leave alternate screen (prefer crossterm commands where exact semantics are portable). Run on every return/error/panic-safe drop path, not only when the disabled `StatusRow` exists. Preserve the current Windows output-mode restoration after the cleanup bytes have been emitted while VT processing is still enabled.\n4. **Replay all render-affecting tracked modes in `ScreenGrid::render_repaint`.** At minimum emit tracked DECSTBM before the final cursor placement. If the grid later tracks DECOM/autowrap/tab stops, they must be emitted too. Add a stateful emulator contract test: dirty initial screen + repaint + one subsequent raw frame must match the server grid, not merely substring-test repaint text.\n5. **Fix picker independently.** Never run a stderr-writing command while ratatui owns the alternate screen. Either make purge return structured diagnostics without writing, temporarily restore/suspend the terminal before calling it and re-enter with a fresh `Terminal`, or force a backend clear/reset of ratatui’s previous buffer before the next draw. The strongest test mutates backend display state out of band between draws and asserts the next frame reconstructs the complete model.\n6. **Regression coverage:** add (a) broker integration asserting final whitespace/EL/leave-alt Output precedes Exit, (b) rc sink capture asserting teardown bytes on every `PumpEnd` and error class, (c) `ScreenGrid` scroll-margin round-trip, and (d) picker out-of-band mutation recovery. Existing isolated view snapshots cannot catch a previous-buffer desync."
}