# Next-milestone bug triage (post-v0.18.0)

> Live tracking doc — operator surfaced 15 issues 2026-06-30 for a follow-up milestone.
> doyle drives diagnosis (root cause + fix approach), then mints REQs → todlando builds → doyle gates.
> Status legend: `TRIAGE` (raw) → `DIAGNOSED` (root cause + fix approach) → `PLANNED` (REQ minted) → `BUILT` → `GATED` → `SHIPPED`.
> Field origin: enlyzeam / sceltouin = remote field nodes; hfenduleam = dev/CI box; win10+pwsh vs win11+WT divergence matters (#12).

## Clusters
- **A — registry gossip & presence:** #2, #3, #15
- **B — cross-node messaging / routing / address-staleness:** #4, #9, #10
- **C — CLI / picker UX:** #1, #5, #11, #13
- **D — PTY / terminal rendering:** #6, #7, #8, #12, #14

---

## Cluster A — registry gossip & presence

### A-#2 — endpoint count on remote nodes does not gossip properly  · STATUS: TRIAGE
Anecdote (enlyzeam had `gerald`): add `ball-a` → purge gerald → sceltouin sees **"0/2"**; start `ball-b` → purge ball-a → sceltouin sees **"1/3"**. Count denominator/numerator drift across add+purge on a remote viewer — purged endpoints not evicted from the gossiped count; new ones miscounted.
- Root cause: _TBD_
- Fix approach: _TBD_

### A-#3 — cross-node "controlled" state not gossiping  · STATUS: TRIAGE
Anecdote: `ball-b` on enlyzeam is **attached/controlled**, yet sceltouin `spt endpoint run` shows it **"online, ready to control"** (see image: status ONLINE, no controlled marker). W5 added `controller_node` gossip — either not populated on the controlling node or not read on the remote viewer. Operator idea: on selecting a remote endpoint, run a fresh `spt node-refresh <node-id>` to pull current gossip before acting.
- Root cause: _TBD_
- Fix approach: _TBD_

### A-#15 — `dormant` applied to a single-instance endpoint  · STATUS: TRIAGE
enlyzeam shows sceltouin's endpoint as **Dormant** — but dormant is the multi-instance routing differentiator (a warm non-target sibling). A single detached instance should not read Dormant. (Flagged during v0.17.0 W4 as a known design-Q: `Detach → Dormant` makes a lone detached instance read Dormant — this is that tail.)
- Root cause: _TBD (resting.rs Detach→Dormant transition; should a lone instance be Active or Suspended?)_
- Fix approach: _TBD_

---

## Cluster B — cross-node messaging / routing / address-staleness

### B-#4 — cross-node online endpoints not detected by `spt rc`  · STATUS: TRIAGE
`spt endpoint list` shows `ball-b ENLYZEAM Active`, but `spt rc ball-b` → `RC_FAIL:ball-b: no live session for endpoint 'ball-b'`. rc resolves only LOCAL live sessions; a cross-node Active endpoint isn't reachable via rc (no remote-attach path wired, or the resolver doesn't route to the owning node).
- Root cause: _TBD_
- Fix approach: _TBD_

### B-#9/#10 — cross-node messaging doesn't deliver (reports SENT(WAN))  · STATUS: TRIAGE
sceltouin ↔ enlyzeam: `spt send` reported **SENT(WAN)** both ways but NOTHING delivered, even though `spt whoami`/endpoint list showed both agents to each other. **KEY LEAD:** sceltouin's IP **changed** since it joined the subnet; enlyzeam's did not. Strong signal: stale gossiped/pinned address → WAN dial to a dead address "succeeds" optimistically → false SENT(WAN). Tangential: SENT(WAN) must not be reported until delivery is actually confirmed (or it must reflect best-effort honestly).
- Root cause: _TBD (address refresh on IP change; SENT(WAN) optimism)_
- Fix approach: _TBD_
- Relates: [[broken-ipv6-poisons-iroh-discovery]] (address/path staleness class).

---

## Cluster C — CLI / picker UX

### C-#1 — `spt adapter update` errors suppressed during `spt adapter add`  · STATUS: SHIPPED @9305d19 (W6, REQ-ADAPTER-ADD-SURFACE-ERRORS)
`adapter add` chains into `adapter update`, but update errors don't surface — swallowed (filter_map ok() / ignored Result on the chained call).
- Root cause: _TBD_
- Fix approach: _TBD_

### C-#5 — `spt endpoint list` show local node name  · STATUS: TRIAGE (easy)
Now: `LOCAL (this node)` → change to `This node: <node-id>`.
- Fix approach: render the self node label/id in the local-section header (cli endpoint-list render).

### C-#11 — `spt endpoint list` status codes + colored markers parity  · STATUS: TRIAGE
`endpoint list` should use the SAME status codes + colored square markers as `endpoint run`'s picker (the W5 palette). Currently text-only / divergent.
- Fix approach: reuse the picker's EpDisplay → glyph/color rendering in the `endpoint list` path.

### C-#13 — `endpoint run` Subnet tab shows endpoints once PER subnet  · STATUS: TRIAGE
A machine in 2 shared subnets appears twice (image: HFENDULEAM listed under BIGNET and SPT_DEV with the same endpoints). Should be ONE endpoint group per machine, with the shared subnets listed beneath the machine name.
- Root cause: _TBD (picker subnet_rows iterates per-subnet, no per-node dedup)_
- Fix approach: _TBD_

---

## Cluster D — PTY / terminal rendering

### D-#6 — scrolling broken for CC (Claude Code TUI)  · STATUS: TRIAGE
Everything above the CC TUI content shows raw terminal history (scrollback corrupt). Proposal: broker uses win32 vterm? Acceptable that Ctrl+B breaks if non-SPT PTY features all work.
- Root cause: _TBD (broker PTY scrollback / alt-screen handling)_
- Fix approach: _TBD_

### D-#7 — PTY clutter as the TUI animates/scrolls  · STATUS: TRIAGE
Residual characters left in whitespace as the TUI animates (image: stray chars). Possibly related to the new top-right ID marker injection.
- Root cause: _TBD_
- Fix approach: _TBD_

