# spt-core public-surface findings

> Per the public-surface-only constraint (`AGENTS.md`, `HANDOFF.md` §1): if a capability needed
> to build the adapter is missing/ambiguous in spt-core's **published** surface (`spt-releases`
> releases + GH Pages docs), that is a **finding** — `spt-core`'s published contract has a gap.
> We report it to the spt-core owner (**doyle**) and do **not** reverse-engineer from legacy
> `claude_skill_owl` or reach into spt-core source. This file is the in-repo log of those findings.

| # | Date | Status | Summary |
|---|------|--------|---------|
| F-001 | 2026-06-14 | **re-scoped 2026-06-15** — most = adapter-authoring (closed); 1 residual spt-core docs item open | Hook-wiring for a CC adapter — boundary clarified |
| F-002 | 2026-06-15 | **RESOLVED-SHIPPED (v0.7.1)** — `<EVENT>` verified on the published `api poll` surface; F-002 dissolved; int flipped | `api poll` agent path has no inter-frame delimiter → multi-message drains are unsplittable |
| F-003 | 2026-06-15 | **RESOLVED + docs CLOSED (v0.7.1)** — capability shipped; the file-pointer syntax is now on the published surface | File-backed `[strings]` IS shipped (value-position table pointer `key = { file = "rel" }`) but was **undocumented** on the published surface |
| F-004 | 2026-06-15 | **CONFIRMED-IMPL-BUG (doyle); fix in progress** — `digest-proof` will fill `{id}`+`{session_id}` matching runtime; int deferred until the carrying release | `spt adapter digest-proof --sample` passes an empty substitution-key map → false-fails any extractor whose command uses `{session_id}` (incl. the published example) |
| F-005 | 2026-06-15 | **TRIAGED (doyle) — (a)+(b) mix, nothing unbuildable** — 2 of 3 sub-claims were docs-read misses; residuals = author Ed25519 key-provisioning doc + zero-touch auto-activation roadmap (REQ-UPD-1/M4). **Bridge that works today: `spt adapter add --release <repo>`** | End-user adapter activation step (`adapter add [--github/--release]`) was undocumented in install-on-demand/checklist; binary-present ≠ adapter-active |
| F-006 | 2026-06-15 | **RESOLVED + interim RETIRED (v0.8.1 dogfood 2026-06-16)** — install-dir resolution (REQ-INSTALL-11) dogfood-proven for BOTH binaries: digest-proof + daemon-hosted Psyche resolve straight from `…/adapters/_github/<safe>/` with nothing on PATH. Interim PATH-copy step dropped from both /sptc:setup bodies; interim copies deleted | `--release` bundles + extracts the adapter binaries beside the manifest, but bare-name `[digest]`/`[session]` templates resolve from PATH only → bundled binaries don't resolve (copy-mode) |
| F-007 | 2026-06-16 | **RESOLVED-SHIPPED (v0.8.0)** — `spt how-to live` topic is live; /sptc:live re-pointed at it; the int's relay leg is green on 0.8.0 (psyche-spawn moved to daemon-host = real-session, see v0.8.0 dogfood note) | `spt how-to live` was `NO_SUCH_TOPIC`; the non-interactive live-bringup was `--manifest` + persistent-child `api listen` (Monitor surrogate) |
| F-008 | 2026-06-16 | **OPEN — reported to doyle** — blocks SCOPE LOCKED v1 setup #5 (legacy migration); /sptc:setup can't author the step against the public surface | No published legacy-migration command (`spt` has no migrate/import/adopt/legacy/owl verb in any subcommand, no how-to) though spt-core CONTEXT.md commits to claude_skill_owl→spt migration as first-class |
| F-009 | 2026-06-16 | **RESOLVED-SHIPPED + RE-VALIDATED (spt v0.8.2, 2026-06-17)**. doyle's fix: command templating now fills each `{key}` as ONE argv element (tokenize-then-fill). Argv-capture confirmed the multi-line `{psyche_prompt}` arrives as a single element, newlines intact. Adapter keeps greedy `--prompt` as defensive | `[session.psyche_init]`/extractor command templating substitutes a `{key}` into the command STRING then WHITESPACE-SPLITS → ANY multi-word fill (e.g. `{psyche_prompt}`) explodes into stray argv tokens. Survived only by single-token fills |
| F-041 | 2026-07-16 | **REPORTED + RESOLVED same day (doyle, docs PR #11) — DOCS GAP, no core code ask.** Field incident: fresh-machine `spt adapter add --release` left the cplugs marketplace + sptc plugin uninstalled with a friendly notice and no visible error. doyle line-level classification: (a) `[update.post]` RUNS ON ADD since v0.19.0 (install-is-the-first-update) — never published; (b) the 120s foreground post-step bound, the `ADAPTER_UPDATE_POST_FAIL` stderr token, and the nonzero-CLI-exit contract were unpublished, and overview.md linked a manifest.md `[update.post]` reference section that did not exist; (c) partial read-miss ours: the failure→fallback-notice rule WAS published ("a failed post-step warns loudly and falls back… an applied update fires [update].message"). The fallback-static-notice-on-failed-post-step is DELIBERATE design (CONTEXT.md: "a post-step failure never swallows the adapter's own notice") — NOT a core bug; masking is avoidable adapter-side and is now the documented VERIFY-THEN-NOTIFY pattern. Fix shipped core-side: PR #11 publishes the full [update.post] contract (when it runs incl. all three add sources, execution model, stdin seam, stdout arbitration, failure contract, recommended verify-then-notify). Adapter half SHIPPED (v0.23.0): post-verify of installed_plugins.json + ✔-VERIFIED custom notice + failure-aware static message + claude auto-install (REQ-DIST-INSTALL-VERIFIED / REQ-DIST-CLAUDE-AUTOINSTALL). | The published [update.post] surface omitted that the post-step runs at adapter ADD, its foreground/timeout/failure-surfacing contract, and the promised manifest.md reference section — an adapter author reading the public docs cannot know a fresh install runs (or can fail) their reconcile, while the deliberate failure-path static notice makes an unverified success message read as a completed install |
| F-042 | 2026-07-16 | **CLOSED-WITH-WATCH same day (doyle): minting node PINNED = kitsubito (cfd9fc55, ssh-confirmed), already self-remedied** — fully current 0.33.0 as of 2026-07-16 04:01; the stale notify was a parting shot from its pre-update generation. NOT a v0.33.0 source defect (URL fixed FORKENING W1 @2552ee6, shipped v0.32.0; current main correct at cli.rs RELEASES_URL + notif.rs CONSENT_CHANGELOG_URL). Classification wrinkle (doyle): a PURE ≤0.31.0 image checked the old SaberMage repo (stale AT 0.31.0) and could not have announced v0.33.0 — the minting state was likely MIXED-generation (resident old brain carrying the old URL template while the update check rode the new channel view). **WATCH CONDITION: any SaberMage-URL notify timestamped after 2026-07-16 04:01 falsifies fleet-lag and REOPENS this as template-from-stale-generation (kin doyle's 2026-07-14 wrong-version notify seed — same worker neighborhood). Keep the raw EVENT if one lands.** | The spt-core update notify announcing v0.33.0 carried the OLD public repo's changelog URL (SaberMage/spt-releases — stale at v0.31.0), while v0.33.0 exists only on the private home. Root: sender-side minting from a mixed-generation node, not a v0.33.0 source defect |
| F-043 | 2026-07-16 | **ACCEPTED same day (doyle) — delivery-correctness class, folds into MESH-RECOVERY W1** (next-ranked after REDISPATCH-TRUTH; resolver chain cache→validated-roster→id-only is likely the same root family — the mid-sequence #2/#3 flap smells like the cache/roster divergence hertz pinned there). doyle classification: the defect is NOT the lag (presence is eventually-consistent by design) — it is that a MAY-LAG presence read can single-handedly select NO_PERCH, the one NON-SPOOLING terminal verdict; "uncertain must never render as nonexistent." Fix shape (triage-time): NO_PERCH requires corroboration (roster/registry validity) or degrades to spool-with-notice on uncertainty. Explicitly NOT REDISPATCH kin (that is target-side dispatcher lifecycle; this is sender-side resolution). Falsification pass live: raw outputs + timestamps kept; if a NO_PERCH repeats post-adapter-update, grab SENDER daemon.stderr PRESENCE_DIAL/PUMP_PEER lines — the discriminator between resolver-cache staleness and registry-row absence. Timeline (HFENDULEAM → GRAVITY-NVDA-PC, same day): send #1 SENT(WAN) → sends #2/#3 (to gaki-n AND slammie-n, minutes-to-~1h later) NO_PERCH → sends #4/#5 SENT(WAN)/delivered — while gaki-n attests its relay was up CONTINUOUSLY (single bringup, no re-bringup, uninterrupted intra-node messaging with slammie-n through the whole window) | Cross-node WAN presence appears to LAG live perch reality: `spt send` from a remote node returned `NO_PERCH:<id> is not listening` for perches that were online and draining intra-node deliveries at that moment. NO_PERCH is the non-spooling verdict, so presence lag converts into silent cross-node message loss with a misleading diagnostic (sender reads "target gone", target never knows) |
| F-010 | 2026-06-16 | **RESOLVED-SHIPPED + RE-VALIDATED (spt v0.8.2, 2026-06-17)**. A spawn-then-exit psyche now stamps `psyche_host_error{reason:"host not resident within 5s ...", attempts:2}` on the parent perch (rendered `psyche-host: FAILED (...)` by `endpoint list`/`whoami`); status stays online (liveness authoritative). Forced fast-exit confirmed it | Silent-exit still maskable: `psyche_host_error` stays clear when the detached spawn() succeeds but the child exits IMMEDIATELY (e.g. arg-parse exit 2). A crash-on-startup host looks identical to a healthy one |
| F-013 | 2026-06-17 | **ROOT-CAUSED (perri) → RULED spt-core BUG (doyle 2026-06-17): fork (a)**. spt-core must honor `[env].value` substitution in endpoint-run (the schema already promises "with substitution"; not applying it is a silent correctness bug). **Adapter manifest is CORRECT as-is — no wrapper** (b rejected: a shim would dodge a bug every `[env]`-routing adapter hits). Dispatched **`REQ-HAZARD-ENV-SUBST` → todlando, v0.11.0-findings** (pairs with REQ-SEND-SPT-HOSTED). **VERIFIED FIXED + INT LANDED (spt v0.11.0, 2026-06-17)** — endpoint run → populated `SPT_ENDPOINT_ID` → bind → BOUND perch → `spt send` SENT (live PTY inject); missing int landed = `ci/launcher/bind-int.sh` (REQ-CC-LAUNCHER-BIND, 4/4 green); `<0.11.0` silent-seed = doc-noted KNOWN-MINOR (no floor-bump / no guard — env-indistinguishable, ruled); ghost-roster self-healed (REQ-HAZARD-ROSTER-GHOST). **CLOSED.** `spt endpoint run` threads the endpoint `{id}` to the `[session.self]` spawn **ONLY** via `{id}` substitution in the command **argv**; `[env.<VAR>].value = "{id}"` is **NOT** substituted (injects empty) — although the schema documents `value` as "Value to inject (**with substitution**)". A flagless harness (bare `claude`, no CLI flag for an id) cannot place `{id}` on argv → SessionStart sees empty `$SPT_ENDPOINT_ID` → `sptc_register_verb` returns **`seed` not `bind`** → endpoint-run yields **ZERO perch** (the operator's wall-b `NO_PERCH`). bind itself is fine (a direct `api bind` builds a fully reachable perch). Fix fork: (a) spt-core honors `[env].value` substitution → current manifest becomes correct, or (b) adapter ships a wrapper launcher that takes `{id}` on argv and exports `SPT_ENDPOINT_ID` before exec-ing claude |
| F-011 | 2026-06-17 | **CONFIRMED + ROOT-CAUSED (doyle, spt-core source) — case-3 robustness, NON-blocking**. doyle: `registry.rs` `manifest_dir` — Pointer/GhReleaseManaged adapters read the manifest LIVE from `source_dir`; a deferred install whose manifest isn't extracted yet → `load_manifest` fails → `registered()` `filter_map(...ok())` **SILENTLY DROPS** the adapter → zero host_binaries candidates (`ADAPTER_UNRESOLVED`) AND `resolve_option/set_active` reads the absent manifest → bare **os-error-2**. Fix shape (doyle minting REQ-HAZARD→todlando): clear diagnostic at resolver + `adapter use` instead of silent-drop/cryptic-os2, possibly eager manifest extract at register. Real `--release`/extracted-dir installs work (v0.3.0 dogfooded clean). **RECURRED 2026-06-22 via the `*-proof` commands (translate-proof/digest-proof) on a GhReleaseManaged dev adapter — acked + batched by doyle into v0.13.x adapter-DX (a `--dir`/`--manifest` override); see "F-011 (cont.)" below.** | A registered **Pointer**-mode adapter whose deferred install dir lacks the extracted manifest vanishes from the active set: bare resolution fails `ADAPTER_UNRESOLVED` (host_binaries never consulted) and `spt adapter use <adapter>` fails cryptic `os error 2` (no path/cause), even though the manifest declares everything needed |
| F-016 | 2026-06-22 | **RESOLVED — both fixes SHIPPED** (doyle, broker.rs-confirmed). The published `[message-idle-translation-binary]` doc omitted `{commit}` from the stdout vocabulary AND its degenerate baseline `{text}{key:enter}` would itself FAULT. `{commit:true}` is the MANDATORY inject-sequence terminator (`run_inject_worker` broker.rs:1075-1090; no-commit → 5s `INJECT_COMMIT_DEADLINE` FAULT, broker.rs:151-169; reference `{text}{key:enter}{commit:true}` translation.rs:74-78). **(i) adapter binary appends trailing `{commit:true}` — DONE (cc-spt-idle-translate, 11 tests); (ii) contract republished + LIVE on gh-pages 2026-06-22 — `{commit}` in the vocabulary + commit-deadline/inject-floor semantics + corrected degenerate; re-verified by re-fetch.** Residual: end-to-end `translate-proof` re-confirm pending v0.13.1 (counter 28+, runner-load delay, not code). Caught by the blind-build BEFORE a live FAULT | The published harness-contract documented the idle-translation binary's stdout vocabulary as `{key}`/`{delay_ms}`/`{text}` only, with a `{text}{key:enter}` degenerate example — but the broker requires a trailing `{commit:true}` terminator or every delivery FAULTs at the 5s commit deadline. A harness author building from the public surface alone ships a binary that faults live |
| F-017 | 2026-06-22 | **RESOLVED-SHIPPED (spt-core v0.14.0, counter 30) + VALIDATED on real claude-spt (perri, 2026-06-23)**. v0.14.0's endpoint-creation-flow milestone (REQ-RUN-MULTISUBNET-HOME, ADRs 0026/0027) closed it: `spt endpoint run` now HOMES at creation — multi-subnet node w/o `--subnet` → instant **`MULTI_SUBNET_HOME`** refuse + subnet list (0.058s, NOT the old ~25s silent `ENDPOINT_RUN_ONLINE_TIMEOUT`); `--subnet <name>` → homes + harness binds (UNBOUND → online), no HOME_REFUSED. Bonus W-state confirmed: the hollow **UNBOUND** pre-bind row shows in `endpoint list` between spawn and bind. My W6 seed `ci/subnet/multi-subnet-bringup-int.sh` FLIPPED from "gap reproduces" → "fix confirmed" (cases 1+2 green; case 3 E2E `--subnet` bringup homed+bound on-node). The home-assignment POLICY (`api bind` w/o home still HOME_REFUSEs) is unchanged + correct — it's WHY endpoint run needed `--subnet`. No claude-spt change: the adapter manifest/hooks were correct as-is throughout (no SPT_DEV workaround existed to revert — grep of adapter + plugin + my own bringup all clean). Originally reported QUEUED (ADRs 0026/0027, v0.13.3+). NOT a version regression — established home-assignment policy that only BITES once a node holds 2+ subnets (real trigger: this node gaining BIGNET as a 2nd subnet). On a multi-subnet node a NEW-endpoint bind must be told its home: `spt api bind <id> --set-session-id <sid>` → **`HOME_REFUSED: this node holds 2 subnets … pass --subnet`**; the same bind WITH `--subnet <name>` → BOUND. The spt-HOSTED bringup cannot supply it: `spt endpoint run` has **no `--subnet` flag** and the broker injects `$SPT_ENDPOINT_ID` but **no home-subnet**, so the plugin SessionStart `bind)` branch fires `api bind … --set-session-id …` (no `--subnet`) → HOME_REFUSED → the hook's `\|\| true` swallows it → zero perch → server `ENDPOINT_RUN_ONLINE_TIMEOUT` (~25s). Pinned HOME_REFUSED on 0.11.0/0.12.0/0.12.1/0.13.1/**0.13.2** (policy, not a code bug). **doyle's fix (ADRs 0026/0027): broker injects `$SPT_ENDPOINT_SUBNET` + `api bind` env-fallback → the SHIPPED hooks stay UNCHANGED.** Adapter manifest/hooks are CORRECT as-is; fix is spt-core-side. **Regression seed COMMITTED: `ci/subnet/multi-subnet-bringup-int.sh`** — 3 cases (Case 2 no-subnet→HOME_REFUSED, Case 3 --subnet→BOUND, Case 1 E2E endpoint-run→no-perch/timeout, gated SPTC_ACCEPTANCE=1); multi-subnet-gated (single-subnet auto-homes + hides it). Operator earlier DECLINED a temp `--subnet` hook band-aid (wait for the real fix). | On a node holding 2+ subnets, every NEW-endpoint bind needs `--subnet <home>` (else HOME_REFUSED), but the spt-hosted `endpoint run` path provides no way to pass it — the broker injects the id but not the home subnet, so the SessionStart self-bind HOME_REFUSEs and the spt-hosted bringup silently yields zero perch (ONLINE_TIMEOUT). Single-subnet nodes auto-home and never see it |
| F-018 | 2026-06-22 | **REPORTED to doyle (perri) — destructive footgun, os-2 family (F-011 sibling)**. Surfaced while constructing the F-015 brick repro on v0.13.2. `spt adapter add --github <user/repo>` on an **already-registered, already-extracted gh_release Pointer adapter** is destructive-then-cryptic: it git-clones the SOURCE repo over the extracted install dir (wiping the root `manifest.toml` + the 3 runtime `.exe` binaries the Pointer resolves), then the post-add first-`[update]` conduct fails bare **`ADAPTER_ADD_FAIL: io: ... (os error 2)`** — leaving the adapter `manifest-not-present` (same broken state as F-011, but operator-induced). No confirm prompt, no "already registered — did you mean `adapter update`?" guard, no rollback. Recovered by extracting `dist/adapter-windows-x86_64.spt` over the install dir (manifest+binaries restored, version 0.6.0, translate-proof OK). **Needs (doyle):** for an already-registered gh_release adapter, `add --github` should refuse/route to `adapter update` (or at least not wipe the working install before the avenue succeeds), and the `os error 2` should carry path+cause (the F-011 diagnostic ask, same resolver). | Re-running `spt adapter add --github user/repo` to "reinstall" a healthy gh_release Pointer adapter silently destroys the working install (clones raw source over the extracted runtime, drops manifest+binaries) and fails `os error 2`, requiring a manual `.spt` re-extract to recover |
| F-020 | 2026-06-24 | **RESOLVED-PENDING — frozen Tier-1 contract, ships v0.15.0 W5** (doyle ruled + grounded; verb committed by todlando @20bfc1f). spt-core publishes NO `spt api` verb to pull resume/psyche context at SessionStart — `download_psyche_context` (resume.rs:88) is exported-but-unexposed (ZERO `ApiCmd` callers); `resume.rs:9` documents the "adapter pulls it in its SessionStart hook" intent but no verb was ever published. **Consequence: claude-spt live agents rehydrate NO durable context on resume today** — SessionStart only runs `api boundary` (session-id rotation, output discarded) + the identity brief. (The `<psyche-context>` seen on a live session is LEGACY owl's hook — the parity precedent, not claude-spt.) **Fix (v0.15.0 W5, FROZEN):** new `spt api psyche-download <id> [--session-id <sid>]` → stdout `<live-role>`/`<live-context>`/`<project-context-resolved/>`/`<project-context>` + appended `<pending-commune>`/`<pending-signoff>` (raw not-yet-synthesized drop, `!!checkpoint!!` trigger stripped core-side), `NO-CONTEXT:<id>` on stderr when empty; race-free + self-clearing. **claude-spt action:** wire SessionStart to call+inject (`REQ-DIST-RESUME-CONTEXT`); validate E2E on doyle's W5 ping. | No published verb lets an adapter's SessionStart hook pull resume context — the documented adapter-pull path was never given a CLI verb, so claude-spt cannot rehydrate a live agent's durable context on resume at all (foundational parity gap, surfaced building checkpoint communes) |

| F-023 | 2026-07-01 | **CONFIRMED + RCA'd (doyle, same day) — build dispatched to todlando (`BUILD-F023-WANIDLE`)**. Both ranked hypotheses real: `receive_wan` has deliver_tcp + spool ONLY (the spt-hosted broker-inject leg exists solely in local `cmd_send`) AND no idle-edge drain exists anywhere. Fix = shared inject leg called from WAN ingress + ACTIVE→IDLE spool drain. Adapter exoneration stands verbatim. `from_id=""` sibling ruled: compose stamps `cli@<node-label>`; ingress renders empty-from as origin node label; no refusal | Cross-node (WAN-arrived) message spools on the destination node but the idle-delivery pump never fires: endpoint verifiably IDLE for minutes, translation binary alive and healthy, zero injection — delivery waits for the adapter's next hook drain (UserPromptSubmit/PreToolUse). Same-machine sends inject fine, so the pump works when the local-send path kicks it; the WAN-receive path never kicks it |
| F-024 | 2026-07-01 | **CLOSED-REFRAMED (doyle, same day) — transport EXONERATED; symptom = F-023 on the remote node.** The `(spooled)` token in `SENT(WAN)` output is RECEIVER-CONFIRMED (only prints on a confirmed round-trip): probe `DIAG-7c1e` reached ENLYZEAM post-restart and sits in ball-b's spool undelivered (`spooled` not `delivered` = no live TCP listener at receive time). Operator workaround: any prompt typed into ball-b UPS-drains the stuck messages. Residues stay real as separate diagnostics in the wave: recv-pull silence (context-sync loop, not messaging) + PENDING-no-DONE ledger semantics | Cross-node delivery HFENDULEAM→ENLYZEAM never lands (reverse direction works instantly): `spt send ball-b` returns `SENT(WAN)… (spooled)` but nothing ever reaches the peer — not even the destination's hook-drain path, so the op never reaches the remote spool. Supporting: 32 never-DONE `net-send` effects in the ledger, recv-pull bundle loop silent since the last daemon restart, daemon holds relay-only connections |
| F-031 | 2026-07-08 | **NOT A FINDING — adapter read-miss (doyle corrected, same day).** The stderr + exit-code contract IS already published in TWO places: **api.md rule 3** ("Status rides stderr; stdout is payload; the exit code is authoritative", names SENT/QUEUED) and **messaging/overview.md** ("classify success by the exit code — 0 for every delivered/spooled outcome"). So there is NO spt-core gap and NO doc change (doyle retracted the doc task). The bug was purely adapter-side: the hook classified off captured **stdout** (empty) instead of the exit code / stderr — I missed api.md rule 3 on my read. The stderr merge in v0.17.3 works; the **design-true** pattern is to branch on the **EXIT CODE** (authoritative: 0 = delivered/spooled, nonzero = failed) and read stderr for the human token — more robust (survives token-wording changes). **claude-spt follow-up:** harden `spt_send` to exit-code branching (next release). **LESSON (public-surface discipline): re-read api.md rule 3 before classifying delivery behavior — this was a published contract I overlooked, not a gap.** Original (mis-filed) report below, kept for the trail. | `spt send`'s result token (`SENT:<id>` / `QUEUED:<id>` / `DEFERRED:<id>` / `NO_PERCH:<reason>`) is written to **STDERR**, not stdout — stdout is empty on a plain `spt send`. This is undocumented in the published harness-contract / CLI reference (they describe the tokens as the send's *result* without naming the stream). It bit the claude-spt tag-messaging hook: the hook classified send outcomes off captured **stdout** (nulling stderr), so every peer `@<…@>` send saw `""` and was reported `NO_PERCH` in the confirm-back even though it DELIVERED — a silent false-negative that made agents re-send by hand. Root-caused offline via an isolated hook-binary rig (peer send returned `raw=Some("")` while stderr carried `SENT:doyle`). Adapter fix: capture stdout+stderr merged for send classification. **Needs (doyle):** either (a) document that `spt send` emits its status token on stderr (so adapters read the right stream), or (b) mirror the token to stdout for scriptable capture (`$(spt send …)`), which would match the least-surprise convention every other queryable verb follows (`whoami --json`, `endpoint list` → stdout). | `spt send`'s success/failure status token is emitted on STDERR (stdout empty), undocumented in the published surface — an adapter that captures stdout (the natural `$(…)` convention) sees nothing and misclassifies every successful send as unreachable |
| F-035 | 2026-07-09 | **ACCEPTED (c) core-contract-violation — VERIFIED (doyle 2026-07-09).** Root (doyle): `send_windowed` (spt-msg/deliver.rs:188-200, ADR-0028 W2) spool-TAGS the delivery window — `active_only` = spool-only-no-wake by contract; the busy→idle **PARKED-DRAIN** surfaces `active_only` rows (an idle-wake) AND fails to **DROP** ephemeral on a window-miss, ignoring the row's window tag. Fix scoped **`REQ-SEND-WINDOW-DRAIN-HONOR`** for the FOLLOWUP release (bundled with the gateway hotfix); todlando RCA-first; **perri = field-verify partner** (repro: end-of-turn `@<…@>` confirm-back must NOT wake; mid-turn folds unchanged). **UPDATE 2026-07-09 (re-RCA — ROOT PIVOTING to adapter, pending live-repro):** todlando's re-RCA proves spt-core is CLEAN on v0.30.2 — every send/deliver/relay/spool leg guards `active_only` against a relay. New prime suspect (doyle + perri trace) = the **adapter `--include-deferred` busy-poll route**: both hook poll sites (UserPromptSubmit `hook.rs:978-985`, PreToolUse `1147-1153`) do `state busy` → `api poll --include-deferred` → `render_frames` → additionalContext. `--include-deferred` DRAINS the active_only channel (`--active-only` replaced the old `--deferred`), and for a live agent UserPromptSubmit fires on a relay-injected EVENT (the relay wake IS the "prompt") — so a relay wake drags the DEFERRED active_only self-confirm into that turn via an adapter route spt-core can't see. Going-IDLE itself does NOT poll (`handle_stop` = scan-outbound + `state idle`; Notification(idle) = `state idle` re-assert). CAVEAT: adapter polls are hook RESPONSES (cannot CREATE a turn), so this explains active_only CONTENT riding a real wake, not a standalone spurious wake — the **live-repro (operator bounce window) pins adapter-root vs a residual delivery path**. Candidate adapter fix (adapter-only, doyle-agreed): stop round-tripping the SELF-confirm via `spt send` — emit it inline as PreToolUse additionalContext on the dispatching hook (rides its OWN active window, never spooled) and DROP it on the Stop/end-of-turn path; `--include-deferred` STAYS (load-bearing for F-021 mid-turn peer delivery). spt-core EXONERATED (active_only correctly stayed spooled — never relay-delivered; todlando's regression-guard locks the invariant). ONE repro gate before this is fully adapter-owned: rule out **active_only ALONE ever waking lia** (a wake EVENT carrying ONLY the active_only, no other relay msg) — if it does, reopens an spt-core delivery-path bug; if it only ever RIDES a real wake, pure adapter. Instrument: lia's relay-EVENT stream vs the hook-poll drain. Original report follows. `spt send --help` (published binary) documents `--active-only` = "Deliver only through the target's own poll (the no-interrupt hook channel); **never wakes an idle target**" — i.e. poll-channel-ONLY, never via the relay/translation-binary, cannot wake. But an `--active-only --ephemeral` **self-send** was delivered **via the relay and woke the agent** as a fresh turn. **Repro:** a live agent emits a shortform `@<target …@>` as its **final** turn output → the adapter Stop hook fires the confirm-back `spt send --from <self> <self> --active-only --ephemeral` (body `[tag-send] delivered → target`) while the perch is still busy, then marks the perch idle one line later. Instead of poll-only delivery (or an `--ephemeral` drop), the confirm surfaced as a bordered `<EVENT type="msg" from="<self>">[tag-send] delivered → …</EVENT>` NEW TURN (field 2026-07-09, endpoint `lia`, operator screenshot). **Hypothesis:** a message parked while the target is busy is flushed by the subsequent idle transition's `IDLE_PARKED_DRAIN` **via the relay** — ignoring BOTH the `--active-only` poll-only constraint AND `--ephemeral` (which should have dropped it, not spooled it). **Adapter is CORRECT as-is** — it already passes `--active-only --ephemeral`; NO adapter workaround taken (public-surface discipline: a Stop-hook idle-before-scan reorder was prototyped then REVERTED — it would mask a core bug). **Needs (doyle):** honor `--active-only` across the busy→idle parked-drain (an active-only message must never relay-deliver/wake; on the idle edge it should be `--ephemeral`-dropped, not drained to the relay). | An `--active-only` message (poll-channel-only, "never wakes an idle target" per published `send --help`) is relay-delivered and WAKES the agent when it is parked-while-busy then flushed by the idle-edge drain — the flag's no-wake contract is violated |
| F-036 | 2026-07-10 | **TRIAGED + ACCEPTED core-side (doyle, same day).** Ruling: (a) daemon identity-env sanitize + (c) psyche-custody bind squat-guard → core REQs, built after the update-wedge lane clears; (b) `recursion_guard_env` IS honored on ALL session roles today — the schema's "summarizer children" wording is the doc gap (adapter now declares `recursion_guard_env = "SPT_PSYCHE_TURN"` on both psyche roles, v0.18.8 — proven live; doyle folds the role-agnostic wording fix, manifest.rs:314 + schema.json:306, INTO the F-036 core PR with legs a+c — one clean core change post-wedge, no standalone docs churn). Daemon clean-restart is operator-timed (update-wedge lane). Original report: **daemon role-spawn env hygiene (core-side sibling of the adapter's KH §2.5 fix).** Field RCA (perri, 2026-07-09/10): the node's daemon — `spt daemon run --detached`, restarted 2026-07-09 22:35 from a session whose env carried `SPT_ENDPOINT_ID=doyle` — passed that identity var through to EVERY `[session.psyche_resume]` spawn (role `env_remove` at the time scrubbed only `OWL_SESSION_ID`/`SPT_AGENT_ID`). Each psyche claude turn then fired a NORMAL SessionStart (`startup` first turn; `--resume` re-fires `SessionStart(source=resume)` EVERY turn), the adapter hook saw the endpoint id → bind path → rotated DOYLE's perch to the psyche's own sid with a valid prior-sid proof, every pulse — regardless of which parent the psyche belonged to (lia's, deployah's, and doyle's own psyches all briefed as `<sptc-active-perch id="doyle">`; 37 peer messages drained into lia's psyche transcript; hook-trace pins `CONFLICT:doyle is live under session <deployah-psyche-sid\|lia-psyche-sid>`). Effects on the victim: deliveries silently eaten by psyche turns, echo communes/pulses dark, real-session sends downgraded to `from:cli@node` (self-resolve miss). **Adapter half is FIXED (v0.18.8, three belts: role env_remove += SPT_ENDPOINT_ID; shim-side last-hop scrub + SPT_PSYCHE_TURN hook-bail guard; echo-commune belt).** **Needs (doyle, core-side hardening):** (a) the daemon should SANITIZE its own inherited env at startup or at role-spawn time — `SPT_ENDPOINT_ID`/`OWL_SESSION_ID`/`SPT_AGENT_ID` are per-session identity and are NEVER correct inherited state for a daemon or its role children; a daemon restarted from inside an agent session (routine during core dev / `spt update apply`) currently poisons every role spawn on the node; (b) DOCS: `manifest.schema.json` describes `recursion_guard_env` as set on "summarizer children" — publish whether core honors it on OTHER session roles (psyche_init/psyche_resume); if yes the adapter could declare it there instead of the shim-side workaround; (c) consider core-side squat protection: a `bind` whose `--set-session-id` equals a NESTED psyche perch's own custody sid is definitionally wrong (core owns psyche-custody.json and can see the collision). | The daemon inherits arbitrary identity env from whoever restarts it and passes it verbatim to every session-role spawn; combined with any adapter whose role env_remove misses a var, a stray `SPT_ENDPOINT_ID` makes every psyche turn silently steal that endpoint's seat (perch sid rotated every pulse, deliveries drained into psyche transcripts, sends downgraded to cli@node) |
| F-040 | 2026-07-15 | **REPORTED to doyle (perri/hertz convergence). (Filed 2026-07-15 as F-037, renumbered twice same day to the next free cross-project number: F-037 = doyle's short-id/fetch-by-id ask, F-038 = flynn's autostart ask (`endpoint run --save`, closed 07-14), F-039 = the W6 CONN-lifecycle-stamps cluster. Doyle's seed also pins this ask BY CONTENT, so a number slip can no longer merge asks at mint.) Core identity-only API request; PROJECT-INDEX promoted separately (doyle re-ranking it MESSAGE-DELIVERY-BREAKING off the same root).** `spt whoami --json` on spt-core >=0.31.0 is an ALIAS of the full enriched endpoint listing (`cmd_whoami` -> `cmd_endpoint_list` -> `latest_project_ref` per local perch -> synchronous Git fanout): a pure identity read costs 15-45s on a Git-heavy node (measured 45.6s on HFENDULEAM), crossing Claude Code's ~30s external hook ceiling. Every claude-spt hook resolved identity through it, so turn-start/turn-end bookkeeping died on loaded nodes — missed busy-mark/drain, stuck-busy black-holes, and (via the then drain-before-fanout ordering) the doyle<->todlando peer-message BODY LOSS (bare stub, body destroyed by a deadline-killed hook). Adapter half SHIPPED (v0.21.0): verified env+carrier identity fast path (zero core calls on spt-hosted sessions, all hook sites), transactional park custody (stage/commit-post-emit, redelivery on kill), per-stage trace. RESIDUAL the core API closes: harness-hosted/mismatch sessions still fall back to whoami and stay deadline-vulnerable. **Needs (doyle):** a narrow read-only `session ID -> endpoint ID or null` verb/API — no `cmd_endpoint_list`, no registry projection, no project history, no Git, no network; payload SID passable explicitly (or scoped env override); stable JSON for adapters. `api endpoint-info` is DISQUALIFIED (its reporting path also computes `latest_project_ref`, api/reporting.rs:953). PROJECT-INDEX remains complementary (list/picker/reporting perf) but insufficient for a correctness-critical hook: hook identity must not depend on index health/rebuild state. Once the verb reaches the adapter's core floor, claude-spt deletes the whoami fallback entirely. | A pure identity lookup (`whoami`) is aliased to the enriched endpoint-list render (project history + synchronous Git per perch), making session->endpoint resolution O(node state) with multi-second worst cases — no narrow identity-only API exists on the published surface for adapters whose hook deadlines cannot absorb a listing |
| F-044 | 2026-08-04 | **TRIAGED + CLOSED-AS-NOT-A-GAP same day (doyle), and the finding INVERTS into a consumer-side rule.** My original filing said the invite-code prefix (`sptkc`) appears ZERO times in the published docs though invite codes are fully described — a blind consumer cannot pattern-match a code shape. The zero was a TRUE READ OF A STALE SURFACE. Doyle's chain, measured: the knocking docs DO document the code shape (prefix, a redeem example with a literal code, and the sealed-code routing section), authored 2026-08-01 and included in **v0.53.0**'s tagged sha — but the docs server on `localhost:5474` **is the spt daemon itself**, so the surface it publishes is pinned to the vintage of the INSTALLED binary. This box's installed spt is **0.52.0** (independently verified here: `spt --version` → `spt 0.52.0`), whose build predates the sealed-code docs entirely; doyle confirmed with a sibling probe that the page serves fine and carries the rest of the knocking content, just not the new section. **THE RULE THIS BUYS, and it generalizes to every finding filed off 5474:** a zero-match on the node-local docs is a claim about the INSTALLED RELEASE, never about the current contract — check `spt --version` against the release that carries the feature BEFORE filing a gap, exactly as a verdict may not assert more than it measured. This is the same class as reading half a population and calling it all of it (REQ-TAG-SEND-PRETOOL-TRACE): the instrument silently covered an older contract and looked identical to one covering the live one. **ON THE PREFIX ITSELF (doyle, from the public contract, so it is depend-able):** the prefix is RATIFIED public surface, not an internal to avoid — deliberately the FORMAT VERSION MARKER (a future format mints a NEW prefix rather than a flag day) and the standing agent-recognition hook. Keying recognition on it is the INTENDED consumer behaviour; treat everything after it as opaque. `/sptc:knock`'s redeem hint keys on the prefix on exactly that basis (REQ-SKILL-KNOCK, v0.26.0). **Owed (mine):** re-run the zero probe once this node serves 0.53.0 — the fleet update is deployah's rollout call and was explicitly not happening the night this was filed (measurement runs own the box). Number is the next free one visible from this repo; doyle pins asks BY CONTENT, so a cross-project collision renumbers without merging the ask. **CLOSED — RE-PROBED AND CONFIRMED 2026-08-04, same night.** Operator reported the docs live; this node now serves **0.53.0** (`spt --version`, up from the 0.52.0 measured at filing). Re-run of the original probe: `sptkc` now returns **2 hits in `networking/knocking.html` and 2 in `print.html`** (573,879-byte body, so a real page and not an error), documenting the prefix, an elided redeem example (`spt knock redeem sptkc_...`), and the sealed-code routing section that explains the route is sealed inside the string alongside the secret. Doyle's chain is confirmed end to end by my own instrument: the content was authored, the surface was stale, and updating the daemon published it. **AND A SECOND INSTRUMENT ERROR, MINE, FOUND WHILE CLOSING THIS:** the original filing cited the zero as holding across `print.html` AND the search index. `GET /searchindex.js` on this server returns a 27-byte body reading `NOT_FOUND: /searchindex.js` (`searchindex.json` likewise, 29 bytes) — **this docs server does not serve that path at all**, so the search-index half of the original measurement was reading a 404 and was void from the start. I cited it as independent corroboration of the print.html zero; it was never a second instrument. That is the same defect as the finding itself, committed inside the report OF the finding: a zero that describes the probe rather than the contract. THE RULE HARDENS ACCORDINGLY — before a zero becomes evidence, assert the instrument returned a PLAUSIBLE BODY (non-trivial length, expected content-type, a positive control term that must match), because an absent page and an absent feature are byte-indistinguishable in a grep count. | A zero-match on the node-local docs surface (`localhost:5474`) is a claim about the INSTALLED daemon's vintage, not about the published contract — the daemon serves its own build's docs, so a consumer can file a docs gap against content that was authored and shipped in a newer release |

> **F-021 / F-022 (NOT spt-core findings — claude-spt parity items, tracked as REQs; surfaced during the 2026-06-24 checkpoint-commune grill)** — (F-021) claude-spt polls only on `UserPromptSubmit` (between turns); legacy spt also polls+injects on **PreToolUse** = the mid-turn half of live-agent reachability (the main path by which a live agent receives a message WHILE working). `api poll` already exists on the public surface → adapter wiring, not an spt-core gap → **`REQ-DIST-PRETOOL-POLL`**. (F-022) subagent worker-perch reachability: wiring is present (`subagent-start.sh` → `api worker-start`; `hostable_types` includes `Worker`) but runtime `spt send`-to-a-worker is UNVERIFIED → validation item **`REQ-DIST-WORKER-PERCH-REACH`**. Neither is an spt-core public-surface gap.

> **F-012 (NOT logged as spt-core)** — legacy-owl 1.11.25 poll-loop exits 1 / orphans the Psyche across daemon churn (`/spt:revive` started gen-7 wrapper+psyche fine but the foreground poll died with a non-fatal `sessions log seal failed: git failed (continuing)` line). doyle ruled this is the **legacy owl listener** (a separate daemon from spt-core), NOT an spt-core public-surface finding; the seal line is non-fatal/continues so isn't the exit cause; it dies with legacy owl's retirement. Re-open as spt-core ONLY if repro'd clean-room (sptc listener, zero legacy owl). No spt-core action.

---

## F-001 — Harness hook-wiring contract incomplete for a Claude Code adapter

**Reported:** 2026-06-14 to doyle (owl). **Status:** awaiting ruling / docs fix.

**What IS published (sufficient):**
- `harness-contract/api.md` — the full `spt api` primitive surface: `seed`, `listen`, `bind`,
  `boundary`, `session-end`, `shutdown`, `state`, `echo-gate`, `presence`, `driven-by`, `poll`,
  `history-log`, `worker-start/poll/stop`, `bind-shell`, `emit`, `owner-shutdown`, `capability`.
- `harness-contract/install-on-demand.md` — install-on-demand bootstrap, exact `sh` + `ps1`
  snippets, canonical installer URLs, env-var customization. **Fully buildable.**
- `harness-contract/integration-checklist.md` + `manifest.md` map: SessionStart →
  `api seed --pid {parent_pid} --session-id {session_id}` then `api listen <id>`; Idle →
  `api state idle|busy`; PreCompact/clear → `api boundary clear|compact <id> --to-session-id <sid>`;
  teardown → `api session-end <id>` / `api shutdown <id>`.

**The gaps:**

1. **Hook-event → `spt api` mapping is incomplete.** No published mapping for **UserPromptSubmit**,
   **PreToolUse**, **SubagentStart**, **SubagentStop**. (`worker-start/stop` exist but are not tied
   to the Subagent hook events.)
2. **Manifest `[hooks.*]` vs harness-native hook config is unspecified.** `manifest.md` declares
   hooks as `[hooks.<event>] fires="api ..." reads=[...] can_inject=bool`, with spt-core performing
   `{key}` substitution from `reads`. It is **silent** on whether spt-core *materializes* the
   harness-native config (Claude Code `hooks.json`) from the manifest, or the adapter author
   hand-writes `hooks.json` that shells out to `spt api`. These imply different substitution models:
   Claude Code delivers hook data as JSON on **stdin**, but the manifest model is spt-core-side
   `{placeholder}` fill — so if `hooks.json` is hand-written, it is unclear who fills
   `--pid {parent_pid}`. (Also reframes the `SCOPE.md` assumption "the plugin ships a `hooks.json`
   calling `spt api`" — it may instead be manifest-declared + spt-materialized.)
3. **Injection mechanism is undocumented.** `can_inject` + an `[inject]` section (methods
   `pty`/`hook`/`relay`/`http`) exist, but there is **no technical detail** on how an adapter emits
   `additionalContext` back through the hook channel. Blocks (a) general mid-session message
   delivery on a prompt/tool hook, and (b) this project's **UPS-injection** skill-instruction design
   (`docs/adr/0001-*`) specifically — it cannot be validated against the public surface until this
   is documented.

**Impact (as first reported):** SessionStart / Stop(idle) / SessionEnd / boundary are buildable now.
UserPromptSubmit + PreToolUse + Subagent wiring + the entire injection path were *thought* blocked.

### Resolution (2026-06-15 — converged with doyle, grounded in `CONTEXT.md`)

The boundary canon (`CONTEXT.md` L52/L181/L112/L149/L137) re-frames F-001: **spt-core is
harness-independent and supplies the agnostic `spt api` primitives + their I/O format; the adapter
authors the harness-specific wiring + output-formatting.** So most of F-001 is **adapter-authoring
work (ours), not an spt-core gap**:

- **Gap 1 (CC-event → api mapping) — OURS, closed.** Which CC hook drives which `api` primitive is
  adapter glue (L181). Owned mappings: SessionStart → `api seed` (+ env aliases); `/sptc:ready|live`
  → `api listen <id>` (the blocking poll loop — *not* SessionStart, which must not block); Stop/Idle
  → `api state idle|busy`; PreCompact/clear → `api boundary`; SessionEnd → `api session-end` /
  signoff → `api shutdown`; SubagentStart/Stop → `api worker-start`/`worker-stop`; UserPromptSubmit
  → `api poll`; **PreToolUse out-of-scope v1** (UPS covers delivery). See `docs/adr/0002-*`.
- **Gap 2 (medium) — OURS, closed.** The `sptc` plugin **hand-writes** its CC `hooks.json` shelling
  `spt api`. spt-core materializing a CC `hooks.json` would violate L52/L181 — it correctly doesn't.
- **Gap 3 (injection HOW) — OURS, closed.** `api poll` emits message frames to **stdout** by design
  (L149) — that IS the hook-injection delivery path; routing/formatting that stdout into CC's
  `additionalContext` channel is adapter glue (L112). The earlier "`can_inject`/`[inject]`
  method-discard is dead code" alarm is **retracted** — it is M2a roadmap staging (stdout/hook only
  now; PTY at M3), and `can_inject` drives the *built* echo-gate/relay fallback (L137) for hooks that
  cannot inject (e.g. CC Stop).
- **CC param-sourcing** ({parent_pid}/{session_id}) — OURS: read off the CC hook-input schema
  (`session_id` is a common stdin field; pid via the hook process). Not an spt-core concern.

**Residual spt-core item (doyle owns; non-blocking):** publish the **agnostic primitives** to the
adapter-facing docs-site — specifically `api poll`'s **emit frame format** (the frame we parse +
format for CC) and the **substitution-key catalog** (currently code-only). doyle verifies against
`CONTEXT.md`/ADRs then propagates (no new design). We will report the **observed** `api poll` frame
format so the publish can confirm-match. Until published we may rely on observed behavior of the
public binary (observable behavior = public surface).

### Validation note (2026-06-15, against live v0.7.0 `spt adapter add` + the shipped mock-adapter)

Authored + registered `adapter/claude-spt.toml` against the real v0.7.0 binary
(`REQ-DIST-MANIFEST-SCHEMA` int — `ci/manifest/registration-int.sh`, 6/6 green). Confirms the
residual: the **published docs still omit** two things a manifest author needs, both learnable only
from the **shipped mock-adapter source** (which IS public surface) + live `adapter add` errors:

1. **Substitution-key catalog.** `{parent_pid}` and `{adapter_name}` are spt-filled SessionStart
   keys (mock `api seed --pid {parent_pid} … --adapter {adapter_name}`); the docs-site lists only a
   partial set. The full catalog is still code-only. **RESOLVED 2026-06-25** — the catalog is now
   published at `harness-contract/manifest.html#substitution-keys` (`{id}`, `{adapter_name}`,
   `{session_id}`, `{session_name}`, `{parent_pid}`, `{agent_type}`, `{agents_json}`, `{psyche_dir}`,
   `{psyche_prompt}`, `{psyche_context}`, `{link_token}`, `{source}`, `{notif_*}`). Note: **no `{node}`**
   fill key — operator is raising one with doyle (would let `[session.*]` name an endpoint `{id}@{node}`;
   see `UPDATE-NAMING-DOYLE-ASKS.md` ask 4 + `UNIFY-CONSOLIDATE-PLAN.md` U6). **{node} SHIPPED in
   spt-core v0.20.0** (advertised node label, single-token fills, both spawn topologies + lazy
   `[strings]`; caveats in the published manifest.md row — single-token only, mid-daemon-life
   hostname change). Adopted in adapter v0.11.0 (`--node {node}` on all four launch lines, floor
   0.20.0); the launch shim stays for the space-carrying display composite, as doyle's caveat notes.
2. **`[digest]` cross-field rule.** The JSON schema accepts `[digest]` with just `extractor`, but
   `spt adapter add` **rejects** it: `[digest] needs source (own-source) or a [history]
   locate_template`. Not documented prose-side; surfaced only at registration. (Worked around with
   `source = "{home}/.claude/projects"` — CC's cwd-slug subdir is not expressible as a flat
   `locate_template`, so `source` names the per-project root and the extractor finds
   `{session_id}.jsonl` within. A CC-shaped extractor is the right design vs the mock's log-less
   `[history] native`.) **Non-blocking** (resolved by reading the shipped mock + the error text);
   logged so doyle can publish the key catalog + the digest source-requirement to the docs-site.

Also observed (not a gap, a roadmap fact): `spt endpoint run` notes the **interactive picker that
emits the `<basename>-<id>` shortcut "lands in a later wave"** — so the `sptc-<id>` *emission* int
(`REQ-DIST-SHORTCUT-BASENAME`) stays held on that wave (and on authoring `[session.self]`, which
`endpoint run` spawns). `shortcut_basename = "sptc"` is confirmed to round-trip into the stored
resolved manifest, so the declaration side is proven.

---

## F-002 — `api poll` (agent path) has no inter-frame delimiter — multi-message drains unsplittable

**Reported:** 2026-06-15 to doyle. **Status:** open — **spt-core CODE gap** (not docs); doyle has
raised the framing as a contract decision to the operator.

**The gap (confirmed from source by doyle):** the **agent** `api poll` path emits each drained
message with `print!("{msg}")` (`delivery.rs:192`) and `format_row` adds **no trailing newline**
(`spool.rs:91-97`). So a multi-message drain **concatenates frames with nothing between them**.
Combined with the frame format (anonymous senders emit a **bare body**, and bodies may be
multiline), a multi-message `poll` stdout is **genuinely unsplittable** — there is no delimiter to
parse on. Tell that this is a rough edge, not intent: the **shell** drain (`cmd_poll_shell`,
`delivery.rs:169`) uses `println!` (newline-framed); only the agent path omits it. The framing is
unspecified in `CONTEXT.md` / `docs/api.md`.

**Impact / our handling:**
- **Single-message poll = unambiguous, works now** — `__REPLY_TO__:<id>\n<body>` or bare body.
- **Multi-frame splitter is HELD** — we do not assume a delimiter that does not exist. The
  UserPromptSubmit wrapper surfaces whatever `poll` returns: a single frame is cleanly per-sender
  formatted; a (rare) concatenated multi-drain degrades to a surfaced-but-unattributed blob rather
  than a parser guessing boundaries.

**Resolution — RESOLVED-BY-DESIGN (operator-ruled 2026-06-15):** the framing question is
settled, more cleanly than a delimiter patch. The **canonical format at every surface — including
`api poll` — is the `<EVENT type="msg" from="<sender>">body</EVENT>` envelope** (`spt-proto::event`,
the ADR-0001 grammar the live listener already emits). `__REPLY_TO__` was a **mis-elevated relic**
(wrongly frozen as the "stable wire format" during the clean-room port) and is being **deleted from
spt-core**. The `<EVENT>` envelope is **self-delimiting**, so multi-message drains split cleanly on
`</EVENT>` — no delimiter needed, and the F-002 multi-frame hold is **lifted**.

- **Our parser** now targets `<EVENT>` (the `from` attr → `<sptc_messages from="…">`, `<br>` →
  newline, entity unescape with `&amp;` last) — exactly the live-agent body-parsing rule. See
  `render_frames` in `plugin/sptc/hooks/_common.sh` + `tests/hooks-parse.sh` (multi-message covered).
- **VERIFIED ON THE PUBLISHED SURFACE (v0.7.1, 2026-06-15).** The canonical poll envelope shipped in **v0.7.1**
  (counter 13); `__REPLY_TO__` relic gone. A throwaway byte-capture against the live 0.7.1 `spt api
  poll` drain (`od`-verified) confirms the canonical envelope end-to-end:
  - single msg: `<EVENT type="msg" from="probe-sender">hello from probe<br>second &lt;line&gt;
    &amp; &quot;stuff&quot;</EVENT>\n` — body escaping (`<br>` + `&lt;/&gt;/&quot;` + `&amp;` last)
    is the **exact** `render_frames` decode rule.
  - whole envelopes, self-delimiting **and** `\n`-framed; **no `<EVENT-PART>`** on a normal hook
    drain; **no `__REPLY_TO__`**. Multi-drain splits cleanly on `</EVENT>` → **F-002 dissolved on the
    published binary**.
  - raw drain piped through our `render_frames` → correct `<sptc_messages from=…>` (parser
    confirm-match PASS). `notify` events ride the same envelope.
  Locked in by `ci/hooks/poll-int.sh` (SPTC_ACCEPTANCE-gated, ≥0.7.1 — 5 envelope assertions of
  the script's 12 since it grew the spill two-overflow step): bind→send→`api
  poll`→`render_frames` assert. **`REQ-DIST-HOOKS-API` + `REQ-UPS-INJECTION` `int` flipped GREEN.**
  Canonical poll envelope loop closed: design→impl→gate→ship→real-surface-verify.

---

## F-003 — No file-backed `[strings]` mechanism on the M12/v0.7.0 public surface (ADR-0001 dependency unmet)

**Reported:** 2026-06-15 to doyle + todlando. **Status:** **RESOLVED 2026-06-15** — the capability
is **shipped** in v0.7.0; the residual is a **docs-visibility** gap (doyle is publishing the syntax).
The text below records the original (wrong) "absent" framing for provenance; see **Resolution**.

**The dependency (ours, ADR-0001):** UPS-injection delivers `/sptc:X` skill bodies from the adapter
`[strings]` tree, explicitly **"file-backed, so the manifest doesn't bloat"** (`REQ-UPS-INJECTION`).
ADR-0001's own Open/to-confirm names it: *"File-backed `[strings]` is an M12 spt-core dependency —
until M12 publishes, instruction bodies cannot be externalized."* M12 is now public (`spt 0.7.0`).

**The gap (confirmed against the live binary AND the published docs):** v0.7.0 exposes **no** way to
externalize a `[strings]` value to a file. Every avenue is inline-only:

- **Schema** (`adapter/manifest.schema.json`, vendored from the docs-site): `[strings]` is a plain
  `object`, `additionalProperties: true` — no `$file`/`include`/`@file` value convention.
- **`spt adapter get-string <opt> <key>`**: "prints the value (strings raw, else JSON)" — reads the
  merged inline view; no file-reference resolution.
- **`spt adapter set-string <opt> <key> <VALUE>`**: takes a **literal** `<VALUE>` positional — no
  `--from-file`/stdin path for an individual value.
- **`spt adapter create-profile --from <file>`**: ingests a *whole overlay TOML* whose values are
  still inline — not a per-value file backing.
- **Published docs** (`llms-full.txt`, manifest + CLI reference): no `@file`/include/external-ref
  syntax documented anywhere; strings are described purely as inline manifest-resident data.

(`[update] file_pull` ships adapter *files*, but it needs an Ed25519 signing key + a registry repo
target we do not hold — already a finding-class gap, see manifest header — and it does not make a
`[strings]` *value* resolve from a shipped file. Not a substitute.)

**Impact / our handling:** non-blocking, by ADR-0001's own fallback — *"skeleton SKILL.md files may
carry interim inline instructions."* So:
- The `/sptc:whoami` + `/sptc:setup` skeletons keep **interim inline bodies** in the cplugs skeleton;
  the manifest does **not** carry (and does not externalize) skill bodies in v0.7.0.
- `REQ-UPS-INJECTION` stays at `[doc, impl, unit]` (activate-don't-pre-fail) — the externalization
  half is held on this capability, not failed.
- The manifest-bloat avoidance ADR-0001 promised is **unachievable on the published surface** until
  spt-core adds a file-backed string mechanism (or rules that large bodies belong in another layer).

**Ask for doyle/todlando:** does M12 intend file-backed `[strings]` and it is unpublished/unshipped,
or has the design moved (e.g. bodies belong in a file-pull-shipped layer, not `[strings]`)? Either a
docs+binary capability or an ADR-0001 amendment closes this.

### Resolution (2026-06-15 — capability-confirmed-shipped; residual is docs-only)

The "absent" framing was **wrong**: the avenue search probed CLI verbs (`set-string --from-file`,
`create-profile --from`) and value-prefix syntax (`@file`/include), but the shipped mechanism is a
**value-position inline-table FILE POINTER** authored directly in `[strings]` — a shape an open
`additionalProperties` schema accepts and a verb-search cannot surface. doyle + todlando both ruled it
shipped (spt-core `REQ-MANIFEST-5`, M12-W3 @ `e08cea0`: `profile.rs::as_file_pointer` +
`registry.rs` lazy `get_string`). Confirmed by **live byte-test** against `spt 0.7.0`:

```toml
[strings]
inline_key  = "INLINE-VALUE"            # inline string — get-string prints as-is
filebacked  = { file = "probe.txt" }     # pointer — get-string RESOLVES to file contents (lazy)
```
```text
$ spt adapter add <dir>                            # ADAPTER_ADD:<name>:Harness:Copy (registered)
$ spt adapter get-string <name> inline_key         # INLINE-VALUE
$ spt adapter get-string <name> filebacked         # RESOLVED-FROM-FILE-OK   (file body, not the table)
# containment (negative): escape = { file = "../outside.txt" }
$ spt adapter add <dir>
#   ADAPTER_ADD_FAIL: invalid [strings] file pointer: pointer ../outside.txt must be a relative
#   path inside the strings/ dir (no absolute paths, no `..` traversal)   ← manifest-first: nothing registered
```

**Confirmed behavior (now the basis of our authoring + doyle's doc):**
- Pointer files live in the per-adapter aux dir `adapters/<adapter>/strings/`; `file = "x"` is a bare
  relative path **inside** that dir. On `adapter add` the whole adapter dir (incl. `strings/`) is
  **copied** into the registry.
- `get-string` **resolves the pointer lazily at read time** (live edits to the shipped file reflect
  without re-register) and prints the **file contents**; inline string values print as-is.
- **Containment** is enforced at register: `..`/absolute escaping `strings/` → `ADAPTER_ADD_FAIL`,
  manifest-first (nothing registers). Missing-at-read is skip-diagnostic (mirrors `[digest]`), not a crash.
- **Update-safe (todlando):** a **local profile's** pointers resolve to the *user-owned local-profile
  dir*, not adapter-shipped `strings/` (adapter updates won't stomp user overrides). A value-table
  carrying a `file` key is **reserved** as the pointer form — it cannot double as inline data.

**Disposition:** ADR-0001's "file-backed `[strings]`" dependency is **SATISFIED** — no ADR amendment,
no spt-core build change. We author UPS-injection skill bodies as `[strings.skills].<x> = { file =
"skills/<x>.md" }` over `adapter/strings/skills/<x>.md`. **Residual = published-docs visibility only:**
the pointer syntax was undocumented on the GH-Pages surface (it lived in spt-core-internal
`CONTEXT.md §adapter strings`) — same class as the `{key}` catalog + `[digest]` cross-field rule.
**CLOSED in v0.7.1 (2026-06-15):** doyle published the `[strings]` file-pointer syntax (plus the
`{key}` substitution catalog and the `[digest]` register rule) to the docs-site manifest reference +
`MANIFEST.md`. The next adapter author discovers the pointer form from the published surface alone —
F-003 fully resolved.

---

## F-004 — `spt adapter digest-proof --sample` does not fill `{session_id}` (false-fails the published extractor shape)

**Surfaced:** 2026-06-15, authoring the claude-spt `[digest]` extractor.

**Symptom.** `spt adapter digest-proof --sample <log>` substitutes only `{source}` (= the sample
path) into the extractor command and hard-fails on any other key. The claude-spt extractor command
is the production-correct, published-example shape:

```
extractor = "claude-spt-digest --session {session_id} --in {source}"
```

→ `DIGEST_PROOF_EXTRACT_FAIL:claude-spt: digest extractor failed: no value for substitution key {session_id}`.

**Isolation (same adapter, only the command line differs):**
- **Variant A** — `<exe> --in {source}` (source-only) → `DIGEST_PROOF_OK`, **parsed 5 / dropped 0**,
  rendered digest correct (incl. sprint-collapse `used: Write(src/a.rs), Bash(cargo build)`). Proves
  the extractor + the digest-proof render pipeline are correct.
- **Variant B** — `<exe> --session {session_id} --in {source}` → fails on the unfilled `{session_id}`.

**Why claude-spt needs `{session_id}` on the command (not a workaround — the only correct shape).**
CC's transcript path is `~/.claude/projects/<cwd-slug>/<session_id>.jsonl`. The `<cwd-slug>` subdir
is CC-internal and has **no published key** — and per doyle, *should* not (spt-core stays
harness-agnostic; it must not bake a harness's directory scheme into the key catalog). So `source`
must be the projects **root** and the extractor receives `{session_id}` on the command, resolving
`<slug>/<session_id>.jsonl` itself (the slug is the harness's business). This matches the published
example and what `spt endpoint digest` fills at runtime.

**Root cause (doyle, spt-core).** `digest-proof --sample` builds an **empty** substitution-key map
(`cli.rs:5135`, `let keys = BTreeMap::new()` — comment admits "placeholders like `{session_id}`
will fail"), whereas the runtime daemon path fills `{id}`+`{session_id}` before running the same
extractor (`digest.rs:208-210`). digest-proof is meant to be the author-time half of the *same*
spt-core digest engine (§diagnostics), so it must supply the same keys. It doesn't → it is infidelitous to
runtime and false-fails any `{session_id}`-templated extractor, including the published example.

Aside: `{home}` is **not** a catalog key — hard-failing on it is correct. Use `~` for home
expansion in `source` (we switched `source` to `~/.claude/projects`; the extractor also expands a
leading `~/` defensively).

**Status — CONFIRMED-IMPL-BUG; fix in progress (doyle's worktree).** digest-proof will fill
`{id}`+`{session_id}` matching runtime exactly (fidelity: *"passes proof" ⟺ "works at runtime"*),
plus an optional `--session <id>` override (default placeholder). The published contract is right;
the tool is catching up. **Our posture:** the extractor is built to the published contract and
proven (cargo unit tests + Variant A `DIGEST_PROOF_OK`); the digest-proof `int`
(`ci/digest/digest-proof-int.sh`, `REQ-DIST-DIGEST-EXTRACTOR`) **skips on the exact substitution
error** until doyle's fix ships, then flips green on the carrying release.

---

## Finding: no `spt how-to subnet` topic (published-guidance gap, minor)

**Surface:** `spt how-to <topic>` ships canonical, always-current guidance for `ready` and `send`
(the messaging hot path) — the claude-spt `ready`/`send` skills lean on it (`spt how-to ready`).
But there is **no `subnet` topic** (`spt how-to subnet` → `NO_SUCH_TOPIC`), despite `spt subnet`
being a multi-verb, pairing-flow-heavy command (create/show-code/join, 6-digit codes, QR) — exactly
the kind of thing a how-to topic serves well.

**Impact (low):** the `/sptc:subnet` skill wraps `spt subnet --help` as its canonical reference
instead. Works, but inconsistent with ready/send (which get richer how-to prose), and a casual user
running `spt how-to subnet` hits a dead end. **Disposition:** mint a `how-to subnet` topic in
spt-core (cross-machine pairing is the highest-value first-run flow; it deserves the same guided
prose as messaging). Carried as a parity finding — NOT a blocker for `/sptc:subnet`.

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** **ACCEPTED 2026-06-15
(doyle).** Confirmed against spt-core source: `HOW_TO_TOPICS` (cli.rs:4692) ships exactly
`["ready","send"]` (v1 topics, M7 plan decision 12, test-locked) → `spt how-to subnet` →
`NO_SUCH_TOPIC` (exit 2). Classified as a genuine spt-core **content gap under existing REQ-DOCS-6**
(in-binary agent guidance, single-source) — NOT a published-docs gap, NOT a docs-read miss.
**Disposition (doyle):** mint a `how-to subnet` topic (HOW_TO_SUBNET text + registry entry + bump
the v1-lock test); **scheduled into M11-W5** (the rig + docs wave). No new REQ (REQ-DOCS-6 owns it).
Tracked, not a mid-flight interrupt. NOT a blocker for `/sptc:subnet` (wraps `spt subnet --help`).

---

## F-005 — End-user adapter distribution/activation is undocumented on the public surface

**Surfaced:** 2026-06-15, dogfooding the first cplugs publish (an agent ran `/sptc:setup` on a fresh
install). The binary was present (`spt 0.7.2`), setup reported "No install needed" and stopped — but
`spt adapter list` showed **`claude-spt: ... deregistered`**. The harness adapter surface
(profiles / strings / hints / `[digest]`) was inert; `/sptc:*` skill-body UPS-injection had nothing
to source from. Binary present ≠ adapter active.

**The gap (public surface).** The published `harness-contract/integration-checklist` documents only
the **local single-machine** registration verb:
> `spt adapter add <dir>` — "Parses + schema-validates + records the manifest; a bad field is
> rejected here, nothing half-registers."
It documents **no distribution/activation path** for the medium-churn manifest layer the whole
distribution split (ADR-0001) leans on — i.e. how an **end-user machine that installed only the
plugin + binary** ever acquires and registers the `claude-spt` manifest. After the bootstrap installs
the binary, there is no published "… → adapter registered & active" step. Concretely undocumented:

1. **`[update] file_pull`** — the spt-core-conducted "file-pull / adapter registry" channel
   (ADR-0001's medium-churn delivery) has **no published adapter-registry repo-target shape** and
   **no documented path to obtain/provision the Ed25519 content-signing key** an author needs to
   publish a signed adapter. Author-side it is currently un-buildable from the public surface.
2. **`[update] delegated`** — whether the `claude plugin update` fallback requires CC to attest
   signature verification (`self_verifies`) is **unconfirmed** on the public surface.
3. **No end-user activation step is documented at all.** The plugin ships **no manifest** (ADR-0001:
   "no binary, no manifest, no embedded logic"), so the registry/file-pull channel is the *only* way
   the manifest can reach a user — yet that channel is undocumented. Net: the "install the plugin,
   get spt-core **and the adapter** for free" invisible-installer story has an undocumented hole at
   the adapter-activation step.

**Note — this is the finding the manifest header already promised but was never filed.**
`adapter/claude-spt.toml` header (lines 20-24) says `[update] file_pull` needs "(a) a published
adapter-registry repo target and (b) an Ed25519 content-signing key we do not yet hold … Tracked as
a finding (docs/SPT-CORE-FINDINGS.md)" — but no entry existed until now. F-005 makes it real.

**Impact / our handling (non-blocking, dev-side).** For local dev + this session, re-activate with
`spt adapter add ./adapter` (the adapter dir is known to the registry; it reads `deregistered`
because the Phase-D int tests register→clean-up by design). This does **not** solve the *end-user*
flow, which has no published path.

**Ask for doyle.** Is end-user adapter distribution (a) an undocumented-but-shipped capability
(publish the `[update] file_pull` registry-target + signing-key provisioning + the post-install
"adapter add/activate" step to the docs-site), (b) a roadmap item not yet shipped, or (c) a deliberate
design where the adapter is expected to ride a different layer for casual users? Any of the three
closes F-005 — but the public contract currently leaves the casual-end-user activation step blank.

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`).

### Triage (2026-06-15, doyle — against spt-core source). NET: (a)+(b) mix, **nothing unbuildable**; the activation step is just undocumented. Two of my three sub-claims were **docs-read misses** — recorded honestly:

1. **`self_verifies` (my #2) — READ MISS, already public.** `manifest.md [update]` documents
   `self_verifies = true`: "attests the updater verifies its content; an unattested `delegated`
   update is SKIPPED as unverifiable." So `delegated` does **not** require CC to silently attest —
   the *author* sets `self_verifies=true` to assert CC verifies its own content; absent ⇒ the update
   is **skipped (not failed)**. It is in the `[update]` section; I missed it.
2. **`file_pull` shape + signing (my #1) — MOSTLY documented; one real residual.** `manifest.md`
   documents `file_pull = repo + signing_key` (Ed25519, 64 hex) + "you sign your releases with your
   own key; spt-core release keys never extend to adapter content." **GENUINE GAP:** author-side key
   **provisioning** (how you *generate* that Ed25519 key) is undocumented → doyle: doc add.
3. **Zero-touch auto-activation via file_pull network-pull (my #3, the substantive one) — ROADMAP,
   not shipped (b).** `adapter_update.rs` is the DECISION+VERIFICATION layer only — "the actual byte
   transport for `file_pull` (the network pull) is REQ-UPD-1/M4; v1 receives the payload via
   self-fetch / out-of-band … does not itself fetch." Install-conduct emits
   `ADAPTER_INSTALL_DEFERRED "(rides the update engine)"`. The invisible-installer's last leg is
   genuine future work.

**THE PATH THAT WORKS TODAY (the missing post-install step F-005 found).** A fresh machine registers
the manifest via **`spt adapter add --github <author>/<repo>`** (clones under `adapters/_github/`,
manifest-first, then conducts the `[update]` avenue once). Shipped (REQ-INSTALL-4 impl/unit; only the
real-repo E2E is deferred, spt-core DEFERRED.md). So **`/sptc:setup`, after confirming the binary is
present, must run `spt adapter add --github <our-adapter-repo>`** (or a local dir) to ACTIVATE — that
is the bridge. "Binary present ≠ adapter active" confirmed correct.

**doyle's doc actions (docs-site):** publish the post-install ACTIVATION step (`adapter add
[--github]`) in `install-on-demand` + the integration checklist · author Ed25519 signing-key
provisioning · an explicit "zero-touch `file_pull` auto-distribution is roadmap (REQ-UPD-1)" note so
no author builds expecting it.

**Follow-up doc-gap (doyle, 2026-06-15) — `adapter add` is ROOT-ONLY, code-confirmed.**
`source_manifest_file` resolves a dir source to `<dir>/manifest.toml` **exactly** (exact filename,
no scan, no subpath); `--github user/repo` reads `<clone-root>/manifest.toml`. Undocumented (in
source, not published) → doyle adding the distribution-repo topology to the activation docs (root
`manifest.toml` requirement, `--github` root-only, copy-vs-pointer by `[update]` avenue, where the
manifest-referenced **binaries** live — NOT auto-copied, only `strings/` is). **Our consequence:**
the end-user `--github` target must be a **dedicated repo** (root = `manifest.toml` + `strings/` +
binaries), distinct from the monorepo; local dev uses the file-form `adapter add
./adapter/claude-spt.toml`. Drives `SETUP-SLICE-PLAN.md` Wave C.

**Our action items (ours, not doyle's):** (i) wire `spt adapter add --github <repo>` into
`/sptc:setup`'s binary-present branch (the activation bridge — folds into the setup-slice below);
(ii) the manifest needs its own **published github repo target** for the `--github` end-user path
(distinct from the cplugs skeleton repo); local dev uses `spt adapter add ./adapter`.

**Status:** **TRIAGED — accepted as (a)+(b) mix.** doyle patches docs; we wire the activation step.

### Resolution (2026-06-15 — doyle shipped `--release`, distribution leg CLOSED)

doyle shipped + published a new acquisition source that closes F-005's end-user distribution leg
without a dedicated repo: **`spt adapter add --release <user/repo> [--tag <ver>]`** fetches a
published **`adapter.spt`** release asset (a tar whose **root** holds `manifest.toml` + `strings/` +
the binaries) from the repo's GitHub release, extracts to the durable home, and registers the root.
**We ship straight from the monorepo as a release asset — no dedicated root-manifest repo** (the old
`--github` root-only constraint, code-confirmed + since doc-patched, is routed around). First
acquisition trusts HTTPS+GitHub like the install one-liner; signing rides the `file_pull` `[update]`
avenue later (this is **acquisition only** — re-running `--release --tag <newer>` is a manual
re-acquire, it does not change the `[update]` route). Docs (revised):
`harness-contract/install-on-demand.html#activate-the-adapter--register-your-manifest`;
`--release` is now the **recommended** distribution, `--github` + local-dir the alternatives.

**Our wiring:** `/sptc:setup` end-user branch → `spt adapter add --release SaberMage/spt-claude-code`
(local dev stays the file-form). **Version gate:** local spt 0.7.2 has only `--github` + local path;
`--release` lands in a newer spt release — int/dogfood waits on the upgrade. The remaining build is
the `adapter.spt` release-asset packer (`SETUP-SLICE-PLAN.md` Wave C′) + OQ2 (binary resolution: bare
-name templates resolve from PATH, so either ship binaries on PATH via the installer or use
home-relative paths). **The `[update]`/self-update channel itself stays unauthored** (our manifest
declares no `[update]` → COPY-mode registration; the signed file_pull self-update is the later,
separate concern — REQ-UPD-1/M4 roadmap + the Ed25519 provisioning doc).

---

## F-006 — `--release` bundles adapter binaries but bare-name templates don't resolve them (copy-mode)

**Surfaced:** 2026-06-15, the first real end-to-end `--release` dogfood (spt v0.7.3, counter 15).

**Chain proven first (all green on the win node):** `spt update fetch` → `apply` (0.7.2→0.7.3, exe
hash flipped to the signed `d867…0794`, seamless no-bounce) → `spt adapter add --release
SaberMage/spt-claude-code --tag v0.1.0` → fetched `adapter.spt` from the release →
`ADAPTER_ADD:claude-spt:Harness:Copy (registered)` + `ADAPTER_INSTALL_SKIP: no [update] avenue
(manifest-only adapter)` → `claude-spt` **ACTIVE**. The end-user activation path works.

**The gap (doyle predicted it; dogfood confirmed).** `--release` extracts the bundled binaries to the
adapter install dir —
`C:\Users\decid\AppData\Local\spt-core\adapters\_github\SaberMage-spt-claude-code\claude-spt-digest.exe`
+ `…\claude-spt-psyche.exe` (note: the `_github/<safe>` dir even for `--release`). But the registered
manifest's command templates invoke them **by bare name**:

```
extractor = "claude-spt-digest --session {session_id} --in {source}"
command   = "claude-spt-psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
```

Bare names resolve from **PATH** only, and the extract dir is **not** on PATH → `command -v
claude-spt-digest` / `-psyche` both **MISS**. Registration/activation succeed, but runtime
session-digest + LiveAgent Psyche can't find the bundled binaries. Copy-mode copies `manifest.toml` +
`strings/` only — the binaries extract but aren't placed on PATH.

**Interim (shipped, adapter-side).** `/sptc:setup` now, after an `--release` activation, copies the
two binaries from the extract dir (the `from …` path in `spt adapter list`) onto a PATH dir (the
`spt` bin dir — already on PATH). Verified: copying them there makes `command -v` resolve both and
`claude-spt-digest --help` run. Unblocks runtime today.

**Proper (doyle, REQ-INSTALL-11).** spt-core resolves `[digest]`/`[session]` program paths against the
adapter's own install dir (the `_github/<safe>` extract home / `adapters/<name>/`) **before** PATH —
so a bundled `.spt` binary resolves with zero PATH placement (truly self-contained `--release`
delivery). Shipped in **v0.8.0 / counter 16** (Feature B).

**Status:** **RESOLVED + interim RETIRED (v0.8.1 dogfood, 2026-06-16).** Install-dir resolution is now
dogfood-proven for BOTH binaries with nothing on PATH:
- **digest** — `spt adapter digest-proof claude-spt --sample …` ran the extractor from the install dir
  (`parsed 5 record(s), dropped 0`) after the interim PATH copies were moved aside and `RELDIR` was
  off PATH.
- **psyche** — the daemon-hosted Psyche resolved + spawned `claude-spt-psyche` from the install dir
  (resident runner, `procs=1`), again with the interim off PATH. (This only became provable once the
  Psyche actually stayed resident — see **F-009**, the prompt-split bug that was masking it.)
The interim PATH-copy step is **dropped** from both `/sptc:setup` bodies and the interim copies under
`…/spt-core/bin/` are deleted. Reported to doyle + deployah 2026-06-15; resolution dogfood-confirmed.

---

## F-007 — no `spt how-to live` topic, though live-agent lifecycle is a first-class advertised concern

**Surfaced:** 2026-06-16, building the REQ-SKILL-LIVE relay int (spt v0.7.3, counter 15).

**The gap.** `spt --help` lists "live-agent lifecycle" among the core concerns, and the task-oriented
`spt how-to <topic>` surface is the contract's canonical, always-current agent guidance. But only two
topics exist:

```
$ spt how-to
Topics (`spt how-to <topic>`):
  ready  receive messages: listener guidance + the --once fallback loop
  send   deliver messages: send vs ring, replies, SENT vs QUEUED

$ spt how-to live
NO_SUCH_TOPIC:live — topics:
  ready  ...
  send   ...
```

`ready` (becoming reachable) has a how-to; `live` (becoming a LiveAgent — a strictly larger, more
hazard-laden lifecycle: `[session.psyche_init]`, daemon-spawned Psyche, the `:live` composite, the
Monitor relay-vs-poll reconcile) does **not**. There is no published, canonical bringup recipe for a
live agent to defer to.

**Why it matters for the adapter.** Our `/sptc:live` body (`adapter/strings/skills/live.md`) was
written to *defer* to `spt how-to live` as "the canonical, always-current guidance" and follow it —
the same pattern `/sptc:ready` uses for `spt how-to ready`. With no such topic, that step dangles on
a dead command, and the adapter must instead **inline** the live-bringup recipe (perch id → seed the
`:live` composite so the daemon resolves `[session.psyche_init]` → run `spt api --adapter
claude-spt:live listen <id>` as the resident relay → poll/relay reconcile). Inlined steps can **drift**
from spt-core's actual live contract with no canonical source to reconcile against — exactly the
divergence the how-to surface exists to prevent.

**Adapter-side hardening (shipped this session).** `live.md` no longer instructs `spt how-to live`
unconditionally; it carries the inline operative recipe as the floor and references `spt how-to live`
only as "if/when the topic lands." So the skill never points at a dead command.

**Empirical addendum (2026-06-16, same session) — no non-interactive live-bringup for acceptance.**
Building the REQ-SKILL-LIVE relay int surfaced a concrete consequence. A live agent was brought up
under a disposable id:

```
$ spt endpoint run --adapter claude-spt:live --id sptc-ci-liveprobe --create --start
ENDPOINT_RUN:sptc-ci-liveprobe adapter=claude-spt:live pid=Some(155916) session=… (harness binds its perch on startup)
ENDPOINT_RUN_STARTED:sptc-ci-liveprobe (attach with `spt rc sptc-ci-liveprobe`)
```

The `claude` SUT spawns and is alive in the broker PTY (`spt rc sptc-ci-liveprobe` attaches), **but it
never binds a live perch** — `spt endpoint list` / `spt daemon status` never show it, and teardown
confirms it never registered (`STOPPED:… no ready marker; address unregistered`). This is **by
design**: `SessionStart` *seeds* but must not *listen* (a listen blocks — F-001 resolution); a live
perch is bound only when the session explicitly runs `/sptc:live`, whose body runs the **blocking**
`spt api --adapter claude-spt:live listen <id>`. So a deterministic live-relay acceptance test cannot
just `endpoint run --start` and assert — it must **drive a persistent session** (submit `/sptc:live`,
keep it alive while a probe is sent + the relay EVENT is asserted, then tear the Psyche+perch down).
That heavier harness has no published non-interactive entrypoint to build against (the missing
`how-to live` would be where such an "acceptance/headless live bringup" path is documented).

**RESOLUTION (2026-06-16, doyle ruling + perri validated).** Both items = docs gap (Bucket 2), NOT a
missing feature.
- **Item 1 (`how-to live`):** accepted = REQ-DOCS-6; doyle adds the topic as a post-M11 fast-follow
  (mechanism exists — the twin `how-to subnet` just landed). live.md hardened meanwhile (inline floor).
- **Item 2 (non-interactive bringup):** the primitive EXISTS and is public — it's NOT `endpoint run`
  and NOT `--once`. CC has the Monitor tool, so the live bringup is a **persistent** `spt api
  --adapter claude-spt:live --manifest <manifest> listen <id>` run as a **child process** (the Monitor
  surrogate; heir to legacy `$LIVE start <id>`). The earlier dead-ends were two omissions, both mine:
  - `endpoint run --start` only brings up the harness PTY; the *session inside* must fire the listen.
  - the Psyche spawns ONLY when the manifest is loaded, and the manifest loads ONLY via **`--manifest`**
    (`--adapter <name>` is just a name string → `LiveHost` None → no spawn). With `--manifest`, spt's
    in-process listen path spawns the Psyche (startup.rs `spawn_psyche`, *before* the once/loop split —
    so `--once` was a red herring). Proven 2026-06-16: `BOUND` / `PSYCHE_SPAWNED:{id}-psyche pid=…` /
    `READY` / the probe relayed as `<EVENT>`. Shipped as `ci/psyche/live-relay-int.sh`
    (REQ-SKILL-LIVE int green).
  - Binary resolution: the psyche_init command's bare `claude-spt-psyche` resolves via the F-006 PATH
    interim on 0.7.3; on **v0.8.0 (Feature B / REQ-INSTALL-11)** spt resolves it against the
    `--manifest` file's dir → still `PSYCHE_SPAWNED` after the interim is dropped. Independent confirm
    Feature B was the right call.
- **Two acceptance gotchas** (doyle folding into the how-to-live docs): anchor on the **Windows** pid,
  not git-bash `$$` (else `STALE_SEED`); `bind <id>` before `send` (else `NO_PERCH`, no queue).

**v0.8.0 marker-model change (doyle heads-up 2026-06-16, M11 restructure).** At v0.8.0 the Psyche is
no longer spawned in-process by `api listen` (which then emits only `BOUND`/`READY` + marks the perch
online); the **daemon livehost** hosts it off that online status (`LIVEHOST_PSYCHE:{id}` on the daemon
+ the `{id}-psyche` perch comes online). `live-relay-int.sh` now version-branches the psyche assertion
(<0.8.0 = `PSYCHE_SPAWNED` off the child; >=0.8.0 = the `{id}-psyche` perch online, **provisional** —
finalized at the v0.8.0 publish ping). Relay leg unchanged. The `how-to live` topic ships in v0.8.0
(todlando @672b928) → re-point `/sptc:live` step 2 at `spt how-to live` then.

**Status:** **RESOLVED to a docs gap (Bucket 2). Live int SHIPPED (0.7.3 green; v0.8.0 psyche-marker
branch pre-staged provisionally). Awaiting the v0.8.0 publish ping to finalize the >=0.8.0 leg +
re-point /sptc:live step 2 at the shipped how-to-live topic.**

---

## F-008 — no published legacy-migration command, though it's a LOCKED v1 setup item

**Surfaced:** 2026-06-16, scoping the LOCKED v1 `/sptc:setup` feature paths {1..7} (SCOPE.md §"/spt:setup skill").

**The gap.** SCOPE item **#5** (LOCKED v1) is: *"Legacy migration — detect claude_skill_owl/owl →
migrate identity+agents+psyche (spt-core CONTEXT.md first-class commitment)."* But there is **no
published command** to drive it. None of `spt --help` (top-level) nor any subcommand
(`adapter`/`daemon`/`grant`/`subnet`/`update`/`endpoint`/`api`/`notif`) exposes a
migrate/import/adopt/legacy/owl verb, and there is no `spt how-to` topic for it:

```
$ spt --help | grep -iE 'migrat|legacy|owl|import|adopt'   # (and every subcommand) → nothing
```

**Why it matters for the adapter.** `/sptc:setup` #5 is supposed to *detect a legacy
claude_skill_owl/owl install and migrate identity + agents + psyche into spt-core*. With no published
command, the adapter cannot author that step against the public surface (and per the
public-surface-only constraint we will not reverse-engineer the legacy on-disk layout or reach into
spt-core source to hand-roll a migration). So setup #5 is **blocked**, distinct from the buildable
items (#7 ccs — shipped; #3/#4 subnet — delegate to `spt subnet`; #6 OS-service — installer-registers
the at-logon daemon task; #1 cc-launcher — gated on full-fat M12).

**Ask (doyle).** Is legacy migration (a) a planned `spt migrate`/identity-import command (then setup
#5 wraps it when it lands), (b) installer-handled (the install bootstrap detects + migrates, so setup
just verifies), or (c) out of v1 scope (then SCOPE #5 should be re-marked defer)? Until a published
surface exists, setup #5 stays unbuilt and SCOPE #5 should note the dependency.

**Status:** **OPEN — reported to doyle 2026-06-16. Setup #5 unbuilt pending a published migration
surface (or a scope re-mark).**

---

## v0.8.0 dogfood results (2026-06-16) — relay/API green; daemon-hosted psyche-spawn open

Upgraded spt 0.7.3 → **0.8.0** (hash `10ff8166…`, daemon restarted). Results against the published build:

- **REQ-API-4 ✓** — `spt api --adapter claude-spt:live <verb>` (capability, listen) resolves the
  registered manifest + install-dir WITHOUT `--manifest`. The require-both-flags wart is gone.
- **F-007 ✓ (shipped)** — `spt how-to live` is a full topic; `/sptc:live` step 2 re-pointed at it.
- **`[update]` gh_release ✓ (declared)** — `adapter/claude-spt.toml` now declares `[update] avenue =
  "gh_release", repo = "SaberMage/spt-claude-code"`; validates against the re-vendored v0.8.0
  `manifest.schema.json`. Live `spt adapter update` test rides the per-OS re-release (the registered
  adapter is still the published v0.1.0 sans `[update]`).
  - **LIVE-TESTED 2026-06-20 (v0.5.0 release) — surfaced TWO gh_release-update gaps (F-014, F-015).**
    First real exercise of the avenue (`0.4.0 -> 0.5.0`). Both flagged to doyle; v0.5.0 unblocked
    via stopgaps, adapter is functionally on 0.5.0 (briefs resolve; up-to-date, no retry-loop).

## F-014 — gh_release update fetches a single fixed `asset` (default `adapter.spt`); no per-OS resolution

**Surfaced:** 2026-06-20 — `spt adapter update claude-spt` →
`ADAPTER_UPDATE_FAIL: fetch: HTTP 404 Not Found` fetching `adapter.spt`. The release ships **per-OS**
assets (`adapter-<os>-<arch>.spt`, REQ-DIST-ADAPTER-PEROS) but the update avenue fetches one fixed
name — `asset` omitted ⇒ default `adapter.spt`, which we deliberately do NOT publish (the manifest
comment pre-flagged exactly this). `spt adapter update --help` exposes no `--asset` override and the
manifest `[update]` has no `{os}`/`{arch}` placeholder. So the avenue cannot target a per-OS asset.
**Stopgap (applied):** published `adapter.spt` = the windows build to the v0.5.0 release (this node's
OS; the active fleet is windows). **Needs (doyle):** per-OS resolution in the gh_release update avenue
— an `asset = "adapter-{os}-{arch}.spt"` placeholder, or host-derive like `--release` acquisition.

- **RESOLVED-SHIPPED (spt-core v0.13.2, ADR-0024 W1) — applied to claude-spt 2026-06-22 (perri, C).**
  doyle's fix took the third path: NOT a per-OS asset placeholder, but **one host-agnostic
  multi-platform fat `.spt`** — a single archive bundles each recognized target-triple's binaries
  under a `<triple>/` dir beside the SHARED `manifest.toml` + `strings/` at root; install classifies
  the triple dirs and flattens this node's triple into the install dir (bare-name resolution
  preserved). So the `[update]` avenue's single fixed `asset = adapter.spt` is now correct on every
  host — no `{os}/{arch}` placeholder needed. **Adapter change (C):** `ci/publish/package-adapter.sh`
  now emits the single fat `dist/adapter.spt` (win `x86_64-pc-windows-msvc/` + linux
  `x86_64-unknown-linux-gnu/`), `min_spt_core_version` → 0.13.2 (mandatory for a fat archive), the
  F-014 windows-copy `adapter.spt` stopgap DROPPED. Win-triple flatten LOCAL-dogfooded (extract →
  flatten → translate-proof + digest-proof OK). **Layout was a published-docs GAP** (only in spt-core
  internal CONTEXT.md; not on docs-site/llms — confirmed with doyle, who is adding it to
  harness-contract + llms; not separately logged per his call). Constraint: only the two x86_64 triples
  are recognized in a fat archive today (an unrecognized dir silently flattens as a shared-root entry —
  the packer guards it); platforms beyond those ship a separate single-triple asset via `--asset`.

## F-015 — Windows update-extract fails (`tar exit 1`) when a live agent locks a bundled binary; whole update reported failed despite manifest+strings applying

**Surfaced:** 2026-06-20 — after the F-014 stopgap, update got past fetch but →
`ADAPTER_UPDATE_FAIL: extract: tar exit Some(1)`. The `.spt` archive is valid (local `tar xvzf` exits
0). Root cause: `claude-spt-psyche.exe` was **running** (pid for the live `wall-a` agent's Psyche), so
Windows locked it and tar could not overwrite the binary → exit 1. The non-binary entries DID extract
(install-dir `manifest.toml` = 0.5.0, `strings/briefs/` present + resolving, `claude-spt-digest.exe`
swapped); only the locked `claude-spt-psyche.exe` stayed the old build. spt-core reports the whole
update FAILED yet still records 0.5.0 (re-run → `UPTODATE installed 0.5.0`), leaving a **partial,
silently-inconsistent** state: a future release that DID change the psyche binary would believe it's
up-to-date with a stale binary. (v0.5.0 is unaffected — it changed no binaries, so the old psyche is
identical.) **Needs (doyle):** graceful locked-binary handling on Windows update — extract-to-temp +
atomic swap when free, or skip-locked + "restart the live agent to finish" semantics, and do NOT
record the new version until the binary swap actually succeeds.

- **W3 FIX VALIDATED on spt-core v0.13.2 (perri, 2026-06-22).** doyle's ADR-0025 W3 fix
  (psyche runs from a `<perch>/.live-bin` own-copy so the shared install binary stays lock-free
  + orphan-reap guard + CRC-swap live-update) is LIVE and correct. Acceptance run on real
  claude-spt 0.6.0 (my F-010×F-015 repro = the W3 acceptance test):
  - **(1a) ✓ psyche runs from `.live-bin`.** Brought up a live endpoint (`api seed` + persistent
    `api listen w3accept --subnet SPT_DEV`; the hook-path `listen` takes `--subnet` where the
    spt-hosted `endpoint run` does not — the F-017 asymmetry, exercised here on a multi-subnet
    node SPT_DEV+BIGNET). Daemon hosted the Psyche; running `claude-spt-psyche.exe`
    ExecutablePath = `…\spt-core\owlery\w3accept\.live-bin\claude-spt-psyche.exe` — the perch
    own-copy, NOT the install dir.
  - **(1b) ✓ install-dir binary stays lock-free.** With the psyche live, an exclusive ReadWrite
    open of `…\adapters\_github\…\claude-spt-psyche.exe` SUCCEEDED — the install copy is unlocked,
    so the `tar exit Some(1)` precondition no longer exists. The lock-brick is eliminated by the
    `.live-bin` design.
  - **(2) ✓ orphan psyche reaps on `spt endpoint stop`.** `spt endpoint stop w3accept` →
    `claude-spt-psyche.exe` process count went 1 → 0. No brain-less orphan left behind.
  - Test scaffold (`w3accept`) purged afterward; node clean, adapter healthy (0.6.0,
    translate-proof OK). **F-010×F-015 psyche-lock: validated CLOSED on v0.13.2.** (A destructive
    `adapter add --github` footgun surfaced while constructing the brick repro — logged as F-018;
    the lock-brick itself did NOT recur.)
- **Relay leg ✓** — `ci/psyche/live-relay-int.sh` green on 0.8.0: BOUND/READY + a probe relayed as the
  escaped `<EVENT>` off the listen child + live_agent kind. No `--manifest` needed.

- **RESOLVED (v0.8.1 dogfood, 2026-06-16) — daemon-hosted psyche-spawn.** Two layered bugs, not the
  net-race we first suspected. (1) spt-core v0.8.0 livehost did not reconcile/host at all (doyle's
  v0.8.1 fix addressed this — the daemon now reaches the spawn). (2) The spawn then SILENTLY failed:
  the daemon spawned `claude-spt-psyche` but it exited instantly (phantom pid, `status=online`, no
  `psyche_host_error`). Root cause = **F-009** (spt-core whitespace-splits the substituted
  `{psyche_prompt}`, so the runner's non-greedy `--prompt` rejected the 2nd word and exited 2).
  **Confirmed via argv-capture instrumentation:** the daemon passed `--prompt PSYCHE REVIVAL time:
  epoch-ms:… incoming event: (none)` as 7 separate argv tokens. Fixed adapter-side (greedy `--prompt`,
  commit ddf1965); the Psyche now stays resident, `ci/psyche/live-relay-int.sh` psyche leg flips
  skip→ASSERT green, and **REQ-INSTALL-11 / F-006** is proven in the same run. The "peer pump STALLED"
  correlate recurs ~7-8 min after every daemon start but did NOT block hosting (a Psyche hosted fine
  with the pump stalled) — flagged to doyle as **F-010**-adjacent, separate from the host path.

- **Field note (2026-07-02, E2E-0104 run — locked-binary class RE-REPRO'D, second time):** the
  operator's first `spt adapter update` on HFENDULEAM printed DONE but left **0.10.3** in the
  install dir — `hall-a`'s running translate binary held the Windows lock, so the swap silently
  deferred. The version-of-truth check caught the skew; the working sequence was
  **stop endpoint → update → verify version → relaunch**. Same class as this finding's original
  brick (and the v0.10.4-cut observation: a mid-live file swap defers until endpoint restart).
  doyle keeps the quiesce-or-verify semantics in the spt-core backlog — now field-repro'd twice,
  weighted accordingly. Operator guidance until then: quiesce endpoints before `adapter update`,
  and always verify the installed version afterward (DONE ≠ swapped).
  - **FIX SHIPPED spt-core-side (F015B, todlando 2026-07-02, branch `f023-wan-idle-delivery`) —
    VERIFIED ON-NODE, CLOSED (see the F-025 re-verify record: real swap + honest report +
    endpoint survived, published v0.20.0 binary, live `:ccs` composite endpoint).** Root cause was NOT the file lock alone: an exact-`==`
    parent-matcher skew dropped every `<adapter>:<profile>` COMPOSITE endpoint from `affected`
    (our `:ccs` endpoints are exactly that shape) → empty-affected early-return → the swap
    silently no-op'd while the update still printed DONE (success-without-effect). Fix @8b9975d:
    shared `adapter_parent_matches` at all 3 seams (live-gate, broker apply, endpoint-select),
    early-return removed (CRC swap runs unconditionally), `KIND_APPLIED` reported ONLY after a
    real swap. Documented spt-core-side as KH 7.24 + ADR-0025 amendment 2; red-first e2e coverage
    @10a316d/@16eef91 (composite-endpoint apply lands + stays online; no-matching-session still
    swaps; foreign apply leaves a live endpoint byte-identical). Adapter-side verification rides
    the wave publish: re-run this field repro (live `:ccs` endpoint, mid-live `spt adapter
    update`) and assert real swap + honest report + endpoint online — CLOSE on green.

---

## F-013 — `spt endpoint run` does not substitute `[env.<VAR>].value = "{id}"` (the spt-hosted bind-path never gets its endpoint id)

**Surfaced:** 2026-06-17, root-causing the operator's wall-b zero-perch (`spt endpoint run claude-spt
wall-b` → CC PTY came up but `spt send wall-b` → `NO_PERCH` and **zero perch on disk**). This is the
M12 cc-launcher path — impl+UNIT only, **never int-proven E2E** — and wall-b was its first real
exercise. (Build orientation: `CC-LAUNCHER-BIND-PLAN.md`.)

**How the adapter intends the spt-hosted bind path to work.** `endpoint run` spawns
`[session.self].command = "claude"` (bare — CC has no CLI flag for an externally-chosen id; it mints
its own session id post-spawn). To tell the spawned CC *which* endpoint it is, the manifest declares:

```toml
[session.self]
command = "claude"
keys = ["id"]

[env.SPT_ENDPOINT_ID]
direction = "inject"
value = "{id}"
```

The `plugin/sptc/hooks/session-start.sh` `bind)` branch then reads `$SPT_ENDPOINT_ID` and self-binds
(`api bind "$SPT_ENDPOINT_ID" --set-session-id "$sid"`). `sptc_register_verb` selects `bind` **iff
`$SPT_ENDPOINT_ID` is set**, else `seed`.

**Root cause (hard repro, probe-adapter — public-surface only, no spt-core source).** Registered a
throwaway clone adapter whose `[session.self].command` was a no-op env/argv-dumping `cmd` probe, then
ran `spt endpoint run --adapter probe-spt --id <ID> --start` and captured exactly what spt-core spawns:

- With `command = "cmd /c probe.cmd"` + `[env.SPT_ENDPOINT_ID] value = "{id}"`:
  `ARGV: ` *(empty)* and **`SPT_ENDPOINT_ID=[]`** — the env inject is **not substituted** (empty, not
  the id). The id appears in **no** env var (grepped full `set` for the literal id → zero hits). spt
  injected only **inherited** daemon env (`OWL_SESSION_ID` = a static inherited UUID, `SPT_RELEASE_SEED`,
  legacy `OWL`) — nothing endpoint-specific.
- With `command = "cmd /c probe.cmd {id}"` (placeholder **in the command**): `ARGV-WITH-ID:[<ID>]` —
  the `{id}` fills **on argv**. ✅

So on spt **0.9.1** the endpoint id is threaded to the `[session.self]` spawn **only** via `{id}`
substitution **in the command argv** (matching `endpoint run --help`: *"The endpoint id rides argv so
the harness binds to exactly it"*). The `[env.<VAR>].value = "{id}"` route the adapter relies on is a
**no-op** — even though `manifest.schema.json` `EnvVar.value` is documented as *"Value to inject (with
substitution); required for `inject`."* (the schema does not enumerate which keys are available to the
env-value substitution context; `[session.<role>].keys` only governs the command-string fills).

**Consequence (the wall-b zero-perch, fully explained).** In the real spawn, `$SPT_ENDPOINT_ID` is
empty → `sptc_register_verb` returns **`seed`**, not `bind` → the hook runs the harness-hosted seed
branch (records an ephemeral seed by PPID) instead of binding the endpoint perch → **no perch on disk
for the endpoint id** → `spt send <id>` → `NO_PERCH`. **bind itself is not broken**: a direct
`spt api --adapter claude-spt bind <id> --set-session-id <sid>` from an ordinary (non-broker) shell
returns `BOUND` and builds a complete, reachable perch (owlery dir + `info.json` + `api.token` +
`spool.db`); `spt send <id>` → `QUEUED` and `api poll <id> --token` drains it. So the **only** broken
link is endpoint-run's failure to deliver the id to the flagless harness. (Also note: `bind` does
**not** require broker parentage — the "broker-parentage IS the credential" comment in session-start.sh
is not load-bearing for the success path; bind succeeded with neither `--token` nor a proof
`--session-id`.)

**Fix fork (reported to doyle; his call on the spt-core half):**
- **(a) spt-core honors `[env.<VAR>].value` substitution** for `endpoint run` (fill `{id}` — and
  presumably the same key set as the spawning role's `keys`). Then the **current manifest is already
  correct** and no adapter binary ships. Cleanest if `[env].value`-with-substitution is intended per
  the schema text.
- **(b) adapter ships a wrapper launcher** — `command = "claude-spt-launch {id}"`, a tiny per-OS shim
  (mirrors the existing `claude-spt-psyche` runner) that captures `$1` as the endpoint id, exports
  `SPT_ENDPOINT_ID`, then exec's `claude`. Works **today** on the proven argv-`{id}` mechanism with no
  spt-core change; cost is a new shipped cross-OS binary in the spt-core-conducted layer.

**Then (regardless of fork): add the missing int** — `endpoint run <id> --start` → assert a **bound
perch** exists on disk under `owlery/` **and** is reachable (a queued `spt send <id>` drains via the
next `api poll`). Gated `SPTC_ACCEPTANCE=1`, disposable id (REQ-HAZARD-PERCH-COLLISION), full teardown.
This int would have caught the wall-b zero-perch.

**Secondary observation (cleanup gap, flagged to doyle, NOT yet a separate finding).** During teardown
of the disposable probe perch: a proper `spt api session-end <id> --erase` wiped the perch (owlery dir
gone, no process) **but the endpoint stayed in the subnet roster** (`identity/registry/SPT_DEV.json`)
listed `Active` with no perch behind it; `spt endpoint stop <id>` reported "address unregistered" yet
the roster line persisted; there is **no CLI verb to forget a roster entry**, and hand-editing the
registry JSON is **immediately overwritten by the single-writer daemon** (which re-adds the entry). A
daemon bounce (the likely reconcile) was declined as too disruptive to the operator's live perches
(F-012). Net: a ghost `Active` roster line can outlive an erased endpoint with no supported way to
evict it. (Also noted: `bind` defaults `--type live_agent`, so a bare disposable `bind` triggers the
livehost to spawn a `<id>-psyche` — bind disposables with `--type gateway` to avoid it.)

**Status:** **ROOT-CAUSED (perri) → RULED (doyle 2026-06-17): fork (a) — spt-core BUG.** doyle: the
schema already promises `value` "with substitution", so spt-core not applying it in endpoint-run is a
silent correctness bug (empty inject → seeds-not-binds → zero perch, no error). **The adapter manifest
is CORRECT as authored — no wrapper binary** (fork b rejected: a shim would mask a bug every adapter
routing an id via `[env]` would hit). Dispatched as **`REQ-HAZARD-ENV-SUBST` to todlando on the
v0.11.0-findings line** (pairs with `REQ-SEND-SPT-HOSTED` — the two spt-core halves that make
`endpoint run` fully reachable). **Our adapter int is HELD** until the env-subst fix gates+releases;
doyle pings when ready, then the int asserts: `endpoint run <id>` → `SPT_ENDPOINT_ID` populated →
SessionStart **binds** → bound perch on disk + reachable via next `api poll`. Ghost-roster (secondary)
= a separate **daemon-side finding doyle is minting** (a local roster entry whose backing perch is
erased should self-heal/epoch-lease-evict); leave it, harmless, no supported evict today. All probe
artifacts torn down; box at the X/help/wall-a/doyle baseline (modulo the harmless ghost). pid 60824
(doyle's original wall-b orphan) — he reaps it pid-scoped.

**VERIFIED FIXED + INT LANDED (2026-06-17, spt v0.11.0 / counter 24).** Updated 0.9.1→0.11.0 and
re-ran the operator's exact flow under a disposable id: `spt endpoint run --adapter claude-spt --id
<disp> --start` → SessionStart saw a **populated** `$SPT_ENDPOINT_ID` → `bind` → **BOUND perch on disk**
in ~4s → `spt send <disp>` → **`SENT`** (live PTY inject, REQ-SEND-SPT-HOSTED). The missing int is
landed: `ci/launcher/bind-int.sh` (**REQ-CC-LAUNCHER-BIND**, int-only — impl/unit/doc stay under
REQ-DIST-SHORTCUT-BASENAME), 4/4 green, SPTC_ACCEPTANCE-gated, disposable id, full teardown,
self-gates SKIP on < 0.11.0. Incidental 0.11.0 confirms: rc/daemon-bounce survives clean; the F-013
ghost-roster (erased endpoint stuck `Active`) **self-healed** — doyle's `REQ-HAZARD-ROSTER-GHOST`.

**KNOWN-MINOR — `< 0.11.0` silent-seed (doyle-ruled 2026-06-17, NON-blocking).** On spt-core
< 0.11.0 the `[env.SPT_ENDPOINT_ID]` inject is not substituted → endpoint-run silently SEEDS instead of
binding (no perch, no error). Ruling: **no min_spt_core floor-bump** (ready/live work on 0.9.0; a
blanket floor is too broad) and **no runtime guard** — a `< 0.11.0` endpoint-run spawn is
**env-indistinguishable** from a normal user-launched seed (both: `SPT_RELEASE_SEED` + `OWL_SESSION_ID`
set, `SPT_ENDPOINT_ID` empty — empirically verified on this node's own session), so any guard would
false-positive "update spt" on every normal seed (worse than the narrow, self-healing miss). Handled by
the doc-note in `adapter/claude-spt.toml` `[env.SPT_ENDPOINT_ID]`. Narrow + transient (only a node
still on < 0.11.0 running `endpoint run`; the fleet updates forward). **F-013 fully closed.**

---

## F-009 — command templating substitutes a `{key}` into the command STRING then whitespace-splits

**Surfaced:** 2026-06-16, v0.8.1 dogfood of the daemon-hosted Psyche (the F-006/REQ-INSTALL-11 proof).

**The gap.** The `[session.psyche_init]` (and `[digest]`) command is authored as a single template
string with `{key}` placeholders:

```
command = "claude-spt-psyche --id {id} --session-id {session_id} --prompt {psyche_prompt}"
```

spt-core substitutes the daemon-filled values **into the string** and then **splits the result on
whitespace** to build argv. So a multi-word fill does not arrive as one argument — it explodes.
`{psyche_prompt}` is always a sentence; argv-capture (instrumented `claude-spt-psyche` dumping its raw
argv to a file when daemon-spawned) showed exactly this:

```
--id  sptc-argv-cap-psyche
--session-id  sptc-argv-cap-sess
--prompt  PSYCHE  REVIVAL  time:  epoch-ms:1781652607426  incoming  event:  (none)   <- 7 tokens
```

**Consequence.** Any adapter whose command has a multi-word fill is broken unless its receiving binary
slurps the stray tokens. The published `[digest]` extractor survives only by accident — its fills
(`{session_id}`, `{source}`) are single-token. The Psyche runner's strict `--prompt` parser took
`PSYCHE`, rejected `REVIVAL` as "unknown arg", and exited 2 — which the detached host masked (F-010).

**Adapter workaround (shipped, ddf1965).** `claude-spt-psyche` parses `--prompt` **greedily/terminally**
— it consumes every remaining token and rejoins with spaces (the manifest keeps `--prompt` last).
Robust whether spt-core splits or passes one arg.

**Recommended spt-core fix.** Split the command template into argv **first**, then fill each
placeholder slot as exactly **one** argv element (no post-substitution whitespace split). This makes
multi-word fills usable without every adapter hand-rolling a slurp. Alternatively, document the
constraint loudly (fills must be single-token) — but that effectively forbids prompt-like keys.

**Status:** **RESOLVED-SHIPPED + RE-VALIDATED (spt v0.8.2 / counter 18, 2026-06-17).** doyle's fix:
command-template substitution now fills each `{key}` as ONE argv element (tokenize-then-fill), applied
to every `[session.<role>]` template. Re-validated by argv-capture on the daemon-spawned psyche with a
STRICT single-value `--prompt` parser (greedy workaround removed so spt-core's fill is what is tested):
the psyche HOSTED, and the raw argv had exactly 7 elements with the `--prompt` value = the entire
multi-line prompt as ONE element (newlines intact). The shipped adapter keeps greedy `--prompt` as
defensive (a single element passes `join(" ")` unchanged; a pre-0.8.2 split is still reconstructed).

---

## F-010 — `psyche_host_error` still masks a host that spawns then exits immediately

**Surfaced:** 2026-06-16, same v0.8.1 dogfood. **Builds on** doyle's v0.8.1 `psyche_host_error`
surface (the additive `info.json` field + `spt endpoint list`/`whoami` rendering).

**The gap.** v0.8.1 makes a *spawn* failure harness-reachable, but the failure mode we actually hit
was a spawn *success* followed by an immediate child exit. Because the daemon's detached `spawn()`
returned `Ok`, `psyche_host_error` stayed **clear** while the hosted process was already gone — nested
`{id}-psyche` `info.json` read `status=online` with a real-looking (already-dead) pid, and `spt
endpoint list`/`whoami` showed no error line. A crash-on-startup host is indistinguishable from a
healthy one. (Here the child exited 2 from F-009; arg-parse is just one way a host can die fast.)

**Recommended spt-core fix.** Treat a non-resident host as a host failure: e.g. confirm the child is
still alive a short interval after spawn (or that the `{id}-psyche` perch actually re-registers /
comes online) before clearing/omitting `psyche_host_error`, and record `{reason: "host exited <code>
within <n>s"}` otherwise. This closes the last masking gap so "online + no Psyche + no cause" cannot
recur.

**Status:** **RESOLVED-SHIPPED + RE-VALIDATED (spt v0.8.2 / counter 18, 2026-06-17).** A daemon-hosted
psyche that spawns then exits immediately is now reported a FAILED host: the daemon stamps the
harness-reachable `psyche_host_error { reason, ts, attempts }` on the PARENT perch, de-onlines the
phantom nested `{id}-psyche` perch, un-hosts, and arms a cooldown. Re-validated by deploying a
psyche that `exit(2)`s at startup: the parent perch got
`psyche_host_error{reason:"host not resident within 5s (psyche perch missing/dead pid)", attempts:2}`,
and both `spt endpoint list` and `spt whoami` rendered `psyche-host: FAILED (...)` after the liveness
line (status itself stays `online` — liveness remains authoritative). No more silent phantom.

---

## F-016 — published `[message-idle-translation-binary]` contract omits the mandatory `{commit}` terminator

**Surfaced:** 2026-06-22, building the `[message-idle-translation-binary]` seam (v0.13.0 harness
contract) public-surface-only. This is the flagship case for the blind-build: a binary authored
strictly from the published contract would FAULT live, because the contract is incomplete.

**The gap.** The published harness-contract
(`sabermage.github.io/spt-releases/harness-contract/manifest.html`, `[message-idle-translation-binary]`)
documents the translation binary's stdout command vocabulary as exactly `{"key":…}` / `{"delay_ms":…}`
/ `{"text":…}`, and gives the degenerate baseline verbatim as `{"text":payload}{"key":"enter"}`. It
makes **no mention** of `{"commit":true}`, an `InjectFloor`, or a commit deadline. But the broker
requires a trailing `{commit}` to terminate an inject sequence — so a binary built faithfully from the
published vocabulary (including the doc's own degenerate example) emits no terminator and **faults on
every delivery**.

**Confirmed from source (doyle, 2026-06-22).** `run_inject_worker` (broker.rs:1075-1090) ends an
inject sequence ONLY on an explicit `{commit}`; `Text`/`Key`/`Delay` just enqueue. With no `{commit}`,
the sequence hits `INJECT_COMMIT_DEADLINE` (5s, broker.rs:151-169) and FAULTs (broker.rs:832-846;
Layer-G "no-commit FAULT test"). On the fault the broker flushes + releases anyway (1015-1022) so
input is not lost, but every delivery faults and stalls to the 5s deadline. The reference response IS
`{text:payload}{key:enter}{commit:true}` (translation.rs:74-78) — `{commit}` is separate from, and
after, the submit. Two distinct signals: the trailing `\r` (or `{key:enter}`) submits the PTY *line*
(verbatim — broker.rs:1066); `{commit}` terminates the *sequence* and releases the InjectFloor that
buffers the live controller's input during emission.

**Resolution — two fixes:**
- **(i) Adapter binary — DONE.** `tools/cc-spt-idle-translate` now appends a trailing `{"commit":true}`
  after the `\r`-submit (sequence: `ctrl+s` · 50ms · `{text:"<envelope>\r"}` · `{commit:true}`). 11
  cargo tests, incl. `sequence_terminates_with_a_mandatory_commit`. The `\r`-in-text submit is
  unchanged (correct + verbatim); `{commit}` is the additive terminator.
- **(ii) spt-core contract — SHIPPED 2026-06-22.** `manifest.md` republished + live on gh-pages
  (`harness-contract/manifest.html`): the stdout vocabulary now lists `{"commit":true}`, documents it
  as the mandatory sequence terminator (inject-floor release + 5s commit-deadline + raw-inject
  fallback), and the degenerate example is corrected to `{text}{key:enter}{commit:true}`. Re-verified
  by re-fetch; the binary's module doc now cites the corrected published contract.

**Status:** **RESOLVED — both fixes shipped** (adapter binary appends `{commit}`; published contract
corrected + live). The only residual is an end-to-end re-confirm via `translate-proof`, pending the
v0.13.1 cut (counter 28+) — not a code wait. Also spawned the sibling DX win (no `translate-proof`):
doyle triaged the missing author-time proof (`spt adapter translate-proof`, the EMIT-half mirror of
`digest-proof`) → todlando built it (PR #28). Its no-commit gate would have caught this same defect at
author time; runs post-release to confirm the fixed binary green. Caught here BEFORE any live FAULT —
exactly what the blind-build exists to surface.

---

## F-011 (cont.) — the `*-proof` commands can't resolve a GhReleaseManaged dev adapter (author-time-loop friction)

**Surfaced:** 2026-06-22, running `spt adapter translate-proof` (v0.13.1) against `claude-spt` in
local dev. Same root class as F-011 (a Pointer/GhReleaseManaged adapter whose manifest isn't at the
resolver's expected install path → resolution fails), now hit via the author-time proof commands.
doyle acked it 2026-06-22 and is folding the fix into the v0.13.x adapter-DX batch.

**The friction.** `claude-spt` declares `[update] avenue = "gh_release"`, so `spt adapter add
<dev-manifest-file>` registers it **GhReleaseManaged**. The `*-proof` resolvers (`translate-proof`,
and the same path `digest-proof` uses) then want the **extracted install shape** — `manifest.toml`
plus the binary **co-located** in the install dir — not the dev source file. With a bare-file add the
proof fails:

```
$ spt adapter add "$PWD/adapter/claude-spt.toml"          # registers a GhReleaseManaged pointer
$ spt adapter translate-proof claude-spt --event '<EVENT…>'
TRANSLATE_PROOF_FAIL:claude-spt: adapter 'claude-spt' is registered as a pointer but its manifest is
not present yet at <dir> — the adapter's binary/manifest has not been extracted/downloaded …
```

…even though the dev manifest is right there (just named `claude-spt.toml`, not `manifest.toml`, and
the binary lives under `tools/…/target/release/`, not co-located).

**Workaround (in `ci/idle-translate/translate-proof-int.sh`).** Stage a disposable install dir that
mirrors the extracted shape — `manifest.toml` (the renamed manifest) + `strings/` + the built binary
co-located — and `adapter add` *that dir*. Then the proof resolves and passes (TRANSLATE_PROOF_OK,
commit: yes). **Knock-on:** the older `ci/digest/digest-proof-int.sh` uses the bare-file form and so is
now **stale on 0.13.x** (would hit the same error); it needs the same install-dir staging.

**Suggested fix (doyle agreed, batched into v0.13.x adapter-DX).** Add a `--dir <path>` / `--manifest
<file>` override to the `*-proof` family — mirroring `digest-proof`'s `--sample` pointing straight at a
file — so an author can proof a DEV binary against a DEV manifest without staging a full extracted
install. Tiny, and it un-stales `digest-proof-int.sh` too.

**Status:** **acked + tracked by doyle (2026-06-22), batched into the v0.13.x adapter-DX scope.**
Non-blocking — the disposable-install-dir staging is a clean workaround and the int is GREEN with it.

---

## F-019 — broker does not deliver `{key:*}` keystroke-commands to Claude Code: idle messages never submit (and the contract's `enter→\r` submit claim is false for CC)

**Surfaced:** 2026-06-23, live diagnosis of idle delivery against a real Claude Code v2.1.186 endpoint
(`call-a`, Opus 4.8). Operator report: idle-delivered messages are *typed into the input box but never
sent* — they accumulate as an unsent multi-line draft.

**Symptom (visually confirmed via screen capture of call-a's window).** On `spt send call-a <msg>`:
- the `{text:"<envelope>"}` command **works** — the full envelope appears in CC's input box;
- the `{key:"enter"}` submit produces a **literal newline** (cursor drops to a new line), **not a
  submit** — the draft is never sent;
- the `{key:"ctrl+s"}` stash **also has no effect** — a second delivery stacks a second envelope below
  the first in the same input box (the prior draft was never stashed/cleared). *(operator-confirmed:
  "ctrl+s isn't working either".)*

So **`{text}` is honored but `{key:*}` commands are not** — both keystroke commands the choreography
relies on (ctrl+s stash, enter submit) silently fail against current Claude Code.

**Why the v0.6.2 adapter "fix" was a no-op.** We first changed the binary from a trailing `\r` in
`{text}` to a discrete `{key:"enter"}` (v0.6.2), on the operator's hypothesis that a real Enter key
differs from a CR byte. But the **published contract is explicit** that they are identical:
`harness-contract/manifest.html` states *"enter → `\r`. Submit either way; just don't do both."* So
`{key:enter}` emits the same `0x0D` the trailing `\r` did → byte-identical → same failure. The deeper
problem (this finding) is that the keystroke-command *delivery path itself* doesn't reach CC as key
events at all.

**Two distinct contract problems for doyle (broker.rs / harness-contract):**
1. **`{key:*}` delivery is broken for Claude Code.** `{key:ctrl+s}` and `{key:enter}` do not take
   effect (stash no-op; enter→newline). `{text}` works. The most likely mechanism: the broker wraps
   injected text in **bracketed paste** (`ESC[200~ … ESC[201~`) — inside which a CR is a literal
   newline by design (so a multi-line paste never auto-submits) — and the following `{key:enter}`'s
   CR lands inside/adjacent to that paste, so CC treats it as newline too. A real submit Enter must
   arrive as a discrete keystroke **outside** any bracketed-paste wrapper. (Hypothesis pending doyle's
   confirmation of the broker's PTY-write internals — does it bracket `{text}`? does it bracket or
   coalesce `{key}`?)
2. **The contract's submit claim is false for CC.** `enter→\r, submit either way` does not hold for
   Claude Code v2.1.186 — a bare `\r` (from `{text}` or `{key:enter}`) inserts a newline, it does not
   submit. The published submit recipe is unbuildable for this harness until (1) is fixed.

**Adapter side.** No adapter fix can resolve this — every byte the binary emits flows through the same
broker PTY-write path, so if that path brackets the input (or doesn't deliver `{key}` as a discrete
keystroke), no choice of command shape submits. v0.6.2 stays as-is (it emits the contract-correct
`{text}{key:enter}{commit}` degenerate form, which becomes correct the moment the broker delivers
`{key:enter}` as a real Enter); the symptom resolution is **doyle's court**.

**doyle's first answer (2026-06-23) — bracketed-paste DISPROVEN; coalesce-delay theory.** doyle read
the inject path: NO bracketed-paste wrapping (broker.rs:1131-1138 — `{text}`/`{key}` both enqueue raw
to one per-session FIFO, zero `ESC[200~`; bracketing exists only on the `rc` Windows-clipboard path).
The `ctrl+s` (0x13) failure rules out a CR-vs-LF issue. His theory: `{key:*}` bytes reach the PTY but
**coalesce** with the preceding `{text}` into one fast burst; CC's ink/React-TUI parser literalizes
the burst (`\r`→newline, 0x13 swallowed). Proposed adapter fix: insert `{delay_ms}` between `{text}`
and each `{key}` (the broker `thread::sleep`s on it, broker.rs:1139, physically spacing PTY writes) —
try 120ms, climb 200/300; *"if a 300ms-spaced `\r` still won't submit, escalate — deeper dig."*

**Empirically tested live against call-a (real CC v2.1.186), screen-captured each — the delay theory
DOES NOT HOLD:**

| submit attempt | delay between text & submit | result |
| --- | --- | --- |
| `{key:enter}` (`\r`, 0x0D) | 50ms (v0.6.2) | newline in box, no submit |
| `{key:enter}` (`\r`) | 120ms | newline, no submit |
| `{key:enter}` (`\r`) | 300ms | newline, no submit |
| `{text:"\n"}` (LF, 0x0A) | 300ms | newline, no submit |
| `{text:"\x1b[13u"}` (kitty/CSI-u Enter) | 300ms | newline/no-op, no submit |
| `{key:ctrl+s}` (0x13, stash) | 50–300ms | never stashes (drafts keep stacking) |

So: **300ms behaves identically to 50ms** (the delay is not the variable), and **CR, LF, and CSI-u
Enter all insert a newline rather than submit** — while `ctrl+s` never stashes at any delay. Test rig:
hot-swap the install-dir binary (per-delivery spawn — no resident child, verified via process list, so
each `spt send` picks up the new binary), `spt send call-a <probe>`, screen-capture the input box +
grep the transcript for the probe (absent = not submitted). Every probe stacked as an unsent draft.

**This is the "deeper dig" doyle named.** The coalesce-delay hypothesis is disproven. Open questions
for doyle: (1) are the `{delay_ms}` actually spacing the PTY writes in real time, or is the sequence
batched (the module-doc/contract word is *"applied ATOMICALLY"*)? 300ms ≡ 50ms suggests the delay
isn't taking effect, OR timing is simply not the cause. (2) How does a REAL operator Enter submit
(it does) when no injected byte/sequence (CR/LF/CSI-u) does — i.e., what does the broker's PTY-write
path NOT reproduce about a genuine keypress (raw-mode termios? an enhanced-keyboard handshake CC
negotiated that the injection bypasses)? (3) Is there ANY injected sequence that submits CC v2.1.186?

**doyle's second answer (2026-06-23) — identical-path fact + the discriminator.** doyle: idle-inject
and `spt rc` operator-input go through the IDENTICAL single PTY-write seam (broker.rs:994, "the ONE
place that touches the PTY writer"); `{key:enter}` and a live operator's Enter both become a standalone
`write_input(b"\r")` — byte-identical. So `{delay_ms}` DOES insert real sleeps (not one batched write;
"applied atomically" = inject-FLOOR coordination, not a single syscall) — confirming timing was a red
herring. His discriminator: attach `spt rc call-a` and press Enter; since rc-Enter ≡ inject-`\r`, it
must behave identically. **Ran it — rc-Enter also did NOT submit** (transcript gained no turn),
corroborating: CC rejects a raw `\r` over the broker PTY from *every* spt path. So the operator's
working Enter is a NATIVE CC terminal, not the broker PTY.

**BREAKTHROUGH — CC negotiates win32-input-mode (root cause).** Captured a fresh `claude.exe`
v2.1.186 startup under a pywinpty ConPTY (call-a-independent). CC emits at startup:

```
ESC[1t   ESC[c   ESC[?1004h   ESC[?9001h
```

then stalls awaiting a DA reply (`ESC[c`) the bare ConPTY didn't send. **`ESC[?9001h` = DECSET 9001 =
win32-input-mode.** With it on, the terminal must deliver keystrokes as win32 key RECORDS
(`ESC[Vk;Sc;Uc;Kd;Cs;Rc_`), NOT a raw `\r`. That explains the whole matrix: CC parses for win32
records, so a bare `\r`/`\n`/CSI-u is treated as content (→ newline) and `ctrl+s` (0x13) is swallowed.
(`ESC[?1004h` = focus reporting, also enabled.) Predicted Enter = down+up records:
`ESC[13;28;13;1;0;1_` then `ESC[13;28;13;0;0;1_` (Vk=13 VK_RETURN, Sc=28, Uc=13, Kd=1/0). A live test
of that record against call-a was INCONCLUSIVE — call-a got repurposed to other work mid-test (window
changed, draft box cleared) so injection was stopped to avoid disruption. (Audit: NO DIAG probe ever
submitted anywhere — `grep -rl DIAG-PROBE` over all transcripts is empty — so the probing never
polluted call-a's real work.)

**Status:** **OPEN — root cause identified, owned by doyle (spt-core broker).** The published
"enter→`\r` submits" holds ONLY in legacy mode; CC v2.1.186 enables win32-input-mode on Windows
ConPTY. The broker (which hosts CC via ConPTY on Windows, so call-a almost certainly also enables
`?9001h`) must (a) answer CC's terminal queries so CC completes its keyboard handshake, and (b) detect
win32-input-mode (and/or kitty, if negotiated post-DA) and ENCODE `{key:enter}` / `{key:ctrl+s}` as the
negotiated key records instead of raw `\r` / `0x13`. Handed to doyle to repro under the broker, capture
the FULL post-DA handshake, and confirm the exact encoding; perri to verify on a fresh endpoint.
`claude-spt` stays at published v0.6.2 (contract-correct `{text}{key:enter}{commit}` — correct the
moment the broker encodes keys for the negotiated mode). No adapter-side byte choice can fix this.

---

### F-019 RESOLVED — actual root cause: the translation binary NEVER SPAWNED (relative path not resolved against install_dir)

**Every theory above (win32-input-mode, coalescing, bracketed-paste, focus/attach-state) was a RED
HERRING.** The decisive find (doyle, source-traced, 2026-06-23): the manifest declares

```toml
[message-idle-translation-binary]
path = "cc-spt-idle-translate"      # RELATIVE — bare name
```

and spt-core spawns it as `Command::new(path)` **without joining it against the adapter install_dir**
(harnesshost.rs:142-145 passes the path verbatim into the SpawnReq; broker.rs:1677 `Command::new`).
A relative path therefore resolves against the **daemon's cwd / PATH** — NOT the
`…/adapters/_github/SaberMage-spt-claude-code/` dir where the binary actually lives. `cc-spt-idle-translate`
is on neither, so **the spawn fails**, and `build_translation` **fails closed to RAW INJECT**
(broker.rs:1671-1677) — which types the message + a raw `\r` as one burst → the exact "envelope stacks
in the box, unsent" symptom. **The choreography binary never executed.** That is why every per-binary
probe behaved identically (they were all raw-inject) and why no byte/shape/timing changed anything.

**Proof (perri, on-node):**
- Edited the *installed* manifest path to the ABSOLUTE exe (`C:/…/_github/…/cc-spt-idle-translate.exe`),
  brought up a fresh endpoint → a deliberately-distinct "minimal" binary (emits a fixed token, ignoring
  the envelope) **ran and SUBMITTED** (token appeared as a CC user turn; CC began responding). Before the
  edit, fresh endpoints ignored every binary variant (raw-inject).
- Restored the real **shipped v0.6.2** binary, fresh endpoint, normal message → **SUBMITTED** (full
  choreography: `ctrl+s` · `{text:envelope}` · `{key:enter}` · `{commit}`). A resident
  `cc-spt-idle-translate.exe` child now spawns per endpoint (it was 0 = failed-spawn before).

**So the adapter choreography was CORRECT ALL ALONG** — `{text}{key:enter}{commit}` submits CC fine once
the binary actually runs. (Corollary: the v0.6.1→v0.6.2 `\r`→`{key:enter}` change was moot — neither ran.)

**THE FIX (spt-core, F-019 → v0.14.2, doyle owns):**
1. **Resolve a relative `[message-idle-translation-binary].path` against the adapter install_dir** at
   spawn (same as bare-name `digest`/`psyche` command resolution, REQ-INSTALL-11). Then the shipped
   `path = "cc-spt-idle-translate"` works as authored, host-agnostic.
2. **Log loudly on translation-binary spawn failure** instead of silently falling back to raw-inject —
   the silent fail-closed is what turned this into a multi-hour black-box hunt. One line
   (`translation binary <path> failed to spawn: <err>; falling back to raw inject`) would have named it
   instantly.
3. Contract note: document that the path resolves against install_dir.

**INTERIM (this node only):** the installed manifest path is left set to the absolute exe so the operator
has working idle delivery now — a machine-specific hack that reverts on the next `spt adapter update`.
There is **no portable adapter-side fix** (the bare name needs spt-core's install_dir resolution); the
published manifest stays relative pending the spt-core fix.

**Status:** **ROOT-CAUSED + FIX DESIGNED + DISPATCHED — spt-core v0.14.2 (doyle/todlando).** Adapter
requires no logic change. doyle's design (`F-019-TRANSLATION-BINARY-INSTALL-DIR-RESOLVE-DESIGN.md`,
spt-core @1941eb8) covers all four points: (1) route the translation binary through the existing
`resolve_program_in_dir` (REQ-INSTALL-11) so the shipped bare `path = "cc-spt-idle-translate"` resolves
to `<install_dir>/cc-spt-idle-translate.exe`; (2) `build_translation` logs `TRANSLATION_SPAWN_FAILED:<path>:<err>`
loudly instead of silent raw-inject fallback (+ KNOWN-HAZARDS note); (3) contract doc note that the path
resolves against install_dir; (4) sibling-surface audit that every adapter-shipped program
(session/psyche/digest/runners/hooks) resolves against install_dir. todlando building for v0.14.2; perri
to verify on real CC-on-Windows when it lands (revert the abs-path interim, confirm the bare name resolves
+ submits on a clean install, and that the loud log fires on a bad path). Interim abs-path workaround stays
on this node until then.

---

## F-023 — WAN-arrived messages spool but never trigger idle injection (translation binary starved; delivery waits for the next hook drain)

**Surfaced:** 2026-07-01, live diagnosis of cross-node delivery between `hall-a` (HFENDULEAM,
Windows 11, spt-hosted via `claude-spt launch --cli ccs`, spt-core **0.19.1**, adapter **0.10.3**)
and `ball-b` (ENLYZEAM). Operator report: cross-node messages to hall-a "finally came in with
either UserPromptSubmit and/or PreToolUse — translation binary did not receive and/or fire."
Same-machine sends inject immediately (operator-confirmed) — only WAN-arrived messages stall.

**Evidence (all timestamps UTC; correlated across spool.db, owlery markers, process table, and the
CC session transcript `b073fa34…`):**

| time | source | event |
| --- | --- | --- |
| 06:02:14 | process table | daemon spawns `claude-spt.exe translate` (pid 55772) — **alive throughout, never restarted** |
| 06:02:44 | CC transcript | turn 1 ends → hall-a **IDLE** |
| 06:05:24 | spool.db + wan_seen | msg 1 from ball-b lands (`wan_seen.at_ms` == `created_at` — transport was instant); `.has-messages` touched. Session has been idle 2m40s. **No injection.** |
| 06:09:44 | spool.db + wan_seen | msg 2 lands. Session idle 7 minutes. **No injection.** |
| 06:10:02 | CC transcript | operator types a prompt → UserPromptSubmit hook drains BOTH messages (`delivered=1`) — the adapter hook path working exactly as contracted |

**Adapter exoneration.** The `[message-idle-translation-binary]` is a pure stdin→stdout reactor: it
emits keystroke-commands ONLY when spt-core writes an `{"type":"event"}` line to it. The binary was
alive and healthy the whole window (same binary, same session delivers same-machine sends fine), the
endpoint was verifiably idle, the messages were verifiably in the spool — and nothing appeared in the
PTY (no envelope text, no partial choreography, no F-019-style unsent draft). Either spt-core never fed
the event to the translate pipe, or the broker never applied its output; both sides of that seam are
spt-core-internal. There is no adapter surface that can influence WAN-arrival→pump wiring.

**Shape of the bug (hypotheses for doyle, ranked):**
1. The local-send path kicks the idle-delivery pump directly, and the WAN-receive path (net op →
   spool insert) **never kicks it** — messages sleep until an adapter hook happens to poll.
2. No flush-on-idle-transition: even at the next Stop→idle edge (06:12:59 here, undelivered messages
   had already been hook-drained by then, so this window couldn't disprove it), pending spooled
   messages are not offered to the pump.
3. Pump fed but broker apply dropped it silently (F-019 family) — LESS likely: nothing at all appeared
   in the PTY, where F-019 left visible unsent text.

**Minor sibling note:** msg 1 arrived with **`from_id = ""`** (a bare CLI `spt send` from a
non-perch shell on ENLYZEAM; wan op `…:cli:6`) — the envelope renders `from=""`. If CLI non-perch
sends are legal, they should probably stamp something (`cli@<node>`?); if not, refuse loudly.

**Status:** **CONFIRMED + RCA'd (doyle 2026-07-01, token `TRIAGE-F023-F024-CLOSED`) — build
dispatched to todlando (`BUILD-F023-WANIDLE`).** Both ranked hypotheses real: `receive_wan` has
deliver_tcp + spool ONLY — the spt-hosted broker-inject leg exists solely in local `cmd_send` — and
NO idle-edge drain exists anywhere. Fix = a shared inject leg called from WAN ingress + an
ACTIVE→IDLE spool drain. Adapter exoneration stands verbatim; adapter requires no change (hooks
drained correctly the moment they ran; translate binary contract-correct and starved). The
`from_id=""` sibling is ruled: compose stamps `cli@<node-label>`, ingress renders empty-from as the
origin node label, no refusal.

**CLOSED END-TO-END — spt-core v0.20.0 (doyle release ping 2026-07-02).** WAN arrivals + the
idle-edge drain are now daemon-driven (both twohost CI legs green core-side); the hook-poll path
stays valid but is no longer the only delivery trigger. The `from_id=""` sibling shipped too:
from-less CLI sends now arrive stamped `cli@<node-label>`, never blank. New loud field diagnostics
in this area: `SPOOL_SENTINEL_CREATE_FAIL` (names the io::Error kind — will answer the ENLYZEAM
`.has-messages` sentinel divergence on its next WAN arrival) and `IDLE_DRAIN` lines. Adapter side
unchanged, as ruled.

---

## F-024 — cross-node delivery HFENDULEAM→ENLYZEAM never lands (reverse direction instant); outstanding net-send effects + silent recv-pull loop

**Surfaced:** 2026-07-01, same diagnosis session as F-023. Operator report: "for ball-b, messages
sent from hall-a are not landing no matter what i do." Both nodes on spt-core 0.19.1.

**Evidence (this node only — ENLYZEAM internals not inspectable from here):**
- ENLYZEAM→HFENDULEAM is **instant** (F-023 table: `wan_seen.at_ms` == `created_at` for both inbound
  messages). The failure is **asymmetric** — HFENDULEAM→ENLYZEAM only.
- Probe `spt send ball-b` (marker `DIAG-7c1e`, 06:37Z) returns
  `SENT(WAN):ball-b@ecb39e40… (spooled)` — accepted, spooled, no error surfaced. Two `net-send`
  effects followed and completed `DONE` — but DONE evidently ≠ delivered-to-peer (operator reports
  nothing landing across many attempts).
- **Delivery failure is upstream of the destination spool:** if the op reached ball-b's spool, the
  F-023 hook-drain path would deliver it on ball-b's next prompt — operator says nothing lands no
  matter what. So the op never reaches the remote spool (transport/replication), or is refused there.
- `daemon-effects.log` ledger: **32 `net-send` effects PENDING with no DONE** across daemon history
  (e.g. effect 3865 at ~06:29Z, minutes before this diagnosis; a periodic pattern of paired
  outstanding net-sends recurs across the log).
- `scratch/pump/recv-pull-*.bundle` cadence was ~5–6 min historically; the **last bundle is 03:57Z**
  — nothing since the daemon restart at 05:36Z (~2h silent at time of diagnosis, while inbound
  pushes from ENLYZEAM still worked at 06:05/06:09).
- Daemon connectivity: TCP to three relay hosts on :443 (Hetzner) + UDP + mDNS; **no direct peer
  connection** visible. SPT_DEV = 4 nodes / 2 endpoint-bearing (2 nodes likely offline — plausibly
  the periodic outstanding net-send pairs).

**For doyle to check (needs ENLYZEAM/spt-core internals):**
1. Does the ball-b node ever RECEIVE the op (relay path up but refused/dropped?), or does the send
   from this node never traverse (hole-punch/relay session half-dead after daemon restart)?
2. `endpoint access` semantics: could an access restriction on ball-b (stored ENLYZEAM-side) refuse
   these as "unsolicited off-node inbound" **silently**? (This node's restriction table is empty;
   sender-side gets `SENT(WAN)` either way — if a refusal exists, it is invisible to the sender.)
3. Why did the recv-pull loop go silent after daemon restart, and are the 32 outstanding net-sends
   expected bookkeeping for offline peers or leaked effects?

**Not adapter:** the adapter has no send-side role (send = `spt` CLI → daemon → WAN) and no
receive-side role upstream of the destination spool.

**Status:** **CLOSED-REFRAMED (doyle 2026-07-01) — transport EXONERATED by the probe itself.**
The `(spooled)` token in `SENT(WAN)…(spooled)` is a RECEIVER-CONFIRMED reply token — it only prints
on a confirmed round-trip. So `DIAG-7c1e` DID traverse to ENLYZEAM post-restart and sits in ball-b's
spool; the operator symptom is **F-023 starving on the remote node** (and `spooled`-not-`delivered`
says ball-b had no live TCP listener at receive time). Immediate operator workaround: type any prompt
into ball-b — its UserPromptSubmit hook drains the stuck messages. The residual observations stay
REAL as separate diagnostics in doyle's wave: the silent recv-pull loop (context-sync, not
messaging) and the PENDING-no-DONE `net-send` ledger semantics.

**REOPENED SAME DAY → AUTH-WEDGE ROOT-CAUSED (doyle receipt `F024-AUTHWEDGE-CONFIRMED`; perri
ssh-verified on ENLYZEAM).** The operator's workaround prompts did NOT drain ball-b. Live dig on the
ENLYZEAM node (ssh) + doyle's code read converged on the full causal chain:

1. **First domino (claude-spt court, ALREADY-FIXED bug, stale copy):** ENLYZEAM's plugin cache holds
   BOTH sptc 0.1.8 and 0.1.9. Session `22ce8af0…` (ball-b's registered session, booted 2026-06-29)
   ran the **0.1.8** dispatch — `printf 'SPTC_HOOK_BIN=%s\n'` UNQUOTED, no `${bin% hook}` strip (the
   v0.9.0 regression, fixed in 0.1.9) — despite installed_plugins.json showing 0.1.9 installed 24 min
   before that boot (CC-side plugin-resolution lag/pin: residual question). The corrupted per-session
   `CLAUDE_ENV_FILE` (`hook: command not found` on every Bash, cache lost) survived every resume of
   that session because the env file lives with the session.
2. **Wedge latch (spt-core defect class, new):** during the corrupted era the session rotated
   (05:52Z, then /clear → `42bb5aae…` 06:06:30Z) and the rotation `api boundary` died — perch stays
   pinned to dead sid `22ce8af0`. `api poll`/`state`/`boundary` are ALL auth-gated on proof-sid vs
   info.json sid (api/mod.rs `gated()` → auth.rs; mismatch → `AUTH_REFUSED`, stderr-only = silent in
   hooks). So every id-scoped call from the healthy new session refuses — **including any later
   boundary attempt**: one missed rotation = permanent strand, NO self-heal. Explains `ready:false`,
   stale `.idle`, dead drain, deaf endpoint. doyle adding wedge-recovery to todlando's wave.
3. **F-023 on top:** even a healthy perch would not have idle-injected the WAN arrivals (see F-023).

Verified remote facts: stuck spool rows delivered=0 on ball-b incl. probes `DIAG-7c1e` (perri) and
`DIAG-DOYLE-e024` (doyle); rc-typed prompts DID land as turns in the new transcripts (rc input path
exonerated); two-dirs/SPT_HOME split RULED OUT (exactly one spt-core root on the node); `.has-messages`
sentinel touch (spool.rs:238 `let _ = File::create` — error swallowed) genuinely failing-or-skipped on
ball-b's WAN inserts since Jun 29 while HFENDULEAM's fires — divergent same-version behavior, todlando
adding a loud-once diagnostic (NOTE: ball-b's sentinel mtime now 2026-07-02T07:07:39Z is perri's
truncate-test artifact). Traversal latency residue: HFENDULEAM→ENLYZEAM took ~3m23s vs instant reverse.

**Operator recovery (doyle-ruled, relayed):** do NOT `/clear` ball-b again (boundary still refuses).
Preferred **(a)** full relaunch: exit the CC session completely, bring ball-b up fresh — the perch
conflict gate allows rebind when the recorded owner pid is dead (it is) → re-pins session id → hooks
work → first prompt drains the stuck spool. Alternative **(b)** token re-pin without relaunch
(`spt api boundary … --token`, api.token exists on the perch). Also advisable: purge the stale 0.1.8
cache copy from the node's plugin tree (latent re-brick hazard while any pre-0.10.2 skew remains).

**CORRECTION (2026-07-02, perri) — adapter exoneration PARTIALLY RETRACTED: the wedge LATCH is
adapter-court.** Operator clean-room re-test: fresh ball-b relaunch, clean env, zero hook errors,
`/clear` → wedged again; same relaunch WITHOUT `/clear` → UPS drained all 27 stuck rows. The
`/clear` boundary rotation is broken outright — no env-corruption domino needed. Empirically pinned
to TWO stacked adapter defects:

- **(a) Silent skip:** the SessionStart boundary branch resolves the perch id via `spt whoami
  --json` keyed by `OWL_SESSION_ID`-else-NEW-sid; whoami resolution is sid-keyed and the new sid is
  not registered yet (catch-22) → `self:null` → `self_id()==""` → the boundary call is **silently
  skipped** (`if !id.is_empty()` guard, zero diagnostics). The SessionStart *brief* path prefers
  `$SPT_ENDPOINT_ID` and works, so the session looks healthy while the perch strands. Verified on
  ENLYZEAM: whoami keyed by the live sid resolves `ball-b`; keyed by the dead `/clear` sid →
  `self:null`.
- **(b) Missing auth proof:** the adapter's call (`api … boundary <src> <id> --to-session-id <new>`)
  carries neither `--session-id` nor `--token`; the verb refuses:
  `AUTH_REFUSED:ball-b (need --token or matching --session-id)` (shell-verified, exit 1). Other hook
  verbs pass `--session-id <current>` and authenticate; boundary is the one verb where the payload
  sid is the NEW one and the OLD sid is gone with the per-session env file.

Fix-design ruling requested from doyle (persist-prior-sid adapter-side + `--session-id` proof vs a
core-side by-pid/token surface). REQ + hazard + regression int to be minted for the `/clear` rotation
path when the ruling lands. **Interim operator guidance: avoid `/clear` on spt-hosted endpoints; a
wedged endpoint recovers by relaunching WITHOUT `/clear`** (resume re-binds the recorded sid —
live-proven). spt-core keeps two threads regardless: the no-self-heal AUTH_REFUSED strand class and
the swallowed `.has-messages` create-fail.

**ADAPTER FIX LIVE-VALIDATED — E2E-0104 GREEN (doyle verdict, 2026-07-02).** The v0.10.4 fix
(REQ-DIST-BOUNDARY-ROTATE: `$SPT_ENDPOINT_ID`-first id + adapter-owned `.sid` state file + prior-sid
`--session-id` proof + loud failure) passed the on-node E2E on HFENDULEAM (adapter 0.10.4,
spt-core 0.19.1, endpoint `hall-a`), all three assertions:

<!-- [e2e->REQ-DIST-BOUNDARY-ROTATE] E2E-0104: doyle-verdict live integration evidence for the
     /clear boundary rotation — an operator-run on-node proof, NOT scriptable in CI (it needs a real
     spt-hosted CC session issuing /clear), which is why it carries `e2e` rather than `int`. -->
1. **ROTATION:** `/clear` on the live spt-hosted `hall-a` → owlery `info.json.session_id` rotated
   `a19521de-…` → `00539523-…`, status `online`; the boundary call authenticated with the PRIOR
   sid — the F-024 wedge class is dead on this path.
2. **`.SID` ROLLFORWARD:** `state/session/hall-a.sid` rolled to the new sid in lockstep
   (== `info.json` post-rotation); also verified at SessionStart pre-`/clear` (file existed and
   matched the fresh session — the bind path seeds, the boundary path rolls forward).
3. **POST-ROTATION DELIVERY:** `spt send hall-a` → SENT with the true-injection token (local idle
   broker-inject path) — the id-scoped auth chain is fully live after rotation (the v0.10.1
   lesson honored: rotation + delivery validated, not just the brief).

ENLYZEAM/`ball-b`: same steps relayed to the operator; the same assertions apply when run there.
Cross-node WAN delivery still waits on BUILD-F023-WANIDLE (arrivals drain on hooks until then —
see F-023). **F-024 adapter court: CLOSED (fix shipped v0.10.4, live-validated E2E-0104).**
spt-core residual threads (no-self-heal strand class Track-2, swallowed `.has-messages` create)
stay open doyle-side.

**E2E-0104 CROSS-NODE CONFIRMED on ENLYZEAM — LEG CLOSED (2026-07-02, perri; adapter 0.11.0,
spt-core 0.21.0, broker image 0.21.0==installed — F-025 precondition-zero MET). doyle verdict:
CONFIRMED-CROSS-NODE-F015B+F024 — the F015B/F-024 arc is now closed on BOTH nodes** (assertions
1+2 full green + auth-chain-live post-rotation is exactly what this leg gated; assertion-3
physical-inject chase DECLINED by doyle — the dormant-hold is F-023 idle-drain behaving as
shipped, orthogonal to rotation auth, covered by its own e2e suite). doyle GO after the operator
updated+bounced ENLYZEAM. The originally-wedged `ball-b` could not serve as the probe (see
env-blockers below); the boundary-rotation assertions were confirmed on a fresh spt-hosted endpoint
`e2e-enlz` (same 0.11.0 binary, same REQ-DIST-BOUNDARY-ROTATE code path):
1. **ROTATION authed w/ PRIOR sid:** sessions.log ordinals show `boot`(95d838e2) → `clear`(e6258f39)
   → `clear`(414b53f3) — TWO clean `/clear`-triggered SessionStart `boundary` re-registrations, each
   succeeded with the perch staying `online`. A live F-024 wedge would have AUTH_REFUSED the boundary
   and stranded the perch (no rotation); instead the rotation landed twice. Zero strict/AUTH_REFUSED
   artifacts in adapter state. The wedge class is dead on the second node too.
2. **`.sid` SEED + lockstep ROLLFORWARD:** `state/session/e2e-enlz.sid` seeded == `session_id` at bind
   (95d838e2), then rolled forward to `414b53f3` == `info.json.session_id` post-rotation.
3. **POST-ROTATION DELIVERY:** sends to the rotated endpoint accepted (`QUEUED`, never AUTH_REFUSED /
   never undelivered — the id-scoped auth chain is live after rotation); injection lands on the
   idle-wake window (endpoint `dormant`, so held rather than immediate true-inject — the F-023 idle
   drain governs the inject, not the auth chain).

**ENLYZEAM environmental blockers (NOT adapter/core defects — field notes for the run record):**
- **`ccs` binary absent** on ENLYZEAM → the shipped `claude-spt:ccs` composite spawn fails silently
  (CC never binds). Worked around with a node-local `spt adapter create-profile claude-spt probe`
  overlay (plain-`claude` spawn, composite shape preserved) for the F015B-class swap verify.
- **CC folder-trust / `settings.local.json` consent gate blocks headless bind.** `C:\Users\decid` was
  never trust-accepted in CC config on ENLYZEAM, and `~/.claude/settings.local.json` pre-approves a
  permission → CC shows a combined "trust this folder? / this folder pre-approves 1 tool permission"
  dialog on every fresh spawn. `--dangerously-skip-permissions` does NOT suppress it (separate gate) →
  SessionStart never runs → no `api bind` → endpoint stuck `offline`/`unbound`. Cleared per-spawn by
  accepting the dialog fast via `spt rc <id>` (interactive), which persists nothing reliably, so each
  fresh headless bringup on this node re-hits it. HFENDULEAM avoids this only because its folders were
  interactively trust-accepted long ago. Worth surfacing to doyle as a headless-bringup friction (a
  sanctioned "pre-trust" seam, or `--dangerously-skip-permissions` covering the workspace-trust +
  settings.local pre-approval consent, would make spt-hosted bringup truly non-interactive on a virgin
  node).
- **`ball-b` `rest_state:dormant`** — a dormant prior-live_agent endpoint does not expose an
  attachable PTY to `spt rc` after `endpoint run --start`, so the wedged endpoint itself could not be
  re-driven without destroying its psyche state; the fresh `e2e-enlz` sidesteps it. The adapter logic
  under test is endpoint-id-agnostic, so the cross-node confirmation holds.
- **External checkpoint communes do NOT fire the clear+wake macro** (by design): `spt send <id>
  --json-payload '{"checkpoint":"v1",…}' --prefer-native` spooled to the target's active-poll channel
  and was delivered as a PLAIN message (the live CC session read+replied to the `<EVENT … json=…>`
  envelope as text). The clear+wake macro (`translate.rs::checkpoint_wake`) only fires for a SELF-sent
  checkpoint that loops back through the endpoint's OWN translate inject path (the PostToolUse commune
  hook) — not an externally-injected one. The E2E rotation was therefore driven by a direct `/clear`
  typed over `spt rc`, matching how the HFENDULEAM assertion-1 was run.

**spt-core residuals SHIPPED in v0.20.0 (doyle release ping 2026-07-02):** `SESSION_REPIN`
dead-owner self-heal is live — the wedge class can no longer PERMANENTLY strand (live-owner
rotation still requires the adapter's v0.10.4 prior-sid credential, unchanged — the two halves
compose). The swallowed `.has-messages` create now logs `SPOOL_SENTINEL_CREATE_FAIL` with the
io::Error kind, so the ENLYZEAM sentinel divergence gets its answer on that node's next WAN
arrival. F-023's WAN idle-drain shipped in the same cut (see F-023 CLOSED). All F-023/F-024
threads are now either closed or reduced to awaiting field confirmation.

---

## F-025 — `spt update apply` leaves a RUNNING daemon on the old version silently (every daemon-side new-version behavior stays dormant; coordinated applies run old code)

**Surfaced:** 2026-07-02 (perri, HFENDULEAM), during the F015B verify-post-fix run on the
spt-core v0.20.0 release ping.

**Sequence observed:**
1. `spt update fetch` → `UPDATE_STAGED:40` → `spt update apply` → **"Updated spt-core to
   v0.20.0."** Disk binary + CLI now 0.20.0 (`spt --version` confirms; bin mtime 04:27Z).
2. The per-machine daemon (pid 45852, started **before** the apply, 22:36 the prior evening)
   **kept running the old 0.19.1 image** — no restart, no staged-restart notice, no
   pending-restart flag anywhere in the apply output.
3. Consequence chain (all verified on-node): a mid-live `spt adapter update claude-spt`
   (0.10.4 → 0.11.0, one live `:ccs`-profile endpoint up — the F015B verify scenario) printed
   `ADAPTER_UPDATE_LIVE:… daemon-coordinated apply` + `ADAPTER_UPDATE_DONE (re-registered)`, but
   the **swap never landed**: registry version, install-dir `manifest.toml`, and the binary (old
   `launch` rejects `--node` — clean discriminator) all still 0.10.4. The "daemon-coordinated
   apply" was coordinated by a daemon still running the **pre-F015B** code — the exact
   matcher-skew silent no-op the fix kills, faithfully reproduced by the stale daemon. The
   daemon-effects ledger shows **zero** adapter-apply entries.
4. A second `spt update apply` fails `UPDATE_APPLY_FAIL:step current binary aside: Access is
   denied. (os error 5)` (nothing staged anymore + the running daemon holds the old image).

**Why it matters:** everything v0.20.0 ships daemon-side is silently dormant until a manual
daemon bounce — the F015B honest-apply path, the F-023 WAN idle-edge drain, `SESSION_REPIN`,
the `{node}` spawn fill, the new loud diagnostics. The node LOOKS updated (CLI reports 0.20.0)
while behaving 0.19.1. This is the same "success-without-effect" genus as F015B itself, one
layer up. The only lever is `spt daemon stop`/`start`, which tears down broker-held PTYs — on
this node a live operator endpoint (hall-a) — so an agent cannot safely bounce it unilaterally.

**Asks (doyle):** (a) should `update apply` restart (or offer to restart) a running daemon, or
at minimum print a loud `DAEMON_RESTART_REQUIRED` with the live-endpoint implications? (b) what
is the sanctioned bounce sequence with live endpoints — quiesce order, and does
`endpoint run --resume` restore a broker-PTY endpoint cleanly after the bounce? (c) is the
second-apply `Access is denied` expected in the already-applied state (cosmetic) or a real
staging bug?

**Adapter-side note:** the v0.11.0 shim's unsubstituted-`{node}` guard already earned its keep
in this exact skew (a 0.19.1 daemon spawning from a 0.11.0 manifest passes the literal `{node}`;
the shim degrades to hostname detection instead of naming the session `x @ {node}`).

**Status:** **TRIAGED (doyle ruling 2026-07-02, code-grounded: applyhost.rs + ADR-0018 D3-3) —
part design, part REAL core gap; next-wave item minted for todlando.**

- **(a) Ground truth:** `spt update apply` is DESIGNED to restart only the **brain** (the
  restartable half); the **broker** deliberately SURVIVES the apply because it anchors every
  hosted endpoint PTY across the cycle (applyhost.rs:188-190; whole-daemon self-update is
  ADR-0004's explicit unbuilt polish). Post-apply, brain-side surfaces run new code while
  BROKER-side surfaces run the old image until a full daemon bounce — and the F015B fix lives in
  broker.rs (`dispatch_adapter_apply`), so this verify exercised the old broker verbatim. The
  GAP (real, kept): nothing surfaces "broker image = 0.19.1 while disk = 0.20.0" and nothing
  says a bounce is needed — silent dormancy across the F015B/inject legs is an
  honest-version-of-truth failure. Wave candidates: (1) `spt version`/doctor report the RUNNING
  broker image version beside the disk version, (2) apply prints a loud
  `BROKER_RESTART_REQUIRED` naming the dormant surfaces, (3) the full broker-restart
  choreography (ADR-0004, needs design). Docs fix lands regardless.
- **(b) Bounce ruling:** operator-gated (hall-a is the operator's and doyle's own perch rides
  the same daemon); doyle raising with the operator directly. Sanctioned sequence once
  green-lit: `spt endpoint stop <each live endpoint>` (reaps psyches correctly) →
  `spt daemon stop` → `spt daemon start` (new image, broker included) → relaunch endpoints
  (`endpoint run … --start`; live agents restore context via the live-resume flow — the
  counter-39 E2E-0104 sequence; `--resume` restores the CC conversation, fresh `--start` is the
  cleaner verify baseline for the probe).
- **(c) Second-apply `os error 5`:** NOT expected; the root fix is that apply should not reach
  the rename at all in the already-applied state — fetch got the friendly already-staged/applied
  classifier in v0.18.0, apply lacks it. Folded into the same F-025 wave item.

F015B verify re-runs after the operator-approved bounce (fresh `:ccs` probe; the published
0.10.4→0.11.0 delta is still pending locally = exactly the swap the re-run asserts).

**RE-VERIFY GREEN (2026-07-02, post-bounce).** Operator green-lit; doyle executed the sanctioned
sequence (hall-a endpoint-stopped → daemon stop → daemon start; new daemon pids postdate the
apply, disk 0.20.0 — checklist items 1+2). Item 3, the behavioral proof, ran clean: fresh
`:ccs` probe online (`live_agent`, ccs config root captured) → mid-live
`spt adapter update claude-spt` → **(a) REAL SWAP:** registry AND install-dir manifest 0.11.0
(floor 0.20.0), binary discriminator flipped (`launch --node` now parses — the 0.11.0 binary);
**(b) HONEST REPORT:** the DONE line finally tells the truth (`ADAPTER_UPDATE_LIVE:
daemon-coordinated apply` → swap actually landed); **(c) ENDPOINT SURVIVED:** probe still
online/live_agent seconds after the swap. The F015B parent-aware matcher + unconditional swap
HOLD on the real path — the composite-endpoint silent no-op is dead. The remaining F-025 wave
item (broker-image version surface / BROKER_RESTART_REQUIRED / apply classifier) is unchanged
by this green — it is what made attempt #1 invalid in the first place.

**WAVE CONSUMABLES SHIPPED (spt-core v0.21.0, 2026-07-02, counter 41 — doyle RELEASE GO ping;
gate-verified update-set==41, no stale window).** All three kept-gap halves landed core-side
(todlando wave), mapping directly onto the (a)/(c) rulings above:

1. **Broker-image surface (ruling a, candidate 1):** `spt daemon status` now prints the
   RUNNING broker image version beside the installed version and flags a mismatch. The broker
   **self-reports over IPC** — never disk-inferred. JSON adds `broker_image` + `broker_stale`
   (tri-state: `null` = daemon down OR query failed; a definite bool only after a successful
   query). This is the exact "broker = 0.19.1 while disk = 0.20.0" invisibility that
   invalidated verify attempt #1, now first-class.
2. **Loud restart notice (ruling a, candidate 2):** `spt update apply` prints a LOUD
   restart-required notice on success — daemon-coordinated features keep running the previous
   version until `spt daemon stop`/`start`.
3. **Already-applied classifier (ruling c):** a second `spt update apply` when already up to
   date is a clean exit-0 "already up to date" — the os-error-5 rename path is dead.

(Same release, non-F-025: `spt endpoint list` is node-grouped with `--show-all`; `--json`
gains additive `endpoint_type`, DTO otherwise frozen.)

Doyle's mandated verify protocol from here: before ANY live-apply verify counts, the FIRST
precondition check is `spt daemon status` on the target node — broker image MUST match
installed. **Status: consumables SHIPPED core-side; on-node confirmation rides this node's
next update to 0.21.0 (not yet taken — this ping was floor + awareness only). min_spt_core
floor for any adapter release cut against v0.21.0 features: 0.21.0.**

## F-026 — headless spt-hosted bringup on a virgin node is blocked by Claude Code's workspace-trust + `settings.local.json` consent gate (`--dangerously-skip-permissions` does not cover it)

> **Namespace note (doyle 2026-07-02):** this **perri finding F-026 (trust-gate)** is distinct from
> spt-core **wave `F026` / `BUILD-F026-PICKERTRUTH`** — separate registries, same number by
> coincidence. Cite the qualified form to keep cross-references unambiguous.

**Surfaced:** 2026-07-02 (perri, ENLYZEAM), during the E2E-0104 cross-node run. **doyle ruling:
formalize as a finding; a sanctioned pre-trust seam is the ask** — enters the next planning pass;
likely fixed adapter-docs-side + a core diagnostic rather than core code (partly CC-harness-side).

**What happens:** `spt endpoint run --adapter claude-spt --id <id> --start` on a node where the
session cwd (`C:\Users\decid`) has never been trust-accepted in Claude Code AND carries a
`~/.claude/settings.local.json` that pre-approves any permission → the spawned CC renders a
combined blocking dialog on **every** fresh spawn:

```
 Quick safety check: Is this a project you created or one you trust? …
 ‼ This folder pre-approves 1 tool permission in .claude/settings.local.json:
   PowerShell(Expand-Archive -Path "D:\…")
 > 1. Yes, I trust this folder
   2. No, exit
```

`--dangerously-skip-permissions` (which the `[session.self]` shim passes) does **NOT** suppress
this — the workspace-trust + settings.local pre-approval consent is a **separate gate** from the
per-tool permission prompts that flag covers. Consequence chain: dialog blocks → SessionStart hook
never runs → no `api bind $SPT_ENDPOINT_ID` → the endpoint is stuck `offline`/`unbound`
indefinitely (the broker holds a live CC PTY sitting at the dialog, but nothing binds it). The
endpoint LOOKS spawned (`ENDPOINT_RUN_STARTED` printed, CC process alive) while never becoming
addressable — a silent-hang, not a loud failure.

**Why it matters:** spt-hosted bringup is advertised as non-interactive (the `cc-<id>` shortcut, the
broker-PTY headless path). On a virgin node it is not — it requires a human (or a scripted rc-accept)
to clear CC's trust dialog first. HFENDULEAM only works headlessly because its folders were
interactively trust-accepted long ago; that history is invisible and non-portable, so the first
spt-hosted bringup on any fresh node silently hangs.

**Per-spawn workaround (used for the E2E run):** attach fast after launch and accept the dialog —
`(sleep 4; printf '\r'; sleep 4; printf '\x02d') | ssh -tt <node> "spt rc <id>"` (send Enter to
select "1. Yes, I trust", then `ctrl-b d` to detach leaving CC bound). This does NOT persist
reliably (a subsequent fresh endpoint in the same cwd re-hits the dialog), so it is a per-bringup
manual step, not a one-time fix.

**Asks (doyle / next planning pass):** a sanctioned **pre-trust seam** so spt-hosted bringup is
truly headless on a virgin node — candidates: (a) adapter-docs: document that operators must
trust-accept the cwd once (or ship a `settings.json` that pre-trusts), (b) a core/adapter diagnostic
that detects the unbound-at-trust-dialog state and reports it LOUD instead of silent-hang (ties to
F-027's fail-loud principle), (c) upstream: `--dangerously-skip-permissions` (or a new flag) covering
the workspace-trust + settings.local consent for broker-PTY spawns.

**Status:** FILED (doyle-accepted 2026-07-02) — next planning pass; partly CC-harness-side.

## F-027 — a profile whose launch binary is absent fails SILENT-UNBOUND, not loud at spawn/bind

**Surfaced:** 2026-07-02 (perri, ENLYZEAM), during the E2E-0104 cross-node run. **doyle ruling:
milestone item #12, dispatched to todlando — "missing profile binary must fail LOUD at spawn/bind,
never silent-unbound." This field evidence is the seed for his red-first test.**

**Repro (exact):** ENLYZEAM has no `ccs` binary on PATH (`Get-Command ccs` → not found; `ccs` is the
model/billing multiplexer the shipped `claude-spt:ccs` profile launches). Running the shipped
composite:

```
spt endpoint run --adapter claude-spt:ccs --id f015b-probe-enlz --create --start --subnet SPT_DEV
→ ENDPOINT_RUN:f015b-probe-enlz adapter=claude-spt:ccs session=… pid=…
→ ENDPOINT_RUN_STARTED:f015b-probe-enlz (attach with spt rc f015b-probe-enlz)
```

Both lines report success. But the `:ccs` overlay's spawn command (`ccs …`) fails because `ccs`
isn't on PATH — and the endpoint never binds: `info.json.session_id` stays `""`, status stays
`offline`, no CC/ccs process appears. **No error is surfaced anywhere** — not on the `endpoint run`
output, not in `daemon status`, not in a log the operator would see. The endpoint sits
permanently `offline`/`unbound` and the operator has no signal WHY (indistinguishable from F-026's
trust-hang without process-table forensics).

**Why it matters:** a mistyped profile, an uninstalled multiplexer, or a PATH gap all present
identically as a silent dead endpoint. The spawn layer knows the exec failed (ENOENT / "not found")
and should propagate it: `ENDPOINT_SPAWN_FAIL:<id> profile=<:profile> — launch binary '<cmd>' not
found on PATH` (or equivalent), never a success line followed by silent unbound. Same
success-without-effect genus as F015B/F-025, at the spawn seam.

**Workaround (used):** created a node-local plain-`claude` composite via
`spt adapter create-profile claude-spt probe --from <overlay>` (composite shape preserved, `ccs`
dependency dropped) to run the F015B-class swap verify.

**Status:** SEED FILED for todlando red-first (milestone #12, doyle-dispatched 2026-07-02).

## F-028 — the published contract never states what mode an extracted adapter entry binary gets; on Linux the bit is dropped and the adapter is undrivable

**Filed 2026-07-31 (perri), from field measurement by lia on Athenaeum-Library (DO box, glibc 2.39,
spt-core v0.49.0). Tracked core-side on spt-core's own release tracker (lia's finding; the issue ref is
recorded internally, not reproduced here — it names a private repository). This entry
is the PUBLISHED-SURFACE half: the contract gap, which is mine to report.**

**The documentation gap (searched, not assumed).** The v0.49.0 docs bundle that landed on this node
(`UPDATE_DOCS_LANDED`, `~/AppData/Local/spt-core/docs`) contains **no statement anywhere** about the
POSIX mode an adapter's declared entry binary carries after extraction — nothing in
`harness-contract/manifest`, `cli/reference`, or `self-update/overview`; no `chmod`, no `0755`, no
exec-bit language. The only `chmod` references in the whole bundle concern the **spt binary itself**
when downloaded by hand (`reference/install`: "gh does not set the exec bit"). So an adapter author
building against the published surface has no way to know whether packaging must carry the exec bit,
whether core sets it, or whether core preserves it.

**Why that gap has teeth.** `[update.post]` and every hook seam invoke `{adapter_dir}/<binary>`
directly. If the extracted file is not executable, the adapter cannot be driven **at all** on Linux —
no hooks, no endpoint hosting — so this is total platform breakage, not a degraded update.

**Measured behavior (lia).** `claude-spt` v0.25.31 ships an archive independently verified correct
(sha256 match; `linux-gnu` 0755, `linux-musl` 0755, `manifest.toml` 0644, owner 0/0). After
`spt adapter update claude-spt`, the extracted binary measured **0644** and the post-step failed
`Permission denied (os error 13)`. **Isolating control:** plain `tar` on the SAME archive, same box,
same user, same minute, preserved 0755 on both Linux binaries; `umask` 0002 cannot strip an exec bit.

**Not yet established — the condition.** `omp-spt` on the same node, same core version, same
filesystem is 0755 and works, so the drop is **conditional** on something not yet identified. lia's
leading (explicitly unproven) hypothesis is that the fault is in the **upgrade** route rather than
extraction as such — every claude-spt measurement came through `spt adapter update` (stage-then-swap),
never a fresh `adapter add`. Weak corroboration only: the docs describe `adapter update` as "fetch the
release archive, verify it..., and **re-register**" versus `adapter add` as "Register an adapter from a
local path... or from GitHub". Distinct CLI wording is not distinct implementation; recorded as a
place to look, not as evidence.

**Adapter-side status.** The packaging half was genuinely ours and is fixed in v0.25.31
(`REQ-HAZARD-ADAPTER-EXEC-BIT`): the mode is forced at tar time and the packer now refuses to emit an
archive whose Linux binaries are not executable. Our previous 0644 archive was **masking** this
defect — only a correctly-packed release could separate the two, which is why the fix stands even
though it does not resolve the symptom.

**What the contract should say (the ask).** State explicitly, in `harness-contract/manifest`, whether
an adapter's declared entry binary is expected to be executable **as packaged**, and what core
guarantees on extraction — preserve the archived mode, force exec on the declared entry binary, or
loudly refuse a non-executable one. Any of the three is workable; silence is not, because it makes a
packaging slip indistinguishable from a core defect for exactly as long as both are present.

**SEQUENCING — do not write this contract text before `#62` is ruled (deployah, 2026-07-31, adopted).**
The three answers above are **different guarantees, not three phrasings of one**. Which is true depends
on what the `#62` fix actually does, so documenting first would either constrain the fix by accident or
publish a guarantee core does not make. The doc must describe **shipped behavior, not intent**. On the
`#141` precedent — where the published contract likewise never said which component carried ring's perch
safety — deployah is the natural author of the eventual text, with the `#62` fix gated first.

**SECOND GAP, surfaced when the mechanism was proven (2026-07-31): the contract is also silent on
whether a METADATA-ONLY delta propagates to an already-installed file.** It does not. spt-core preserves
the archived mode faithfully **when it creates the file**, and skips rewriting a destination whose
content is identical — so a mode-only change never lands on an existing install. Proven both ways on one
box: a fresh `adapter add --release --tag v0.25.31` with the destination moved aside created a new inode
at 0755 from *byte-identical* content, while the failing update left the binary's btime **and** mtime at
their original values (payload never written) despite rewriting `manifest.toml`. The `omp-spt`
counterexample resolves to **content change, not install route** — same box, same route, ten versions,
bytes differing each time, so its file was rewritten and picked up 0755.

This supersedes the "extractor drops the bit" reading that both lia and I stated first. Core drops
nothing; it declines to rewrite.

**A second box, and precisely what it does and does not add (lia, conditions confirmed on request).**
An independent Linux node on a *different* spt-core (0.40.0, not 0.49.0), sitting at claude-spt 0.25.7
since 2026-07-22, was silently unusable: mode 0644, `Permission denied` on exec, while its sibling
adapter on the same box was 0755 and healthy. A single `spt adapter update claude-spt` — **no chmod
on that box at any point** — took it to 0.25.31 at mode 0755, size changed 900832 → 962712, with a
**new btime** (so the file was created, not modified in place), executing, and hashing to the same
known-good gnu artifact. `manifest.toml` shares the new btime; `strings/` still carries its original
timestamp, i.e. the update rewrote what changed and left the rest.

**What that does NOT establish, per lia's own qualifications, which are load-bearing:**
- **It is not a controlled pair.** The two boxes differ in core version, starting adapter version, OS
  install, user, filesystem and install age simultaneously. Two observations consistent with one
  mechanism is the correct description; a controlled pair varies one thing.
- **It shows only the HEAL half.** The second box had no opportunity to skip — its content necessarily
  differed. **The skip half rests on a single box.**
- **Weaker instrumentation.** Inode was captured before/after on the first box and observed to change;
  on the second, recreation is inferred from size + btime. Archive modes were inferred from the
  resulting file rather than verified independently. Single trial.
- Load average on the second box sat at a steady ~2.14 from an unidentified source. lia reported that
  rather than declaring the box quiet, and states she cannot retroactively prove nothing else wrote
  during the ~30s window — only that nothing indicates it did.

**What survives:** content changed → file recreated → corrected 0755 landed → adapter works, with no
chmod, on a core version different from the first box's. The practical radius follows from the
mechanism plus an operator fleet check: only nodes at the single version whose binary is byte-identical
to its successor are stuck; older ones self-heal on update.

**Why this compounds the first gap into a trap.** A reproducible build is a virtue everywhere else. Here
it means a release that changes *only packaging* emits byte-identical binaries — and is therefore
**undeliverable to existing installs**. `claude-spt` v0.25.31 fixes fresh installs only; a node already
carrying 0.25.30 stays 0644 through the update, and **the update reports success** with no
operator-visible signal that the payload was skipped. An adapter author cannot discover any of this from
the published surface: nothing states that content-identity gates the write, so nothing warns that a
metadata-only fix cannot ship. The ask extends accordingly — state whether metadata deltas propagate,
and if they do not, say so where adapter authors will read it.

**A third box — the musl-host arm (lia, 2026-07-31, gated by doyle; first time anyone has run it).**
`gravity-linux`, re-provisioned: spt-core 0.49.0 running the **musl core build** (static-pie), kernel
6.8.0-136, x86_64. Pre-state measured before touching anything — `claude-spt` not registered
(`ADAPTER_VERSION_FAIL`), zero files matching `*claude-spt*` anywhere under `~/.spt-core`, no prior
install and **no chmod history on the box at all**. One `spt adapter add --release
<private-release-repo> --tag v0.25.31 --gh`; no chmod before or after.

- **Triple selection is now OBSERVED, not inferred.** The extracted binary hashed
  `8a2b8a2f…bfd298` — matching the archive's **musl** artifact exactly, and not the gnu one
  (`4521388e…adbead1`). Corroborated twice over: size 962944 (the musl artifact's), and `file`
  reporting *statically linked* where the gnu build is dynamic. Three independent discriminators
  agree. This is the claim the adapter side could never make from its own box.
- **Mode 0755** (`-rwxr-xr-x`), fresh inode, fresh btime — created correctly, no chmod.
- **Executes natively on the host** as its own native binary (usage banner, exit 0) — distinct from the
  earlier evidence, which was the musl artifact run by hand on a glibc box.
- **The post-step did real work rather than passing vacuously**: it moved the Claude Code plugin
  `sptc` 0.1.11 → 0.1.17. A genuine version movement, so the step ran rather than being skipped and
  reported fine. `ADAPTER_UPDATE_SUMMARY: up to date (0.25.31)`, `EXIT=0`.
- **Incidental, corroborating not testing:** a follow-up `spt adapter update` left the inode
  **identical** (1715830). Content unchanged → file not rewritten. The same skip mechanism recorded
  above, sighted here on the healthy path where it does no harm.

The measurement was **pre-registered**: the expected outcome (musl triple selected, 0755, executes,
version 0.25.31, post-step clean) was written down and sent before the box was touched, and every field
matched. That matters only as a guard against post-hoc reading, not as extra confirmation.

**What this arm does NOT establish.** The two limits already on record hold — it is the **CREATE**
path, which was never in doubt, so it **cannot produce skip-half evidence**; and it is one trial on one
box. A third limit is lia's, raised by the person on the box:

> ***This is a musl-CORE-BUILD host, not a musl-libc distro.*** `gravity-linux` is Ubuntu with glibc
> 2.35. It qualifies as a "musl host" only in that the spt-core binary installed on it is the musl
> build, and core's platform detection follows the **running binary's build triple**. That is the
> mechanism the manifest documents and it is genuinely what selected the musl subdir, so the selection
> result is real — but a **true musl-userland distro (Alpine, or anything without glibc) remains
> UNTESTED**. Read as "musl distros are now covered", this arm is wrong.

Recorded in her words deliberately: over-reading a green result is how the original 0644 stayed hidden
as long as it did.

**The general class, which outlives the exec bit.** An unstated ownership boundary does not fail loudly.
It fails as two parties each being reasonable: lia and I each concluded the other side was faithful from
the same absence of a stated contract, and we were each half wrong. That is the cost of silence in a
contract — not confusion, but confident agreement on a false split.

## F-029 — nothing documents that `identity/release-keys.json` is an OVERRIDE, not the trust source of truth

**Filed 2026-07-31 (perri) from lia's observation, handed over explicitly as adjacent to F-028 rather
than part of it. Same class as F-028: a contract silence, not a code defect.**

**The silence.** A HEALTHY node has **no `identity/release-keys.json` at all** — the genuine release
trust anchor is compiled into the `spt` binary, and that file is purely an *override* when present.
Nothing in the published surface says so. lia established it only by reading a known-good node and
finding the file absent, which is discovery by comparison, not by contract.

**Why the silence has teeth.** Anything that writes such a file silently **narrows the node's trust
anchor to whatever keys it names**, with no operator-visible signal. Field instance (lia, a third Linux
box): a node bootstrapped 2026-06-06 carried a debug-signed release record pinned at version 9001
against a real latest of 84, key_id `gravity-debug-bootstrap`, expired 2026-06-13, together with a
`release-keys.json` override trusting **only** that debug key. Every genuine release was rejected as a
rollback — *"candidate 84 not greater than current 9001"*. The node reported a healthy-looking version
and was **structurally incapable of ever updating**, with nothing in the surface saying so until
something tried. Resolved by operator-ruled re-provision (prior provisioning backed up first).

**The shape, and why it belongs beside F-028.** Both are nodes or adapters in a state that *looks*
serviceable while being unable to do the one thing they exist to do, discoverable only by an action
that fails. In F-028 an adapter that cannot execute; here a node that cannot update. In both, the
published surface documents neither the guarantee nor its absence.

**The ask.** State in the published surface that the compiled-in key set is the trust anchor and that
`identity/release-keys.json`, where present, **replaces rather than augments** it — and say what a node
does when an override is expired or names no currently-valid key. An operator cannot audit a trust
narrowing that is undocumented and whose healthy state is *file absent*.

**Not claimed:** this is lia's field observation, reported once, on one box, and I have not reproduced
it. The mechanism above is as relayed, not as measured by me.
