# V0.13.0 — viewer SKIP-TO-LIVE on eviction — JIT plan

**Status:** DESIGNED (this doc). Closes **p0_paste** + **a_journaled-Linux** (post-b4 these
collapsed to ONE root — doyle's post-b4 re-measure 2026-06-21). doyle-GATED: "skip-to-live
is REAL, not relax" (eviction itself is correct session-protection; SILENT+PERMANENT
eviction is the bug). Off `viewer-drain-decouple-b4@677c3e7`. Implement AFTER b4.

## Root

Viewer eviction in `OutputLog::append` (broker.rs:326-338, `try_send` Full / Disconnected
→ `viewers.remove`) is **SILENT + PERMANENT**:

- serve_attach (attach.rs) subscribes to the broker as a VIEWER → `add_viewer` (broker.rs:445)
  → `ViewerSink` (256-deep `VIEWER_CHANNEL_DEPTH`) + a `viewer_writer` thread.
- serve_attach forwards each frame `read_event → b64decode → re-encode AttachRecord →
  net_stream_send` — SLOWER than the drain fans out under flood. The 256 channel overflows.
- The drain (append) evicts: `viewers.remove(vid)` drops `ViewerSink` → drops `tx` →
  `viewer_writer`'s `rx.recv()` returns Err → the writer `return`s **writing nothing**
  (broker.rs:655-663). serve_attach's `brain.read_event()` then **just stops** getting
  `Output` — NO EOF, NO error → serve_attach blocks forever → operator receives nothing.
  `attach_received_output=FALSE`.

Post-b4 (doyle 2026-06-21): a_journaled-Linux now = subscribed=true, drain FREE (88902
appends), VIEWER EVICTED (c1-EVICT @ seq 88901), got_output=FALSE — the **identical
signature to p0_paste**. b4 unthrottled the drain; the remaining failure is this
viewer-eviction. ONE root → skip-to-live closes BOTH.

## Design (doyle's 3 constraints, b4-doc lines 24-32)

<!-- [doc->REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT] -->
The skip-to-live contract: an evicted viewer is signalled (not silently dropped), re-subscribes
from the live ring tail (skips the uncatchable backlog), and is rate-limited so a hopelessly-
behind viewer under flood sees intermittent LIVE bursts rather than spinning. Eviction itself
stays — it is correct session-protection; only the SILENT+PERMANENT death is fixed.

### (1) EVICTION SIGNAL — broker→viewer, DISTINCT from session-exit EOF
The evicted viewer must learn it was evicted (≠ the session ended). serve_attach must NOT
tear down on it (it tears down on real EOF/Exit).

- `ViewerSink` += an eviction notifier shared with its `viewer_writer`: `evicted: Arc<AtomicBool>`.
- `append` overflow/disconnect arm: set `evicted.store(true)` BEFORE `viewers.remove(vid)`
  (drop `tx`). (Still under the log lock — just an atomic store, never a blocking write.)
- `viewer_writer`: when `rx.recv()` returns Err, check `evicted`:
    - `true`  → write ONE `KIND_VIEWER_EVICTED` marker frame to `send`, then exit.
    - `false` → exit silently (normal teardown: session end / conn drop).
  The marker write happens **in the writer thread = OFF the log lock / off the drain** — same
  isolation as every viewer frame (a bounded, isolated, dedicated-writer write). No W1/b4
  hazard re-introduced.