### D-#8 — same as #7 but on window resize  · STATUS: TRIAGE
Resize leaves artifacts; the top-right ID marker is also missing unless you scroll up to where it first injected. Likely the ID-marker feature interacting badly with reflow/resize.
- Root cause: _TBD (ID-marker injected once, not re-painted on resize/scroll)_
- Fix approach: _TBD_

### D-#12 — `spt rc` to an existing headless session totally broken on win10/pwsh  · STATUS: TRIAGE
Confirmed on enlyzeam (Win10, raw PowerShell 7). NOT reproduced on hfenduleam (Win11, Windows Terminal). Win10-conhost vs Win11-WT vterm divergence (image: garbled `←[K` / ANSI raw).
- Root cause: _TBD (conhost VT support on Win10 raw pwsh; ENABLE_VIRTUAL_TERMINAL_PROCESSING)_
- Fix approach: _TBD_

### D-#14 — top-right ID marker starts scrolled off-screen  · STATUS: TRIAGE
The new top-right-corner ID marker injects at a fixed position that scrolls away; missing until you scroll back. Maybe DISABLE it for now if no clean fix. (Couples with #7/#8.)
- Root cause: _TBD (absolute-positioned marker, not sticky)_
- Fix approach: _TBD — or feature-flag off pending a sticky-overlay approach._

---

## Diagnosis dispatch
4 parallel read-only diagnostic agents (one per cluster) launched 2026-06-30. Each returns per-bug {root cause file:line, evidence, fix approach, confidence, effort}. Synthesized back here on report.

---

# DIAGNOSES (raw agent findings)

## Cluster C — CLI/picker UX (DIAGNOSED)

**C-#1 adapter-update errors suppressed in adapter add** · conf H · effort S. `AdapterCmd::Add` inline (cli.rs:6975-7139); install=first-update → `conduct("INSTALL",&cmd)`. ROOT: `conduct` Ok-failure arm (cli.rs:6963-6966) prints only `ADAPTER_INSTALL_FAIL: exit Some(1)`, DISCARDS out.stdout/stderr (sibling run_update_post_step :7592-7602 does it right). Exit code DOES propagate — only detail is lost. Secondary: verdict `Skipped`(7113)+catch-all(7124) return 0 unconditionally; gh_release `[update]` is no-op at add-time, `[update.post]` never runs at add. FIX: include out.stderr/stdout in the FAIL message.

**C-#5 endpoint-list local node name** · conf H · effort S. ROOT: `render_local_section` (cli.rs:4359) hardcodes `"LOCAL (this node)\n"` via print_local_section(4349)←cmd_endpoint_list(2516). Node-ident render already at cli.rs:5531-5542 (os_hostname + nodeid::load_or_create().public_key().to_hex() → `label (prefix…)`), open-coded ~6 places (2901/3006/3124/5641/6019/5930), no helper. (SELF pin = perch id, NOT node id — don't reuse.) FIX: factor `node_ident_display()`, compute in impure print_local_section, pass to pure render_local_section, `This node: {ident}`. Update tests cli.rs:10711+10716.

**C-#11 endpoint-list palette parity** · conf H · effort M · COUPLED #13. ROOT: `format_subnet_rows` (cli.rs:2529) prints raw `{:?}` status (2538), no color/glyph; picker derives from SAME ResourceRow: Status→EpStatus (data.rs:273-278), bound/controller/harness (289-323), display_status()→EpDisplay (model.rs:292), square_span (view.rs:64)+display_color (view.rs:28). Shared input = ResourceRow (registry.rs:383). BLOCKER: picker submods private (picker/mod.rs:18-21). FIX: (1) pub EpStatus/EpDisplay/display_status; (2) extract (glyph,Color)/ANSI accessor (plain stdout — use helpfmt::stdout_color cli.rs:7532, not ratatui Span); (3) one `ResourceRow→EpDisplay` builder shared by subnet_rows + format_subnet_rows.

**C-#13 subnet tab dup per subnet** · conf H · effort M · COUPLED #11. ROOT: `subnet_rows` (data.rs:253-331) iterates PER subnet, pushes row per (subnet,endpoint), `group="{subnet}:{node}"` (298), no cross-subnet dedup → machine in 2 subnets = 2 headers (view.rs:268-276). Dedup key = row.node (node pubkey, 1/machine)+endpoint_id (ResourceRow lacks machine_id). FIX: (1) data.rs map keyed (node,endpoint_id) across subnet loop, collect subnet-name SET, emit 1 row/machine-endpoint group=node_display, reconcile status most-alive; (2) model.rs EndpointRow + `subnets: Vec<String>`; (3) view.rs list shared subnets beneath machine header.

**C cross-cut:** #11+#13 both rewrite data.rs::subnet_rows → sequence as ONE REQ/ordered-pair: (a) extract shared ResourceRow→EpDisplay/EndpointRow builder + pub picker enums; (b) #13 regroup-by-machine; (c) #11 reuse from cli.rs. Separately = double-touch churn.

## Cluster D — PTY/terminal rendering (DIAGNOSED)

**ID-marker spine (#7/#8/#14):** it's `StatusRow`/REQ-RC-IDENTITY (v0.16.0 W6), CLIENT-side in rc.rs:198-307 (NOT broker). Paints `{subnet}:{id}@{node}` right-aligned cyan on real terminal row 1 via DECSTBM scroll-region `ESC[2;{rows}r` + DECSC `\x1b7`/home/`\x1b[2K`/right-align/cyan/DECRC `\x1b8` — ABSOLUTE one-shot. Repainted only on: attach (1297-1302), ReassertScanner alt-screen/DECSTBM-reset hit (1413-1419), polled size-change (1329-1349). NOT on ordinary scroll/redraw.

**D-#6 scrollback corrupt** · conf H · effort L · cross-platform. ROOT: broker is raw-byte pump, NO terminal/grid model — OutputLog.ring VecDeque<(seq,Vec<u8>)> (broker.rs:213-216), on attach replays raw bytes from from_seq; cold rc → net_from_seq=0 (attach.rs:198-213)+cursor=0 (rc.rs:1313) → ENTIRE ring dumped into fresh terminal. For alt-screen TUI (CC) history has interleaved ?1049h/l + scroll-region + abs-cursor → flips alt on/off mid-replay → TUI frames into main buffer = corrupt scrollback. Codebase already documents this (spt-term/lib.rs:19, stream.rs:11-15 "consumers match value-set not byte-exact"). NOTE: PTY backend already IS ConPTY (pty.rs:21) — "win32 vterm" must mean a SERVER-SIDE VT parser/grid (tmux/mosh-style): maintain authoritative screen+alt/main+cursor, synthesize CLEAN repaint of current screen on attach. Ctrl+B-breaks acceptable.

**D-#7 residual chars on animate** · conf M · effort M · coupled marker · cross-platform. ROOT: (1) raw passthrough can't clear cells / reconcile ConPTY repaint-dup (stream.rs:11-15); (2) StatusRow injection (rc.rs:1404-1420) splices DECSTBM+DECSC/2K/SGR/DECRC into harness in-flight drawing — DECSC/DECRC unreliable on legacy conhost; scanner runs after writing chunk → banner spliced mid-sequence; reserved scroll-region fights CC's abs-cursor moves. FIX: folds into #6 grid; short-term disable marker (#14).

**D-#8 resize artifacts + marker vanishes** · conf H(marker)/M(artifacts) · effort S-disable/L-right · cross-platform. ROOT: marker is static absolutely-positioned one-shot, repainted only on detected size-change/reassert; resize reflow pushes row-1 banner into scrollback + re-emits dup cells; written-to-buffer not live-overlay → "only visible if you scroll up". NOT an axis-swap (size destructure correct). FIX: sticky overlay (needs #6 grid); until then disable.

**D-#12 rc-to-headless garbled Win10/pwsh, fine Win11/WT** · conf H · effort S · **Win10-conhost-specific, INDEPENDENT**. ROOT: rc never enables VT OUTPUT on client stdout — `RawGuard::enable()` (rc.rs:729-741) only `enable_raw_mode()` (INPUT mode), NO `ENABLE_VIRTUAL_TERMINAL_PROCESSING` on STD_OUTPUT anywhere in spt. Win11 WT/new-conhost auto-enables VT-out; Win10 legacy conhost under raw pwsh leaves it off → ANSI prints literally (`←[K`). FIX: on Windows in RawGuard::enable, GetStdHandle(STD_OUTPUT)→GetConsoleMode→OR ENABLE_VIRTUAL_TERMINAL_PROCESSING (consider DISABLE_NEWLINE_AUTO_RETURN), restore in Drop. **Fix regardless of #6.**

**D-#14 marker starts off-screen** · conf M-H · effort S-disable · coupled #7/#8 · cross-platform. ROOT: same StatusRow one-shot; cold attach paints row 1 but full-ring replay (#6)+scroll push it into scrollback, never re-stickies. REC: **ship DISABLED behind a feature flag now** — net-negative until #6 grid enables a proper per-frame sticky top-row repaint.

**D sequencing:** (1) flag the ID-marker OFF now → cheaply fixes regression-half of #7/#8 + all #14. (2) #12 independent + cheap (VT-out enable) → do regardless. (3) #6 server-side grid = foundational, real fix for #6 + prereq for redoing marker as sticky overlay (#8/#14) + killing residual artifacts (#7). Key files: rc.rs (StatusRow/RawGuard/pump), broker.rs (ring+replay), attach.rs (from_seq=0), spt-term/{lib,stream,pty}.rs.

## Cluster B — cross-node messaging/routing (DIAGNOSED)

**B-#4 cross-node rc can't find remote live session** · conf H · effort M · MISSING-FEATURE. ROOT: rc.rs:1063-1072 `run_attach_inner` cold-starts the LOCAL broker then `resolve_session` (rc.rs:758-765) queries only `brain.sessions()` (local table) → errors "no live session" (1070-1072) → RC_FAIL (cli.rs:1294-1299). rc always rides loopback (`net_dial_loopback` rc.rs:1083-1085), never consults registry/resolves owning node. `endpoint list` shows it because it reads gossiped registry snapshots. Transport for cross-node attach EXISTS (broker dispatch.rs serve_attach), but the CLIENT leg was never built. FIX: remote branch in run_attach_inner — on local miss, `resolve_across_visible` over registry (same as wansend.rs:113) → `net_dial(addr_for_node_hex(node))` → NEW remote session-resolve hop + serve_attach (mirror wansend resolve→dial→round-trip); keep loopback as local case.

**B-#9/#10 cross-node send SENT(WAN) but no delivery** — TWO issues:
- **(b) PRIMARY DEFECT — SENT(WAN) is optimistic local-buffer ack, not delivery confirmation** · conf H · effort M. ROOT: wansend.rs:138-149 send is fire-and-forget — net_stream_send → SendHalf::write_all (buffers) + finish() (nethost.rs:370-374 quinn finish() = non-blocking, does NOT await peer stopped() ack) → Ok()=bytes-buffered+locally-finished only → prints SENT(WAN) (cli.rs:4187). Receiver `receive_wan` (wan.rs:133-185) COMPUTES Refused/NoPerch/Duplicate/DeliveredTcp/Spooled but **writes NOTHING back** → Refused/NoPerch = SILENT DROP while sender already said SENT(WAN). Stale gossip → wrong node → NoPerch → permanent silent loss (NoPerch doesn't claim op wan.rs:118-120, sender never replays). CONTRAST: wan_rest (wansend.rs:255)+wan_shell_link (381) DO round-trip + surface NoReply — only wan_send lacks the reply leg. FIX: receiver writes WanOutcome back before close; wan_send_with blocks for it (QUIC deadline); map Refused/NoPerch→honest CLI line, only Delivered/Spooled/Duplicate→SENT(WAN). Interim if wire-change too costly: relabel to best-effort/unconfirmed.
- **(a) CONTRIBUTING MISSING-MECHANISM — no address-refresh-on-IP-change; send bypasses warm cache** · conf M · effort L. ROOT: gossiped registry `Instance` carries NO address (wanmsg.rs:104-120) → IP change NEVER re-propagated via gossip; addressing = 100% iroh (mDNS/n0-DNS/relay) + local `PeerAddrStore` (peeraddrs.rs) refreshed ONLY reactively by peer pump `dial_seeded` (pump/mod.rs:694-713); writers = pairing (pairhost.rs:547)/roster gapfill (seedproofx.rs:933)/pump write-back. NO proactive "my IP changed, re-publish/invalidate" path. AND `spt send` doesn't use PeerAddrStore at all — wansend.rs:79/207/333 hard-wired id-only `addr_for_node_hex` (endpoint.rs:538-542) → cold id-only discovery every WAN op. sceltouin IP-change → iroh paths churn, conn establishes over transient/relay then drops before un-acked bytes flush → invisible (issue b). Couples [[broken-ipv6-poisons-iroh-discovery]]. FIX: (1) route WAN resolver through dial_seeded seed-then-id-only; (2) proactive addr-change signal — bump epoch/push observed-addr on local addr change so peers invalidate stale PeerAddrStore; (3) pair with (b) ack so stale-routed send fails loud + retries.

**B cross-cut:** #4 + #9/#10 both want the same primitive: resolve-owning-node → dial-by-id → round-trip-to-that-node's-broker. wansend.rs has the resolve+dial half. **Do #9/#10(b) FIRST** (receiver→sender reply, highest-leverage/lowest-risk, makes failure visible), then #4 cross-node rc reuses the helper, then (a) address-refresh.

## Cluster A — registry gossip & presence (DIAGNOSED)

**A-#2 remote endpoint count drifts (purged rows never leave count)** · conf H · effort S(+M). ROOT: `node_status_rows` (cli.rs:5307-5316) `e.2 += 1` UNCONDITIONAL — `total` counts EVERY row incl non-routable Offline ghosts; numerator `e.1` only Active. Offline rows pile up on remote viewer: `endpoint purge` (cli.rs:9524/9610) deletes only home-node records, NOT a registry eviction; propagates via ghost-heal in advertise_local (registryhost.rs:496-547) = re-advertise Offline ONCE w/ fresh epoch; remote merges it + it's IMMORTAL (evict only by WHOLE node registry.rs:280, enlyzeam stays alive). Repro exact: add ball-a+purge gerald→{gerald:Offline,ball-a:non-active}=0/2; start ball-b+purge ball-a→{gerald:Off,ball-a:Off,ball-b:Active}=1/3. Picker already skips non-routable (data.rs:266/registry.rs:449) — only the --nodes COUNT is wrong. FIX: (primary S) routable-only denominator `e.2 += usize::from(inst.status.routable())`, keep separate raw count for the all-Offline liveness branch (cli.rs:5336). (secondary M) per-row Offline-TTL eviction (rows Offline beyond grace dropped even while node alive) — distinct from node-silence eviction; without it snapshots leak unbounded w/ purge churn (KH REQ-HAZARD-REGISTRY-GHOST-ROWS). Purge IS gossiped — as a one-shot Offline row, never a true eviction.

**A-#3 controlled-state not gossiped (the legacy local attach path left driven_by None)** · conf H · effort M. **MODEL CORRECTION (doyle ruling v3, 2026-07-19; applied here 2026-08-19):** this entry was written as "driven_by is remote-only BY DESIGN". That is FALSIFIED. `driven_by` names the CONTROLLING node, own node INCLUDED (CONTEXT.md:386), and the own-hex latch is truthful — the `by = None` behavior described below was a legacy-local-path artifact, not the model. KH 7.15 governs CLEARING the stamp when the session is gone, which is a different rule and still stands. The DIAGNOSIS and the FIX below are unaffected: the datum really was absent at source for a host-local controller, and a separate any-controller field really was the right answer. Only the "by design" framing was wrong. ROOT: W5 wiring is correct end-to-end, but the SOURCE is structurally None for a host-local controller. advertise_local reads `info.json.driven_by` (registryhost.rs:437) ✓; picker reads controller_node→Controlled (data.rs:289/model.rs:312) ✓; BUT `driven_by` stamped by broker from `controller_by().by` (broker.rs:630-636/384-386), and `by` = Some(origin_node) ONLY for a REMOTE WAN attach (attach.rs:337); a LOCAL controller uses by=None (broker.rs:1750). INTENTIONAL: msg.rs:715-721 "None is AMBIGUOUS — empty slot AND local controller"; KH 7.15 + inject_control_wedge W5-A1 "local-only controller must NOT latch driven_by". So locally-driven ball-b gossips controller_node=None → remote sees "ready to control". `driven_by` answers "is a REMOTE node driving me?" but picker needs "is ANYONE driving this?". FIX: add a SEPARATE broker-stamped field (e.g. `controlled` bool / `controller_node_any`) = true/Some(host_hex) for ANY controller (local or remote), stamped alongside stamp_driven_by (broker.rs:630), advertise_local gossips Instance::controller_node from IT. Keep `driven_by` (remote-only) untouched (don't trip REQ-HAZARD-DRIVEN-BY-SELFHEAL / KH 7.15). **operator node-refresh idea = WRONG fix:** no node-refresh primitive exists; gossip carries None AT SOURCE so re-pulling returns same None — must add the datum, not the cadence. (refresh-on-select may help #9/#10 addr-staleness, not #3.)

**A-#15 dormant on lone detached instance — FAITHFUL to CONTEXT, display problem** · conf H · effort S-display/M-model. ROOT: resting.rs:123 `(Active,Detach)=>Dormant` exactly implements CONTEXT.md:283/600 ("active→dormant: the driver detaches"); active REQUIRES a driver (CONTEXT:599). NOT an impl defect — it's CONTEXT's internal tension (281 calls active/dormant the "routing differentiator" yet 283/600 make Detach→Dormant even for a lone instance). Naive fixes all wrong: Detach→Active breaks active-requires-driver + leaks deferred msgs (REQ-INST-6 gate resting.rs:427 holds only Dormant/Suspended); Detach→Suspended false (session still warm; Suspended=cold). Picker already maps Dormant→Online (data.rs:273-276) so picker shows it Online correctly — the bare "Dormant" word reaching enlyzeam comes from text-only `endpoint list` = #11. **FIX: primarily DISPLAY (couples #11 — Dormant→Online glyph parity); leave resting.rs as-is.** If doyle wants the MODEL changed (lone instance never Dormant) → CONTEXT.md amendment FIRST (per gate-against-documented-design) + rethink deferred-gate/auto-suspend keyed on Dormant (M, real risk).

**A cross-cut:** #2 (stale rows counted) + #3 (missing source datum) are the same family — "remote viewer derives wrong fact from gossip" — but NEITHER is a freshness problem (node-refresh helps neither). #15 couples #11 (display). 

---

# SYNTHESIS — fix ordering / REQ shape (doyle)
- **Quick independent wins (S):** D-#12 (VT-out enable, Win10), C-#5 (node-name label), D-#14 (flag ID-marker OFF — also defuses #7/#8 regression-half), A-#2 primary (routable denominator).
- **Picker cluster (one ordered build):** C-#11 + C-#13 + A-#15-display — extract shared `ResourceRow→EpDisplay` builder + pub picker enums FIRST, then regroup-by-machine (#13), then endpoint-list reuse (#11) which also fixes #15's label.
- **Gossip-truth (M):** A-#3 (separate any-controller datum) + A-#2 secondary (Offline-TTL eviction).
- **WAN cluster (ordered):** B-#9/#10(b) reply-leg FIRST (makes loss visible) → B-#4 cross-node rc (reuse resolve→dial→round-trip) → B-#9/#10(a) address-refresh-on-IP-change (L).
- **PTY foundational (L):** D-#6 server-side VT grid → unlocks proper sticky marker redo (#8/#14) + kills residual artifacts (#7).
- **Design-gate first:** A-#15 model-change (if wanted) needs CONTEXT.md amendment; C-#1 decide if install-time runs [update.post].

---

# ROUND 2 — new bugs + reopens (2026-06-30)

## NEW

### E-#16 — eel-a (sole live endpoint, hfenduleam) can no longer be attached  · STATUS: TRIAGE
`spt rc eel-a` → `RC_FAIL:eel-a: attach request: brain IPC read deadline elapsed`. Worked shortly after creation. **LIVE EVIDENCE (doyle, this box):** endpoint list/daemon show eel-a `ready=true alive=true` (alive is BIND-gated — set at api bind, does NOT reflect live brain health), yet the attach IPC times out → the brain isn't servicing IPC (wedged/dead) while the perch still reads alive. daemon pid 19180. **STRONG LEAD:** the v0.18.0 `spt update apply` daemon/brain RESPAWN (done on this box earlier today) likely orphaned/wedged eel-a's brain — perch survived, brain IPC didn't. Class: brain-respawn / attach-IPC-deadlock (cf. v0.13.0 attach-deadlock + REQ-HAZARD-BRAIN-RESPAWN-PATH). (NOTE: does NOT couple #17 — agent G proved #17 independent.)
- **Root cause (agent E, conf M-mechanism-H): broker-resident shared-state corruption from the self-update brain-RESPAWN — NOT the brain itself.** The only deadline-bounded call in request_attach is `net_open_stream` (rc.rs:1086, awaits NetStreamOpened under ~10s); error minted brain.rs:1442 = broker's per-conn serve thread sent NO frame for KIND_NET_STREAM_OPEN. DISCRIMINATOR: on the same rc conn, KIND_SESSIONS + KIND_NET_DIAL_LOOPBACK already SUCCEEDED (broker pid 19180 alive, thread reached attach) — only the journaled+runtime call hangs. Working calls touch neither effect-journal nor tokio runtime; the failing one touches BOTH (dispatch_net_stream_open broker.rs:2459 does journal.apply_once AND host.open_stream→runtime block_on nethost.rs:1060). **Mechanism 1 (leading): POISONED EffectJournal mutex** — apply_once locks self.inner, every accessor `.expect("effect journal lock poisoned")` (effect.rs:209+); one prior panic-while-locked → EVERY subsequent journaled op panics its per-conn thread → no reply → rc deadlines, while non-journaled ops keep working. Permanent (poisoned mutex stays poisoned), broker stays up. EXACT recurrence of inject_control_wedge.rs:2172 historical symptom (root "journal lock unavailable") → REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE. **Mechanism 2 (secondary): dead/saturated NetHost runtime** — loopback open_stream does `runtime.block_on` with NO timeout (nethost.rs:1060, unlike QUIC bounded_block_on) → if runtime shut down/saturated by dead-peer QUIC ([[broken-ipv6-poisons-iroh-discovery]]) → panic/hang → permanent deadline; dial_loopback survives (never enters runtime). **SELF-UPDATE-SPECIFIC (H):** applyhost.rs:239 = brain-ONLY restart; broker keeps running (brainproc.rs:990 supervises brain IN broker proc) → broker's journal mutex + NetHost runtime + conn tables PERSIST across the cycle, one entered a bad state during respawn. Generic attach is CI-green → not a generic wedge, it's post-respawn broker-state corruption. RULED OUT: op-id collision (random per-proc base rc.rs:442), dead dispatcher (broker replies NetStreamOpened before brain accepts broker.rs:2453 → would render BLANK not deadline). **Fix (effort S-M):** (a) panic-proof apply_once — recover PoisonError via into_inner() not .expect() so one panic can't brick all attaches; (b) bound the loopback block_on (nethost.rs:1060) like QUIC paths → fail-fast error frame not opaque 10s deadline; (c) confirm which fired via daemon stderr (effect-journal-poison / runtime-shutdown backtrace) — the one piece static analysis can't settle.

### G-#17 — no digest for eel-a  · STATUS: SHIPPED CLOSED — W6 env-read @9305d19 (REQ-DIGEST-PROFILE-ENV) + W6b fetcher-strategy @b9e5ce9 (REQ-DIGEST-FETCHER-STRATEGY)
> **W6** shipped the harness-agnostic `[env] direction="read"` seam (capture-at-bind → info.json.read_env → digest fill_template keys → `value` fallback → `~` expansion → skip-diagnose). **W6b** closed the `{project}` design-gate per doyle's CONTEXT-symmetry ruling: `[digest]` gained a `fetcher` strategy mirroring `[history]`'s — the ADAPTER's extractor locates+reads+emits (no spt-core pre-read, no `{project}` slug), fed only harness-neutral `{session_id}`+`{cwd}`(=info.json.cwd)+captured read-vars. spt-core side is DONE end-to-end. **PERRI LOOP (unblocked):** claude-spt sets `[digest] strategy = "fetcher"` + extractor becomes the locator (owlery::claude_projects_root exists) fed `{session_id}`/`{cwd}`/`{CLAUDE_CONFIG_DIR}`.
`spt endpoint digest eel-a` → `NO_DIGEST:eel-a has no activity buffer (no session-log source / no records yet?)`. **LIVE EVIDENCE:** eel-a has ZERO entries in daemon-effects.log → no records produced. Operator hypothesis: manifest setup vs code, perri-side. Either (a) the claude-spt adapter's session-log source (manifest [digest]/session-log path) isn't wired for eel-a, or (b) eel-a's brain wedged (#16) before producing records. Disentangle adapter-manifest-wiring vs spt-core-digest-source vs downstream-of-#16.
- **Root cause (agent G, conf H): ADAPTER-MANIFEST CONFIG (perri-side), NOT spt-core, NOT downstream of #16.** digest reads ONLY two sources, neither auto-populated by spt-core: (1) `[digest]` extractor (digest.rs:164-177 → spt_live::extract_digest) resolving `[digest].source` template (else `[history].locate_template`) over the harness's own transcript (CC `~/.claude/projects/{project}/{session_id}.jsonl`); (2) log-less `perch/digest.log` via explicit `api digest-entry`. NO_DIGEST = both empty + never-published (digesthub.rs:203). **The "empty daemon-effects.log" is a RED HERRING** — that's the broker effect journal (broker.rs:2673), NOT a digest source; digest never reads it. spt-core does NOT mechanically capture a harness-hosted endpoint's turns. The only in-repo adapter (mock) declares `[history] native` + NO `[digest]`. LIKELY: claude-spt declares no `[digest]`/`[history].locate_template` (→NoSource) OR `[digest].source` doesn't resolve (missing {project}/{session_id} subst, empty info.json session_id, wrong CC JSONL path →Read err) — all swallowed as "no records" by design (digest.rs:130). INDEPENDENT of #16 (extractor reads CC's transcript, needs no brain). **Fix (perri): add/repair claude-spt `[digest]` block; diagnose via `spt adapter digest-proof` (surfaces NoSource vs Read vs OK).** effort S (perri).

### G-#18 — `spt adapter update claude-spt` re-register fails  · STATUS: SHIPPED @9305d19 (W6, REQ-ADAPTER-UPDATE-INPLACE)
`claude-spt 0.9.0→0.9.2`: fetch OK, unsigned-trusting OK, LIVE daemon-coordinated apply, then `ADAPTER_UPDATE_FAIL:claude-spt: re-register: io: The system cannot find the file specified. (os error 2)`. The post-extract re-register step can't find a file — likely the extracted adapter root path or a manifest-referenced binary/install_dir. Class: adapter update ripple / install-dir resolve (cf. F-019 translation install-dir, REQ-HAZARD-DEFERRED-MANIFEST). perri-adjacent (claude-spt v0.9.2).
- **Root cause (agent G, conf H): SPT-CORE DEFECT (3 coupled), NOT perri packaging, NOT coupled to #16.** Chain: update writes to HARDCODED `_github/{safe}` dest (cli.rs:7674) ignoring the adapter's registered source_dir — claude-spt was plugin/sptc:setup-installed, NOT via `adapter add --release`, so `create_dir_all(dest)` makes a FRESH EMPTY dir. `adapter_has_live_endpoint` (cli.rs:7394/7407) scans on-disk perch info.json parent-prefix → eel-a alive → routes to `apply_release_via_daemon` (ADAPTER_UPDATE_LIVE). Broker `dispatch_adapter_apply` (broker.rs:2597) filters `self.sessions` by exact adapter match — but self.sessions holds ONLY spt-PTY-hosted sessions (dispatch_spawn broker.rs:1813); claude-spt is HARNESS-hosted (CC) → not in map → `affected.is_empty()` → broker returns KIND_APPLIED **success WITHOUT the CRC swap** (broker.rs:2612-2617). CLI deletes daemon-stage, sees Ok → `registry::register(dest)` reads `dest/manifest.toml` (registry.rs:294) → empty dir → ENOENT → "re-register: io: os error 2". Corroborated: log has ADAPTER_UPDATE_LIVE then re-register-fail with NO `apply:` fail line (broker returned Ok-empty); crc_swap is additive-never-strand (crc_swap.rs:38-79) so manifest absence = swap NEVER ran. **3 defects:** D1 broker empty-affected reports APPLIED but no swap (broker.rs:2612); D2 adapter_has_live_endpoint over-routes harness-hosted to the daemon path meant only for a resident spt-hosted translation binary locking install-dir (cli.rs:7394); D3 hardcoded `_github/{safe}` ignores registered source_dir (cli.rs:7674). **Fix (conf H, effort S-M):** broker empty-affected branch still runs plan/apply_crc_swap(staging→install_dir) before APPLIED; CLI falls back to apply_release_crc_swap when daemon coordinated nothing; gate adapter_has_live_endpoint on broker-hosted (resident-translation) sessions not any on-disk perch; resolve dest from record source_dir (D3).

## REOPENS (new evidence refutes/extends prior diagnosis)

### B-#9/#10 — REOPENED: delivery fails with STABLE IPs too
NEW: hfenduleam→enlyzeam ALSO failed to deliver, BOTH IPs stable. So address-staleness (prior cause (a)) is NOT the whole story, and the no-reply-leg (b) explains silent-LOSS but not WHY nothing delivers AT ALL. Need a DEEPER dive into the actual delivery path on a stable-IP pair: receiver perch-resolution, access gate (Refused?), spool-not-inject, whether the WAN stream even reaches receive_wan, whether the connection/membership-proof actually establishes. The false-SENT(WAN) (b) stands as a separate fix, but there's a real non-delivery underneath it.
- **Root cause (agent F, conf H):** the REAL drop is the DATA-PATH (candidate #4). `spt send` resolves the dial with id-only `addr_for_node_hex` (wansend.rs:79/207/333 → endpoint.rs:538-541 = EndpointAddr::new(id), ZERO direct paths/relay) → EVERY send forces a fresh iroh DISCOVERY round-trip (mDNS/n0-DNS/relay). The gossip PUMP — which makes the nodes "mutually visible" — instead uses `dial_seeded` (pump/mod.rs:694-716): tries the durable last-known direct addr from PeerAddrStore FIRST + caches the conn, discovery only as fallback. So gossip stays GREEN on a stable pair (never needs discovery) while `spt send` is fully exposed to degraded discovery (enlyzeam = Win10 broken-IPv6 [[broken-ipv6-poisons-iroh-discovery]]). The QUIC+membership handshake completes over a marginal/transient-relay path → net_dial Ok → prints SENT(WAN), but the fire-and-forget payload (send_stream write_all + bare finish(), no stopped() ack, nethost.rs:1128-1137) is lost when that path can't carry data. Explains BOTH stable-IP + both-directions. **RULED OUT (each verified correct):** access-gate (default-OPEN access.rs:95-98), no-perch (perch_exists true on owning node wan.rs:149), spool (spool_wan_message_at writes a normal drainable non-deferred row spool.rs:236) — drop is UPSTREAM of receive_wan.
- **Fix (conf H, effort M):** route WAN dial through the pump's seeded-direct-addr resolution (PeerAddrStore first, id-only fallback — mirror dial_seeded); PAIR with (b) the ack reply-leg (receiver writes WanOutcome back, sender blocks under QUIC deadline → honest CLI + retry next addr). Contributing/lower-now: proactive addr-refresh on local IP change (a). **Confirm field (read-only):** PeerAddrStore peer-addrs.json WILL hold a good direct addr the send never tries; add a WanOutcome eprintln at dispatch.rs:615 receiver — nothing logs during a failing send = data-path drop confirmed.

### D-#12 — REOPENED: `rc` garbled but `endpoint run --attach` renders FINE (same Win10/pwsh env)
NEW: operator confirms `spt endpoint run --attach` renders cleanly in the SAME Win10/raw-pwsh environment; ONLY `spt rc` to a pre-running endpoint is garbled (raw color+cursor codes). This REFUTES "VT-output never enabled" (if so, --attach would garble too). Real question: what does the `rc`-to-existing path do DIFFERENTLY from `endpoint run --attach` in terminal/VT setup? Diff the two paths' client terminal-mode init.
- **Root cause (agent E, conf H): #12 IS A SURFACE OF #6 — fold them; prior VT-not-enabled theory REFUTED BY CONSTRUCTION.** Both paths call the SAME fn `run_attach_inner` (rc → session_confirmed=false rc.rs:1000; run --attach → =true rc.rs:993 via cli.rs:1702); the ONLY difference is the offline-failfast short-circuit (rc.rs:1047-1056). RawGuard::enable (rc.rs:730) calls only enable_raw_mode (NO ENABLE_VIRTUAL_TERMINAL_PROCESSING) in BOTH — so there's no client-VT difference to explain the asymmetry, and run --attach rendering ANSI cleanly proves VT-out is already usable in that Win10/pwsh terminal. REAL differentiator = REPLAYED BYTE CONTENT (server-side): both subscribe from_seq=0, but run --attach attaches to a JUST-SPAWNED session whose OutputLog ring holds the clean from-scratch init (?1049h then coherent frames) → coherent repaint; rc-to-PRE-RUNNING attaches from_seq=0 to a BOUNDED ring that already ROLLED PAST setup → replay begins MID-alt-screen-stream (orphaned CSI/SGR/abs-cursor + possibly a torn oldest chunk) → garbled ←[K. SAME root as #6 (raw-ring replay, no server screen model). **Fix = the #6 server-side VT/grid (clean current-screen repaint on attach); NOT a client-VT patch.** VT-enable in RawGuard is only a cheap defensive add for legacy conhost generally — will NOT fix rc-to-existing alone. **effort L — folds into #6, no cheap standalone fix.**

---

# VERIFIED CORRECTIONS (doyle, live artifacts on hfenduleam — operator-flagged the subagent inferences)

**Adapter-hosting clarification (operator):** claude-spt is a `kind=harness` adapter that supports BOTH spt-hosted endpoints (via `[session.self]` → `endpoint run`/`bind` → broker-hosted PTY) AND harness-hosted. **eel-a IS spt-hosted** (record + the fact it has broker/rc involvement proves it). The subagents conflated "harness *adapter*" with "harness-*hosted* endpoint" — wrong. This invalidates the empty-affected framing in #18.

**#17 CORRECTED (agent G was WRONG — [digest] IS wired):** the installed claude-spt manifest DOES declare `[digest] extractor = "claude-spt-digest --session {session_id} --in {source}", source = "~/.claude/projects"`. eel-a's info.json HAS a stamped `session_id = d84df205-6f96-4379-ad7a-c5fa1067aa46`, adapter=claude-spt, spt-hosted. So NO_DIGEST is NOT a missing-manifest-config. VERIFIED real cause: **no CC transcript exists for the stamped session_id** — `find ~/.claude/projects -iname "d84df205*"` returns NOTHING (eel-a's CC session produced no `{session_id}.jsonl`). Candidates (need digest-proof to split): (a) eel-a's CC never wrote a transcript — plausibly COUPLED to #16 (the wedge meant the session never really ran/produced output), (b) session-id mismatch: post_spawn stamped an id that ≠ CC's actual transcript filename, (c) extractor-binary resolution (`claude-spt-digest` is NOT on PATH; it lives in the source_dir `_github/SaberMage-spt-claude-code/` — digest must resolve it from there). LIKELY a/b (the transcript is simply absent). **OWNERSHIP: mixed/uncertain — possibly spt-core (session-id↔transcript correspondence) and/or downstream-of-#16, NOT cleanly perri-config.** Run `spt adapter digest-proof` + check whether CC actually wrote a transcript for d84df205 to split.

**#18 CORRECTED (agent G D1/D2 framing wrong; D3 CONFIRMED by evidence):** claude-spt record.toml → `source_dir = …\_github\SaberMage-spt-claude-code` (mode=pointer). The `[update]` repo = `SaberMage/claude-spt` → the update derived dest `_github\SaberMage-claude-spt` (≠ the registered source_dir!) and `create_dir_all`'d it — it is now EMPTY (verified: `_github/SaberMage-claude-spt/` contains only `.`/`..`). So the update wrote/re-registered to the WRONG, empty dir → `register` reads `_github/SaberMage-claude-spt/manifest.toml` → ENOENT → os-error-2. **ROOT (conf H): the update derives the install dir from the `[update]` repo NAME instead of the adapter's REGISTERED `source_dir`.** Compounded by a perri manifest inconsistency (the adapter was installed from repo `spt-claude-code` but its `[update].repo` says `claude-spt` — two different repo names → two different `_github/<safe>` dirs). **FIX (spt-core, conf H, effort S-M): `adapter update` must target the registered `source_dir` from the record, NOT re-derive `_github/<safe>` from `[update].repo`.** (eel-a being spt-hosted means the broker DID have an affected session — the empty-affected/no-swap theory does NOT apply; the swap/extract landed in the wrong dir, not "nowhere".) Secondary: flag the repo-name inconsistency to perri.

**#12 REFINED (operator):** "ring rolled past setup" is NOT guaranteed — a low-activity session could still hold its setup in the ring and render fine. The SOLID, dispositive fact stands: rc and `endpoint run --attach` are the SAME client code (VT theory refuted). The garbling is a server-side REPLAY-CONTENT problem (no grid model) — exact trigger varies (ring rolled past setup on a busy session, OR a torn sequence at the ring's oldest retained chunk, OR alt-screen/scroll-region state not reconstructed on a cold from_seq=0 attach). All are the SAME #6 root (raw-ring replay can't guarantee a coherent screen). Fold into #6; the fix (synthesize a clean current-screen repaint on attach) covers every trigger.

**DESIGN-GATE RULINGS (operator 2026-06-30):**
- **#15 → DISPLAY-ONLY.** Leave resting.rs/CONTEXT.md as-is; fix the lone-instance "Dormant" label at the display layer (couples #11). No model change.
- **#1 → ALSO RUN `[update.post]` at install-time** (not just surface the swallowed error). So `adapter add`'s install-as-first-update should run the composite post-step too, AND surface the error detail.

## CORRECTION 2 (operator, 2026-06-30 — my #17 "no transcript" was WRONG)
- **#17 RE-CORRECTED:** eel-a runs the `claude-spt:ccs` profile. The transcript DOES exist — under `.ccs\` not `.claude\`: VERIFIED `~/.ccs/instances/bigscreen/projects/C--Users-decid-Documents-projects/d84df205-….jsonl`. Manifest (lines 24-30) is explicit: `[profiles.ccs]` deliberately has NO `[profiles.ccs.digest]` override because ccs relocates CC's transcript tree via a RUNTIME `CLAUDE_CONFIG_DIR` env (→ `~/.ccs/instances/<account>/.claude`) that isn't a static catalog path — resolution is meant to happen INSIDE the extractor (`claude-spt digest`'s env-aware `owlery::claude_projects_root` resolver), so base `[digest] source=~/.claude/projects` is supposed to serve both. **REAL ROOT (leading, conf M — confirm w/ digest-proof): the on-demand `spt endpoint digest` runs the extractor in the DAEMON's context WITHOUT eel-a's per-session `CLAUDE_CONFIG_DIR`, so the env-aware resolver can't find the `.ccs`-relocated transcript → empty → NO_DIGEST.** It's a DIGEST-ENV-PROPAGATION seam (spt-core must carry/persist the profile's transcript-location env to the on-demand extractor), NOT a missing-config and NOT a missing-transcript. Possibly couples the profile-merge (does the digest projection resolve the `:ccs`-merged manifest + its env at all?). Confirm exact extractor verdict via `spt adapter digest-proof`. **Ownership: spt-core (digest env/profile propagation) ± claude-spt extractor resolver — NOT a manifest config gap.**
- **#18 CONFIRMED (repo rename is INTENTIONAL, not a perri bug):** perri+operator deliberately renamed the repo (spt-claude-code → claude-spt) between releases. So `adapter update` MUST update in place at the registered `source_dir` and tolerate a changed `[update].repo`/URL — never re-derive `_github/<safe>` from the repo name. Drop the "perri inconsistency" note; the fetch-from-new-repo worked, only the install-dir derivation is the spt-core defect.
- **LESSON (reinforced):** on adapter/profile questions, verify against ALL relevant on-disk trees INCLUDING profile-relocated paths (`.ccs\` as well as `.claude\`) before asserting absence. I asserted "no transcript" having searched only `~/.claude` — wrong twice on this bug.

## Operator rulings logged
- **#11:** picker remote-detail + status palette DID ship in v0.17.0 (W5) — CHANGELOG bullet "endpoint picker shows remote endpoints in full detail" + the lifecycle "Reading the picker" legend; it just didn't spell out "colored squares." #11 here = bring the non-interactive `endpoint list` to that same palette (NOT yet shipped).
- **#14:** confirmed — switch the top-right ID badge OFF next release; save the concept for a future web SPT GUI. **NON-NEGOTIABLE: accurate PTY representation with zero artifacts** (raises the bar on #6/#7/#8 — a real fix, not just disabling the badge).
- **#6:** server-side grid approach confirmed correct.
- **#15:** display-fix path accepted.

## Backlog (non-blocking, surfaced during the build)
- **Persist daemon stderr to a rotating log.** #16 could not be split poison-vs-runtime from live evidence because the daemon is launched detached and its stderr is not persisted anywhere (no panic backtrace to read on hfenduleam). A rotating daemon stderr log would make the next broker wedge diagnosable directly. Small future item (doyle/todlando), not a blocker for counter-38. (todlando, 2026-06-30)
