# Unbound endpoint state + attach-on-session-exists

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->

## Status

accepted (2026-06-23; v0.14.0 endpoint-creation-flow, W6 int keystone green — `multi_subnet_bringup_e2e` proves a fresh hold-unbound endpoint is `spt rc`-attachable pre-bind, and `picker::data::tests::gather_renders_live_unbound` proves the real on-disk→render Unbound seam). Sibling of ADR-0026 (multi-subnet home at creation). Delivered in the endpoint-creation-flow milestone (post-v0.13.2).

## Context

`spt endpoint run`, for an attach/view bringup, waits for the perch to reach `STATUS_ONLINE` — i.e. for the harness to **bind** — before attaching (`await_endpoint_online`, REQ-HAZARD-RC-ATTACH-ONLINE-RACE). That gate was added because attaching too early lost the handshake three ways (empty ring → EOF, the offline status-gate, no live session yet).

But the broker creates the **session + PTY + OutputLog at spawn — before bind**. Gating the attach on *bind* (perch online) deadlocks a real case: a harness that shows a startup prompt **before** it binds (e.g. Claude Code waiting for the user to clear an initial prompt) never binds until the operator interacts — but the operator can't see or interact, because nothing is attached until bind. Result: the operator stares at a 25s timeout for a prompt they were never shown. The attachable thing (the broker session/PTY) exists well before the gate fires.

## Decision

**Gate the attach on the broker SESSION being attachable, not on the perch being bound/online**, and surface the in-between lifecycle point as a first-class state.

- **Attach-on-session-exists.** `cmd_endpoint_run` (and `spt rc <id>`) attach as soon as a **live broker session** exists for the id — regardless of perch status — so the operator sees and drives the harness immediately, including clearing a bind-gating prompt. This also makes a **headless** bringup attachable before/without bind. The source of truth is the broker `sessions` map (the per-endpoint registry, ADR-0025 W3a). This *preserves* REQ-HAZARD-RC-ATTACH-ONLINE-RACE's "don't attach before there's a session" intent — it just moves the gate from the later perch-online point to the earlier session-exists point. Local-only: a remote row can't see another node's broker (renders plain, like the existing controllability limitation).

- **New on-disk status `STATUS_UNBOUND`.** The daemon stamps `UNBOUND` at spawn; `bind` flips it to `ONLINE`; session death → `OFFLINE`. (Chosen over a purely-derived display so the state is explicit and queryable in the daemon-authoritative liveness model.)
  - **Lifecycle reuses the existing reconcile/exit-waiter** — a dead broker session → `OFFLINE` (the same path that marks dead-but-latched perches offline). No new teardown; `UNBOUND` auto-resolves (→`ONLINE` on bind, →`OFFLINE` on session death).
  - **`UNBOUND` is attachable but NOT message-addressable** — it has a live PTY but no bound `session_id` yet, so messaging stays gated on `ONLINE`/bound while attach is gated on session-exists.

- **Display.** `EpDisplay` gains an `Unbound` variant. **Amber is already taken** (`HarnessOnly` = online-but-not-broker-controllable — the semantic *opposite* of `Unbound`, which *is* broker-attachable), so `Unbound` uses a **hollow** treatment (the existing filled/hollow square precedent), plus a **controlled-variant** (an `Unbound` endpoint a node is driving) — i.e. hollow vs hollow-controlled, paralleling the filled Online vs Controlled pair.

## Considered Options

- **Keep `await_endpoint_online` + a grace-then-attach-anyway fallback** — a race-prone timer heuristic still anchored on bind, rather than fixing the gate's basis. Rejected.
- **Derive `Unbound` in the picker only (no on-disk status)** — one source (broker session map), no new liveness status; but the operator chose an explicit on-disk `STATUS_UNBOUND` (queryable, honest in `endpoint list`, not just picker-local). Accepted the extra reconcile/teardown edges that come with it.
- **Reuse amber for `Unbound`** — semantically wrong (amber = not-controllable; Unbound is attachable) and ambiguous against `HarnessOnly`. Rejected; hollow instead.

## Consequences

- A new on-disk liveness status (`STATUS_UNBOUND`) — the liveness predicates and reconcile/teardown must treat it honestly (attachable, not message-addressable; auto-resolves).
- `spt rc <id>` works against an unbound endpoint (a live session with no bind) — useful for headless bringups and for clearing a bind-gating prompt.
- The attach gate moves earlier (session-exists), removing the prompt-before-bind deadlock while keeping the original race protection.
- `EpDisplay` grows (hollow + hollow-controlled); the picker/legend gain the Unbound state.