- New wire surface:
    - `msg.rs`: `KIND_VIEWER_EVICTED` + `evicted_envelope(session_id)` (+ a tiny
      `ViewerEvictedEvent { session_id }` payload struct, mirror `SizeEvent`).
    - `brain.rs`: `BrokerEvent::ViewerEvicted { session_id }` + a decode arm (mirror `KIND_SIZE`).
  Additive + tagged → N-1 peers skip an unknown kind (degrade-don't-die, per AttachRecord posture).

### (2) serve_attach: re-subscribe from the CURRENT RING FLOOR (skip-to-live)
On `BrokerEvent::ViewerEvicted` (NOT EOF — does NOT return/teardown):
- Re-issue the subscribe at the live tail: `brain.attach_as(session, LIVE_TAIL, Viewer, origin)`.
- LIVE_TAIL semantics: `from_seq = u64::MAX` → `add_viewer`'s `s >= from_seq` ring filter
  (broker.rs:455) replays NOTHING → the viewer jumps to live, sees the next live burst, skips
  the uncatchable backlog (tail -f reconnect). (Confirm u64::MAX is safe vs any `from_seq`
  arithmetic; else add an explicit "tail" flag to the subscribe.)

### (3) NO evict→resubscribe busy-loop — RATE-LIMIT (HARD constraint)
serve_attach tracks `last_resubscribe: Instant`. Min `RESUBSCRIBE_INTERVAL` (~150-250ms).
If a `ViewerEvicted` arrives within the interval, sleep the remainder before re-subscribing.
Forward-progress guarantee: each re-subscribe delivers the live tail → under max-flood the
operator sees intermittent LIVE bursts, never a CPU spin. (Do NOT lead with serve
stall/gap-detection — fragile fallback only.)

## ⚠ RISK / open gate — COLD-BRAIN REJECT-GAP (must resolve, B2-adjacent)

serve_attach's `brain` is **cold-start** (brain.rs:1186-7: "the production dispatch serve
brain keeps the legacy `next_seq` path"; `session_cursors` EMPTY). The legacy path
(brain.rs:618-626) treats a forward seq jump as a **FATAL "output gap" Err**. The
post-eviction replay-from-floor (skip-to-live) IS a forward jump → serve_attach returns Err →
**tears down** → skip-to-live BREAKS at the cold-brain dedup.

FIX: on the skip-to-live re-subscribe, RESET serve_attach brain's `next_seq` to the new floor
so the replay is contiguous-by-construction. Mirror the seeded-map reset (brain.rs:1189-1190
`session_cursors.insert(session_id, from_seq)`) but for the legacy `next_seq` field. Add a
`Brain` method (e.g. `reset_output_cursor(from_seq)` / extend `attach_as` to reset `next_seq`
on the cold path too). The snap-above path (brain.rs:595-606) ALREADY documents "a
post-eviction ring-floor jump" as the only legitimate forward jump — so the resume-brain side
is ready; this only needs the cold serve-brain to accept it.

OPERATOR render cursor: serve_attach forwards `seq` verbatim; the rc operator must accept the
forward jump (skip-to-live) in its AttachRecord::Output dedup. VERIFY rc.rs render dedup is
snap-above, not reject-gap. If reject-gap, that is a 2nd reset point.

B2 / KH: both resets are VIEWER-only — a viewer never advances `delivered_through`, is not
authoritative, exposes no resume cursor → B2-SAFE (doyle's gate rationale, b4-doc line 24).
Intersects REQ-HAZARD-CONTROLLER-WRITER-REORDER only by adjacency; no controller path touched.
**CONFIRM with doyle before fold** (he owns the B2 invariant + the p0_paste forkpty verify).

## Traceability (CLAUDE.md binding)

New req `REQ-VIEWER-SKIP-TO-LIVE-ON-EVICT` (or fold into REQ-HAZARD-VIEWER-ISOLATION). Add to
`traceable-reqs.toml` FIRST, then satisfy: `doc` (this file) / `impl` (broker+brain+attach) /
`unit` (below). INT evidence = p0_paste warm-forkpty gate (activates on fold, rule 5).

## Tests (delegate → spt-test-engineer)

- unit (broker): `append` overflow-evict sets the eviction notifier (not a silent remove);
  `viewer_writer` writes `KIND_VIEWER_EVICTED` on an evicted rx-close + NOTHING on a normal
  close.
- unit (attach/serve): on `ViewerEvicted` serve_attach re-subscribes from the floor, does NOT
  tear down (distinct from EOF), and rate-limits (≥2 rapid evictions ⇒ ≥1 interval of spacing,
  no busy-loop).
- unit (brain): cold-brain `next_seq` reset accepts the post-eviction forward jump (no
  "output gap" Err); a NON-reset forward jump still errors (guard unchanged elsewhere).
- int: p0_paste (warm forkpty, kitsubito) green — operator receives intermittent live bursts
  after eviction. (doyle's gate.)

## Files

- `crates/spt-daemon/src/msg.rs` — `KIND_VIEWER_EVICTED`, `evicted_envelope`, payload struct.
- `crates/spt-daemon/src/broker.rs` — `ViewerSink.evicted`, `append` evict arm, `add_viewer`,
  `viewer_writer`.
- `crates/spt-daemon/src/brain.rs` — `BrokerEvent::ViewerEvicted` + decode arm; cold-cursor reset.
- `crates/spt-daemon/src/attach.rs` — `serve_attach` ViewerEvicted handling + rate-limit +
  re-subscribe-from-floor.
- `traceable-reqs.toml`.

## Sequencing vs the board (doyle 2026-06-21)

- b4 = keep (works). Windows a_journaled flip = STRUCTURALLY EXONERATED (subscribe path =
  nethost StreamLog, not OutputLog; Windows ctrl channel never fills at pumped=249<<4096) →
  likely flake; doyle confirming repro. Don't block fold on it.
- THIS (skip-to-live) = closes p0_paste + a_journaled-Linux. NEXT.
- g2 = real inject-acceptance fix (dispatch_endpoint_input must not starve under output flood);
  still RED post-b4. SEPARATE, AFTER this.
- Fold order: b4 + pump-carrier-fix + skip-to-live → delivery-control → doyle folds → release.
