# F-027 — ENDPOINT_SPAWN_FAIL: pre-spawn seam + bind-deadline honesty (DESIGN)

Status: **FROZEN 2026-07-03 — field review complete.** Author: doyle. Inputs:
perri field seeds + spawn-fail taxonomy (2026-07-03, 16-part dispatch,
observed-vs-inferred flagged), doyle code recon of the spawn/ready seam
(anchors below, verified @b6d8357), perri design-review answers Q1–Q4
(2026-07-03; all four folded in — see §7).

Scope split: **Half A (bind-deadline honesty) and Half B (pre-spawn manifest
seam) are spt-core changes** (todlando's wave, sequenced after F-029). **The
trust-seeder binary itself is adapter-side** (perri's wave — spt-core stays
harness-agnostic per the glue-model boundary: adapters = manifest + binary only;
no harness-aware source in spt-core).

---

## 1. Problem

`spt endpoint run <id> --start` prints `ENDPOINT_RUN_STARTED` when the broker
has accepted the spawn RPC and a child PID exists — **nothing more is verified**
(cli.rs:1819 machine line, cli.rs:1833 `--start` success print). A harness that
launches but wedges post-spawn (workspace-trust prompt, null stdin) never fires
its SessionStart hook, so `establish_perch` never runs (api/startup.rs:299 is
the ONLY writer of the `ready` marker), and the endpoint sits `ready=false`
**forever, silently** — while the operator was told it started.

`ready=false` is already rendered by `spt list`/picker (roster.rs:69 →
cli.rs:2735/3826) but nothing gates or alarms on it: passive display only. The
attach path's `await_endpoint_session` (cli.rs:1854, 25 s) gates on broker
**session existence** — created synchronously at spawn, so it passes instantly
for a wedged harness — and the `--start` path skips even that.

Precedent trap to avoid: `TRANSLATION_SPAWN_FAILED` (broker.rs:1271) is loud on
the **detached daemon's stderr, which nobody reads** (ADR-0022:130,
KNOWN-HAZARDS F-019 lesson). Any F-027 token must land on **CLI stderr and/or
the perch record**, never daemon-stderr-only.

## 2. Field evidence (perri, 2026-07-03 — provenance table)

| # | Mode | Observed | Class |
|---|------|----------|-------|
| 1 | TRUST-WEDGE — CC at workspace-trust dialog, null stdin; `ENDPOINT_RUN_STARTED` printed; ready=false forever | every fresh probe cwd, this session | SILENT |
| 2 | ACCOUNT-ROOT-MISMATCH — trust seeded in a DIFFERENT config root than the spawn resolves; wedges despite a valid-looking seed | Reavo/Brandon dual-root box (si1 vs b6b probes) | SILENT |
| 3 | PSYCHE-CRASHLOOP — nested psyche CC dies at trust dialog; wrapper respawned ~3/s, ordinal 5358 in 30 min | F-h; now guarded by adapter v0.13.0 give-up + core C3(b) | SILENT (guarded) |
| 4 | ADAPTER-UNRESOLVED — no registered adapter hosts the parent binary | seed→listen path, bash parent | LOUD pre-spawn (good shape) |
| 5 | STALE-CLI-CONTRACT — bringup recipe passes a removed flag; "unexpected argument"; spawn never happens | old recipe on 0.22.0+ | LOUD pre-spawn (good shape) |
| 6 | RELAUNCH-NEVER-BINDS — 2nd `endpoint run` same id after tree-kill; prints but never binds | **0.22.0 only — FIXED on 0.23.0 by F-028 B1/B4** (perri definitive repro 2026-07-03: tree-kill → same-id re-run re-binds fresh session, live PTY, honest info.json; first check was rig-void, --subnet omitted, caught before reporting) | FIXED-NOTE (not in F-027 scope) |
| — | UAC-740 elevation spawn-fail | NOT observed (subnet-op elevation only) | out of scope |

Seed-1 correction (accepted): the "backslash-keyed `hasTrustDialogAccepted`
silently ignored" pin from the RUNTRUTH triage was a **rig artifact** (hand-
seeded backslash key), not a natural writer — CC writes and matches
forward-slash keys. The surviving pin: **trust is matched by an EXACT string
key**, so any path-form drift (slash direction, casing, canonicalization)
silently misses → wedge.

Seed-2 (the deeper root): trust is stored **per config root**, and the root a
spawn resolves is a **function of whether `CLAUDE_CONFIG_DIR` survives the
spawn env-rebuild** — which differs by spawn mode (detached daemon
`CreateEnvironmentBlock` rebuild vs foreground inherit). Two spawns on one box
resolved different accounts (Reavo vs Brandon). Seeding the operator shell's
root is therefore WRONG in general.

## 3. Design

Target invariant (headline): **"started" means BOUND, or the failure is LOUD.**
The silent class (modes 1, 2, 3, 6) must fail with the same loudness as the
pre-spawn class (modes 4, 5) already does.

### Half A — bind-deadline honesty (spt-core, harness-agnostic)

