required_stages = ["doc", "impl", "unit"]  # v0.13.2 W6. doc = DOCS-STRATEGY public-help no-internal-codes rule. impl = (a) the forbidden-token scan in xtask check over reference.md; (b) the sweep of all existing clap-/// leak sites. unit = xtask test: a planted forbidden token FAILS the scan, a clean reference PASSES (non-vacuous).
=======
# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──

required_stages = ["doc", "impl", "unit"]  # v0.13.2 W6. doc = DOCS-STRATEGY public-help no-internal-codes rule. impl = (a) the forbidden-token scan in xtask check over reference.md; (b) the sweep of all existing clap-/// leak sites. unit = xtask test: a planted forbidden token FAILS the scan, a clean reference PASSES (non-vacuous).

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──


The file C:\Users\decid\Documents\projects\spt-core\traceable-reqs.toml has been updated successfully. (file state is current in your context — no need to Read it back)

required_stages = ["doc", "impl", "unit"]  # v0.13.2 W6. doc = DOCS-STRATEGY public-help no-internal-codes rule. impl = (a) the forbidden-token scan in xtask check over reference.md; (b) the sweep of all existing clap-/// leak sites. unit = xtask test: a planted forbidden token FAILS the scan, a clean reference PASSES (non-vacuous).
=======
# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──

required_stages = ["doc", "impl", "unit"]  # v0.13.2 W6. doc = DOCS-STRATEGY public-help no-internal-codes rule. impl = (a) the forbidden-token scan in xtask check over reference.md; (b) the sweep of all existing clap-/// leak sites. unit = xtask test: a planted forbidden token FAILS the scan, a clean reference PASSES (non-vacuous).

# ── Endpoint-creation-flow milestone (operator grill 2026-06-22; ADR-0026/0027) ──

title = "Public CLI --help (the clap `///` doc-comments) and the generated `docs-site/src/cli/reference.md` MUST NOT contain internal tracker/decision codes — `REQ-*`, `F-###`, `M#-W#`, `ADR-####`. They are meaningless to an end user reading --help and ship to GH-Pages. A CI-gated scan (the `xtask check` docs gate) fails on any such token in the GENERATED reference.md (which by construction contains only clap help, so rustdoc `///` on fns/structs is OUT of scope and keeps its REQ/ADR cross-refs). Substance is kept; codes are rewritten to plain language. (v0.13.2 W6)"

# doc stage ACTIVE (the ADR IS the doc evidence); impl/unit/int activate at the milestone build (rule 5).


Edit

required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.
>>>>>>> 5fe1f0e (docs: ADR-0026/0027 endpoint-creation-flow milestone (operator grill))

required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.


required_stages = ["doc"]  # doc = ADR-0027 + CONTEXT "Unbound endpoint". impl/unit/int activate at the milestone build.
>>>>>>> 5fe1f0e (docs: ADR-0026/0027 endpoint-creation-flow milestone (operator grill))

title = "An spt-hosted endpoint is ATTACHABLE between spawn and bind: gate the attach on the broker SESSION being attachable (session+PTY+OutputLog exist at spawn, before bind), not on perch STATUS_ONLINE (bind). cmd_endpoint_run + `spt rc <id>` attach to a live broker session regardless of perch status (headless bringups too; lets an operator clear a bind-gating prompt) -- replaces await_endpoint_online; preserves REQ-HAZARD-RC-ATTACH-ONLINE-RACE's 'no attach before a session' intent at the earlier session-exists point; source = the broker sessions map (ADR-0025 W3a); local-only. New on-disk status STATUS_UNBOUND (spawn->unbound, bind->online, death->offline); lifecycle reuses the existing exit-waiter/reconcile (session death->offline); unbound is attachable but NOT message-addressable (messaging stays online/bound-gated). EpDisplay gains Unbound = HOLLOW (+ hollow-controlled variant) -- amber=HarnessOnly is taken + means not-controllable (the opposite of attachable). (ADR-0027)"

->>>>>>> 5fe1f0e (docs: ADR-0026/0027 endpoint-creation-flow milestone (operator grill))


Union resolved. Stage + continue rebase (avoid editor hang):


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && git add -A && GIT_EDITOR=true git rebase --continue 2>&1 | tail -8 && echo "===log===" && git log --oneline -4

Continue rebase after resolving conflict


[detached HEAD 305d308] docs: ADR-0026/0027 endpoint-creation-flow milestone (operator grill)
 4 files changed, 94 insertions(+)
 create mode 100644 docs/adr/0026-multi-subnet-home-at-endpoint-run-creation.md
 create mode 100644 docs/adr/0027-unbound-endpoint-state-and-attach-on-session.md
Rebasing (2/2)

Successfully rebased and updated refs/heads/design/endpoint-creation-flow.
===log===
30bc260 docs: v0.14.0 endpoint-creation-flow JIT build plan
305d308 docs: ADR-0026/0027 endpoint-creation-flow milestone (operator grill)
2bb9c8f release: v0.13.2 — bump 0.13.1->0.13.2 + changelog + regen CLI reference
a2667de Merge pull request #29 from SaberMage/v0.13.2-adapter-packaging


Clean rebase: design branch now = 30bc260 (JIT) → 305d308 (ADR/REQ) → 2bb9c8f (v0.13.2). Confirm traceable EXIT=0, then force-push the rebased branch:


cd "C:/Users/decid/Documents/projects/spt-core" && traceable-reqs check >/dev/null 2>&1; echo "TRACE_EXIT=$?" && echo "===push rebased branch===" && git push --force-with-lease origin design/endpoint-creation-flow 2>&1 | tail -5

Traceable check + force-push rebased branch


/spt:send doyle a message about W4 for his gate and approval


TRACE_EXIT=0
===push rebased branch===
To https://github.com/SaberMage/spt-core.git
 + 7aab0fb...30bc260 design/endpoint-creation-flow -> design/endpoint-creation-flow (forced update)


STEP 0 complete: rebased clean onto v0.13.2, **traceable EXIT=0**, force-pushed → origin/design/endpoint-creation-flow @30bc260.

Per doyle's green-light + project rules (context high from the W4–W6 + release run), I'll commune + self-clear to start W1 fresh. First read the JIT (authority) to ground the commune:


