# ADR-0043: Terminal render lifecycle — ordered output, owned baselines, unconditional teardown

- Status: accepted (doyle triage 2026-07-18, RC-RENDER-TRUTH milestone, W3)
- Source: hertz stale-glyphs/missing-whitespace RCA (2026-07-18, doyle seam-verified all
  four legs same day: broker exit-waiter vs controller_writer ordering, `RawGuard::drop`
  display-state gap, picker inline-purge stderr under the live TUI, ScreenGrid repaint
  DECSTBM omission). KNOWN-HAZARDS 7.47. Prior art surveyed: herdr v0.7.4 (AGPL — design
  ideas only, no code reuse without license review). Kin: ADR-0038 (stream lifecycle),
  KH 7.43/7.44 (write-path substrate).

## Context

Field symptom: stale glyphs and "missing whitespace" across picker and rc PTY surfaces.
There is NO shared whitespace-trimming defect — raw live PTY bytes (spaces, ANSI erases)
are preserved end-to-end. The symptoms are distinct render-lifecycle failures with one
common invariant violation: **the physical terminal is mutated or terminated outside its
renderer's ordered state model.**

1. **Broker Exit overtakes final Output.** The exit waiter direct-writes `KIND_EXIT` via
   `all_sinks()` while normal PTY output queues through `controller_writer`; the
   per-connection gate serializes writes but does not impose producer order. `serve_attach`
   returns immediately on Exit; rc returns immediately on that record. A final erase, SGR
   reset, cursor-show, DECSTBM reset, or `?1049l` can be stranded. The race is already
   admitted in the broker test suite (the env-echo test documents that EXIT can arrive
   before OUTPUT and compensates by post-exit draining) — production rc has no such
   compensation.
2. **rc never unconditionally restores display state.** `RawGuard::drop` restores input
   raw mode, mouse capture, and the Windows console output mode — it does not reset
   child-controlled VT display state. Every final path (detach, child exit, displacement,
   first-event stall, fatal error, reconnect give-up) can leave alt-screen/cursor/SGR/
   scroll-region dirty; the reconnect banner clears+homes and the give-up prints at the
   centered cursor with no final reset.
3. **Picker invalidates ratatui's diff baseline with out-of-band stderr.** Purge runs
   inline while the TUI owns the alternate screen; the purge core writes diagnostics to
   stderr, mutating the physical screen without updating ratatui's previous Buffer. The
   next draw correctly emits only model diffs; cells ratatui believes blank are skipped —
   stderr glyph fragments persist. (Unrelated to ScreenGrid.)
4. **Cold repaint omits tracked modes.** ScreenGrid tracks DECSTBM margins but
   `render_repaint` never replays them; client and server grids then interpret subsequent
   raw scrolling against different regions. (The trailing-blank omission after `ESC[2J`
   is semantically correct and NOT the bug.)

## Decisions

<!-- [doc->REQ-BROKER-OUTPUT-BEFORE-EXIT] -->
1. **One FIFO sequencer per attach sink.** The PTY drain/output writer is the sole
   sequencer for terminal Output and Exit: Exit is enqueued behind all prior output for
   each sink (drain EOF/completion first, then Exit). A mutex alone is insufficient —
   producer order is the contract. Output-before-Exit is a production-path invariant,
   regression-proven end-to-end (broker → attach → rc).
   <!-- [doc->REQ-RC-VT-TEARDOWN] -->
2. **rc display teardown is unconditional, idempotent, and separate from input teardown.**
   A display RAII guard (distinct from the OS input/raw-mode guard) runs on every exit
   path including errors and unwind: best-effort SGR reset, full scroll-region reset,
   cursor show, leave alternate screen, clear+home — emitted while VT output processing is
   still enabled, THEN the prior console output mode is restored, then parting prose.
   <!-- [doc->REQ-PICKER-PURGE-STRUCTURED] -->
3. **A TUI surface has exactly one renderer.** Core verbs invoked from inside an active
   TUI return structured outcomes and write NOTHING to the terminal; the TUI converts
   outcomes to model state (picker purge → `model.flash`). Alternatives (suspend/restore
   + forced baseline reset) are fallbacks, not the design. Baseline-desync regressions
   require a stateful/recording backend — pure view snapshots cannot catch them.
   <!-- [doc->REQ-SCREENGRID-REPAINT-MODE-REPLAY] -->
4. **Cold repaint replays every tracked render-affecting mode** (DECSTBM at minimum)
   before final cursor placement, under a stateful emulator contract: dirty screen +
   synthesized repaint + next raw frame ≡ server grid.
5. **Deferred (P2, seeds — not this milestone):** `Exit { after_seq }` final-watermark
   defense; herdr-style per-client semantic baseline (only relevant if spt ever transforms
   live frames instead of forwarding raw bytes).

Design principles adopted from prior art (ideas only): one baseline per terminal surface;
blanks are real cells; attach/reconnect/resize/write-failure invalidates the baseline and
forces full clear+repaint; commit a baseline only after successful delivery; teardown is
idempotent and comprehensive (panic path included). Explicitly rejected: importing a
multiplexer stack; committing a baseline while ignoring write errors.

## Consequences

- A short-lived child's final bytes (including erases and mode resets) always render
  before the session ends; "the last frame" is a guarantee, not a race.
- No rc exit path leaves the operator's terminal in the child's display state.
- TUI screens repaint whole and correct after any out-of-band mutation, because there are
  no sanctioned out-of-band mutations left and the unsanctioned ones invalidate the
  baseline.
- Repaint after reattach/resize is state-complete: scrolling behaves identically on both
  ends of the wire.