After the spawn RPC returns in `cmd_endpoint_run --start`, the CLI **awaits
bind** — the appearance of the `ready` marker (`resolve_ready_file`, the
`establish_perch` effect) — bounded by a deadline:

- **Success:** `ENDPOINT_RUN_STARTED:{id}` moves to AFTER the bind is observed
  — its plain meaning finally true. The early machine line `ENDPOINT_RUN:{id}
  … pid={pid}` stays at spawn-accept (scripts parse it; unchanged).
- **Deadline exceeded:** emit **`ENDPOINT_SPAWN_FAIL:{id}: reason=<CLASS>:
  <detail>`** on CLI stderr, exit non-zero, and **stamp the perch record**
  (`spawn_fail` field via the locked `mutate_info` RMW) so `spt list` and the
  picker render the honest state. A later successful bind clears the stamp
  (`establish_perch` already rewrites state; clearing rides there).
- Classification probes (all CLI-observable; no daemon change needed to
  detect): child pid alive? broker `has_session` still true? ready marker
  present? →
  - `WEDGED_PRE_BIND` — pid alive, session alive, never bound (modes 1, 2)
  - `EXITED_PRE_BIND` — child exited before binding (mode 5-shaped recipes
    that pass argv but die fast; exit detail when capturable)
  - `NO_SESSION` — broker session vanished mid-wait (mode 6 shape)
- Deadline: **default 60 s** (field-confirmed: real binds land t+5–15 s;
  every observed bringup exceeding ~20 s was a wedge that never bound — the
  deadline separates cleanly at ≥30 s; **never configure below 30 s**),
  overridable per-adapter via a manifest key (the bringup-time contract
  belongs to the adapter) and per-invocation via `--bind-deadline <secs>`
  (the per-node escape for cold-broker first-spawn / loaded boxes). Escape
  hatch `--no-wait` restores fire-and-forget and prints
  `ENDPOINT_RUN_DETACHED:{id}` (explicitly unverified — never `_STARTED`).
- The attach (non-`--start`) path upgrades the same way: `await_endpoint_session`
  keeps its session gate, then the same bind-await runs before declaring health.

Failure of the await machinery itself (CLI killed mid-wait) fails open to
today's behavior — no stamp, no token. Acceptable: strictly better than now; a
daemon-side watchdog is a possible follow-up, not this wave.

### Half B — pre-spawn manifest seam (spt-core executes; adapter owns the binary)

New manifest key **`pre_spawn`** inside `[session.<role>]` tables (applies to
`self` AND `resume` — field-confirmed necessary on resume: a resume is a
distinct harness process that RE-CHECKS trust; perri INT-2 observed a resumed
CC dying at the trust dialog, so trust is durable across resumes only if the
same root+seed persists — exactly the seed-2 fragility): a command the broker
runs **synchronously, before the session spawn**, with:

1. **Env parity via capture-at-creation (BINDING; sharpened by perri review):**
   env vars the adapter declares `[env.<NAME>] direction="read"` are **captured
   at endpoint creation and stamped into the perch record**; the broker threads
   the IDENTICAL stamped values to BOTH the pre-spawn command AND the session
   spawn. Neither consumer reads its own process env for a stamped var — if the
   seeder read its own env while the spawn read the stamp (or vice versa),
   parity breaks and the account-root mismatch returns through the back door.
   Mere same-rebuild parity is NOT enough (both could drop the var — parity,
   wrong root); the stamp is the single source. This kills mode 2 by
   construction, and fixes wrong-account SESSIONS generally (detached respawns
   re-apply the stamp). Core stays harness-agnostic: it captures whatever
   `[env]` declares, knowing nothing of `CLAUDE_CONFIG_DIR` specifically.
   (Field-confirmed adapter shape: claude-spt declares
   `[env.CLAUDE_CONFIG_DIR] direction="read"` with `~/.claude` fallback —
   adapter reads, never sets.)
2. **Install-dir-first resolution (REQ-INSTALL-11):** the program path resolves
   via `resolve_program_in_dir` like every other adapter-manifest program.
3. **Bounded + loud:** runtime capped (30 s); non-zero exit or timeout ABORTS
   the session spawn with `ENDPOINT_SPAWN_FAIL:{id}: reason=PRE_SPAWN_FAIL:
   <exit/detail>` on CLI stderr (relayed through the spawn RPC error — this
   path already reaches the CLI as `ENDPOINT_RUN_FAIL` does today).
4. **Idempotency contract:** the command must be safe to run on every spawn
   (documented in MANIFEST.md; the seeder re-asserting an existing trust entry
   is a no-op).

