# ADR-0044: Controller lease truth — unique lease identity, loud takeover, broker input fencing

- Status: accepted (doyle triage 2026-07-18, RC-RENDER-TRUTH milestone, W2)
- Source: hertz same-machine `--take` split-brain RCA (2026-07-16 field repro, filed
  2026-07-18; doyle seam-verified same day: `resolve_subscribe` same-identity branch,
  `dispatch_input` unfenced). KNOWN-HAZARDS 7.48. Kin: ADR-0038 Amendment fix-6
  (ownership/generation validation — the generation half of this contract), ADR-0040
  (stream lease classes), KH 7.41 (control steal via replay).

## Context

Field reproduction: endpoint controlled in terminal A; terminal B on the SAME machine ran
`spt rc <id> --take`. A stayed attached and kept sending input — but stopped receiving
output. Confirmed source-level split-brain:

1. Local rc attaches over broker loopback, whose origin is the NODE identity — two
   windows on one node are indistinguishable at the current `by` granularity.
2. `OutputLog::resolve_subscribe` computes `same_identity = controller_by() == by` and
   takes the silent same-identity successor path even for a distinct `--take` — intent is
   not consulted in that branch. It replaces the output sink without sending `Displaced`
   or closing the old attach stream. The only loud-displace branch is
   different-remote + Take.
3. `dispatch_input` accepts `InputReq { session_id, .. }` without proving the sender
   still owns the controller slot — the displaced window's input still reaches the PTY.

Net: terminal A remains input-capable but renders nothing; terminal B renders but shares
control. The frozen viewport is the expected consequence of sink replacement; the defects
are missing lease identity, missing input fencing, and the same-node silent-retake
policy. `brain.rs` already documents the general `None==None` shape of this defect.

## Invariant

At most ONE input-capable controller lease exists per PTY session. Replacing that lease
atomically revokes the old lease, delivers/closes its attach stream, and rejects every
later Input/Resize from the old lease. Node identity is authorization/attribution — it is
NOT a unique controller lease identity.

## Decisions

<!-- [doc->REQ-CONTROLLER-LEASE-IDENTITY] -->
1. **Distinct viewport/lease identity.** Each rc invocation/attach stream mints a unique
   controller lease id, carried through SubscribeReq, the controller slot, and
   Input/Resize. Node identity is kept separately for display/access policy. The ONLY
   silent successor/replay case is same-lease + equal-or-newer generation (the ADR-0038
   fix-6 dispatcher-recovery contract, preserved exactly). Same node but different lease
   is a DISTINCT controller.
2. **Explicit Take always revokes a distinct incumbent loudly — and revocation is
   authoritative, notification is not.** If `intent == Take` and the incumbent lease
   differs: atomically revoke/fence the old lease and FORCE its attach stream closed,
   then install the taker — regardless of whether the `by` node strings match. The
   `Displaced` notice is enqueued best-effort through the old controller's writer, but
   the fix must never rely on it: today's `old.tx.try_send(...)` can drop the notice on a
   Full queue while `become_controller` still replaces the sink — the revoke/close must
   land even when the notice enqueue fails (the closed stream is itself the terminal
   signal rc already handles). Silent takeover is never keyed on
   `controller_by() == by` alone. An acceptable identity seam is
   `(by, nonzero attach generation)` as the logical lease: equal generation = redispatch
   successor, silent; distinct lease + Take = loud revoke; distinct lease + Control =
   Busy.

   **Generation-ladder refinement (doyle ruling 2026-07-18, W2 build — resolves the
   fix-6/Busy conflict todlando surfaced via redispatch_stall T6):** within the SAME
   `by` identity the attach generation stays the lifecycle discriminator, exactly as
   ADR-0038 fix-6 rules — but supersession is LOUD and FENCED instead of silent:
   - **equal generation** → silent successor (dispatcher recovery re-serving the same
     Request) — unchanged;
   - **strictly newer generation** (a genuinely newer viewport, `Control` or `Take`) →
     supersedes the older-gen incumbent with the full W2 revoke: best-effort Displaced,
     attach stream force-closed, input fence moves with the slot. Without this a live
     replacement viewport bounces Busy off a stale-replay worker serving a zombie stream
     to a dead viewport indefinitely (no reaper exists for that shape) — a post-restart
     lockout, order-dependent, a regression against fix-6's order-independence (T6);
   - **older generation** (stale replay) → refused Busy — unchanged.

   Across DIFFERENT `by` identities the intent split stands: `Control` = Busy, `Take` =
   loud revoke. Deliberate, documented UX consequence: a second same-node window's plain
   `rc` now LOUDLY displaces the first (newest viewport wins within one identity) — the
   pre-W2 behavior was the same replacement done SILENTLY with the loser left interactive
   and blind; loud + fenced is strictly better on every axis, and `--view` remains the
   coexistence path. Every ruled invariant holds: at most one input-capable lease, the
   incumbent always ends terminally, a displaced window can never type, equal-gen
   recovery stays silent, older-gen replay stays refused.
   <!-- [doc->REQ-INPUT-CONTROLLER-FENCE] -->
3. **Broker-enforced input fencing (required defense, not optional hardening) — scoped
   to RC-origin input.** RC-origin Input/Resize are bound to the active controller lease
   (or originating broker connection); commands from a displaced/stale lease are
   rejected/dropped after replacement. This is what makes the one-controller invariant
   TRUE even when a displacement notification is delayed or lost. The fence does NOT
   globally gate generic `KIND_INPUT`: shell/system injection legitimately sends
   `InputReq` from non-controller connections (`Minter::Shell` et al.). The fence keys on
   an additive controller-ownership token validated only for token-bearing /
   `Minter::Rc` requests — or a dedicated guarded RC-input verb; token
   optional/default-none preserves generic injection semantics exactly.
4. **N-1 wire discipline.** The lease id is an additive field; an absent lease (older
   client) degrades to today's node-identity semantics for that subscriber only, and the
   fence treats connection identity as the lease surrogate so the invariant still holds
   for mixed-version attaches.

## Consequences

- Two same-machine windows behave exactly like two operators: `--take` displaces loudly,
  the loser's rc exits through its existing `PumpEnd::Displaced` path (already
  implemented client-side — the broker simply never emitted the event on this path).
- A displaced viewport can never keep typing into the PTY, regardless of notification
  timing.
- Genuine dispatcher recovery (same lease, equal/newer generation) remains silent — the
  regression suite proves both directions.

<!-- [doc->REQ-DRIVEN-BY-OWN-NODE-NORMALIZE] -->
### v0.38.1 consequence note — `driven_by` own-node truth (ruling v3)

W1's canonical-wire-id work routes a same-node rc attach through `serve_attach`, so the
controller's `by` legitimately carries the daemon's OWN node hex. Per CONTEXT.md:386,
`driven_by` is the broker-single-writer "controller node" datum — own node INCLUDED; the
old stamp comment's remote-only claim was an artifact of the legacy local path (`by =
None`), not the model. The own-hex latch is therefore TRUTHFUL and stays. Its client-side
consequence is also the documented design: plain `Control` on a CONTROLLED endpoint is
refused with guidance (never silent-displace; `--take` is the opt-in kick), and that
refusal now correctly fires for a second window on the SAME machine too — the guidance
copy names the state plainly ("controlled from another window on this machine") instead
of a raw node hex. The W2 generation ladder is unchanged and stays reachable exactly
where it belongs: the recovery seams (the rc reconnect re-drive and dispatcher re-serve
enter at the broker, below the client gate), which is what keeps post-restart recovery
order-independent without ever letting an ordinary second `spt rc` steal control.
