# BUILD-F028-RUNTRUTH — post-v0.22.0 bugfix milestone (triage)

Triaged by doyle 2026-07-03 from (a) six operator reports against the v0.22.0
`spt endpoint run` picker, (b) four session-lifecycle findings from the hall-b
attach-stall diagnosis, (c) two infra findings from the ENLYZEAM adapter-update
diagnosis. Every item below is RCA'd to a code-level root with evidence; none
are speculative. Wave owner: todlando. Gate: doyle, per sha, isolated worktree
+ fresh target; **path-semantics items (A1, C1) need the Linux gate leg**
(gate-cross-platform-leg rule, born of the v0.22.0 cut blocker).

## Track A — picker display/action truth (operator #1–#6)

### A1. `github-com-*` "ghost" project entries (operator #1, #4, #6-display)
Not a phantom: it is the spt-core repo itself. `project_id_for_dir`
(spt-store/src/project.rs:64) derives ids from the git remote slug BY DESIGN
(REQ-STORE-1, cross-machine context sync) — `github.com/SaberMage/spt-core` →
`github-com-sabermage-spt-core`. Endpoints (hall-a ordinals 1–3, hall-b origin)
genuinely ran sessions inside the spt-core checkout, so the ref is truthful.
The BUG is presentation: the raw slug renders as the display name everywhere
(history line, choose-project rows, endpoint-list project column), which no
operator recognizes as "spt-core".
**Fix:** keep the slug as the KEY; render a friendly display name — the repo
tail (`spt-core`) with the existing `disambiguate_project_ids`
(picker/model.rs:346) suffix mechanism for collisions. Applies to confirm-panel
history (view.rs:415-419), choose-project labels (model.rs:314-337), resume-row
titles, and the `endpoint list` project column (shared source:
`latest_project_ref`, picker/data.rs:417).

### A2. "controlled locally" shown for OFFLINE endpoints (operator #2)
view.rs:425-436 builds `control_line` from `ep.controlled` with no status gate;
an offline endpoint with a stale `controlled` stamp renders "controlled
locally" (operator screenshot: hall-a offline + controlled locally).
Two roots compound:
  (a) render: `control_line` must be empty when `status != Online`;
  (b) upstream: the `controlled` stamp is STICKY — confirmed ≥5 min after the
      attach client was SIGKILL'd with no spt client process alive (B3 below).
**Fix:** gate the render (a) AND clear the stamp on serve conn-drop (b, B3).

### A3. "View now (read-only)" offered for OFFLINE endpoints (operator #3)
model.rs:1030: the offline branch of `confirm_options` is
`vec![Start, View]` — View is meaningless with no live PTY.
**Fix:** offline set = `[Start]` (+ the existing Resume/ChangeAdapter/… tail).
Update the view.rs options tests.

### A4. Choose-project duplicate rows (operator #5)
model.rs:314-337 `build_project_choices`: the `Here: <run_cwd>` row dedups only
against the HEAD ref's dir (line 324) — an OLDER history ref with the same dir
still renders, giving `Here: C:\Users\decid\Documents\projects` + `projects`
as two rows for one project (operator screenshot).
**Fix:** dedupe `Here` against ALL history dirs (and skip rest-rows whose dir
== run_cwd when Here is present). Extend the model.rs:1847 test.

