{
  "summary": "Exact root cause: same-machine `spt rc … --take` is misclassified as a brain-restart reconstruction because controller identity is only the origin node id. A local rc rides the broker’s reused loopback connection, whose inbound streams all carry this node’s own key (`nethost.rs:1661-1692`). `serve_attach` forwards that origin as `SubscribeReq.by` (`attach.rs:242-265`). Therefore the old and new rc both reach `OutputLog::resolve_subscribe` with the same `by`. At `broker.rs:1296-1318`, the `same_identity` arm runs before the `AttachIntent::Take` arm at `1334-1347`; a newer same-node `Take` is consequently treated as a silent successor re-take, returns `SubscribeOutcome::Controller`, and never enqueues `Displaced`. `become_controller` then removes the old `ControllerSink`, increments `controller_epoch`, and installs the new writer (`broker.rs:1005-1068`). Broker truth is still exactly one controller—the new sink—but the old target-side `serve_attach` worker remains alive with cached `ServeRole::Controller`, because sink replacement neither closes its broker connection nor emits a terminal event. Output fan-out now targets only the new sink, so the old rc freezes after any already-queued output drains. Worse, the stale worker can still forward keystrokes: `serve_attach` gates input only on its cached role (`attach.rs:456-493`), while `Broker::dispatch_input` performs no current-controller identity check (`broker.rs:3920-3998`). Resize does perform the correct `is_controller(send)` check (`broker.rs:4200-4213`), proving the missing input authorization seam. The apparent dual-controller state is thus: one broker output controller, two input-capable serve loops, one frozen rc viewport. Persisted control state does not distinguish them: `stamp_driven_by` records only `controller_by`/any-controller (`broker.rs:1458-1472`), and both viewports have the same node id, so `driven_by=<this-node>, controlled=true` remains unchanged and truth-convergence correctly continues to see one current controller (`broker.rs:3351-3410`).",
  "files": [
    {
      "path": "crates/spt-daemon/src/nethost.rs",
      "description": "Same-node identity source. `NetHost::dial_loopback` at lines 1661-1692 returns a reused singleton connection and stamps `remote_id_hex` as this node’s own public key. `open_stream` at 1694+ creates distinct cross-wired stream pairs, but every local rc stream has the same origin node. The unit at 2315+ explicitly proves singleton + own-node origin."
    },
    {
      "path": "crates/spt/src/rc.rs",
      "description": "Operator path. `establish_attach` at 1417-1517 resolves a local session, calls `net_dial_loopback`, opens a fresh attach stream, and sends the requested intent. The old rc pump exits only on an `AttachRecord::Displaced`, stream EOF, exit, or transport failure (`pump`, especially 1955-2003); because the same-node broker path emits none of those, the old terminal remains raw/attached and waits indefinitely while receiving no new PTY output."
    },
    {
      "path": "crates/spt-daemon/src/attach.rs",
      "description": "Wire-to-broker bridge and stale-role source. `request_attach_endpoint` at 658-689 mints a per-request wall-ms `gen` and carries `Take`. `handle_attach_request` at 242-265 converts every non-viewer request into `brain.attach_as(..., Some(origin_node))`. `serve_attach` caches `intent`, `role`, `attached_session`, and `serve_gen` at 336-354; it marks the role Controller on `Subscribed::{Controller,TookControl}` at 515-529. Input at 456-493 is authorized only by that cached role. A real `Displaced` is forwarded with FIN and returns at 549-555, but the same-node path never receives it. Normal EOF teardown at 579-595 uses generation-validated unsubscribe, so a late old detach cannot clear the newer broker slot, although its stale cached Controller role can still fire a resting Detach edge."
    },
    {
      "path": "crates/spt-daemon/src/broker.rs",
      "description": "Root and lifecycle. `ControllerSink`/`OutputLog` at 539-650 separate node identity (`by`), connection identity (`SharedSend`), attach generation, writer queue, and epoch. `become_controller` at 1005-1068 drops the old sink, bumps the epoch, spawns the new writer, and re-stamps control, but does not notify or close the old serve connection. `resolve_subscribe` at 1263-1352 checks same node at 1296 before inspecting `Take` at 1335, making same-node Take a silent re-take. Detach and resize correctly use `Arc::ptr_eq` connection identity (`detach_if` 1396-1429; `is_controller` 1518-1523), but arbitration does not. `controller_writer` at 1671-1778 epoch-gates only the initial batch; after replacement the old live queue may drain, then closes with only a lifecycle log—no broker event to the old serve worker. `dispatch_input` at 3920+ accepts any connection’s session input, unlike controller-exclusive resize at 4200+. Control stamps at 1458+ and sessions convergence at 3321-3428 describe the one current broker slot, not individual viewport ownership."
    },
    {
      "path": "crates/spt-daemon/src/brain.rs",
      "description": "Broker client semantics. `attach_as` at 982-999 carries intent, gen, and node identity. The caution at 1771-1788 already documents the generic failure shape: a same-identity silent re-take orphans the prior local controller, stops its output, does not close its connection, and freezes it. That comment is effectively the mechanism seen here; local rc reaches it with `Some(self-node)` rather than `None` because it uses the loopback network path."
    },
    {
      "path": "crates/spt-daemon/src/msg.rs",
      "description": "Protocol currently conflates operator/viewport with node. `SubscribeReq.by` at 397-414 is documented as the controller identity and `gen` at 415-421 as replay ordering. `SubscribeOutcome` at 424-443 promises Take means TookControl. `DisplacedEvent`/`UnsubscribeReq` at 465-485 carry terminal notice and generation-protected release, but there is no stable attach-stream/viewport identity."
    },
    {
      "path": "crates/spt-net/src/net/attach.rs",
      "description": "Public contract. `AttachIntent` at 39-55 says Control is only for a free endpoint and Take kicks the incumbent. `AttachRecord::Request.gen` at 87-105 is a replay generation, and `Displaced` at 121-125 promises loud notice plus detach. Current same-node behavior violates all three statements."
    },
    {
      "path": "crates/spt-daemon/tests/attach.rs",
      "description": "Coverage gap and deterministic regression seam. `controller_viewer_matrix_and_loud_take` at 1076-1108 proves Take only with different node ids. `same_origin_re_subscribe_does_not_displace` at 1116-1154 uses legacy `gen=0`, plain Control, and intentionally proves the restart self-kick guard; it does not distinguish a new same-node viewport from reconstruction. Add the nonzero-generation same-node Take case here."
    },
    {
      "path": "CONTEXT.md",
      "description": "Lines 381-389 define the binding behavior: one interactive controller, explicit Take always loud/full-detach, Control to controlled always refused, and redispatch reconstruction remains silent based on lifecycle rather than origin identity."
    },
    {
      "path": "traceable-reqs.toml",
      "description": "REQ-RCVIEW-1 and REQ-KICK-1 at lines 535-541 explicitly require one input+resize controller, no silent displacement, Take always loud, and full detach. The current node-only arbitration and unauthenticated input path violate these observable contracts."
    },
    {
      "path": "docs/adr/0038-broker-stream-lifecycle-truth.md",
      "description": "Redispatch design authority. Decision 4 says legitimate successor re-take remains silent, but explicitly says lifecycle—not origin identity—is the discriminator. The implementation currently uses origin identity (`by`) as the primary discriminator, which is exactly why concurrent same-node viewports collapse together."
    },
    {
      "path": "docs/KNOWN-HAZARDS.md",
      "description": "Relevant existing invariants: 7.21 covers writer epoch/order but not logical viewport takeover; 7.29 covers truthful control-stamp convergence. Neither prevents the orphan serve worker because the broker slot and stamp are internally truthful while the old stream lifecycle is not terminated."
    }
  ],
  "architecture": "Transition trace\n\n1. Old local rc opens attach stream S1 through `establish_attach`; loopback gives origin `N=self-node`. Target dispatcher creates serve worker W1 with its own broker `Brain` connection C1. W1 sends `SubscribeReq { intent=Control, by=Some(N), gen=G1 }`. Broker installs `ControllerSink { send=C1, by=N, attach_gen=G1, tx=Q1 }`; writer E1 drains Q1 to C1. W1 caches `role=Controller`; the perch reads `driven_by=N, controlled=true`.\n2. New local `rc --take` opens a different attach stream S2, but the reused loopback connection gives the same origin N. Target creates W2/C2 and sends `SubscribeReq { intent=Take, by=Some(N), gen=G2 }`, normally G2>G1.\n3. In `resolve_subscribe`, `controller_by()==by` is true. Generation validation rejects only G2<G1; newer/equal generations enter silent re-take. The function never reaches the Take match. No `CtrlMsg::Control(Displaced)` is created and W2 receives `SubscribeOutcome::Controller`, not `TookControl`.\n4. `become_controller` takes/drops Q1’s sink, increments `controller_epoch`, and installs Q2/E2/C2. OutputLog now has exactly one controller: C2. E1’s initial replay is epoch-canceled; its already-queued live frames can still drain because the live loop deliberately has no epoch gate, after which Q1 closes and E1 exits. Writer exit emits only `CONN_LIFECYCLE`; it does not close C1 or produce `BrokerEvent::Displaced`/EOF.\n5. W1 is still subscribed to S1 and still caches Controller. S1 is ConnectionBound to the old rc’s still-live broker connection, so no FIN is generated. Old rc remains in `pump`, gets no output, no Displaced, and no EOF. W1 continues converting old rc Input records to `KIND_INPUT`; broker accepts them because `dispatch_input` never validates C1 against the current controller C2. The old rc therefore still types into the PTY while only the new rc renders output. Old resize is rejected because resize does validate C1; the ignored error makes this asymmetry invisible to the operator.\n6. The persisted stamp stays stable because both old/new identities are N and the slot never becomes empty. If W1 later detaches, `detach_session_gen(G1)` cannot clear C2/G2, which is correct for the broker slot. However W1’s cached Controller role fires `feed_rest(Detach)` before that no-op unsubscribe, so stale teardown can also mark the endpoint dormant while W2 is still driving—an adjacent lifecycle consequence.\n\nWhy this is deterministic, not a writer race\n\nThe decisive branch is pure: same `by`, non-older `gen`, any non-Viewer intent => silent `become_controller`. Queue timing changes only how many final old output frames drain. It cannot create a Displaced frame because the only enqueue site is in the later different-remote Take branch. The apparent dual state does not require queue overflow, a stalled pipe, brain restart, or persisted-state corruption.\n\nFalsifiable regression test\n\nAdd `same_node_take_displaces_distinct_viewport_and_revokes_old_input` beside `controller_viewer_matrix_and_loud_take` in `crates/spt-daemon/tests/attach.rs`, using real broker connections and pump-mode bounded reads:\n\n- A: `attach_as(sid, 0, Control, 100, Some(\"node-A\"))`; require `Controller`.\n- B: `attach_as(sid, 0, Take, 200, Some(\"node-A\"))`; this models two viewports on one node but distinct requests.\n- Required contract: B receives `TookControl`; A receives exactly one `BrokerEvent::Displaced { by: \"node-A\" }` and then its serve terminates; only B receives subsequent PTY output.\n- Authorization assertion: after takeover, input submitted over A/C1 must be rejected/not reach the PTY; input over B/C2 must echo to B. Resize already provides the reference implementation for this identity check.\n- Stamp assertion with a seeded perch: throughout the atomic takeover, `controlled=true` and `driven_by=node-A`; A’s late generation-100 unsubscribe must not clear B/G2.\n- Reconstruction control arm: repeat a subscribe with the SAME logical attach token/request generation and require silent `Controller`, no Displaced, preserving ADR-0038 redispatch behavior.\n\nCurrent-code prediction `[INFERENCE, source-determined; tests were intentionally not executed under the read-only/skip-tests constraint]`: B returns `Controller`, A times out waiting for Displaced, PTY output appears only on B after Q1 drains, and input sent by A is accepted and echoed to B. This distinguishes the bug from a broker dual-slot claim.\n\nA production-faithful manual repro is equally sharp: run a continuously repainting/echoing hosted endpoint; attach rc A locally; attach rc B locally with `--take`; type a distinct marker in A. Current prediction: A receives no displacement notice and freezes visually, B keeps rendering and shows A’s marker. Cross-node B (different node id) instead reaches the existing loud branch, isolating same-node identity collapse.\n\nSource-level fix proposal\n\nA mere reordering of `Take` before `same_identity` is insufficient: it would make a redispatched Request whose original intent was Take self-kick after a target brain restart. The correct discriminator is the attach stream/viewport lifecycle, exactly as ADR-0038 requires.\n\n1. Add a stable logical attach id to the broker subscribe state. Best source is the accepted attach `stream_id`, paired with `origin_node`: `(by, stream_id)` is unique per viewport, remains identical when the SAME broker-held stream is redispatched after a brain cycle, and differs for a second rc even on the reused same-node loopback connection. Thread it from `serve_attach` through `Brain::attach_as`/`SubscribeReq` into `ControllerSink`. Keep it additive/defaulted for N-1. Do not rely solely on wall-ms `gen`: it can collide within a millisecond and is ordering metadata, not identity.\n2. Resolve by logical ownership, not node:\n   - Viewer: unchanged.\n   - No controller: install.\n   - Same attach id (same stream reconstruction or same-worker gap resume): silent/idempotent re-take; no Displaced.\n   - Different attach id + Take: revoke incumbent, return TookControl, regardless of whether `by` is the same node or a different node.\n   - Different attach id + Control: BusyControlled, including a second same-node plain rc.\n   - Preserve the explicit local-spawner-placeholder rule (`by=None`, no operator attach id) so the first real rc can claim an otherwise undriven spawned session without a fake kick. Legacy absent-id behavior must be deliberately compatibility-gated rather than allowed to redefine modern semantics.\n3. Make displacement a guaranteed terminal lane, not `old.tx.try_send`. Today the different-node path at `broker.rs:1343-1344` ignores `Full`/`Disconnected`; a full 4096-frame controller queue can lose the loud notice and reproduce the same orphan shape cross-node. Give `ControllerSink` a separate revocation/terminal slot observed by its writer, set it before dropping the output sender, have the writer abort queued output, perform one bounded `Displaced` write, and exit. If the physical write fails, poison/close the old connection so its serve worker observes EOF. Never block the OutputLog/drain lock. This keeps output backpressure bounded while making terminal teardown non-droppable.\n4. Enforce current controller identity on input at the broker, mirroring resize. Clone the session log with the input writer and reject `KIND_INPUT` unless `log.is_controller(send)` (or, after the stronger model, its attach id matches). This closes the interval before the old serve consumes Displaced and prevents any orphan/stale worker from remaining input-capable. Viewer and endpoint-injection paths remain separate.\n5. Make serve teardown authority-derived. A displaced path already correctly avoids a dormant edge. For normal EOF, do not fire `feed_rest(Detach)` solely from cached `ServeRole::Controller`; have unsubscribe report whether this worker actually released the current controller (or otherwise validate attach id), and emit the rest edge only for the current owner. This prevents an old same-node viewport’s late close from putting a still-controlled endpoint dormant.\n6. Preserve writer invariants: bump epoch before any successor output; epoch-cancel old data writers; serialize the terminal Displaced after any already-in-flight frame but before successor-visible teardown completion; keep `delivered_through` advanced only by successful controller Output, never by terminal control. Keep the stamp continuously controlled across replacement and let the old generation/token’s late detach be a no-op.\n\nSecondary robustness finding\n\nEven outside the same-node root, `old.tx.try_send(CtrlMsg::Control(displaced))` followed immediately by sink replacement is best-effort. If Q1 is Full, the result is discarded; the old writer drains/ends without any notice and its serve stream can remain alive. A gate-pinned writer plus a filled controller channel makes this independently deterministic. The terminal-lane fix above should therefore accompany the identity fix; otherwise same-node takeover is repaired while the stated `REQ-KICK-1` loud/full-detach contract remains falsifiable under backpressure."
}