1	# v0.14.0 — Endpoint creation flow (multi-subnet home + Unbound state): JIT build plan
2
3	**Design (operator grill, 2026-06-22):** ADR-0026 (multi-subnet home at `endpoint run` creation) + ADR-0027 (Unbound endpoint state + attach-on-session-exists). CONTEXT.md term: **Unbound endpoint**. Two REQs registered (doc-active, this branch): `REQ-RUN-MULTISUBNET-HOME`, `REQ-ENDPOINT-UNBOUND-ATTACH`. MINOR bump (new endpoint-creation UX + a new on-disk liveness status).
4
5	**PREREQUISITE (binding):** start v0.14.0 from `main` **after v0.13.2 has merged** — this milestone builds on v0.13.2's broker `sessions` map carrying the per-endpoint `endpoint`/`adapter`/`install_dir` fields (ADR-0025 W3a), the source of truth for "is there a live session for this id." Rebase/merge this design branch (ADR-0026/0027 + the two REQs + this JIT) onto post-v0.13.2 main before activating.
6
7	**Motivating real-world case:** perri's multi-subnet bringup gap (operator-hit, 2026-06-22) — a **latent** gap (not a regression; `HOME_REFUSED` is the established ≥0.11.0 no-guess policy), exposed when the node crossed 1→2 subnets (BIGNET added). The spt-hosted bringup never supported a multi-subnet home. W2 closes it; perri's repro is the W6 regression test (**MUST run on a multi-subnet node** — a single-subnet node auto-homes and hides the gap).
8
9	**The two "resident" facts that shape the waves** (verified at code, the grill):
10	- Home is **immutable** (ADR-0010). `establish_perch → stamp_creation_fields` (home.rs:122) **inherits** a prior perch's `home_subnet` (prior-branch) and only `assign_home`s on the fresh (None) branch. So the gap is **only first-creation** on multi-subnet; pre-creating a skeleton perch WITH the home makes bind inherit it — no hook/env change.
11	- The broker (`spt daemon run`) and the brain (`spt daemon brain`) are **separate processes** (v0.13.2 W3c finding); the broker `sessions` map is the IPC-reachable, brain-restart-surviving source of "live session." Attach + the Unbound display both read it.
12
13	---
14
15	## W1 — `STATUS_UNBOUND` status primitive + lifecycle  (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
16	Foundational — the skeleton (W2), the attach (W4), and the display (W5) all key on it.
17	- **Scope:** add `STATUS_UNBOUND` (`liveness.rs:37`, beside `STATUS_ONLINE`/`STATUS_OFFLINE`). Lifecycle transitions: spawn / skeleton-create → `unbound`; `bind` (cmd_bind establish-online) → `online`; broker session death → `offline` via the EXISTING exit-waiter/reconcile (the B2/B5 dead-but-latched path — reuse, no new teardown). Liveness predicates: an `unbound` perch is **attachable** (a live broker session exists) but **NOT message-addressable** (no bound `session_id`) — `is_perch_alive` / deliver resolution stay gated on `online`/bound; a new "has a live session" predicate (reads the broker sessions map) backs attach + display.
18	- **Files:** `crates/spt-store/src/liveness.rs` (the constant + predicates); the reconcile/exit-waiter that stamps offline (livehost.rs / broker session-exit path); `info.json` status writer.
19	- **Gate:** unit (the transitions; `unbound` is attachable-not-addressable; reconcile resolves a dead-session `unbound` → `offline`). doc = ADR-0027 + CONTEXT (active). No int yet (composed in W6).
20
21	## W2 — Home resolution + skeleton-with-home  (REQ-RUN-MULTISUBNET-HOME, ADR-0026) — the multi-subnet UNBLOCK
22	- **Scope:** at `endpoint run`'s skeleton-create step, resolve the home and **pre-create the skeleton perch** (status `unbound` from W1, `home_subnet` set) BEFORE spawning the harness; the hook's `bind` then inherits home via `establish_perch`'s prior-branch (UNBOUND→ONLINE). Resolution policy:
23	  - sole subnet → auto (`assign_home`, unchanged);
24	  - multi-subnet + no `--subnet` + **non-interactive** (`std::io::IsTerminal` on stdin, the cli.rs:1006/3956 precedent) → **refuse early** (`MULTI_SUBNET_HOME:` clear message + the subnet list) — never the silent 25s timeout;
25	  - multi-subnet + no `--subnet` + **interactive** → print the proposed config (id / project / adapter[:profile] / home = the default) + `Ok to proceed? Y/n`; `n` → print the `--subnet` guidance;
26	  - `--subnet <name>` overrides + validates membership (`assign_home` `NotMember`).
27	  - The default home (for the confirm) = a **node-global last-used** value to start (W3 upgrades it to the two-level lists).
28	- **Files:** `cmd_endpoint_run` (cli.rs:~1384, where it spawns straight to `launch_harness_brokered_in` today — add the resolve + skeleton-write before it); a `--subnet` arg on `endpoint run`; `assign_home` (home.rs:71) reused; the skeleton write via `info::write_info` (status `unbound`, `home_subnet`, `state=live_agent`, `adapter`, `cwd`). Conflict-safe: a skeleton has an empty `session_id`, so `establish_perch`'s Conflict check (owner-alive ∧ session-mismatch) never trips.
29	- **Gate:** unit (resolution matrix: sole-auto / multi-noninteractive-refuse / multi-interactive-confirm / --subnet override+NotMember; the skeleton carries the resolved home; bind inherits it via prior-branch). doc = ADR-0026 + CONTEXT. (int in W6.)
30
31	## W3 — Two-level MRU home lists  (REQ-RUN-MULTISUBNET-HOME)
32	Upgrades W2's single-value default to the full UX.
33	- **Scope:** persisted **ordered move-to-front LISTs** of recently-chosen home subnets at **two levels**: per-project (keyed by project_id) + an always-updated node-global list (the fallback for a project with no list yet). The interactive default home = project-list head ?? node-global head; the interactive confirm + the `--subnet` refusal guidance list subnets **MRU-ordered**. Both lists update (move-to-front) on every home choice (run-confirm or explicit `--subnet`).
34	- **Files:** a small MRU store under `$SPT_HOME` (node-global) + per-project (project store / `p-<project>` adjacency or a sibling file); the `endpoint run` resolve path reads/writes it; project_id from the run cwd (`project_id_for_dir`).
35	- **Gate:** unit (move-to-front; project-then-node-global precedence; node-global fallback for an MRU-less project; the guidance/confirm ordering). doc = CONTEXT (the MRU note on the home-subnet term).
36
37	## W4 — Attach-on-session-exists  (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
38	- **Scope:** gate the attach on the broker **session being attachable**, not on perch `STATUS_ONLINE`. Replace `await_endpoint_online` (cli.rs:1434) with an await-session-attachable poll (reads the broker sessions map / a "has live session" query, the W1 predicate). `cmd_endpoint_run` AND `spt rc <id>` attach to a live session **regardless of perch status** (so an `unbound` endpoint is attachable — headless bringups too, and the operator can clear a bind-gating prompt). Preserve REQ-HAZARD-RC-ATTACH-ONLINE-RACE's "no attach before a session" intent at the earlier session-exists point; bounded deadline; local-only (a remote row can't see another node's broker).
39	- **Files:** `cmd_endpoint_run` (cli.rs:1426-1447, the await+attach block); `run_attach` / `spt rc` status-gate (rc.rs) — attach when a session exists, not only when online; the broker "has live session for id" query (over the W3a sessions map).
40	- **Gate:** unit (the gate keys on session-exists not perch-online; the offline-but-session-live case attaches; no-session still awaits/fails). int folded into W6.
41
42	## W5 — `Unbound` display  (REQ-ENDPOINT-UNBOUND-ATTACH)
43	- **Scope:** `EpDisplay` gains `Unbound` (picker/model.rs:55; today Offline/Online/HarnessOnly/Controlled). Render **hollow** (the existing filled/hollow-square precedent) + a **hollow-controlled** variant (an `unbound` endpoint a node is driving) — paralleling filled Online vs Controlled. **NOT amber** — amber is `HarnessOnly` (online-but-not-broker-controllable), the semantic opposite of `Unbound` (which IS broker-attachable). Derivation: `STATUS_UNBOUND` (+ `driven_by` for the controlled variant). Surface in the picker, `endpoint list`, and `whoami`. Remote rows render plain (the existing controllability limitation, picker/data.rs:254).
44	- **Files:** picker/model.rs (`EpDisplay::Unbound` + label/legend + the type-gated derivation), picker/data.rs (derive from status), `endpoint list`/`whoami` renderers.
45	- **Gate:** unit (status `unbound` → `EpDisplay::Unbound`; + driven_by → the controlled variant; offline/online/harness-only unchanged; remote renders plain). doc = the picker legend.
46
47	## W6 — int keystone + the multi-subnet regression  (both REQs)
48	- **Scope:** real-daemon e2e (reuse the v0.12.1 dummy-harness fixture, NO mocks):
49	  - **perri's multi-subnet repro (the regression test, MUST run on a multi-subnet node):** join a 2nd subnet → `spt endpoint run --adapter X --id Y` (no `--subnet`, non-interactive) → REFUSES clearly (not a 25s timeout); with `--subnet S` → the skeleton homes to S, the harness binds (UNBOUND→ONLINE), no `HOME_REFUSED`. A single-subnet control proves auto-home still works.
50	  - **attach-before-bind:** `endpoint run` (or `spt rc`) attaches while the endpoint is `unbound` (session live, not yet bound), the operator drives it, then bind flips it ONLINE.
51	  - **interactive confirm:** (where a TTY can be simulated) the proposed-config + Y/n path; non-interactive refuse.
52	- **Activate:** `REQ-RUN-MULTISUBNET-HOME` → `[doc,impl,unit,int]`; `REQ-ENDPOINT-UNBOUND-ATTACH` → `[doc,impl,unit,int]`. Flip ADR-0026/0027 status → **accepted** (with any build amendments, the ADR-0025 precedent).
53	- **Gate:** doyle gates the int hard against perri's repro; **perri validates on real claude-spt at publish** (her queued v0.13.3/v0.14.0 validation).
54
55	---
56
57	## Build order, gating, ship
58	**Order:** W1 (status primitive) → W2 (home resolve + skeleton — the unblock) → W3 (MRU lists) → W4 (attach-on-session) → W5 (Unbound display) → W6 (int + regression). W2/W4/W5 share the broker sessions map + the perch status seam — sequential, doyle gates each (CI green BOTH runners per wave; the contended Windows host can't run the lifecycle/daemon suite — CI is authority). Activate each REQ's real stages at its wave (rule 5).
59
60	**Scope note (operator decision):** shipped as ONE milestone (not split into a minimal-unblock fast-follow) — the operator accepted staying multi-subnet-blocked until the cohesive landing. The whole thing is v0.14.0.
61
62	**Downstream:** perri drops her temp `--subnet` hook stopgap (she declined it — never applied; she's been multi-subnet-blocked by choice). She validates her exact repro on real claude-spt at publish. Coordinate the v0.14.0 publish ping to perri.
63
64	**Numbering note:** ADR-0024/0025 ship with v0.13.2; ADR-0026/0027 are this milestone (stable).
65

# v0.14.0 — Endpoint creation flow (multi-subnet home + Unbound state): JIT build plan

**Design (operator grill, 2026-06-22):** ADR-0026 (multi-subnet home at `endpoint run` creation) + ADR-0027 (Unbound endpoint state + attach-on-session-exists). CONTEXT.md term: **Unbound endpoint**. Two REQs registered (doc-active, this branch): `REQ-RUN-MULTISUBNET-HOME`, `REQ-ENDPOINT-UNBOUND-ATTACH`. MINOR bump (new endpoint-creation UX + a new on-disk liveness status).

**PREREQUISITE (binding):** start v0.14.0 from `main` **after v0.13.2 has merged** — this milestone builds on v0.13.2's broker `sessions` map carrying the per-endpoint `endpoint`/`adapter`/`install_dir` fields (ADR-0025 W3a), the source of truth for "is there a live session for this id." Rebase/merge this design branch (ADR-0026/0027 + the two REQs + this JIT) onto post-v0.13.2 main before activating.

**Motivating real-world case:** perri's multi-subnet bringup gap (operator-hit, 2026-06-22) — a **latent** gap (not a regression; `HOME_REFUSED` is the established ≥0.11.0 no-guess policy), exposed when the node crossed 1→2 subnets (BIGNET added). The spt-hosted bringup never supported a multi-subnet home. W2 closes it; perri's repro is the W6 regression test (**MUST run on a multi-subnet node** — a single-subnet node auto-homes and hides the gap).

**The two "resident" facts that shape the waves** (verified at code, the grill):
- Home is **immutable** (ADR-0010). `establish_perch → stamp_creation_fields` (home.rs:122) **inherits** a prior perch's `home_subnet` (prior-branch) and only `assign_home`s on the fresh (None) branch. So the gap is **only first-creation** on multi-subnet; pre-creating a skeleton perch WITH the home makes bind inherit it — no hook/env change.
- The broker (`spt daemon run`) and the brain (`spt daemon brain`) are **separate processes** (v0.13.2 W3c finding); the broker `sessions` map is the IPC-reachable, brain-restart-surviving source of "live session." Attach + the Unbound display both read it.

---

## W1 — `STATUS_UNBOUND` status primitive + lifecycle  (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
Foundational — the skeleton (W2), the attach (W4), and the display (W5) all key on it.
- **Scope:** add `STATUS_UNBOUND` (`liveness.rs:37`, beside `STATUS_ONLINE`/`STATUS_OFFLINE`). Lifecycle transitions: spawn / skeleton-create → `unbound`; `bind` (cmd_bind establish-online) → `online`; broker session death → `offline` via the EXISTING exit-waiter/reconcile (the B2/B5 dead-but-latched path — reuse, no new teardown). Liveness predicates: an `unbound` perch is **attachable** (a live broker session exists) but **NOT message-addressable** (no bound `session_id`) — `is_perch_alive` / deliver resolution stay gated on `online`/bound; a new "has a live session" predicate (reads the broker sessions map) backs attach + display.
- **Files:** `crates/spt-store/src/liveness.rs` (the constant + predicates); the reconcile/exit-waiter that stamps offline (livehost.rs / broker session-exit path); `info.json` status writer.
- **Gate:** unit (the transitions; `unbound` is attachable-not-addressable; reconcile resolves a dead-session `unbound` → `offline`). doc = ADR-0027 + CONTEXT (active). No int yet (composed in W6).

## W2 — Home resolution + skeleton-with-home  (REQ-RUN-MULTISUBNET-HOME, ADR-0026) — the multi-subnet UNBLOCK
- **Scope:** at `endpoint run`'s skeleton-create step, resolve the home and **pre-create the skeleton perch** (status `unbound` from W1, `home_subnet` set) BEFORE spawning the harness; the hook's `bind` then inherits home via `establish_perch`'s prior-branch (UNBOUND→ONLINE). Resolution policy:
  - sole subnet → auto (`assign_home`, unchanged);
  - multi-subnet + no `--subnet` + **non-interactive** (`std::io::IsTerminal` on stdin, the cli.rs:1006/3956 precedent) → **refuse early** (`MULTI_SUBNET_HOME:` clear message + the subnet list) — never the silent 25s timeout;
  - multi-subnet + no `--subnet` + **interactive** → print the proposed config (id / project / adapter[:profile] / home = the default) + `Ok to proceed? Y/n`; `n` → print the `--subnet` guidance;
  - `--subnet <name>` overrides + validates membership (`assign_home` `NotMember`).
  - The default home (for the confirm) = a **node-global last-used** value to start (W3 upgrades it to the two-level lists).
- **Files:** `cmd_endpoint_run` (cli.rs:~1384, where it spawns straight to `launch_harness_brokered_in` today — add the resolve + skeleton-write before it); a `--subnet` arg on `endpoint run`; `assign_home` (home.rs:71) reused; the skeleton write via `info::write_info` (status `unbound`, `home_subnet`, `state=live_agent`, `adapter`, `cwd`). Conflict-safe: a skeleton has an empty `session_id`, so `establish_perch`'s Conflict check (owner-alive ∧ session-mismatch) never trips.
- **Gate:** unit (resolution matrix: sole-auto / multi-noninteractive-refuse / multi-interactive-confirm / --subnet override+NotMember; the skeleton carries the resolved home; bind inherits it via prior-branch). doc = ADR-0026 + CONTEXT. (int in W6.)

## W3 — Two-level MRU home lists  (REQ-RUN-MULTISUBNET-HOME)
Upgrades W2's single-value default to the full UX.
- **Scope:** persisted **ordered move-to-front LISTs** of recently-chosen home subnets at **two levels**: per-project (keyed by project_id) + an always-updated node-global list (the fallback for a project with no list yet). The interactive default home = project-list head ?? node-global head; the interactive confirm + the `--subnet` refusal guidance list subnets **MRU-ordered**. Both lists update (move-to-front) on every home choice (run-confirm or explicit `--subnet`).
- **Files:** a small MRU store under `$SPT_HOME` (node-global) + per-project (project store / `p-<project>` adjacency or a sibling file); the `endpoint run` resolve path reads/writes it; project_id from the run cwd (`project_id_for_dir`).
- **Gate:** unit (move-to-front; project-then-node-global precedence; node-global fallback for an MRU-less project; the guidance/confirm ordering). doc = CONTEXT (the MRU note on the home-subnet term).

## W4 — Attach-on-session-exists  (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
- **Scope:** gate the attach on the broker **session being attachable**, not on perch `STATUS_ONLINE`. Replace `await_endpoint_online` (cli.rs:1434) with an await-session-attachable poll (reads the broker sessions map / a "has live session" query, the W1 predicate). `cmd_endpoint_run` AND `spt rc <id>` attach to a live session **regardless of perch status** (so an `unbound` endpoint is attachable — headless bringups too, and the operator can clear a bind-gating prompt). Preserve REQ-HAZARD-RC-ATTACH-ONLINE-RACE's "no attach before a session" intent at the earlier session-exists point; bounded deadline; local-only (a remote row can't see another node's broker).
- **Files:** `cmd_endpoint_run` (cli.rs:1426-1447, the await+attach block); `run_attach` / `spt rc` status-gate (rc.rs) — attach when a session exists, not only when online; the broker "has live session for id" query (over the W3a sessions map).
- **Gate:** unit (the gate keys on session-exists not perch-online; the offline-but-session-live case attaches; no-session still awaits/fails). int folded into W6.

## W5 — `Unbound` display  (REQ-ENDPOINT-UNBOUND-ATTACH)
- **Scope:** `EpDisplay` gains `Unbound` (picker/model.rs:55; today Offline/Online/HarnessOnly/Controlled). Render **hollow** (the existing filled/hollow-square precedent) + a **hollow-controlled** variant (an `unbound` endpoint a node is driving) — paralleling filled Online vs Controlled. **NOT amber** — amber is `HarnessOnly` (online-but-not-broker-controllable), the semantic opposite of `Unbound` (which IS broker-attachable). Derivation: `STATUS_UNBOUND` (+ `driven_by` for the controlled variant). Surface in the picker, `endpoint list`, and `whoami`. Remote rows render plain (the existing controllability limitation, picker/data.rs:254).
- **Files:** picker/model.rs (`EpDisplay::Unbound` + label/legend + the type-gated derivation), picker/data.rs (derive from status), `endpoint list`/`whoami` renderers.
- **Gate:** unit (status `unbound` → `EpDisplay::Unbound`; + driven_by → the controlled variant; offline/online/harness-only unchanged; remote renders plain). doc = the picker legend.

## W6 — int keystone + the multi-subnet regression  (both REQs)
- **Scope:** real-daemon e2e (reuse the v0.12.1 dummy-harness fixture, NO mocks):
  - **perri's multi-subnet repro (the regression test, MUST run on a multi-subnet node):** join a 2nd subnet → `spt endpoint run --adapter X --id Y` (no `--subnet`, non-interactive) → REFUSES clearly (not a 25s timeout); with `--subnet S` → the skeleton homes to S, the harness binds (UNBOUND→ONLINE), no `HOME_REFUSED`. A single-subnet control proves auto-home still works.
  - **attach-before-bind:** `endpoint run` (or `spt rc`) attaches while the endpoint is `unbound` (session live, not yet bound), the operator drives it, then bind flips it ONLINE.
  - **interactive confirm:** (where a TTY can be simulated) the proposed-config + Y/n path; non-interactive refuse.
- **Activate:** `REQ-RUN-MULTISUBNET-HOME` → `[doc,impl,unit,int]`; `REQ-ENDPOINT-UNBOUND-ATTACH` → `[doc,impl,unit,int]`. Flip ADR-0026/0027 status → **accepted** (with any build amendments, the ADR-0025 precedent).
- **Gate:** doyle gates the int hard against perri's repro; **perri validates on real claude-spt at publish** (her queued v0.13.3/v0.14.0 validation).

---

## Build order, gating, ship
**Order:** W1 (status primitive) → W2 (home resolve + skeleton — the unblock) → W3 (MRU lists) → W4 (attach-on-session) → W5 (Unbound display) → W6 (int + regression). W2/W4/W5 share the broker sessions map + the perch status seam — sequential, doyle gates each (CI green BOTH runners per wave; the contended Windows host can't run the lifecycle/daemon suite — CI is authority). Activate each REQ's real stages at its wave (rule 5).

**Scope note (operator decision):** shipped as ONE milestone (not split into a minimal-unblock fast-follow) — the operator accepted staying multi-subnet-blocked until the cohesive landing. The whole thing is v0.14.0.

**Downstream:** perri drops her temp `--subnet` hook stopgap (she declined it — never applied; she's been multi-subnet-blocked by choice). She validates her exact repro on real claude-spt at publish. Coordinate the v0.14.0 publish ping to perri.

**Numbering note:** ADR-0024/0025 ship with v0.13.2; ADR-0026/0027 are this milestone (stable).


Full JIT captured. STEP 0 done. Now commune the resume brief to my psyche, then self-clear to start W1 fresh (per doyle's green-light + project rules):


Skill

MILESTONE HANDOFF — v0.13.2 SHIPPED, v0.14.0 STARTING.

v0.13.2 endpoint... (adapter packaging + live update) PUBLISHED ✓ counter 29 (deployah cut, doyle CHANGELOG vet PASS, perri pinged to drop F-014 stopgap). All on origin/main @2bb9c8f. My part this session: built+gated W4 (ADAPTER-UPDATE-MESSAGE @311b2dc), W5 (ADAPTER-PROOF-DIR-OVERRIDE @acf2387+ce179a1), W6 (ADAPTER-VERSION-CMD + DOCS-NO-INTERNAL-CODES @7c377f0); merged PR#29→main; deployah bumped+published.

NEW MILESTONE v0.14.0 endpoint-creation-flow on branch design/endpoint-creation-flow @30bc260 (STEP 0 DONE: rebased clean onto v0.13.2 main, traceable EXIT=0, force-pushed). AUTHORITY = V0.14.0-ENDPOINT-CREATION-FLOW-JIT.md (read it first on resume). ADR-0026 (multi-subnet home) + ADR-0027 (Unbound state). 2 REQs doc-active: REQ-RUN-MULTISUBNET-HOME, REQ-ENDPOINT-UNBOUND-ATTACH.

IMMEDIATE NEXT = W1 (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027). Add STATUS_UNBOUND in crates/spt-store/src/liveness.rs:37 (beside STATUS_ONLINE/STATUS_OFFLINE). Lifecycle: spawn/skeleton-create→unbound; bind (cmd_bind establish-online)→online; broker session death→offline via the EXISTING exit-waiter/reconcile (B2/B5 dead-but-latched path — REUSE, no new teardown). Predicates: unbound = ATTACHABLE (live broker session exists) but NOT message-addressable (no bound session_id); is_perch_alive/deliver-resolution STAY online/bound-gated; ADD a NEW "has a live session" predicate reading the broker sessions map (ADR-0025 W3a, per-endpoint endpoint/adapter/install_dir) — backs W4 attach + W5 display. Files: liveness.rs (constant+predicates), livehost.rs/broker session-exit reconcile (stamps offline), info.json status writer. Gate: unit (transitions; unbound attachable-not-addressable; reconcile dead-session unbound→offline). doc=ADR-0027+CONTEXT (already active). NO int (composed in W6). Activate REQ-ENDPOINT-UNBOUND-ATTACH impl+unit stages at W1 (rule 5).

CADENCE every wave: clippy --workspace --all-targets -D warnings; traceable standalone EXIT=0; the contended Windows host CAN'T run the lifecycle/daemon suite — CI is authority, so push + CI green BOTH runners per wave; doyle gates each wave before the next. Order: W1→W2(multi-subnet unblock)→W3(MRU lists)→W4(attach-on-session)→W5(Unbound display)→W6(int+perri regression). Commit trailer: Co-authored by: todlando. Ping doyle per-wave for gate.


Launching skill: sptc:commune


Base directory for this skill: C:\Users\decid\.claude\plugins\cache\cplugs\sptc\0.1.4\skills\commune

# /sptc:commune

Brief your Psyche with a context DELTA so it can carry you across a `/clear` or compact. Live agents
only (a ready agent has no Psyche).

This is a **file-drop, not a command** — you write the file; spt absorbs it.

1. Get your id: `spt whoami` (empty ⇒ no perch — `/sptc:live` first).
2. Write `.claude/<id>-commune.md` — a concise delta: current task + status, decisions since the last
   commune, immediate next steps. One atomic write of the full body.
3. The file disappearing is the success signal (spt ingested it). If it lingers, you are not live.

ARGUMENTS: MILESTONE HANDOFF — v0.13.2 SHIPPED, v0.14.0 STARTING.

v0.13.2 endpoint... (adapter packaging + live update) PUBLISHED ✓ counter 29 (deployah cut, doyle CHANGELOG vet PASS, perri pinged to drop F-014 stopgap). All on origin/main @2bb9c8f. My part this session: built+gated W4 (ADAPTER-UPDATE-MESSAGE @311b2dc), W5 (ADAPTER-PROOF-DIR-OVERRIDE @acf2387+ce179a1), W6 (ADAPTER-VERSION-CMD + DOCS-NO-INTERNAL-CODES @7c377f0); merged PR#29→main; deployah bumped+published.

NEW MILESTONE v0.14.0 endpoint-creation-flow on branch design/endpoint-creation-flow @30bc260 (STEP 0 DONE: rebased clean onto v0.13.2 main, traceable EXIT=0, force-pushed). AUTHORITY = V0.14.0-ENDPOINT-CREATION-FLOW-JIT.md (read it first on resume). ADR-0026 (multi-subnet home) + ADR-0027 (Unbound state). 2 REQs doc-active: REQ-RUN-MULTISUBNET-HOME, REQ-ENDPOINT-UNBOUND-ATTACH.

IMMEDIATE NEXT = W1 (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027). Add STATUS_UNBOUND in crates/spt-store/src/liveness.rs:37 (beside STATUS_ONLINE/STATUS_OFFLINE). Lifecycle: spawn/skeleton-create→unbound; bind (cmd_bind establish-online)→online; broker session death→offline via the EXISTING exit-waiter/reconcile (B2/B5 dead-but-latched path — REUSE, no new teardown). Predicates: unbound = ATTACHABLE (live broker session exists) but NOT message-addressable (no bound session_id); is_perch_alive/deliver-resolution STAY online/bound-gated; ADD a NEW "has a live session" predicate reading the broker sessions map (ADR-0025 W3a, per-endpoint endpoint/adapter/install_dir) — backs W4 attach + W5 display. Files: liveness.rs (constant+predicates), livehost.rs/broker session-exit reconcile (stamps offline), info.json status writer. Gate: unit (transitions; unbound attachable-not-addressable; reconcile dead-session unbound→offline). doc=ADR-0027+CONTEXT (already active). NO int (composed in W6). Activate REQ-ENDPOINT-UNBOUND-ATTACH impl+unit stages at W1 (rule 5).

CADENCE every wave: clippy --workspace --all-targets -D warnings; traceable standalone EXIT=0; the contended Windows host CAN'T run the lifecycle/daemon suite — CI is authority, so push + CI green BOTH runners per wave; doyle gates each wave before the next. Order: W1→W2(multi-subnet unblock)→W3(MRU lists)→W4(attach-on-session)→W5(Unbound display)→W6(int+perri regression). Commit trailer: Co-authored by: todlando. Ping doyle per-wave for gate.


Commune is a file-drop. Get my id + write the commune delta:


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && spt whoami 2>&1 | head -5

Get perch id for commune file


SUBNET SPT_DEV
  (no endpoints)
SUBNET BIGNET
  (no endpoints)
ENDPOINTS:0


whoami returns a roster, not a clear id (this session's perch is adapter-registered, not a standard endpoint). Find the id the system uses for my commune file:


cd "C:/Users/decid/Documents/projects/spt-core" && echo "===existing commune/perch hints in .claude===" && ls -1 .claude/ 2>/dev/null | grep -i "commune\|perch\|psyche\|owl\|todlando\|spt_dev" ; echo "===any *-commune.md ever===" && ls -1 .claude/*commune* 2>/dev/null; echo "===OWL env / whoami via owl===" && $OWL whoami 2>&1 | head -5

Find the live-agent id for the commune filename


===existing commune/perch hints in .claude===
===any *-commune.md ever===
===OWL env / whoami via owl===
Found session match for live agent 'todlando'.
✓ WHOAMI:todlando live=true


Id = **todlando** (`$OWL whoami` → live=true). Write `.claude/todlando-commune.md` (disappears on ingest = success):


Write

# Commune delta — milestone handoff (v0.13.2 shipped → v0.14.0 W1 next)

## Just shipped: v0.13.2 (adapter packaging + live update) — PUBLISHED ✓ counter 29
- deployah cut it; doyle CHANGELOG vet PASS; perri pinged (drops F-014 stopgap). On origin/main @2bb9c8f.
- My part this session: built+gated **W4** ADAPTER-UPDATE-MESSAGE @311b2dc · **W5** ADAPTER-PROOF-DIR-OVERRIDE @acf2387+ce179a1 · **W6** ADAPTER-VERSION-CMD + DOCS-NO-INTERNAL-CODES @7c377f0. Merged PR#29→main; deployah bumped+published.
- New gotcha recorded in memory: `public-help-no-internal-codes-gate` — `xtask check` now bans REQ-*/F-###/M#-W#/ADR-#### from clap `///` (scans generated reference.md). Keep CLI doc-comments plain.

## Now: NEW MILESTONE v0.14.0 endpoint-creation-flow
- Branch **design/endpoint-creation-flow @30bc260**. STEP 0 DONE: rebased clean onto v0.13.2 main, traceable EXIT=0, force-pushed. traceable-reqs.toml conflict resolved as UNION (kept both v0.13.2 + v0.14.0 REQ blocks).
- **AUTHORITY = `V0.14.0-ENDPOINT-CREATION-FLOW-JIT.md`** (read FIRST on resume). ADR-0026 (multi-subnet home) + ADR-0027 (Unbound state). 2 REQs doc-active: REQ-RUN-MULTISUBNET-HOME, REQ-ENDPOINT-UNBOUND-ATTACH.

## IMMEDIATE NEXT = W1 (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
Foundational — W2 skeleton / W4 attach / W5 display all key on it.
- Add **`STATUS_UNBOUND`** in `crates/spt-store/src/liveness.rs:37` (beside STATUS_ONLINE/STATUS_OFFLINE).
- Lifecycle: spawn/skeleton-create → unbound; bind (cmd_bind establish-online) → online; broker session death → offline via the **EXISTING** exit-waiter/reconcile (B2/B5 dead-but-latched path — REUSE, no new teardown).
- Predicates: `unbound` = ATTACHABLE (a live broker session exists) but NOT message-addressable (no bound session_id). `is_perch_alive`/deliver-resolution STAY online/bound-gated. ADD a NEW "has a live session" predicate reading the broker **sessions map** (ADR-0025 W3a, per-endpoint endpoint/adapter/install_dir) — backs W4 attach + W5 display.
- Files: `liveness.rs` (constant+predicates) · `livehost.rs`/broker session-exit reconcile (stamps offline) · `info.json` status writer.
- Gate: **unit** (transitions; unbound attachable-not-addressable; reconcile dead-session unbound→offline). doc = ADR-0027 + CONTEXT (already active). **NO int** (composed in W6).
- Activate REQ-ENDPOINT-UNBOUND-ATTACH **impl+unit** stages at W1 (rule 5; doc already active).

## Cadence EVERY wave (binding)
`clippy --workspace --all-targets -D warnings` · `traceable-reqs check` EXIT=0 · the contended Windows host CAN'T run the lifecycle/daemon suite → **CI is authority**, push + CI green BOTH runners per wave · **doyle gates each wave before the next** (ping him). Commit trailer: `Co-authored by: todlando`.

## Order: W1 → W2 (multi-subnet UNBLOCK) → W3 (MRU lists) → W4 (attach-on-session) → W5 (Unbound display) → W6 (int + perri multi-subnet regression, MUST run on a multi-subnet node).
Downstream: perri validates her exact repro on real claude-spt at v0.14.0 publish.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\todlando-commune.md (file state is current in your context — no need to Read it back)

# Commune delta — milestone handoff (v0.13.2 shipped → v0.14.0 W1 next)

## Just shipped: v0.13.2 (adapter packaging + live update) — PUBLISHED ✓ counter 29
- deployah cut it; doyle CHANGELOG vet PASS; perri pinged (drops F-014 stopgap). On origin/main @2bb9c8f.
- My part this session: built+gated **W4** ADAPTER-UPDATE-MESSAGE @311b2dc · **W5** ADAPTER-PROOF-DIR-OVERRIDE @acf2387+ce179a1 · **W6** ADAPTER-VERSION-CMD + DOCS-NO-INTERNAL-CODES @7c377f0. Merged PR#29→main; deployah bumped+published.
- New gotcha recorded in memory: `public-help-no-internal-codes-gate` — `xtask check` now bans REQ-*/F-###/M#-W#/ADR-#### from clap `///` (scans generated reference.md). Keep CLI doc-comments plain.

## Now: NEW MILESTONE v0.14.0 endpoint-creation-flow
- Branch **design/endpoint-creation-flow @30bc260**. STEP 0 DONE: rebased clean onto v0.13.2 main, traceable EXIT=0, force-pushed. traceable-reqs.toml conflict resolved as UNION (kept both v0.13.2 + v0.14.0 REQ blocks).
- **AUTHORITY = `V0.14.0-ENDPOINT-CREATION-FLOW-JIT.md`** (read FIRST on resume). ADR-0026 (multi-subnet home) + ADR-0027 (Unbound state). 2 REQs doc-active: REQ-RUN-MULTISUBNET-HOME, REQ-ENDPOINT-UNBOUND-ATTACH.

## IMMEDIATE NEXT = W1 (REQ-ENDPOINT-UNBOUND-ATTACH, ADR-0027)
Foundational — W2 skeleton / W4 attach / W5 display all key on it.
- Add **`STATUS_UNBOUND`** in `crates/spt-store/src/liveness.rs:37` (beside STATUS_ONLINE/STATUS_OFFLINE).
- Lifecycle: spawn/skeleton-create → unbound; bind (cmd_bind establish-online) → online; broker session death → offline via the **EXISTING** exit-waiter/reconcile (B2/B5 dead-but-latched path — REUSE, no new teardown).
- Predicates: `unbound` = ATTACHABLE (a live broker session exists) but NOT message-addressable (no bound session_id). `is_perch_alive`/deliver-resolution STAY online/bound-gated. ADD a NEW "has a live session" predicate reading the broker **sessions map** (ADR-0025 W3a, per-endpoint endpoint/adapter/install_dir) — backs W4 attach + W5 display.
- Files: `liveness.rs` (constant+predicates) · `livehost.rs`/broker session-exit reconcile (stamps offline) · `info.json` status writer.
- Gate: **unit** (transitions; unbound attachable-not-addressable; reconcile dead-session unbound→offline). doc = ADR-0027 + CONTEXT (already active). **NO int** (composed in W6).
- Activate REQ-ENDPOINT-UNBOUND-ATTACH **impl+unit** stages at W1 (rule 5; doc already active).

## Cadence EVERY wave (binding)
`clippy --workspace --all-targets -D warnings` · `traceable-reqs check` EXIT=0 · the contended Windows host CAN'T run the lifecycle/daemon suite → **CI is authority**, push + CI green BOTH runners per wave · **doyle gates each wave before the next** (ping him). Commit trailer: `Co-authored by: todlando`.

## Order: W1 → W2 (multi-subnet UNBLOCK) → W3 (MRU lists) → W4 (attach-on-session) → W5 (Unbound display) → W6 (int + perri multi-subnet regression, MUST run on a multi-subnet node).
Downstream: perri validates her exact repro on real claude-spt at v0.14.0 publish.


Verify the commune was ingested (file disappears = success):


Bash

cd "C:/Users/decid/Documents/projects/spt-core" && sleep 2 && (test -f .claude/todlando-commune.md && echo "STILL PRESENT (not ingested)" || echo "INGESTED (file gone = success)")

Confirm commune ingestion


STILL PRESENT (not ingested)


Still present — watcher may poll on a longer interval. Wait and recheck once:


cd "C:/Users/decid/Documents/projects/spt-core" && sleep 8 && (test -f .claude/todlando-commune.md && echo "STILL PRESENT" || echo "INGESTED (gone)")

Recheck commune ingestion after longer wait


STILL PRESENT


cd "C:/Users/decid/Documents/projects/spt-core" && sleep 20 && (test -f .claude/todlando-commune.md && echo "STILL PRESENT after ~30s total" || echo "INGESTED (gone)")

Final commune ingestion check (longer wait)


STILL PRESENT after ~30s total


Commune isn't ingesting on a short poll (the psyche sweeps on its own cycle — the file is the durable backstop, and $OWL confirms live=true). `/self-clear` isn't in my available skills, so I won't gamble a clear that I can't cleanly execute — I'll **continue W1 in this session** with full context. The commune file + the JIT (authority) are the on-disk handoff either way.

Starting W1. Read the core W1 surfaces — `liveness.rs` + ADR-0027:


1	//! **The** single liveness resolver — daemon-authoritative for daemon-hosted
2	//! perches, per-pid for everything else (KNOWN-HAZARDS 2.5,
3	//! REQ-HAZARD-DAEMON-HOSTED-LIVENESS).
4	//!
5	//! ## Why one resolver
6	//! The sister project, and the spt-core **interim** (M1/M2a/M2b) model, evaluate
7	//! a perch's liveness from its owning process pid: `is_process_alive(info.pid)`,
8	//! with `clean_stale_entries` dropping dead-pid registry rows. That is correct
9	//! while every perch has a *dedicated* per-process owner.
10	//!
11	//! Under ADR-0004 the daemon hosts the Psyche (and any spt-hosted Self) as a
12	//! **loop inside the daemon**, not a separate process. Such a perch holds no
13	//! dedicated pid; its `claude`/summarizer subprocess is **ephemeral** (spawned
14	//! per pulse/commune, then exits). If liveness keyed on that pid, killing the
15	//! summarizer would read the whole perch "offline"; if it keyed on the daemon's
16	//! pid, every hosted endpoint would share one pid and `clean_stale_entries`
17	//! could never distinguish a torn-down endpoint from a live one (2.5).
18	//!
19	//! So for **daemon-hosted** perches liveness is the daemon-managed `status` field
20	//! on `info.json` ([`crate::info::set_status`]); for **external/interim** perches
21	//! it stays the per-pid probe. Both decisions live **here**, behind one resolver
22	//! (mirroring how [`crate::registry::resolve_address`] centralizes stale-clean),
23	//! so the daemon swap is localized and the per-pid assumption never leaks into a
24	//! new call site. The three call sites — `deliver::is_online`,
25	//! `ring::from_has_live_perch`, `roster::enumerate` (perch path) and
26	//! `registry::clean_stale_entries` (registry row) — all route through this module.
27	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
28
29	use std::path::Path;
30
31	use serde_json::Value;
32
33	use crate::perch::{self, ParentHint};
34	use crate::proc::is_process_alive;
35
36	/// Daemon-managed `status` value meaning the hosted endpoint is live.
37	pub const STATUS_ONLINE: &str = "online";
38	/// Daemon-managed `status` value meaning the hosted endpoint is torn down.
39	pub const STATUS_OFFLINE: &str = "offline";
40
41	/// Torn-read retry budget (mirrors [`crate::info`]): a reader may hit the file
42	/// mid-replace; retry a few times, then degrade rather than panic.
43	const READ_ATTEMPTS: u32 = 5;
44
45	/// One tolerant read of a perch's `info.json` as a raw value — schema-minimal
46	/// records (`{"pid": N}` from wrapper-owned listeners) parse where the full
47	/// [`crate::info::InfoJson`] shape would not. `None` on persistent absence/corruption.
48	fn read_raw(perch_path: &Path) -> Option<Value> {
49	    let path = perch_path.join("info.json");
50	    for _ in 0..READ_ATTEMPTS {
51	        if let Ok(content) = std::fs::read_to_string(&path) {
52	            if let Ok(val) = serde_json::from_str::<Value>(&content) {
53	                return Some(val);
54	            }
55	        }
56	    }
57	    None
58	}
59
60	/// The daemon-managed `status` of a perch, if it is daemon-hosted. `Some(_)` ⇒
61	/// daemon-hosted (liveness is authoritative via status); `None` ⇒ external /
62	/// interim (liveness is the per-pid probe).
63	fn read_status(perch_path: &Path) -> Option<String> {
64	    read_raw(perch_path)?
65	        .get("status")
66	        .and_then(|v| v.as_str())
67	        .map(str::to_string)
68	}
69
70	/// Liveness for a perch by its **directory**. THE resolver for the path-aware
71	/// call sites (`is_online`, `from_has_live_perch`, roster).
72	///
73	/// - **Daemon-hosted** (`status` present): live iff `status == "online"`. The
74	///   `info.pid` (an ephemeral summarizer pid, or the shared daemon pid) is
75	///   **never** consulted — killing the summarizer does not flip the perch.
76	/// - **External/interim** (`status` absent): the per-pid probe, preserving the
77	///   exact interim semantics — a numeric pid is probed; a `"BUSY"` sentinel or
78	///   unparseable/absent pid reads alive (the listener is up between polls).
79	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
80	pub fn is_perch_alive(perch_path: &Path) -> bool {
81	    match read_raw(perch_path) {
82	        Some(val) => {
83	            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
84	                return status == STATUS_ONLINE; // daemon-authoritative; never pid
85	            }
86	            match val.get("pid").and_then(|v| v.as_u64()) {
87	                Some(pid) => is_process_alive(pid as u32),
88	                None => true, // BUSY sentinel / unparseable pid — interim parity
89	            }
90	        }
91	        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
92	    }
93	}
94
95	/// Liveness for a **registry row**: its id plus the pid it was registered under,
96	/// resolved within `owlery` (the registry's own root). Daemon-hosted rows key on
97	/// the endpoint `status`; external rows pid-probe the **row's** pid exactly as
98	/// the interim `clean_stale_entries` did — so the daemon swap touches only the
99	/// hosted rows. An offline daemon-hosted endpoint is cleaned even though the
100	/// (shared) daemon pid is still alive; a still-online one survives a dead
101	/// ephemeral pid.
102	// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
103	pub fn is_registry_entry_alive(id: &str, registry_pid: u32, owlery: &Path) -> bool {
104	    let perch_path = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
105	    match read_status(&perch_path) {
106	        Some(status) => status == STATUS_ONLINE, // daemon-hosted: authoritative
107	        None => is_process_alive(registry_pid),  // external/interim: pid-probe the row
108	    }
109	}
110
111	#[cfg(test)]
112	mod tests {
113	    use super::*;
114	    use crate::info::{set_status, write_info, InfoJson};
115
116	    /// A pid that is never a real allocation (above Linux pid_max ~4.2M and
117	    /// Windows ranges; positive as i32). Deterministic — avoids pid-reuse flake.
118	    const DEAD_PID: u32 = 2_000_000_000;
119
120	    fn perch() -> tempfile::TempDir {
121	        tempfile::tempdir().unwrap()
122	    }
123
124	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
125	    // `online` reads alive even though its recorded (summarizer) pid is DEAD —
126	    // killing the summarizer subprocess does not flip the perch offline.
127	    #[test]
128	    fn daemon_hosted_online_survives_dead_pid() {
129	        let d = perch();
130	        // Record the ephemeral summarizer pid (now dead) then mark daemon-online.
131	        write_info(
132	            d.path(),
133	            &InfoJson::new("doyle-psyche", "t", DEAD_PID, "sid", "psyche"),
134	        )
135	        .unwrap();
136	        set_status(d.path(), STATUS_ONLINE).unwrap();
137	        assert!(
138	            is_perch_alive(d.path()),
139	            "online status is authoritative; the dead pid is ignored"
140	        );
141	    }
142
143	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
144	    // `offline` reads dead even though its recorded pid is ALIVE (this process).
145	    #[test]
146	    fn daemon_hosted_offline_overrides_live_pid() {
147	        let d = perch();
148	        write_info(
149	            d.path(),
150	            &InfoJson::new("doyle-psyche", "t", std::process::id(), "sid", "psyche"),
151	        )
152	        .unwrap();
153	        set_status(d.path(), STATUS_OFFLINE).unwrap();
154	        assert!(
155	            !is_perch_alive(d.path()),
156	            "offline status is authoritative; the live pid is ignored"
157	        );
158	    }
159
160	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] an external/interim perch (no
161	    // status) still pid-probes: a dead pid reads offline, a live pid reads online.
162	    #[test]
163	    fn external_perch_still_pid_probes() {
164	        let dead = perch();
165	        write_info(
166	            dead.path(),
167	            &InfoJson::new("ext", "t", DEAD_PID, "sid", "ready_agent"),
168	        )
169	        .unwrap();
170	        assert!(
171	            !is_perch_alive(dead.path()),
172	            "external dead pid reads offline"
173	        );
174
175	        let live = perch();
176	        write_info(
177	            live.path(),
178	            &InfoJson::new("ext", "t", std::process::id(), "sid", "ready_agent"),
179	        )
180	        .unwrap();
181	        assert!(
182	            is_perch_alive(live.path()),
183	            "external live pid reads online"
184	        );
185	    }
186
187	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] BUSY sentinel / unreadable record
188	    // keep the interim "alive" parity (listener up between polls).
189	    #[test]
190	    fn busy_and_missing_keep_interim_parity() {
191	        let busy = perch();
192	        crate::atomic::atomic_write_string(&busy.path().join("info.json"), r#"{"pid":"BUSY"}"#)
193	            .unwrap();
194	        assert!(
195	            is_perch_alive(busy.path()),
196	            "BUSY sentinel ⇒ alive (interim parity)"
197	        );
198	        // No info.json at all.
199	        let missing = perch();
200	        assert!(
201	            is_perch_alive(missing.path()),
202	            "absent info.json ⇒ alive (interim parity)"
203	        );
204	    }
205
206	    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] registry-row liveness: a
207	    // daemon-hosted row keys on status (offline ⇒ stale even with a live daemon
208	    // pid; online ⇒ live even with a dead ephemeral pid); an external row with
209	    // no perch falls back to probing the registered pid.
210	    #[test]
211	    fn registry_entry_keys_on_status_for_hosted_rows() {
212	        let owlery = perch();
213	        let root = owlery.path();
214
215	        // Daemon-hosted offline endpoint, registered under a LIVE (daemon) pid.
216	        let off = perch::resolve_perch_path_in(root, "off", ParentHint::Infer);
217	        std::fs::create_dir_all(&off).unwrap();
218	        write_info(
219	            &off,
220	            &InfoJson::new("off", "t", std::process::id(), "sid", "psyche"),
221	        )
222	        .unwrap();
223	        set_status(&off, STATUS_OFFLINE).unwrap();
224	        assert!(
225	            !is_registry_entry_alive("off", std::process::id(), root),
226	            "offline hosted row is stale despite a live daemon pid"
227	        );
228
229	        // Daemon-hosted online endpoint, recorded summarizer pid is DEAD.
230	        let on = perch::resolve_perch_path_in(root, "on", ParentHint::Infer);
231	        std::fs::create_dir_all(&on).unwrap();
232	        write_info(&on, &InfoJson::new("on", "t", DEAD_PID, "sid", "psyche")).unwrap();
233	        set_status(&on, STATUS_ONLINE).unwrap();
234	        assert!(
235	            is_registry_entry_alive("on", DEAD_PID, root),
236	            "online hosted row survives a dead pid"
237	        );
238
239	        // External row (no perch dir / no status): pid-probe the registered pid.
240	        assert!(
241	            !is_registry_entry_alive("ext", DEAD_PID, root),
242	            "external dead-pid row is stale"
243	        );
244	        assert!(
245	            is_registry_entry_alive("ext", std::process::id(), root),
246	            "external live-pid row is alive"
247	        );
248	    }
249	}
250

//! **The** single liveness resolver — daemon-authoritative for daemon-hosted
//! perches, per-pid for everything else (KNOWN-HAZARDS 2.5,
//! REQ-HAZARD-DAEMON-HOSTED-LIVENESS).
//!
//! ## Why one resolver
//! The sister project, and the spt-core **interim** (M1/M2a/M2b) model, evaluate
//! a perch's liveness from its owning process pid: `is_process_alive(info.pid)`,
//! with `clean_stale_entries` dropping dead-pid registry rows. That is correct
//! while every perch has a *dedicated* per-process owner.
//!
//! Under ADR-0004 the daemon hosts the Psyche (and any spt-hosted Self) as a
//! **loop inside the daemon**, not a separate process. Such a perch holds no
//! dedicated pid; its `claude`/summarizer subprocess is **ephemeral** (spawned
//! per pulse/commune, then exits). If liveness keyed on that pid, killing the
//! summarizer would read the whole perch "offline"; if it keyed on the daemon's
//! pid, every hosted endpoint would share one pid and `clean_stale_entries`
//! could never distinguish a torn-down endpoint from a live one (2.5).
//!
//! So for **daemon-hosted** perches liveness is the daemon-managed `status` field
//! on `info.json` ([`crate::info::set_status`]); for **external/interim** perches
//! it stays the per-pid probe. Both decisions live **here**, behind one resolver
//! (mirroring how [`crate::registry::resolve_address`] centralizes stale-clean),
//! so the daemon swap is localized and the per-pid assumption never leaks into a
//! new call site. The three call sites — `deliver::is_online`,
//! `ring::from_has_live_perch`, `roster::enumerate` (perch path) and
//! `registry::clean_stale_entries` (registry row) — all route through this module.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]

use std::path::Path;

use serde_json::Value;

use crate::perch::{self, ParentHint};
use crate::proc::is_process_alive;

/// Daemon-managed `status` value meaning the hosted endpoint is live.
pub const STATUS_ONLINE: &str = "online";
/// Daemon-managed `status` value meaning the hosted endpoint is torn down.
pub const STATUS_OFFLINE: &str = "offline";

/// Torn-read retry budget (mirrors [`crate::info`]): a reader may hit the file
/// mid-replace; retry a few times, then degrade rather than panic.
const READ_ATTEMPTS: u32 = 5;

/// One tolerant read of a perch's `info.json` as a raw value — schema-minimal
/// records (`{"pid": N}` from wrapper-owned listeners) parse where the full
/// [`crate::info::InfoJson`] shape would not. `None` on persistent absence/corruption.
fn read_raw(perch_path: &Path) -> Option<Value> {
    let path = perch_path.join("info.json");
    for _ in 0..READ_ATTEMPTS {
        if let Ok(content) = std::fs::read_to_string(&path) {
            if let Ok(val) = serde_json::from_str::<Value>(&content) {
                return Some(val);
            }
        }
    }
    None
}

/// The daemon-managed `status` of a perch, if it is daemon-hosted. `Some(_)` ⇒
/// daemon-hosted (liveness is authoritative via status); `None` ⇒ external /
/// interim (liveness is the per-pid probe).
fn read_status(perch_path: &Path) -> Option<String> {
    read_raw(perch_path)?
        .get("status")
        .and_then(|v| v.as_str())
        .map(str::to_string)
}

/// Liveness for a perch by its **directory**. THE resolver for the path-aware
/// call sites (`is_online`, `from_has_live_perch`, roster).
///
/// - **Daemon-hosted** (`status` present): live iff `status == "online"`. The
///   `info.pid` (an ephemeral summarizer pid, or the shared daemon pid) is
///   **never** consulted — killing the summarizer does not flip the perch.
/// - **External/interim** (`status` absent): the per-pid probe, preserving the
///   exact interim semantics — a numeric pid is probed; a `"BUSY"` sentinel or
///   unparseable/absent pid reads alive (the listener is up between polls).
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_perch_alive(perch_path: &Path) -> bool {
    match read_raw(perch_path) {
        Some(val) => {
            if let Some(status) = val.get("status").and_then(|v| v.as_str()) {
                return status == STATUS_ONLINE; // daemon-authoritative; never pid
            }
            match val.get("pid").and_then(|v| v.as_u64()) {
                Some(pid) => is_process_alive(pid as u32),
                None => true, // BUSY sentinel / unparseable pid — interim parity
            }
        }
        None => true, // unreadable info.json — interim parity (read_pid None ⇒ alive)
    }
}

/// Liveness for a **registry row**: its id plus the pid it was registered under,
/// resolved within `owlery` (the registry's own root). Daemon-hosted rows key on
/// the endpoint `status`; external rows pid-probe the **row's** pid exactly as
/// the interim `clean_stale_entries` did — so the daemon swap touches only the
/// hosted rows. An offline daemon-hosted endpoint is cleaned even though the
/// (shared) daemon pid is still alive; a still-online one survives a dead
/// ephemeral pid.
// [impl->REQ-HAZARD-DAEMON-HOSTED-LIVENESS]
pub fn is_registry_entry_alive(id: &str, registry_pid: u32, owlery: &Path) -> bool {
    let perch_path = perch::resolve_perch_path_in(owlery, id, ParentHint::Infer);
    match read_status(&perch_path) {
        Some(status) => status == STATUS_ONLINE, // daemon-hosted: authoritative
        None => is_process_alive(registry_pid),  // external/interim: pid-probe the row
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::info::{set_status, write_info, InfoJson};

    /// A pid that is never a real allocation (above Linux pid_max ~4.2M and
    /// Windows ranges; positive as i32). Deterministic — avoids pid-reuse flake.
    const DEAD_PID: u32 = 2_000_000_000;

    fn perch() -> tempfile::TempDir {
        tempfile::tempdir().unwrap()
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
    // `online` reads alive even though its recorded (summarizer) pid is DEAD —
    // killing the summarizer subprocess does not flip the perch offline.
    #[test]
    fn daemon_hosted_online_survives_dead_pid() {
        let d = perch();
        // Record the ephemeral summarizer pid (now dead) then mark daemon-online.
        write_info(
            d.path(),
            &InfoJson::new("doyle-psyche", "t", DEAD_PID, "sid", "psyche"),
        )
        .unwrap();
        set_status(d.path(), STATUS_ONLINE).unwrap();
        assert!(
            is_perch_alive(d.path()),
            "online status is authoritative; the dead pid is ignored"
        );
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] a daemon-hosted perch marked
    // `offline` reads dead even though its recorded pid is ALIVE (this process).
    #[test]
    fn daemon_hosted_offline_overrides_live_pid() {
        let d = perch();
        write_info(
            d.path(),
            &InfoJson::new("doyle-psyche", "t", std::process::id(), "sid", "psyche"),
        )
        .unwrap();
        set_status(d.path(), STATUS_OFFLINE).unwrap();
        assert!(
            !is_perch_alive(d.path()),
            "offline status is authoritative; the live pid is ignored"
        );
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] an external/interim perch (no
    // status) still pid-probes: a dead pid reads offline, a live pid reads online.
    #[test]
    fn external_perch_still_pid_probes() {
        let dead = perch();
        write_info(
            dead.path(),
            &InfoJson::new("ext", "t", DEAD_PID, "sid", "ready_agent"),
        )
        .unwrap();
        assert!(
            !is_perch_alive(dead.path()),
            "external dead pid reads offline"
        );

        let live = perch();
        write_info(
            live.path(),
            &InfoJson::new("ext", "t", std::process::id(), "sid", "ready_agent"),
        )
        .unwrap();
        assert!(
            is_perch_alive(live.path()),
            "external live pid reads online"
        );
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] BUSY sentinel / unreadable record
    // keep the interim "alive" parity (listener up between polls).
    #[test]
    fn busy_and_missing_keep_interim_parity() {
        let busy = perch();
        crate::atomic::atomic_write_string(&busy.path().join("info.json"), r#"{"pid":"BUSY"}"#)
            .unwrap();
        assert!(
            is_perch_alive(busy.path()),
            "BUSY sentinel ⇒ alive (interim parity)"
        );
        // No info.json at all.
        let missing = perch();
        assert!(
            is_perch_alive(missing.path()),
            "absent info.json ⇒ alive (interim parity)"
        );
    }

    // [unit->REQ-HAZARD-DAEMON-HOSTED-LIVENESS] registry-row liveness: a
    // daemon-hosted row keys on status (offline ⇒ stale even with a live daemon
    // pid; online ⇒ live even with a dead ephemeral pid); an external row with
    // no perch falls back to probing the registered pid.
    #[test]
    fn registry_entry_keys_on_status_for_hosted_rows() {
        let owlery = perch();
        let root = owlery.path();

        // Daemon-hosted offline endpoint, registered under a LIVE (daemon) pid.
        let off = perch::resolve_perch_path_in(root, "off", ParentHint::Infer);
        std::fs::create_dir_all(&off).unwrap();
        write_info(
            &off,
            &InfoJson::new("off", "t", std::process::id(), "sid", "psyche"),
        )
        .unwrap();
        set_status(&off, STATUS_OFFLINE).unwrap();
        assert!(
            !is_registry_entry_alive("off", std::process::id(), root),
            "offline hosted row is stale despite a live daemon pid"
        );

        // Daemon-hosted online endpoint, recorded summarizer pid is DEAD.
        let on = perch::resolve_perch_path_in(root, "on", ParentHint::Infer);
        std::fs::create_dir_all(&on).unwrap();
        write_info(&on, &InfoJson::new("on", "t", DEAD_PID, "sid", "psyche")).unwrap();
        set_status(&on, STATUS_ONLINE).unwrap();
        assert!(
            is_registry_entry_alive("on", DEAD_PID, root),
            "online hosted row survives a dead pid"
        );

        // External row (no perch dir / no status): pid-probe the registered pid.
        assert!(
            !is_registry_entry_alive("ext", DEAD_PID, root),
            "external dead-pid row is stale"
        );
        assert!(
            is_registry_entry_alive("ext", std::process::id(), root),
            "external live-pid row is alive"
        );
    }
}


liveness.rs understood: `is_perch_alive`/`is_registry_entry_alive` gate on `status == STATUS_ONLINE`, else pid-probe. An `unbound` status naturally fails the online gate (not message-addressable) — exactly W1's intent. Read ADR-0027 for the precise contract before implementing:


1	# Unbound endpoint state + attach-on-session-exists
2
3	<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->
4
5	## Status
6
7	proposed (2026-06-22; grilled with the operator). Sibling of ADR-0026 (multi-subnet home at creation). Targets the same endpoint-creation-flow milestone (post-v0.13.2).
8
9	## Context
10
11	`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).
12
13	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.
14
15	## Decision
16
17	**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.
18
19	- **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).
20
21	- **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.)
22	  - **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).
23	  - **`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.
24
25	- **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.
26
27	## Considered Options
28
29	- **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.
30	- **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.
31	- **Reuse amber for `Unbound`** — semantically wrong (amber = not-controllable; Unbound is attachable) and ambiguous against `HarnessOnly`. Rejected; hollow instead.
32
33	## Consequences
34
35	- A new on-disk liveness status (`STATUS_UNBOUND`) — the liveness predicates and reconcile/teardown must treat it honestly (attachable, not message-addressable; auto-resolves).
36	- `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.
37	- The attach gate moves earlier (session-exists), removing the prompt-before-bind deadlock while keeping the original race protection.
38	- `EpDisplay` grows (hollow + hollow-controlled); the picker/legend gain the Unbound state.
39

# Unbound endpoint state + attach-on-session-exists

<!-- [doc->REQ-ENDPOINT-UNBOUND-ATTACH] -->

## Status

proposed (2026-06-22; grilled with the operator). Sibling of ADR-0026 (multi-subnet home at creation). Targets the same 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.


I've reached a clean milestone boundary. ADR-0027 confirms the W1 contract; I now have the full design but haven't touched code yet — so this is the natural place to start W1 in fresh context (per doyle's green-light and the project's clear-at-boundary rule).

**State:**
- **v0.13.2 SHIPPED** (counter 29, published, perri pinged).
- **STEP 0 done:** design branch rebased clean onto v0.13.2 main, traceable EXIT=0, pushed (@30bc260, union-resolved the traceable-reqs.toml conflict).
- **Commune written** (`.claude/todlando-commune.md`) — full W1 brief + cadence + wave order. It's lingering (psyche sweeps on its own cycle, not a short poll; `$OWL whoami` confirms live=true), so it's a durable on-disk handoff, not lost.
- **W1 fully scoped:** `STATUS_UNBOUND` in liveness.rs (unbound = attachable-not-addressable; `is_perch_alive` already fails the online gate for it ✓), lifecycle stamping (spawn→unbound, bind→online, reconcile→offline reusing the existing exit-waiter), unit gate. No REQ stages activated yet (rule 5 — activate at impl).

**The catch:** `/self-clear` isn't in my available skills, so I can't trigger the context reset myself.

**Recommendation:** run `/clear` (or `/self-clear` if you have it) — my psyche will brief the resume from the commune + the JIT (the authority), and W1 starts in fresh context. That's the cleaner path given the W4–W6 + release load this session is carrying.

Or, say the word and I'll **continue W1 in this session now** — I have everything I need (just need to explore the daemon spawn/bind/reconcile stamping points in livehost.rs/broker before editing).

Your call.


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<bash-input>$LIVE stop to</bash-input>


<bash-stdout>✖ STOPPED:to
✖ LIVE-STOPPED:to (+ psyche)</bash-stdout><bash-stderr></bash-stderr>


<task-notification>
<task-id>bsp4y8a8u</task-id>
<tool-use-id>toolu_01Xq2CRhhYxhSEJwDw62cvxT</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\2730b458-50d2-48aa-b752-3ad22fefaa0b\tasks\bsp4y8a8u.output</output-file>
<status>failed</status>
<summary>Monitor "« spt event »" script failed (exit 1)</summary>
</task-notification>


<bash-input>$LIVE stop todlando</bash-input>


<bash-stdout>✖ STOPPED:todlando
✖ LIVE-STOPPED:todlando (+ psyche)</bash-stdout><bash-stderr></bash-stderr>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


[Request interrupted by user]


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>Catch you later!</local-command-stdout>