# M12 Wave 2.5 — controller/viewer model + loud Kick — JIT plan (real broker wave)

> todlando 2026-06-14, against doyle's `M12-W2.5-RULING.md` (operator scope: build the FULL
> CONTEXT.md:317 controller/viewer model, not the single-subscriber stopgap). Hazard-zone broker
> change. **DESIGN-CHECK PENDING — ping doyle with this, build only after his ruling.**
> Surfaces mapped 2026-06-14 (todlando + Explore). The earlier "(a) implicit displace" plan is
> dead (it was the forbidden silent-displace anti-pattern).

## Locked design (CONTEXT.md:317-318) — the target
ONE interactive **controller** (its viewport sets PTY size; **resize controller-exclusive**) +
ANY NUMBER of read-only **`--view`** attachers (never resize, **letterbox client-side**).
`spt rc kick <target>` displaces the incumbent controller with a **LOUD notice**; `--take` =
kick+attach. _Avoid_: screen-share framing, resize-per-viewer, **silent controller displacement**.

## Impl reality (mapped)
- **rc.rs** `run_attach(endpoint_id, view: bool)` (rc.rs:168) ALREADY gates the stdin reader on
  `view` (viewer = no input). Detach/exit notices render post-pump (rc.rs:220-231) — the displaced
  notice slots in there. rc sends **no resize** today (geometry static).
- **broker** `OutputLog.subscriber: Option<SharedSend>` (broker.rs:87), `SharedSend =
  Arc<Mutex<SendHalf>>` (a brain↔broker IPC local-socket write half, one per attach connection).
  `append` (broker.rs:137) holds the log lock and does a **blocking** `write_frame`+flush to the
  subscriber → **a slow subscriber stalls the single drain thread** = the viewer-isolation hazard.
  `delivered_through` (ADR-0018 brain-resume cursor) advances only on a successful controller write.
- **resize** = `dispatch_resize` (broker.rs:681) via IPC `KIND_RESIZE` → `PtySession::resize(
  SurfaceSize{rows,cols,..})` (pty.rs:203). NOT on the attach wire — remote resize is unbuilt.
- **attach wire** `AttachRecord` (spt-net/src/net/attach.rs:38) = Request{session_id,from_seq} /
  Output{seq,data_b64} / Input{data_b64,op_id} / Exit{code}. **No role, no resize, no size, no
  displacement.** Decoder already skips unknown kinds (N-1-safe, proven test).
- **serve_attach** (attach.rs:100) has **no role param**; dispatched from dispatch.rs:350.
- **int harness** (tests/attach.rs) — shared `attach_drive_detach` helper; two independent operator
  conns already supported → a 2nd (viewer) attacher is a natural extension.

## Answers to doyle's 5 open sub-questions (recommendations — confirm/adjust)

