# M12 W2.5 — doyle ruling (controller/viewer model + Kick) — the last slice

> doyle 2026-06-14. **SCOPE CHANGED (operator 2026-06-14): build the FULL controller/viewer
> model now** — the complete CONTEXT.md:317 design, not the single-subscriber stopgap.
> My earlier "(a) implicit displace" ruling is RETRACTED: it gated the impl's single-subscriber
> limitation, which contradicts the LOCKED design. This wave is now a real broker change in the
> hazard zone → todlando JIT-plans against this, pings me a design-check, then builds.

## The locked design (CONTEXT.md:317-318)
One interactive **controller** at a time — its viewport sets the PTY size (resize is
**controller-exclusive**; ConPTY repaint cost is why). **Any number of `--view`** attachers:
read-only, never resize, **letterbox client-side**. `spt rc kick <target>` displaces the
incumbent controller with a **LOUD notice**; `--take` = kick + attach in one motion.
**Avoid:** "screen share" framing, resize-per-viewer, **silent controller displacement**.

## Current impl gap (verified)
`OutputLog.subscriber: Option<SharedSend>` (broker.rs:87) is SINGULAR — one subscriber, silent
last-wins displace. `--view` (rc.rs:165) is only a client-side no-input choice; at the broker it
still takes the single slot + silently displaces (the forbidden anti-pattern). The controller/
viewer model is UNBUILT. PTY-ownership (child survives any attach/detach — broker.rs:28-31) DOES
already hold and is preserved.

## Crystallized design

### 1. Broker — multi-subscriber `OutputLog`
- `subscriber: Option<SharedSend>` → **`controller: Option<SharedSend>` + `viewers: <keyed collection>`** (key by attach-id so a viewer removes cleanly). Append fans the frame out to the controller AND every viewer, **under the existing log lock**, each in strict `seq` order (preserve the no-interleave invariant).
- **Brain-resume cursor (`delivered_through`, ADR-0018) tracks the CONTROLLER only.** Viewers are ephemeral: each replays from its own `subscribe` cursor (the existing `attach(sub, from_seq)` per-attach replay), and a viewer's write success/failure NEVER moves `delivered_through`. This preserves at-least-once brain resume.

### 2. Viewer isolation — the new hazard (load-bearing)
**A slow / dead / hostile viewer must NEVER stall the controller, the child, or the PTY drain.**
Today every subscriber write happens under the log lock — a blocked viewer would freeze everyone.
Required: viewer writes are **best-effort, non-blocking — a viewer that can't keep up is dropped**
(removed from the collection), never backpressured into the drain. The controller keeps the
authoritative bounded-backpressure path (as today). → **REQ-HAZARD-VIEWER-ISOLATION** (unit + int):
a wedged viewer is evicted and the controller's stream + the child run on unaffected.

### 3. Resize — controller-exclusive
Only the controller's attach may send `KIND_RESIZE` (broker.rs:425/681 `dispatch_resize`). A
viewer's resize is rejected/no-op. The attach must carry a **role** (controller vs viewer) so the
broker gates resize by role. On a controller change (kick/--take) the new controller's viewport
size takes over (resize to the new controller).

### 4. rc `--view` — coexist, no input, letterbox
`--view` = VIEWER role: receives output, sends no input (rc.rs:165), sends no resize. The broker
conveys the **current PTY size** to viewers (a size control-frame on attach + on every controller
resize). The viewer renders the controller-sized stream into its own terminal, **letterboxing
client-side** when the size/ratio mismatches (center + pad; if smaller than the PTY, clip with an
indicator — NEVER resize the PTY). → part of **REQ-RCVIEW-1**.

### 5. Kick / `--take` — explicit + loud, never silent
- **Default attach to a CONTROLLED endpoint = become a VIEWER** (not a silent displace). This is
  the core fix for CONTEXT.md:318.
- **`--take` = explicit kick + attach** → you become controller; the displaced controller gets a
  **LOUD notice** ("displaced by `<node>`") via a broker→controller **displacement control-frame**
  (a new control frame is fine now), then detaches. `spt rc kick <target>` = displace without
  necessarily attaching. → **REQ-KICK-1**.
- Gate: `access_check(endpoint, origin, Unsolicited)` already decides who may control; taking
  control is the same gate (no elevated kick policy — if you may drive, you may take).

### 6. Picker (W2 picker integration)
blue ■ = a controller is present (`driven_by` = controller node) **+ a viewer count** ("controlled
by `<node>` (+N viewing)"). Confirm options: **View** (attach as viewer) and **Kick `<node>` and
attach** (= `--take`). Both route through the EXISTING attach dispatch (W2 single-bringup-path
invariant holds — role is a parameter, not a new path). The registry/info needs controller identity
+ viewer count (extend `driven_by` → controller + a viewer tally).

## REQs (todlando's granularity, lean below)
- **REQ-RCVIEW-1** — controller/viewer multi-attach: 1 controller (input + exclusive resize) + N
  view-only (output-only, no resize, client letterbox). doc+impl+unit+**int** (multi-subscriber is
  cross-process/cross-node → int warranted; extend the attach.rs int suite).
- **REQ-KICK-1** — explicit `kick`/`--take` displaces the controller with a loud notice; default
  attach to a controlled endpoint = viewer (never silent displace). doc+impl+unit+int.
- **REQ-HAZARD-VIEWER-ISOLATION** — a slow/dead/hostile viewer never stalls the controller, child,
  or PTY drain (evicted instead). unit+int. + KNOWN-HAZARDS entry.

## Open sub-questions for todlando's JIT plan (ping me a design-check before building)
1. Viewer collection shape + eviction signal (write-error vs a bounded per-viewer queue overflow) —
   how a "slow" viewer is detected without a heartbeat.
2. The PTY-size control-frame to viewers (new `KIND_*`?) + the displacement-notice control-frame —
   confirm the wire additions (additive, N-1-safe; this is the one place new wire surface is OK).
3. Letterbox UX when the viewer terminal is SMALLER than the PTY (clip + indicator vs scroll) — and
   whether viewers get any input at all (scroll keys?) or truly zero input.
4. Registry/info shape for controller + viewer count (extend `driven_by`, or a new field).
5. Does `--take` demote the old controller to a viewer, or fully detach it? (CONTEXT.md says
   "displaces" — lean detach + loud notice; confirm.)

## Binding gate conditions (I verify)
1. **PTY-ownership** preserved: no attach/detach/kick/viewer-churn kills the child/PTY/session.
2. **Viewer isolation**: a wedged viewer is evicted; controller + child unaffected (int).
3. **Resize controller-exclusive**: a viewer cannot resize the PTY.
4. **No silent displace**: default attach to a controlled endpoint = viewer; control change only via
   explicit `--take`/`kick`, always with the loud notice.
5. **Brain-resume cursor** tracks the controller only — viewers never perturb `delivered_through`.
6. **Single-bringup-path**: attach (controller or viewer) routes through the existing dispatch.

This is a real broker wave (hazard zone), not the picker-only stopgap. Plan → design-check → build
→ gate. Then full M12 gate → perri.