### A5. Resume-from-history labels every session with the endpoint's newest
project (operator #6)
data.rs:480-496 `resume_rows_for`: `project_history.first()` is cloned onto
EVERY ResumeRow (line 481/488), so all sessions read as the head project
(the ghost). The per-row `e.cwd` is right there and already carried for
launch-into-dir.
**Fix:** derive per-row: `project_id_for_dir(e.cwd)` (owlery-excluded → fall
back to trigger token), rendered through A1's display-name path.

## Track B — session lifecycle (hall-b diagnosis, all verified on 0.22.0)

### B1. `endpoint run --id X --create` on an endpoint with a live session mints
a silent DUPLICATE session — and attach output can CROSS sessions
Observed live: second create for diag-hallc minted a new session while the old
one ran; the new run's attach viewport rendered the OLD session's screen
(claude resume-picker UI belonging to pid 84512, while the new claude 356020
had no `-r` flag). Also the origin of the triplicate `launch --id ball-b`
processes on ENLYZEAM. This is the ROOT CLASS of the operator's original
0.21.0 attach-stall (zero events in FIRST_EVENT_GRACE — rc.rs:1402): attach
bound to the dead/wrong same-id session slot.
**Fix:** (i) run-on-live-session must refuse or reattach, never silently
duplicate; (ii) RCA the attach/output routing that let frames cross
same-id sessions (broker session-slot keying, dispatch_adapter vs serve_attach
resolution) and lock it with an int test: two sessions one endpoint id →
each attach sees only its own frames.

### B2. Respawn/`--resume` feeds the SPT session id to `claude -r`
After the 02:05 daemon bounce, respawn built
`claude.exe -r 70b5bfa40901b7d4 …` — an spt session id in claude's OWN
session-id namespace → claude hangs forever at a "No sessions match"
resume-picker while the endpoint reads online. Hits after EVERY daemon bounce
and every picker Resume. The harness session id (claude UUID, stamped in
sessions.log/info.json by the hooks) is what `{session_id}` must mean in the
adapter's [session.resume] command; the spt sid must not leak into it.
**Fix:** substitute the HARNESS session id in the resume template (spt-core
substitution-key semantics + likely claude-spt manifest coordination — flag to
perri if the manifest side moves). Int test: resume template receives the
ledger UUID, not the spt sid.

### B3. Dead-pid ONLINE decay window (#11) + sticky CONTROLLED stamp
Deliberate repro now exists: /exit → all endpoint processes dead → `endpoint
list` stays ■ ONLINE for a decay window before OFFLINE. Separately the
`controlled` stamp survives attach-client SIGKILL ≥5 min with no client alive
(caveat: may reflect claude's `--remote-control` channel, not the PTY attach —
disambiguate first). This is the deferred #11 seed; RCA belongs to this wave.
**Fix:** broker reap on harness-exit + serve conn-drop must clear presence and
control stamps promptly; int tests for both edges.

### B4. `--resume` respawns a second harness onto the SAME session without
reaping the first
Observed live: resume of b4421cf9 spawned pid 34432 while gen1 (250376) kept
running — two claude.exe stacks, one session id.
**Fix:** resume must reap/refuse when the session already has a live harness.

## Track C — infra

### C1. crc_swap `.old`-litter bricks every subsequent adapter update
(memory: crc-swap-old-litter-brick; RCA'd on ENLYZEAM, confirmed systemic on a
healthy box.) `apply_crc_swap` Phase-3 `let _ = remove_file(.old)`
(spt-daemon/src/crc_swap.rs:129-133) silently fails whenever ANY pre-update
process still image-maps the old binary — the NORMAL state on a live box,
since endpoint `launch` children survive updates. The NEXT update's first
commit-op `rename(exe → exe.old)` = MoveFileExW(REPLACE_EXISTING) must delete
the mapped `.old` → win32 err 5 → whole apply fails + rolls back, forever,
with a context-free error.
**Fix:** (i) DISPLACE, don't replace: when `<target>.old` exists, rename it
aside to a unique suffix (rename always succeeds on mapped files — spt's own
binary updater already does `spt.exe.old-<counter>`); GC stale `.old.*`
opportunistically on later runs. (ii) Wrap swap io errors with op + path
(`rename claude-spt.exe -> claude-spt.exe.old: …`) — the bare `os error 5`
cost a full RCA. Unit seam exists (crc_swap tests); Linux leg required.

### C2. Cross-endpoint perch contamination — psyche sessions REBIND foreign
perches (ROOT-CAUSED, upgraded severity; doyle RCA 2026-07-03)
Evidence: hall-a's ledger (ordinals 4–5) holds its own psyche's session AND
f015b-probe-psyche's session; worse, hall-a's `info.json.session_id` IS
f015b-probe-psyche's session (`359d7bd7…`) — the foreign psyche's SessionStart
hook stamped hall-a's ledger AND rebound hall-a's identity record to itself.
f015b-probe's own ledger has neither psyche session. Same class as hall-b's
dead-pid `info.json` stamp (141556) seen in the attach-stall diagnosis.
Three verified defects compose:
  (1) `roster::detect_self_id` leg (a) scans the owlery ONE level deep
      (roster.rs:107 `read_dir(owlery)`) — a NESTED psyche perch
      (`owlery/<parent>/nested/<id>-psyche`) can never resolve to itself, so a
      psyche-hosted session always falls through or mis-binds.
  (2) `[session.psyche_init]` (claude-spt manifest line ~347) spawns the psyche
      with NO `env_remove` and no PINNED child identity — whatever
      OWL_SESSION_ID/SPT_AGENT_ID reaches the child resolves leg (a)/(b) to a
      FOREIGN top-level perch (or the adapter hook's fallback picks one).
  (3) The SessionStart hook then writes BOTH `info.json` (session_id/pid
      rebind) and `sessions.log` on the mis-resolved perch — corrupting the
      victim's identity ground truth. This is UPSTREAM of B3: corrupted
      session/pid stamps are exactly what presence/CONTROLLED read.
**Fix (spt-core half):**
  (i) Identity must be PINNED at spawn, not guessed at hook time:
      ManifestRuntime role spawns inject the child's OWN identity
      (SPT_AGENT_ID=<child perch id> + OWL_SESSION_ID=<child session>), and/or
      detect_self_id enumerates nested perches;
  (ii) BIND HONESTY: a session-start stamp may only write a perch whose
      resolved id affirmatively matches — never a fallback pick; refuse +
      loud-skip when unresolved (kin of REQ-MSG-CLI-ORIGIN's honest default);
  (iii) belt-and-braces: filter owlery-cwd rows out of resume_rows;
  (iv) one-time repair path for already-contaminated perches (hall-a on
       HFENDULEAM) — or self-heal on next legitimate session-start.
**Adapter half (PERRI touchpoint, like B2):** psyche_init gains env scrubbing /
relies on runtime pinning; the hook loses any silent fallback-perch behavior.
**ORDERING:** C2 moves ahead of/with B3 (W3) — B3's presence/stamp fixes read
the very records C2 corrupts.

## Track A additions — operator list-rendering batch (2026-07-03, second report)

### A6. `spt endpoint list` render polish (operator, 4 asks)
  (a) "Shared subnets" line is NOT dim: `LIGHT_GRAY = "37"` (cli.rs:3019) is
      standard-palette WHITE — indistinguishable from row text. Use SGR 90
      (bright-black / gray) for the dim intent.
  (b) NEW: the `Total:` line takes the same dim color.
  (c) Move the status glyph adjacent to the endpoint name (operator: "right
      behind the endpoint name"), mirroring the picker's glyph-beside-name
      presentation (today the glyph sits at the end next to the status word).
  (d) Color the status word like the picker TUI does (green ONLINE / gray
      OFFLINE / blue when driven, matching picker glyph semantics).
  All in `render_node_grouped`/`render_instance_row` (cli.rs ~3000s); pure
  render with injected color decision — unit-testable off a tty.

### A7. Windows console: ANSI emitted without VT enable → garbled conhost
Operator evidence (raw PowerShell 7, Win10 conhost): `←[36m` literals in
`endpoint list` and `--help` output. Root: `ENABLE_VIRTUAL_TERMINAL_PROCESSING`
is enabled ONLY on the rc attach path (rc.rs:746, REQ-RC-WIN-VT-OUTPUT) —
plain CLI stdout never enables it, and the color decision doesn't fall back
when the console can't render VT. Fix: lift the rc.rs VT-enable into a shared
startup helper for every colored-output path; if `SetConsoleMode` fails (or
stdout isn't a console), STRIP colors (the `ansi_wrap`/helpfmt color=false
path already exists — this is plumbing the decision, not new rendering).
Windows Terminal masks this (VT always on) — test on raw conhost.

### B7. Cross-node CONTROLLED not shown for a remote endpoint
Operator evidence: ball-b ONLINE + CONTROLLED on ENLYZEAM (local view, both
nodes 0.22.0), but HFENDULEAM renders remote ball-b as plain ONLINE. The
F-026 #4 gossiped any-controller datum (REQ-GOSSIP-CONTROLLED-ANY) either
isn't sent for the locally-controlled case, isn't applied on the receiving
row, or decays. perri's validation had this exact leg ENV-BLOCKED — now two
live nodes exist to RCA against. Note the sibling excerpt ALSO shows local
hall-b CONTROLLED rendering blue-glyph correctly, so local is fine; the gap
is the remote leg.

## Track B/C additions — perri v0.22.0 validation findings (2026-07-03)

Source: perri's counter-42 validation report (evidence-classified, probe rig
`si1-probe` on HFENDULEAM). GREEN halves: endpoint-info VALIDATED, SI-1
relative drop dirs VALIDATED E2E, CONTROLLED clear-on-rc-detach VALIDATED on a
healthy daemon. Remote-row truth ENV-BLOCKED (no live remote in window).

### B5 (perri F-a). Peer pump stall under rc attach/detach churn
[DEFECT, daemon, OBSERVED-ONCE, HIGH]. Fresh 0.22.0 daemon, ~10 min after
restart, during rapid rc attach/EOF-detach/--take cycling: `peer pump: STALLED
(last tick 122s)`; while stalled `spt rc --view` → `RC_FAIL: attach request:
brain IPC read deadline elapsed` (repeatable) and controlled-clear stopped
propagating. Daemon restart recovered; endpoints auto-revived. Priors in this
class: pump-IPC-deadline (reader-thread+channel carrier), broker-QUIC-deadline
(bounded_block_on) — something in the rc churn path can still wedge the pump
tick.
REPRO PACKET (perri 2026-07-03, times local HFENDULEAM): target = an UNBOUND
trust-wedged endpoint (ready=false alive=false, broker PTY alive). Churn
02:13–02:16, all stdin-EOF detaches (never ctrl-b d):
  (1) `printf '\r' | timeout 6 spt rc <id>` control-attach EOF-detach;
  (2) `timeout 8 spt rc <id> --view` ×2;
  (3) re-attach → REFUSED "take control (kicks <node>)" — the controller
      record from (1) STILL HELD after its EOF-detach (pre-stall detach-clear
      miss — also B3-relevant);
  (4) `--take` kick then EOF-detach;
  (5) `--view` → EMPTY render, then repeatable
      `RC_FAIL: attach request: brain IPC read deadline elapsed`.
~02:19 `daemon status`: peer pump STALLED (last tick 122s → 136s). While
stalled, controlled-clear propagation dead (≥30s stale after clean detach).
Restart recovered instantly + auto-revive of suspended endpoints worked.
Unknown load-bearing ingredient (EOF-detach vs --take kick vs unbound target).
Heartbeat-semantics oddity to reconcile during RCA: the captured
pump_heartbeat_ms (09:19:18.586Z) POST-dates the churn window while the human
view called STALLED — tick source vs render may disagree.

### B3 acceptance BROADENED (perri F-b, CONFIRMED). Stale CONTROLLED on a DEAD
endpoint persists >20 min AND ACROSS A DAEMON RESTART (hall-b:
controlled=true + attached_node set while alive=false/OFFLINE). So B3's reap
must cover (i) clean exit, (ii) conn-drop, (iii) session-died-without-exit
(crash/bounce), and (iv) a BOOT-TIME sweep — a restarted daemon must not
resurrect control stamps for endpoints it can see are dead.

### B6 (perri F-e). Translation-binary liveness decay
[DEFECT?, core, intermittent]. FORCE_NATIVE_UNDELIVERED "no live translation
binary" while the endpoint's translate process was ALIVE (same daemon's
child); worked at probe-minute-4, refused at minute-28 (clear boundaries /
dormancy cycles suspected of decaying the liveness registration). Gates the
adapter's checkpoint self-send chain (ARM/FIRE are --force-native by design).
REPRO PACKET (perri 2026-07-03): constants pinned — same daemon (139644, no
restart), same translate pid (297520, spawned 02:25:20, verified ALIVE at
failure 02:53), endpoint active, CC idle at prompt. Force-native GOOD at
02:29:41 (checkpoint ARM) and ~02:37 (post-clear-boundary delivery — rules
out session-key invalidation at boundary alone). REFUSED twice 02:52–02:53
(`FORCE_NATIVE_UNDELIVERED: no live translation binary — binary-or-nothing`);
`endpoint wake` returned NO_EDGE (already Active) and did not help. Between
good and bad: ZERO harness turns (02:38→02:52), MANY dormancy flip-flops,
wake loops every 10s, two rc nudges. Hypothesis menu: liveness registration
decays on dormancy transitions and wake doesn't restore it; or it refreshes
only on harness idle/turn events (none occurred). Repro sketch: bind an
endpoint, leave it turn-less through several dormant/wake cycles ~15 min,
then any `--force-native` send while the translate pid is verifiably alive.

#### RCA (todlando 2026-07-03, code-trace; the "liveness decay" framing is a
#### mis-diagnosis). The `FORCE_NATIVE_UNDELIVERED: no live translation binary`
string is the CLI's GENERIC force-native-miss message (cli.rs:5037) — it is
printed for ANY `try_spt_hosted_inject` miss and never actually probes the
binary. So "translate pid alive" does not contradict it; the pid is a red
herring. Tracing the miss:

- **NOT the `is_spt_hosted_no_relay` gate (H1, refuted).** Its three inputs —
  the `.ready` marker, `status==online`, and `controllable==Some(true)` — ALL
  survive dormancy/suspend/wake: the resting state machine (`resting::apply_event`,
  the auto-suspend + wake path via `BrainLifecycle::rest_event`) writes ONLY
  `rest_state`; it never removes `.ready` (that lives only in `signoff_with`,
  which auto-suspend does NOT call), never flips `status`, never clears
  `controllable`. `fire_wake_effects` (the wake seam) restores notifs + a
  freshness pull but touches none of these either.
- **NOT a faulted binary (H3, refuted).** `fault_translation` → `child.terminate()`
  is a real kill+wait (translation.rs:333) → a faulted binary is a DEAD pid.
  297520 is verifiably alive, so its session's translation is `Some` +
  un-faulted.
- **NOT a missing broker session (H2, refuted).** A broker session is removed
  ONLY on `KIND_EXIT` (broker.rs:1998) — a real harness exit. Same live pid,
  active endpoint, no exit in the window ⇒ the session persists and
  `dispatch_endpoint_input` finds it.
- **LEADING (H4): a PANIC in `run_inject_worker` orphans the child + strands
  delivery.** The floor `Mutex` is locked via bare `.lock().unwrap()` at THREE
  sites that a POISON cascades across: the inject worker (broker.rs:1297,
  `open()`), `flush_inject_floor` (1163), and — crucially — the CONTROLLER-input
  path `dispatch_input` Layer C (2142, `buffer_if_held`). The repro's "two rc
  nudges" are controller input through 2142. A panic under the floor lock on
  ANY of these (a slot the controller and the inject worker SHARE) POISONS the
  mutex; the worker's next `floor.lock().unwrap()` then panics, the worker thread
  dies WITHOUT running its fault/terminate path, so `event_rx` drops but the
  child is NEVER reaped (pid stays alive) and `event_tx.send` now fails →
  `dispatch_endpoint_input` reports the "worker-gone" delivered=false →
  force-native refused, PERMANENTLY (a faulted/dead worker never respawns).
  Matches EVERY pinned constant (same daemon, same LIVE pid, un-faulted, no
  exit, no adapter-apply, active endpoint, rc-nudge involvement) and the "wake
  did not help" tell (wake rebuilds neither the worker nor the session).

CONFIRMATION NEEDED (one artifact): perri's DAEMON STDERR for 02:38–02:53 —
(a) any panic backtrace (proves the worker died), and (b) the `ENDPOINT_INJECT`
line at each refused send: `…worker-gone… -> SPOOLED` present ⇒ H4 CONFIRMED;
NO `ENDPOINT_INJECT` line at all ⇒ the broker was never reached ⇒ re-open H1
(and capture the perch `info.json` at failure). FIX DIRECTION (well-precedented
by bug #16 / REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE): (i) make the floor lock
poison-tolerant (`.lock().unwrap_or_else(|p| p.into_inner())`) at all three
sites so one panic can't brick the worker; (ii) make `run_inject_worker`
panic-resilient (catch_unwind → fault+terminate the child on a worker panic, so
a dead worker never orphans a live binary + strands force-native forever).

### C3 (perri F-h). Psyche wrapper crash-loop: no backoff + shutdown miss
Probe psyche crash-looped ~3 boots/sec for ~30 min (CC died instantly on the
untrusted owlery cwd; ledger hit ordinal 5358) — silent; and `spt endpoint
shutdown` did NOT tear the looping wrapper down (docs say shutdown tears the
Psyche with the perch; manual kill was required). Fixes: (i) bounded
backoff + loud give-up on psyche boot loop (the `psyche_host_error` surface
already exists), (ii) shutdown must cover a wedged/looping wrapper.

### D-track (docs/truth, small)
- D1 (perri F-c): SI-1's resolution rule (relative watched dirs resolve
  against the ENDPOINT's cwd) is documented nowhere public — add to
  harness-contract/manifest.md + schema descriptions (docs-drift gate applies).
- D2 (perri F-d): `daemon status --json` truth drift — managed_by/-active null
  while the human view says "managed-by: manual — at-logon task registered";
  and pump staleness (the STALLED diagnosis) is not computable from JSON (raw
  pump_heartbeat_ms only) — add a derived staleness/stalled field.

### Parked for design/other waves (not this milestone's code scope)
- perri F-f: the physical-inject dormant-gate also blocks the checkpoint FIRE
  self-send for headless agents (wake fires next clear — v1 residual,
  live-confirmed). Needs a doyle DESIGN ruling (dormant wake queue?) — parked
  to the next design cycle, stated loud here so it isn't lost.
- perri F-g: workspace-trust root cause (~/.claude.json projects keys are
  FORWARD-SLASH; backslash-keyed hasTrustDialogAccepted silently ignored) +
  the fail-loud gap (endpoint run reports started while the harness is wedged
  at a trust prompt, ready=false forever, silent) — feeds F-027
  (ENDPOINT_SPAWN_FAIL, perri's wave), where the pre-trust seam + spawn-fail
  token get designed.

## Suggested wave order

- W1 (small, self-contained picker fixes): A3, A4, A5, A2(a-render).
- W2 (display): A1 (display naming — list column + picker + resume titles),
  A6 (list render polish), A7 (Windows VT enable-or-strip).
- W3 (identity + lifecycle; C2 FIRST — it corrupts what B3 reads): C2
  (identity pinning + bind honesty + resume filter + repair), B3 (reap/stamp
  clearing — closes A2(b)), B1 (dup-mint refuse/reattach + crossed-attach
  RCA), B4.
- W4: B2 (resume session-id semantics; perri touchpoint, pairs with C2's
  adapter half), B6 (translation-liveness decay — get perri's repro first),
  C3 (psyche backoff + shutdown coverage).
- W5: C1 (crc_swap displace + error context; Linux leg), B5 (pump-stall RCA —
  needs perri's timestamps + a churn repro harness; may promote earlier if the
  repro is easy), D1/D2 (docs + status-JSON truth).

Seed rules: mint REQ ids in traceable-reqs.toml at W0 (activate per-wave, int
at final wave); this doc rides the seed commit. Every sha doyle-gated;
worktree-of-sha + fresh CARGO_TARGET_DIR; A1/C1 shas need the Linux leg.