**Q1 — viewer collection + eviction signal.**
`OutputLog`: `subscriber` → `controller: Option<SharedSend>` + `viewers: HashMap<u64 attach_id,
ViewerSink>` (keyed by attach id for clean removal). **Eviction = bounded-queue overflow, NOT a
shared blocking write** (no heartbeat): each viewer gets a small **bounded SPSC channel + a
dedicated viewer-writer thread** that does the blocking socket write. `append` fans out under the
log lock by **`try_send`** to each viewer queue — a viewer whose queue is FULL (can't keep up) is
**evicted** (queue dropped, writer thread ends, removed from the map). The **drain thread never
touches a viewer socket**, so no viewer can stall it. The **controller** keeps the existing direct
blocking-write bounded path (authoritative, advances `delivered_through`). *Lean: bounded-queue
overflow as the slow-viewer signal* — it fully decouples the drain. (Lighter alt doyle could pick:
non-blocking viewer sockets, evict on `WouldBlock`/short-write — less machinery, but a partial
frame mid-write forces eviction anyway; the queue+thread model is cleaner isolation.)

**Q2 — wire additions (additive, N-1-safe; new wire OK here per doyle).** Add to `AttachRecord`:
- `Request` gains `role: AttachRole` (`#[serde(default)]` = `Controller`) — N-1 clients omit it →
  Controller (today's behavior). *(Note: default=Controller means an old client still displaces;
  the new-client DEFAULT attach is Viewer — see Q5. The wire default and the client default differ
  intentionally; the client always sends an explicit role.)*
- `Resize { rows: u16, cols: u16 }` — operator→target, **controller-only** (broker rejects from a
  viewer's stream); forwarded into `dispatch_resize`.
- `Size { rows: u16, cols: u16 }` — target→operator, the **current PTY size**, sent to a viewer on
  attach + to all viewers on every controller resize (drives client letterbox).
- `Displaced { by: String }` — target→operator, the **loud kick notice** to the displaced
  controller (its rc renders it, then the stream closes → pump ends).
All are additive enum variants / a defaulted field; the decoder's skip-unknown-kind contract keeps
N-1 peers degrading, not dying. **Confirm the four shapes.**

**Q3 — letterbox UX + viewer input.**
- Viewer terminal **smaller** than the PTY: **clip + a one-line indicator** (e.g. `⊞ viewing
  <cols>×<rows> — terminal smaller, content clipped`), NOT scroll (the viewport has no local
  scrollback model; clip+indicator is honest and cheap). Larger/equal: center + pad (letterbox).
- Viewer PTY input: **truly zero** — no keystrokes reach the PTY. The only local key honored is the
  **ctrl-b d detach chord** (already the rc detach machine, purely client-side). No scroll keys v1.
*Lean: clip+indicator; zero PTY input; detach chord only.*

**Q4 — registry/info shape for controller + viewer count.**
Keep `info::driven_by: Option<String>` = the **controller node** (unchanged semantics). Add an
additive `info::viewer_count: u32` (`#[serde(default)]` = 0). **The broker is the single writer**
(it owns the `viewers` map) — it stamps `viewer_count` on every viewer add/remove, so there is **no
multi-writer race** (unlike a per-serve increment). Same best-effort/self-heal caveat as
`driven_by` (a crash without decrement over-counts until the next attach re-stamps). Picker renders
`controlled by <driven_by> (+N viewing)`. *Lean: extend `driven_by` (controller) + broker-owned
`viewer_count`; no new store.*

**Q5 — does `--take` demote or detach the old controller?**
**Fully DETACH the displaced controller** (not demote-to-viewer): broker sends `Displaced{by}` →
the old controller's rc renders the loud notice → its stream closes → its pump ends with a
displaced-exit. Matches CONTEXT's "displaces"; auto-demoting to a silent viewer would surprise.
The displaced user can re-attach as a viewer if they choose. *Lean: detach + loud notice. Confirm.*

## Tasks (pending design-check)
- **T0** REQs in `traceable-reqs.toml`: REQ-RCVIEW-1 (doc+impl+unit+int), REQ-KICK-1
  (doc+impl+unit+int), REQ-HAZARD-VIEWER-ISOLATION (unit+int).
- **T1 wire** (spt-net attach.rs): `AttachRole` enum + `Request.role` (serde default Controller) +
  `Resize` / `Size` / `Displaced` records. Round-trip + unknown-skip units (extend the existing).
- **T2 broker** (broker.rs): `OutputLog` → controller + viewers map; `append` fan-out (controller
  blocking authoritative + cursor; viewers `try_send` to bounded queues, evict-on-overflow, writer
  threads). `delivered_through` advances on the controller write only. New controller displaces:
  send `Displaced` to the old controller's sink, then close it; broker stamps `driven_by` +
  `viewer_count`. `dispatch_resize` rejects a viewer-role stream.
- **T3 serve/dispatch** (attach.rs/dispatch.rs): thread `role` from `Request` into `serve_attach`;
  controller path = today (input + resize forwarding + cursor); viewer path = output-only subscribe,
  no input, no resize, receives `Size` frames. Guard the `driven_by` clear (clear-if-still-self) so
  a displaced controller's teardown can't wipe the new controller's marker.
- **T4 rc** (rc.rs): `--view` → Viewer role on `Request`; controller sends `Resize` (wire the
  terminal size + window-change — NEW: rc must read the initial size + SIGWINCH/Windows resize);
  viewer renders `Size`+letterbox (clip+indicator); both render the `Displaced{by}` loud notice
  post-pump. `kick`/`--take` surface on the `rc` command.
- **T5 picker** (W2 integration): blue ■ tri-state from `driven_by` (+ `viewer_count`); confirm
  options **View** (viewer attach) + **Kick <node> and attach** (`--take`) — both via the EXISTING
  attach dispatch (single-bringup-path invariant holds; role is a parameter).
- **T6 docs**: CONTEXT.md §remote-attach reconcile (mark the model BUILT) + the picker tri-state;
  KNOWN-HAZARDS REQ-HAZARD-VIEWER-ISOLATION entry (+ the no-silent-displace / driven_by-tracks-live
  -driver invariants).
- **Sweep**: full suite + clippy -D + traceable EXIT=0 + the int extension (2nd viewer; wedged-viewer
  eviction; controller-exclusive resize; displaced→loud-notice). W2.5 gate (doyle) → full M12 gate.

## GATE #7 (doyle/operator, NON-NEGOTIABLE) — a brain update must NOT kick attached operators
The seamless-self-update invariant (ADR-0018) ∩ the controller/viewer model. Today ONE operator
survives a target-brain restart exactly-once (attach.rs:16/509; broker owns the stream/session/
OutputLog/journal; successor re-serves the SAME stream from seq 0; int
`attach_survives_target_brain_restart_exactly_once`; REQ-HAZARD-BROKER-PROCESS-ISOLATION + REQ-DAEMON-2).
W2.5 MUST PRESERVE + EXTEND to the full controller + viewer set:
1. The broker holds the controller stream + ALL viewer streams (broker-owned OutputLog — T2 lands
   them there) → they survive a brain restart by construction.
2. The successor brain's re-serve must reconstruct the CONTROLLER (from the persisted
   `delivered_through`) AND re-establish EVERY viewer fan-out (viewers re-serve from the ring floor,
   best-effort).
3. ⚠ **THE TRAP — DO NOT SELF-KICK ON UPDATE:** the successor re-subscribing / re-taking the
   controller slot MUST NOT fire `Displaced{by}`. **`Displaced` fires ONLY on a genuine cross-operator
   `Take` (a DIFFERENT node taking control), NEVER on a brain-restart re-serve of the SAME controller.**
   Distinguish the two re-subscribe causes: (a) brain-restart re-serve = restore SAME controller, NO
   notice, seamless cursor resume; (b) operator `Take` = NEW controller displaces incumbent →
   `Displaced{by}` to the victim. Gate the notice on a genuine control-OWNERSHIP change, not a slot
   re-subscribe. (Impl: the `Resume`/handoff subscribe role re-takes the controller slot silently; only
   the `Take` intent path emits `Displaced` — and only when displacing a DIFFERENT live controller.)
- **GATE CONDITION #7** (doyle verifies) + **INT TEST**: a brain update (Life1→Life2) mid-session WITH
  a controller + ≥1 viewer attached → ALL survive exactly-once, NO `Displaced` to anyone, seamless
  resume from cursors. Extend `attach_survives_target_brain_restart_exactly_once` to the
  controller+viewer case.

## doyle's binding gate (6) — how each is evidenced
1. **PTY-ownership** — child/PTY/session survive attach/detach/kick/viewer-churn (int: child outlives
   a kick + a viewer eviction).
2. **Viewer isolation** — a wedged viewer is evicted; controller + child unaffected
   (REQ-HAZARD-VIEWER-ISOLATION int: a viewer whose queue overflows is dropped, controller stream
   keeps flowing).
3. **Resize controller-exclusive** — a viewer's `Resize` is rejected at the broker (unit + int).
4. **No silent displace** — default attach to a controlled endpoint = viewer; control change only via
   explicit `--take`/`kick`, always with the `Displaced` loud notice (int).
5. **Brain-resume cursor** tracks the controller only — viewer writes never move `delivered_through`
   (unit on the fan-out).
6. **Single-bringup-path** — viewer + controller + kick all route through the existing attach
   dispatch (role is a parameter, no second path).

## DESIGN-CHECK OUTCOME (doyle GO + refinements, 2026-06-14) — the LOCKED decisions
doyle: "GO with refinements." All Q answers approved with these deltas (these override the
recommendations above where they differ). **Build only after gaps A + B settle.**

- **Q1 APPROVED** (per-viewer bounded channel + writer thread, `try_send` under the log lock,
  evict-on-overflow, drain never touches a viewer socket; controller keeps the authoritative
  blocking path + advances `delivered_through`). **+ soft viewer cap** to bound thread count (N
  viewers = N threads) — reject/evict beyond the cap.
- **Q2 REFINED → THREE-valued intent (not a binary role).** `AttachIntent = Viewer | Control |
  Take`, `Request.intent` with `#[serde(default)] = Control`:
  - **Control → FREE** endpoint = become controller.
  - **Control → CONTROLLED** = **REFUSE with guidance** ("controlled by `<node>`; `--view` to
    watch or `--take` to control") — a **normal reply**, NOT a new frame, NOT auto-viewer, NOT
    silent-displace.
  - **Take** = kick incumbent + become controller (`Displaced{by}` loud notice).
  - **Viewer** = read-only attach (coexists; never displaces).
  - **N-1 omit → Control**: an old `rc` drives a FREE endpoint as today, but **cannot `--take`** (so
    it can never silently steal) and gets a **clean refusal** on a busy one (the fix).
- **Q2 wire (4 frames) APPROVED**: `Resize{rows,cols}` (controller-only) · `Size{rows,cols}`
  (→viewer, on attach + every controller resize) · `Displaced{by}` (→displaced controller). The
  busy-refusal is a normal reply, not a frame. `Request.intent` defaulted as above.
- **Q3 APPROVED** (clip+1-line indicator when viewer < PTY; center+pad when larger; zero viewer PTY
  input except the local ctrl-b d detach chord).
- **Q4 APPROVED + CONFIRMED**: `driven_by` = controller node + additive `viewer_count: u32`; **the
  BROKER is the SINGLE writer of ALL `driven_by`/`viewer_count` writes** (on controller
  attach/detach/displace + viewer add/remove). The per-attach teardown `mark_driven_by(None)` in
  `serve_attach` (attach.rs:163/183/190) **MOVES OUT to the broker** → resolves the earlier
  clear-race by construction (a displaced operator's teardown never touches the marker).
- **Q5 APPROVED** (fully DETACH the displaced controller, not demote).
- **New-work APPROVED → use `crossterm::event::Event::Resize`** (cross-platform, already a dep —
  skip raw SIGWINCH / Windows-console plumbing) for the controller's window-change `Resize`; **send
  the INITIAL size on attach too** (PTY matches from the start), not only on change.
- **T5 picker (operator-confirmed in step) → STATUS-CONDITIONAL confirm options, 1:1 with the
  intents:**
  - **CONTROLLED** (blue ■, `driven_by=Some`) → **View** (Viewer) + **Kick `<node>` and attach**
    (Take) ONLY. **No plain "Attach"** (it would hit busy-refuse / risk silent displace).
  - **ONLINE, UNCONTROLLED** (`driven_by=None`) → plain **Attach** = Control (become controller),
    plus Start/Fork/etc per the existing W2 confirm layer.
  - Pin/description shows `controlled by <node> (+N viewing)`.
  - Mapping: **Attach→Control, View→Viewer, Kick-and-attach→Take** — all via the EXISTING attach
    dispatch (single-bringup-path holds; intent is a parameter).

## TWO GAPS — RESOLVED (doyle 2026-06-14)
- **Gap A — access gate: viewer vs controller. CONFIRMED v1: viewing is gated IDENTICALLY to
  driving** — a Viewer attach runs the SAME `access_check(endpoint, origin, Unsolicited)` as a
  controller. Rationale: watching reveals full session contents = a real disclosure → the
  drive-equivalent gate in v1 (single-trusted-subnet, mutual trust). **Stated as an EXPLICIT
  decision** in the plan + KH/CONTEXT: "v1: viewing is gated identically to driving; a lighter
  distinct watch-gate is deferred to when cross-subnet / finer consent lands — CONTEXT.md:317
  'driving ≠ watching' is the future seam." No silent inherit.
- **Gap B — dormancy keys on the CONTROLLER ONLY (not the full attacher set).**
  - **Controller** attach → **wake** (active), as today. **Controller** detach → **dormant** (EVEN
    IF viewers remain).
  - **Viewer** attach/detach is **WAKE-NEUTRAL**: never wakes a dormant endpoint, never keeps an
    active one awake. Watching ≠ driving — a viewer must NOT side-effect the drive state (else it
    wrongly perturbs dormancy-driven behaviors: Psyche pulses, echo timing, most-recently-active).
  - A viewer **MAY attach to a DORMANT endpoint** → **watch-as-is** (sees the ring / last state),
    does NOT wake it; when a controller later attaches+wakes+drives, the viewer sees live output
    resume. **Do NOT refuse viewer-to-dormant** (watching a resting session is legitimate).
  - Consistent with `driven_by`/MRA already keying on the controller (the driver). **Impl:** move
    the wake/re-activate edge in `serve_attach` to fire **ONLY for the Controller role**; the Viewer
    path **skips wake + skips the dormant-on-end transition**.

## BUILD PROGRESS (todlando, in flight — UNCOMMITTED on m12-w1-bringup-rc)
- **T2 DONE + GREEN (broker)** — full `spt-daemon` suite 285 + all int green (3× stable, no flake).
  OutputLog → `controller: Option<SharedSend>` + `controller_by: Option<String>` + `viewers:
  HashMap<u64, ViewerSink>` + `endpoint`/`size`. `append` fan-out: controller blocking authoritative
  (advances `delivered_through`), viewers `try_send`→bounded channel (depth 256)→`viewer_writer`
  thread, evict-on-Full/Disconnected (drain never touches a viewer socket); soft cap MAX_VIEWERS=32.
  `resolve_subscribe` identity matrix (by-keyed): free→controller; same-by→silent re-take (GATE #7);
  by=None while remote controls→silent VIEWER (gate#7 local-resume); remote takes undriven(by=None
  ctrl)→silent controller; different-remote Control→`BusyControlled{by}`; different-remote Take→loud
  `Displaced{by}`+take→`TookControl`. spawn pre-attaches controller by=None (undriven placeholder).
  Broker SINGLE-writer of driven_by (`stamp_driven_by`, [impl->REQ-REACH-1] moved here) +
  viewer_count (`set_viewer_count` new in info.rs). `dispatch_resize` controller-gated by `send`
  (is_controller ptr_eq) + `set_size_and_notify` pushes Size to viewers. NEW IPC:
  KIND_SUBSCRIBED{SubscribeOutcome}, KIND_SIZE, KIND_DISPLACED, KIND_UNSUBSCRIBE (+SubscribeReq
  gains intent+by). RACE FIXED: feed_rest (serve thread RMW of info.json) must run BEFORE
  detach_session (broker stamps driven_by=None) so None is the last write — else resting RMW
  restores the stale marker (the clear-race the single-writer model averts; caught via flaky
  detection int test, fixed by ordering in serve_attach EOF).
- **T3 DONE + GREEN (serve_attach)** — intent→role via `Subscribed` outcome: Controller/TookControl
  →wake+flush buffered input; Viewer→wake-neutral+discard input; BusyControlled→refuse (close
  stream, rc renders guidance client-side). Input gated on Request intent (viewer never types) +
  buffered until role confirmed (no PTY leak on busy, no loss on restart-replay). Forwards Size +
  Displaced to wire. Controller-only resting edges. Removed serve_attach's own mark_driven_by
  (broker owns). `attach_registers_remote_drive_detection` updated (labeled spawn + async-clear poll).
- **T0 DONE** — REQ-RCVIEW-1 (doc+impl+unit+int) · REQ-KICK-1 (doc+impl+unit+int) ·
  REQ-HAZARD-VIEWER-ISOLATION (unit+int) added to `traceable-reqs.toml`.
- **T4 DONE (rc)** — `run_attach(id, AttachIntent)`; `spt rc --take` (Take) + `--view` (Viewer) +
  default Control; client-side busy-refuse guidance (reads `current_driver`/driven_by, no dial);
  initial-size-on-attach + window-change Resize via `crossterm::terminal::size()` polling each
  pump slice (cleaner than SIGWINCH/event-loop vs the raw-stdin reader); `Displaced{by}` → loud
  parting line; viewer `Size` → one-line size indicator (true clip/pad letterbox deferred — rc is a
  byte pump, needs a grid model). `send_attach_resize` helper added.
- **T5 DONE (picker)** — `EndpointRow` gains `driven_by`/`viewer_count` (local rows from info.json;
  subnet rows None); `ConfirmOption::Kick`; status-conditional `confirm_options` (CONTROLLED online
  → View+Kick ONLY, no plain Attach; else Attach=Control+full set); `Outcome::Attach{id,intent}`
  (View→Viewer, Attach→Control, Kick→Take — single-bringup-path, intent is a parameter); confirm
  pane pins `controlled by <node> (+N viewing)`.
- **T6 DONE (docs)** — CONTEXT.md §remote-attach marked BUILT (`[doc->REQ-RCVIEW-1]`/`[doc->REQ-KICK-1]`);
  KNOWN-HAZARDS 7.7 `REQ-HAZARD-VIEWER-ISOLATION` + conformance row; broker unit
  `viewer_overflow_or_disconnect_evicts_never_blocks` (`[unit->REQ-HAZARD-VIEWER-ISOLATION]`);
  spt-net unit `kick_take_and_displaced_round_trip` (`[unit->REQ-KICK-1]`); xtask gen (CLI ref).
- **INT suite DONE** — appended to tests/attach.rs: `controller_viewer_matrix_and_loud_take`
  (REQ-RCVIEW-1/REQ-KICK-1 int: free→controller, busy-refuse, viewer coexist, Take→loud Displaced),
  `same_origin_re_subscribe_does_not_displace` (GATE #7 self-kick guard — same-by re-take silent, no
  Displaced), `resize_is_controller_exclusive` (gate #3), `wedged_viewer_does_not_stall_controller`
  (REQ-HAZARD-VIEWER-ISOLATION int).
- **SWEEP GREEN (2026-06-14)** — full `cargo test --workspace` (no failures) · `cargo clippy
  --workspace --all-targets` clean · `traceable-reqs check` EXIT=0 (REQ-RCVIEW-1/REQ-KICK-1
  doc+impl+unit+int, REQ-HAZARD-VIEWER-ISOLATION unit+int all OK) · `cargo xtask check` OK. ALL
  T0–T6 COMPLETE. Ready for doyle W2.5 gate (7 conditions) → full M12 gate.
- **T1 DONE + GREEN (behavior-preserving)** — `AttachIntent{Viewer,Control,Take}` (Default=Control)
  + `Request.intent` (`#[serde(default)]`) + `Resize`/`Size`/`Displaced` records in
  spt-net/attach.rs. All `request_attach` call sites thread intent (rc maps `view`→Viewer/Control;
  tests→Control); `serve_attach` Request arm binds `intent:_` (controller behavior unchanged for
  now). `cargo build -p spt-net -p spt-daemon -p spt` clean; spt-net attach units 4/4 (incl.
  `request_without_intent_defaults_to_control` = N-1 compat, `attach_intents_round_trip`).
- **ARCHITECTURE confirmed** — each inbound attach runs on a FRESH brain connection
  (dispatch.rs `worker` → `connect(broker_name)`), so each attach is a DISTINCT broker IPC
  `SharedSend` → the broker can hold controller + viewers as distinct sinks. `HostedSession.endpoint`
  (broker.rs:209) carries the label, so the broker CAN own the `driven_by`/`viewer_count` writes.
  IPC `subscribe` (brain.rs:998) is fire-and-forget today → the controller/viewer/busy/took OUTCOME
  + the Displaced notice need IPC additions (SubscribeReq role + a reply/outcome + a KIND_DISPLACED
  broker→brain event); resize gate compares the caller `send` to the session's controller.
- **REMAINING (the heavy block):** T2 broker (`OutputLog`→controller+viewers map, fan-out under the
  log lock, per-viewer bounded-channel + writer-thread isolation + evict-on-overflow + soft cap;
  displace→Displaced+close old controller; broker-owned driven_by/viewer_count; resize controller-
  gate) · T3 serve_attach (intent→role, busy-refuse, controller-only wake, viewer wake-neutral,
  Displaced forward) · T4 rc (crossterm Resize + initial size, viewer letterbox clip+indicator,
  Displaced loud render, `kick`/`--take`) · T5 picker (status-conditional View/Kick) · T6 docs +
  int suite (2nd viewer, wedged-viewer eviction, controller-exclusive resize, displaced→loud-notice).

## Risk / scope notes
- Biggest new surface: the broker fan-out + viewer-writer-thread isolation (T2) and rc reading the
  terminal size for the controller `Resize` (T4 — new on the operator side; today rc never resizes).
- ADR-0001 wire territory: the 4 additive `AttachRecord` changes — additive + skip-unknown = N-1-safe
  (no ADR change, but note it in the commit).
- Single-bringup-path (W2 gate invariant) is binding — Kick/View are role parameters on the existing
  dispatch, never a new attach path.
- Build UNCOMMITTED until sweep + doyle gate; then commit the gated batch myself (standing operator
  instruction + "don't wait").