The claude-spt seeder binary (perri's): resolve config root as **the spawn
will** (`CLAUDE_CONFIG_DIR` from its own env — which IS the spawn env by
invariant 1 — else default `~/.claude`), then assert the endpoint home's trust
entry with a **forward-slash exact key** (CC's own writer form). spt-core never
knows what the command does — it only guarantees env parity, resolution, bound,
and loudness.

**Seeder atomic-write rule (BINDING; amendment 2026-07-04, field-proven):** the
seeder mutates the trust store via **write-temp + fsync + rename**, never
open-truncate-in-place. Evidence: a transient ENOSPC killed a rig seeder
mid-`open(truncate)+dump` of `~/.claude.json`, wiping the box's whole trust
store to zero bytes — CC quarantined it and regenerated minimal, and every
subsequent spawn hit the silent-unbound trust-wedge (mode 1, environmental
edition). An interrupted seeder must corrupt NOTHING: a trust seeder that can
destroy the trust store on ENOSPC/kill is strictly worse than no seeder. (Also
live re-confirmation of the Half-A case: those spawns died silently where
`ENDPOINT_SPAWN_FAIL` would have been loud.)

### What Half A/B deliberately do NOT do

- No harness-aware code in spt-core (no `~/.claude.json` reads, no
  `hasTrustDialogAccepted` knowledge) — boundary holds.
- No widening of psyche spawn handling: mode 3's cause disappears when the
  owlery cwd is trust-seeded by the same pre-spawn seam, and the crashloop
  backstop (C3(b) + adapter give-up) already guards the symptom.
- No mode-6 work: FIXED on 0.23.0 by F-028 B1/B4 (perri definitive repro).
  The Half-A deadline token still catches its shape if it ever regresses.

## 4. Coverage matrix

| Mode | Prevented by | Caught loud by |
|------|--------------|----------------|
| 1 TRUST-WEDGE | Half B (seeded before spawn) | Half A `WEDGED_PRE_BIND` |
| 2 ACCOUNT-ROOT-MISMATCH | Half B env-parity invariant | Half A `WEDGED_PRE_BIND` |
| 3 PSYCHE-CRASHLOOP | Half B (owlery cwd seed) | existing C3(b)/v0.13.0 guard |
| 4 ADAPTER-UNRESOLVED | — (already good) | existing pre-spawn error |
| 5 STALE-CLI-CONTRACT | — (already good) | existing / Half A `EXITED_PRE_BIND` |
| 6 RELAUNCH-NEVER-BINDS | FIXED (F-028 B1/B4, 0.23.0-verified) | Half A `NO_SESSION`/`WEDGED_PRE_BIND` (regression net only) |

## 5. Requirement seeds (register at wave start, `required_stages=[]` until active)

- `REQ-ENDPOINT-RUN-BIND-DEADLINE` — "started" implies observed bind;
  deadline → `ENDPOINT_SPAWN_FAIL{reason}` on CLI stderr + non-zero exit +
  perch stamp; stamp cleared on later bind. Red-first: wedge a mock harness
  (never binds) → assert today's silent `ENDPOINT_RUN_STARTED` becomes the
  token. Real-daemon int candidate (mock-adapter seam exists — F-028 pattern).
- `REQ-PRESPAWN-COMMAND-SEAM` — manifest `pre_spawn` executes with spawn-env
  parity + install-dir-first resolution + bounded runtime; failure aborts
  loud. Red-first unit on env parity (seeded sentinel var visible to pre-spawn
  iff visible to session spawn).
- `REQ-HAZARD-ENDPOINT-STARTED-SILENT-WEDGE` — hazard entry (paid-for field
  bug, perri 2026-07-03): the conformance test IS the Half-A red-first int.
- Adapter-side (perri's repo, NOT a core REQ): trust-seeder contract —
  forward-slash exact key, root-as-spawn-resolves, idempotent.

## 6. Docs surfaces (docs-drift gate applies)

- `docs/MANIFEST.md` `[session.<role>]`: `pre_spawn` key + idempotency + env
  parity contract; parallel the `TRANSLATION_SPAWN_FAILED` precedent line
  (MANIFEST.md:327) with the new `ENDPOINT_SPAWN_FAIL` semantics.
- `docs/KNOWN-HAZARDS.md`: new hazard entry (above).
- Worked example (MANIFEST.md:540 claude-spt section): pre-spawn trust-seeder
  once perri's binary ships.
- D1 (bringup cwd doc gap, RUNTRUTH triage :320) rides the same MANIFEST pass.

## 7. Field-review ledger (perri, 2026-07-03 — all answered, design frozen)

1. **Deadline default — ANSWERED:** 60 s safe with margin (real binds t+5–15 s;
   every >20 s bringup was a wedge, never a slow success). Floor 30 s; per-node
   override kept for cold-broker first-spawn / loaded boxes. Folded into §3.
2. **`pre_spawn` on resume — ANSWERED: YES.** Resume re-checks trust (INT-2:
   resumed CC died at the trust dialog). Folded into §3 Half B.
3. **Mode-6 — ANSWERED: FIXED** by F-028 B1/B4, 0.23.0 definitive repro
   (same-id re-run after tree-kill re-binds fresh session, live PTY, honest
   info.json). Provenance table updated; out of F-027 scope.
4. **Env capture-at-creation — ANSWERED: CONFIRMED**, promoted from open
   question to the §3 Half-B binding invariant: `[env] direction="read"` vars
   are captured at creation, perch-stamped, and BOTH the pre-spawn command and
   the session spawn consume the stamp — neither reads its own process env.
   Adapter shape confirmed: `[env.CLAUDE_CONFIG_DIR] direction="read"`,
   fallback `~/.claude`, adapter reads and never sets.
