# v0.13.0 P0 — PTY input single-writer thread (paste-wedge fix; doyle 2026-06-19)

**Operator HITL:** pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type, and can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). BLOCKS v0.13.0 ship. Builder: todlando. Gate: doyle.

## Root (doyle /diagnose, code-grounded)
The operator keystroke path: `rc` → net-stream `Input` → `serve_attach` (attach.rs:197 `brain.send_effect(op_id)`) → KIND_INPUT → broker dispatch loop (broker.rs:1091) → `dispatch_input` (broker.rs:1459) → `session.write_input(&bytes)` **synchronously on the broker request-handling thread**.

W1b (`REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE`, shipped) released the effect-journal lock ACROSS the effect (fix 1) and made `PtyWrite` ephemeral / no-fsync (fix 3). But it **explicitly DEFERRED fix (2)** — "bound/fail-fast the PtyWrite itself; write_input must never block indefinitely." `write_input` still runs synchronously on the dispatch thread. A single keystroke never fills the ConPTY input buffer; a **paste burst does** → `write_input` blocks → the thread cannot service the next frame (a re-attach's subscribe, a become_controller restore-write, an inject-floor flush) → wedge. This is THE paste-trigger of the W1b-deferred (2).

**Not a bug-2 regression** — the byte path funnels to the same `write_input`; the paste just reliably fills the buffer. The exact cross-attach starvation vector (a shared per-session writer mutex held by the stuck write vs the dispatch-thread block) is **pinned by the repro** (below); the fix cures it either way.

## CONTEXT.md grounding (design source of truth)
- L33: the **broker** holds only the un-transferable must-not-die resources (PTY master fds, child processes, sockets) and is **minimal, dumb, almost-never-updates**. A per-session writer thread is a broker-owned PTY-management detail — fits the charter; adds no brain/logic.
- L435: `spt_term::SessionSurface` = the `write_input` + `resize` seam (`send_keys`/`send_line` first-class). The single-writer change lives behind this seam.
- L437-438: input injection is two granularities (send-keys raw bytes / send-line cooked) — both still first-class, just enqueued.
- Single-writer is the established spt-core pattern (broker-owned `driven_by`, daemon-owned drop files — KNOWN-HAZARDS 6.4). This extends it to the PTY **input** side.

## Decision: one input-writer thread per session = the SOLE PTY writer
- Each `PtySession` (broker.rs, when the session is created) spawns ONE dedicated **input-writer thread** that owns the PTY master write handle and is the **only** code that calls the blocking `write_input`.
- It is fed by a **bounded FIFO channel** (`mpsc`/`sync_channel` with a fixed capacity — sized for a generous paste, e.g. a few hundred KB worth of records).
- EVERY PTY-write caller — `dispatch_input`, `serve_attach`→`send_effect`, the W2 inject-floor flush (`flush_inject_floor`) — **ENQUEUES a record and returns immediately**. No caller ever touches the blocking write or the PTY writer lock.
- The writer thread drains the channel to `write_input` at the harness's pace. A blocked/slow harness blocks ONLY its own writer thread — the broker dispatch thread, attach-opens, resizes, and every other session stay fully live.

## Backpressure (operator ruling: DROP + surface)
- Queue full (a genuinely wedged harness that never drains): **drop the excess input** and **stamp the session `INPUT_BACKPRESSURE`** (a visible health signal surfaced to the operator — "input dropped, harness not draining"). The daemon NEVER wedges. A truly-stuck harness is already dead; dropping input beats freezing the daemon (fail-fast half of fix 2; same spirit as `REQ-HAZARD-RC-ATTACH-FAILFAST`).
- A merely SLOW (not dead) harness self-heals: the writer thread drains as the harness catches up, input resumes — strictly better than today's hard wedge.
- Enqueue itself is non-blocking (try_send); a full channel returns the drop signal, never blocks the dispatch thread.

## Exactly-once (preserved)
- `apply_once(key, PtyWrite, effect)` where the `effect` is now the **enqueue** (fast, non-blocking): reserve key under lock → try-enqueue → mark Applied. `PtyWrite` is already ephemeral (no journal lines, no fsync), so marking applied at enqueue is sound — a keystroke lost to a broker crash is retyped; PTY state is never rebuilt from keystroke replay (effect.rs:189-199 invariant).
- The brain/operator ack (`applied_envelope`) now means "accepted + ordered + will land," not "fsynced to the PTY." The rc pump does not gate on landing (fire-and-forward, op_id is for dedup only), so the ack-timing shift is benign.
- Order preserved: single FIFO + single writer = strict keystroke order. Same-key dedup unchanged (applied-set).
- Durable kinds (NetSend/NetDial/Registry/Spool) do NOT route through `write_input` — untouched, keep their fsync'd PENDING/DONE.
- W2 inject-floor (`REQ` Layer C) buffering moves to its natural home: the writer thread is the lone writer, so the "buffer operator keystrokes during an injected sequence, flush after commit" choreography is enforced THERE (no second writer to race).

## Repro-first (W1b's int ESCAPED this — non-negotiable)
W1b's int relied on ConPTY "absorbing" the write, so it never exercised an INDEFINITE block. The new int MUST:
1. Make `write_input` **actually block forever** — a real PTY consumer (harness stand-in) that stops draining its stdin, so the ConPTY/forkpty input buffer genuinely fills and the write parks.
2. Prove a **concurrent `spt rc` attach still OPENS and RECEIVES output** while that write is parked (RED pre-fix = `brain IPC read deadline` / no subscribe; GREEN post-fix).
3. Prove the **operator's own next keystroke is still accepted** (enqueued) post-paste.
4. Prove **DROP + `INPUT_BACKPRESSURE`** fires when the queue saturates (and clears/heals when the consumer resumes).
Real broker + real PTY + real rc pump, NO mocks (W1b precedent). cfg(unix) forkpty park covered too (the W1b "gravity-linux follow-up" leg folds in here).

## REQ + gates
- **Mint `REQ-HAZARD-PTY-INPUT-WRITER-WEDGE`** (registry-first), stages **impl, unit, int**. References + COMPLETES the W1b-deferred fix (2). Note in `REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE` that (2) is delivered here.
- KNOWN-HAZARDS entry (7.17) + CONTEXT note (PTY input is single-writer via a per-session writer thread; callers enqueue, never block; drop+signal on a wedged harness).
- unit: the writer-thread drain order + the bounded-queue drop/heal decision (injected channel, no wall-clock) + exactly-once enqueue (PtyWrite reserve→enqueue→Applied; dedup).
- int: the repro above.
- clippy --workspace -D warnings = 0; traceable EXIT=0; docs-drift xtask check.

## doyle gate criteria
Repro genuinely blocks write_input (non-vacuous: RED pre-fix) · concurrent attach opens + gets output under a parked write · operator keystroke still accepted · DROP+`INPUT_BACKPRESSURE` on saturation, heal on resume · exactly-once + order preserved · inject-floor choreography intact · Unix forkpty leg covered · clippy/traceable/docs-drift. Then operator HITL (paste a large block in a real CC rc session: lands, no wedge, can still attach).

**BLOCKS the v0.13.0 ship.**
