EXIT=0
       Peer address seeding, both cold starts: durable peer-addrs.json (identity dir) maps peer pubkey → last-known dialable address; the pump's resolver consults it FIRST with id-only discovery fallback on miss or dial failure (a stale addr never strands a peer); written by the pairing ceremony (both sides, from the live connection) and by the pump on successful connect; post-join first sync and post-restart resync converge in seconds, not ~1 min (M8 decisions 14, 20)
       Net-bind boot-race resilience: a daemon that comes up net-less (NetHost::start failed — e.g. the systemd unit autostarted before the network/DNS stack was ready, `Failed to create an address lookup service`) must SELF-HEAL — retry the net bring-up in the background with capped backoff and, on success, attach net to the broker + spawn the dispatcher/peer-pump (which today are gated on `net_up` at boot and so never start, leaving the node silently unreachable until a manual restart — kitsubito 2026-06-08). Status surfaces the net-less state honestly (a net-less broker renders as 'no connection', not only a pump-STALLED line with a bogus pre-boot heartbeat age). The installer's autostart unit waits for the network (`Wants=/After=network-online.target`) as belt-and-suspenders.
  [OK] REQ-EP-8  required: []  stages: +doc -impl -unit -int
  [OK] REQ-EP-9  required: []  stages: +doc -impl -unit -int
       A legitimately dead PTY child (real crash/kill) + an undrained operator pump must NOT wedge the broker for all other clients. ROOT (v0.12.0 real-harness defect): loopback attach output is a blocking write_all into a bounded 64KB tokio duplex (nethost.rs:1040,1090); when the operator's rc pump stops draining (tab closed) the buffer fills and write_all blocks forever (the 'loopback never hangs' assumption at nethost.rs:1103 is false), parking a worker in the 2-worker net runtime (nethost.rs:640); a couple of these saturate BOTH workers → every new attach / `endpoint run` stalls right after 'PUMP_IPC_READER: spawned' → 30s FIRST_EVENT_GRACE → 'no output / dead or wedged'; `daemon stop` cannot join the stuck workers. DISTINCT from the removed B1 path-(c) mutex deadlock. DISPOSITION = PROVE-DON'T-CHANGE (doyle GATE-PASS @e883f45, 2026-06-18): this ROOT is the SUPERSEDED v0.12.0 hypothesis — the post-L0 code ALREADY prevents the wedge, so NO fail-fast / worker-count code was added. serve_attach forwards fire-and-forget (net_stream_send op_id=None) and the broker-side send_stream is already BROKER-QUIC-DEADLINE-bounded (bounded_block_on, 10s); the loopback duplex is drained broker-INTERNALLY by the operator row's own read pump (RecvHalf::Loopback, retentive_cap==0 → evict-not-park) so a dead rc (a dropped IPC subscriber) never backs peer_w up; bounded_block_on parks the BROKER DISPATCH thread, not a net worker → no worker-pool exhaustion (full mechanism in the required_stages comment). Folds the status=online sub-check: a dead spt-hosted endpoint is marked OFFLINE within one reconcile tick on abrupt child death (broker exit-waiter reaps the session → B2 sees it absent) — PROVEN, no change. (v0.12.1)
       A bare brain restart leaves EXACTLY ONE `{id}-psyche` process per endpoint — no duplicate. On an abrupt brain death stop_host never runs (the LiveSet + owned child handles die with the brain) and Breap's job/group only reaps at DAEMON stop, so the PRIOR brain's Psyche stays ALIVE; the respawned brain's reconcile re-hosts a SECOND Psyche and overwrites the `{id}-psyche` perch pid, leaving the old one untracked + alive = a duplicate that lingers until daemon-stop (the operator's 'brain kill+restart wedged everything'). FIX: at brain start, BEFORE the first reconcile re-hosts, reap any pre-existing `{id}-psyche` orphan — ID-SPECIFICALLY (recycle-safe on the shared box, where sibling agents share the `claude` basename): scoped-kill the recorded pid ONLY IF it is alive AND its exe basename == the adapter's psyche program (normalize_basename) AND its COMMAND LINE contains the full psyche id `<id>-psyche` (baked via {id}); a sibling never carries THIS id, and any unreadable signal FAILS SAFE (decline to reap — a missed dup is bounded by Breap, a wrong-kill is catastrophic). CAVEAT: the cmdline carries `<id>-psyche` only when the adapter's psyche_init.command uses {id} (the norm); a non-{id} adapter safely MISSES the reap (today's behavior, Breap bounds it) — never a wrong-kill. (v0.12.0)
       The broker bounds every brain-waiting QUIC op (dial / open_stream / send_stream) so a black-holed or dead peer fails PROMPTLY with an ORDINARY error the broker REPLIES, never an unbounded await. The bound (< the brain's 30s PUMP_PEER_IO_TIMEOUT so the BROKER fires first) surfaces to the pump as a normal broker error reply → peer_outcome's non-TimedOut arm → drop conn + redial next tick, the round CONTINUES and the heartbeat keeps advancing — it must NEVER manifest as the brain's own read-deadline (the A-half poison → supervised-restart path REQ-HAZARD-PUMP-IPC-DEADLINE guards). Exactly-once is preserved: a timed-out journaled op fails INSIDE its apply_once closure so no phantom conn_id/stream_id is recorded and a fresh tick re-dials cleanly. The happy path is unchanged (a live peer completes with zero added latency; the bound only bites a non-responsive peer). This is the ROOT-cause cure for the 2.2h hfenduleam pump wedge — a dead roster peer whose QUIC path the broker awaited unbounded — recurring on hfenduleam 2026-06-16.
       A daemon-state wire-format change (e.g. the v0.9.0 adapter-agnostic Seed) does NOT take effect until a DELIBERATE full broker restart: the broker serves the seed-control channel and is RESIDENT across a brain-only self-update (ADR-0004 no-terminate-during-update forbids auto-killing it), so a NEW-version CLI talking to a still-resident OLD broker fails the seed handshake — the old broker cannot deserialize the new Seed (its formerly-required `adapter` field is gone) and drops the conn without an ack, which surfaces to the CLI as a raw UnexpectedEof 'failed to fill whole buffer'. spt-core must (a) surface an ACTIONABLE diagnostic on that seed-ack EOF (name the stale-broker cause + the `spt daemon stop` fix — the broker restarts on the next api call), never the cryptic io error; and (b) document the operational rule (a deliberate broker restart is required on any daemon-state wire change — NOT automatic) + the FORWARD discipline (daemon-state/Seed schema changes stay additive + serde-default so a resident OLD broker tolerates a NEW CLI across a brain-only update; note this would NOT have rescued 0.9.0 itself, since the old broker's `adapter` was a required field). perri PREP-4 FINDING 1 (v0.9.0 CLI vs stale 0.8.x broker).
       A surfaced concurrent context pair is durably preserved (both versions, tracked artifacts) until a strictly dominating write clears it; no reconcile failure path discards an unmerged version (6.6, ADR-0013)
       Two `controller_writer` threads must never race ONE brain↔broker connection's socket. ROOT (doyle, instrumented RACEDIAG repro on kitsubito): on a brain-restart re-serve the handoff brain registers as controller on the SAME session TWICE over the SAME `Brain::conn` socket — (1) `Brain::handoff` eagerly `subscribe(prior.session_id, prior.next_seq=1)` → `become_controller(from_seq=1)`, initial=[1], spawns writer-A (writes seq 1); (2) `serve_attach` re-handles the replayed `Request{from_seq:0}` → `attach_as(sid,0)` → `become_controller(from_seq=0)`, initial=[0,1], spawns writer-B (writes 0 then 1). `become_controller` (broker.rs) drops the prior `ControllerSink` (its `tx`) but does NOT stop the prior writer thread — writer-A keeps flushing its owned `initial` batch, and both writers hold clones of the same `SharedSend` (`Arc<Mutex<socket>>`) with NO inter-thread ordering. When writer-A's seq 1 wins the socket before writer-B's seq 0, the strict legacy consumer (brain.rs read_event reject-gap path) sees `output gap: got seq 1 want 0` → the test `attach_survives_target_brain_restart_exactly_once` panics at `.expect("re-serve")` OR HANGS in `render_until` (serve thread died on the gap → MARKER_TWO never reaches the wire). `prior.next_seq` is life1's CONSUMPTION cursor, NOT life2's connection state — life2's socket has been sent NOTHING, so a `from_seq=0` full replay on a connection that already streamed seq 1 is contradictory. Snap-above tolerance ALONE can't fix it (it would dedup-drop the late seq 0 → byte loss → the exactly-once byte-identity assert fails). PRE-EXISTING, surfaced by the v0.13.0 green-both-runners release gate; P1b is INNOCENT (its diff touches only input-ack machinery, proven mechanically + the test passes post-P1b in isolation). Sibling flaky cluster: `inject_control_wedge::g2`, `broker::spawn_env_reaches_child`. INVARIANT: on a single brain↔broker connection the controller output-frame stream is monotonic non-decreasing in seq (modulo dedup re-sends); exactly ONE `controller_writer` is ever live per connection; a SUPERSEDED writer writes NO further frames; a re-serve never replays a seq below what the connection already received. FIX (doyle design, corrected at the gate 2026-06-20): fix #1 as designed ('drop handoff's eager subscribe so serve_attach's attach_as is the sole registration') was REVERTED — handoff's `subscribe(prior.next_seq)` IS the standalone-resume mechanism (the brain-only update engine `apply_brain_only` + the `handoff`/`idempotent`/`daemon_e2e` int tests replay output through it with NO `serve_attach`; dropping it hung every resume-via-handoff test). The shipped fix is three parts: (1) CORRECTNESS — `Brain::handoff` seeds `session_cursors` at `prior.next_seq` so the consumer runs the production dedup-below+snap-above path, never the strict reject-gap legacy trap; this is COMPLETE (not merely tolerant) because every `controller_writer` emits an ASCENDING seq stream and the surviving writer (serve_attach's attach_as(sid,0)) offers the complete `[0,end]` range, so a snap-above merge of ascending writers delivers `[K,end]` with no skip/dup (first sighting of any seq>M is preceded by M on that writer). (2) INVARIANT — `controller_writer`'s INITIAL-BATCH replay is epoch-gated: `controller_epoch` is a shared `Arc<AtomicU64>`, the writer re-reads it UNDER `send.lock()` (atomically with `write_frame`) and returns the instant it is superseded — no check-then-block-then-write window, no superseded replay (W1-safe: never blocks the drain under `Mutex<OutputLog>`). The LIVE loop is NOT gated (new output only flows to the current controller; a superseded writer must still deliver its terminal `Displaced` kick — gating it suppressed the loud-take notice; it ends on `tx`-drop). (3) EXPLICIT-RESUME / OPERATOR-STREAM BOUNDARY (the LOAD-BEARING fix — kitsubito RACEDIAG ~33% repro the keystones missed) — `Brain::subscribe_with` (shared by attach/attach_as) resets the resume-mode dedup cursor to `from_seq`. The handoff eager subscribe makes serve_attach's brain receive the replay's seq K BEFORE the operator Request is processed (`attached`=false); that frame is dropped by the if-attached forward gate but the snap-above cursor already advanced past K, and `attach_as(sid,0)`'s re-subscribe used to leave the cursor advanced → the broker's re-send of seq K arrives below it, deduped, never forwarded → operator viewport forward-gap (silent content loss in the real rc consumer). Resetting to from_seq on the attach_as re-subscribe re-delivers from 0 (operator dedups the overlap) so seq K reaches the viewport. The epoch gate (2) is sound (RACEDIAG: zero socket interleaving above K); cold-start brains (empty map — production dispatch serve) keep the legacy next_seq path, so production is unaffected. (v0.13.0)
       B3: `spt daemon stop` then an immediate `spt daemon start` does NOT race — stop fully completes before it returns. Today request_stop (seedmap.rs:240-255) returns on the KIND_STOPPING ack (sent seedmap.rs:174-176) BEFORE the seed socket unbinds, so a following is_running ping (daemon.rs:375) wins the exit window and start reports ALREADY_RUNNING (operator: daemon stop → STOPPED then start → ALREADY_RUNNING). FIX: unbind/stop-gate the seed socket BEFORE acking KIND_STOPPING, OR request_stop waits for a ping-to-fail before returning. Unit: stop then immediate is_running()==false. (v0.12.0)
       A pointer-mode (delegated / GhReleaseManaged) adapter whose binary/manifest is not yet extracted is reported with a CLEAR diagnostic, never silently dropped. Today such an adapter reads its manifest LIVE from source_dir (registry.rs manifest_dir ~146/149); a deferred / un-extracted install makes load_manifest fail → registered() (~410, filter_map(.ok())) SILENTLY DROPS the row → downstream ADAPTER_UNRESOLVED + a cryptic os-error-2 on `spt adapter use`. FIX: surface a clear diagnostic at the resolver + at `adapter use` (name the adapter + the deferred/missing-manifest cause + the fix), not a silent filter-drop and not a bare os-error-2; consider an eager manifest copy at register time so host_binaries survive before the binary download completes. doyle Finding A. (post-v0.10.0)
       The effect journal serializes EVERY PTY effect under one mutex held ACROSS two fsyncs AND the blocking PTY write — so interactive input stutters and ultimately wedges the daemon hard. ROOT (doyle /diagnose, code-grounded + MEASURED on the operator's real Windows box, 2026-06-19): EffectJournal::apply_once (effect.rs:168-188) takes `inner.lock()` and holds it across `write_line(PENDING)` → `effect()` → `write_line(DONE)`, where write_line (effect.rs:235-239) does flush()+sync_all() (a full FlushFileBuffers) — so each effect pays TWO fsyncs under a GLOBAL lock, and the closure `effect()` (the actual PTY write, broker.rs:1257 EffectKind::PtyWrite via attach.rs:197 send_effect) runs while the lock is held. Two operator-visible facets, ONE root: (A) STUTTER/LAG — every keystroke is a PtyWrite effect = 2× sync_all serialized; measured fsync on %LOCALAPPDATA%\spt-core = median 6.5ms, spikes to 198ms (C: was recently at 100%), so ~13ms+ per keystroke best case, hundreds under contention → 'many but not all keypresses take 100s of ms, choppy, worsens with volume'. (B) HARD PERMANENT WEDGE — when a PtyWrite `effect()` blocks (ConPTY input buffer full / harness not draining stdin), the journal lock is held INDEFINITELY → the single-threaded inbound-stream dispatch (dispatch.rs serve_attach, which both applies input effects AND opens attaches) can never progress → EVERY subsequent attach (`spt rc --view`/`--take`) fails with 'attach request: brain IPC read deadline elapsed' (confirmed: two retries deadline identically; broker control-plane KIND queries still answer — different thread). This REFUTES the W2-deferred ruling that park-(b)/(c) is 'Windows-benign because ConPTY absorbs 4MiB' — on the real box the input path wedges regardless. DISTINCT from W1 (REQ-HAZARD-INJECT-CONTROL-COEXIST = the OUTPUT drain, correctly fixed @8b5583e; output uses broker.rs:1106 append, NOT the fsync journal). This is the INPUT/effect-journal path W1 never touched, and it is THE wedge the operator hits with --take/--view. FIX DIRECTION (candidates, repro-first — extend inject_control_wedge.rs to a REAL backed-up-PTY-consumer + a real rc-client attach assertion, the gap W1's gate missed): (1) do NOT hold the journal lock across effect() — reserve the key + fsync PENDING under lock, RELEASE, run effect(), re-acquire to fsync DONE + mark applied (preserve crash-idempotency via the per-key reservation, not a global hold); (2) bound/fail-fast the PtyWrite itself (the W2-deferred park bound — write_input must never block indefinitely, DSR-answer must not hold the writer mutex across a blocking write); (3) drop per-keystroke fsync on the interactive path — PtyWrite effects are EPHEMERAL (a keystroke lost on a broker crash is retyped; PTY state is not reconstructed from keystroke replay), so in-memory applied-set dedup suffices (the broker survives the brain — that IS the dedup anchor), with async/batched fsync or no-fsync for EffectKind::PtyWrite while durable kinds (NetSend/NetDial/Registry/Spool) keep their fsync. Combine (1)+(3) at minimum. Add a KNOWN-HAZARDS.md entry on landing. (v0.13.0)
       A clean `spt rc` attach to a LIVE spt-hosted (`endpoint run`) harness must DELIVER the harness's PTY output. KEYSTONE — the operator's central 'attach shows no output' symptom, reproduced on the real dummy-harness fixture (v0.12.1 Wave 1) with NO death and NO wedge: bringup succeeds (online, harness pid alive + heartbeating, psyche hosted), the attach CONNECTS (PUMP_IPC_READER spawned, no RC_FAIL, holds the full window) — but receives EXACTLY 0 bytes over 10s of the harness's flushed [session.self] stdout. DISTINCT from REQ-HAZARD-VIEWER-CLOSE-DETACH (death) and REQ-HAZARD-ATTACH-WEDGE (dead-child backpressure): here the harness is ALIVE and the attach is a clean first subscribe. This BLOCKS the 'view is independent' invariant — re-attach is meaningless if a live endpoint-run harness shows nothing. KNOWN-GOOD (rules out 'no drain'): attach.rs `local_attach_via_loopback_conn_rides_the_same_pump` + `broker_spawns_the_pty_child_in_the_requested_cwd` prove the broker DOES drain+fan a `spawn_session` PTY child to a loopback attach over the SAME transport rc uses. Both spawn_session and endpoint-run's spawn_session_pid send KIND_SPAWN → the same dispatch_spawn (broker.rs:706/835) which starts the per-session drain+OutputLog — so the gap is NARROWER than 'no drain', endpoint-run-specific. Root candidates: (a) spawn_session_pid's SpawnReq stdio/env/cwd differs so the dummy's stdout isn't the captured ConPTY; (b) the harness stdout WRITE BLOCKS because the ConPTY buffer fills (drain not reading THIS pty) — explains alive-but-0-bytes; (c) ConPTY reader-park (KH 7.6) on this path; (d) `spt rc` resolve_session/subscribe for an endpoint-run session subscribes to the wrong/empty log. (v0.12.1)
       `spt endpoint run` HONORS manifest [env.<VAR>] direction=inject values (with {key} substitution) on the spt-hosted spawn. Today only the [session.self] command ARGV is {id}-substituted; the [env] inject value is NEITHER substituted NOR applied — manifest.schema.json promises EnvVar.value = 'Value to inject (with substitution)' but prepare_harness_spawn fills only argv and SpawnReq carries no env, so a [env.SPT_ENDPOINT_ID].value='{id}' arrives EMPTY. A FLAGLESS harness (bare `claude`, no argv slot for {id}) then routes the id via [env] → empty → SessionStart sees empty $SPT_ENDPOINT_ID → seeds-by-PPID instead of binding → ZERO perch → NO_PERCH (the actual wall-b bind blocker; perri hard-repro'd). SILENT failure (empty inject, no error). FIX (doyle ruled a): fill every [env] inject value from the SAME {key} catalog as argv/role (mirror F-009 TEMPLATE fill, whole-string fill_template for an env value), thread it through SpawnReq.env → the broker sets it on the spawned PTY child. Correctness fix — schema already promises it, NO manifest change, NO new binary. PAIRS with REQ-SEND-SPT-HOSTED to make endpoint run fully work. doyle F-013. (post-v0.10.0)
       SPINE INVARIANT (v0.13.0 keystone): the broker must accept INJECTED keystrokes into an spt-hosted PTY (the v0.11.0 raw direct-inject today; the ADR-0022 translation-binary choreography tomorrow) WHILE a live `spt rc` controller is attached to the SAME PTY, without (a) the operator losing control, (b) the endpoint latching ONLINE+CONTROLLED, or (c) the broker wedging. The injection inlet is PERMANENT — spt-claude-code requires keystroke injection — so this is root-caused + fixed at the PTY-injection layer, IN STEP with the ADR-0022 delivery redesign that formalizes the inlet. REOPENS the wedge facet of REQ-HAZARD-ATTACH-WEDGE: the v0.12.1 prove-don't-change covered only DEAD-CHILD backpressure, NOT the injection trigger (operator's signal — one injected keystroke succeeds, the next wedges → the single-threaded broker parks on a blocking PTY/loopback write after injection-induced harness output). REPRO-FIRST on the real dummy-harness fixture (NO theory): instrument to nail the exact blocking call before any fix. Fix candidates: non-blocking/fail-fast PTY write, split input/output, bounded-evicting. Mechanism shared with W2 — spt-core owns EVERY PTY write and applies an injected sequence ATOMICALLY (controller input buffered during the sequence, flushed after) so a stash/restore can't be clobbered. CONFIRMED ROOT (doyle /diagnose 2026-06-19, code-grounded): Broker::append (broker.rs:205-227) fans each live output chunk to the CONTROLLER on a SYNCHRONOUS BLOCKING write_frame held inline in the session's drain thread (the 'authoritative, advances delivered_through' path, D4-1), while VIEWERS use a dedicated writer thread + bounded evicting sync_channel (add_viewer:273 / viewer_writer) that can never stall the drain. So a slow/backed-up controller socket — or the full 64KB loopback duplex (the ATTACH-WEDGE buffer) — BLOCKS the drain thread → output stalls → keystroke echoes stall (PERCEIVED input lag) → unrecoverable wedge when the consumer never drains. TRIGGERS ON NORMAL INTERACTIVE rc USE under heavy harness output (TUI redraw), NOT only message injection — same root, wider repro. FIX DIRECTION: move controller delivery off the drain thread onto a dedicated writer (the viewer_writer pattern) BUT preserve the authoritative cursor — block the WRITER thread (not the drain), bound the wedge (deadline → detach/mark-gone, never park forever), never silently evict the operator's authoritative view. (v0.13.0)
       B5: `spt daemon start` does NOT revive phantom Psyches for dead-but-online-latched perches. Today reconcile_once (livehost.rs:285) spawns a Psyche per status=online live_agent perch at boot WITHOUT verifying the harness child / {id}-psyche is actually alive — so a Cold start after an unclean stop revives N psyches for N dead-but-latched perches (3 psyches for 3 dead perches). FIX: gate the boot psyche-spawn on real child-liveness — a perch with NO live broker session (the B2 reconcile signal) is marked OFFLINE at boot instead of hosted, so a dead-harness perch is never revived. Shares the B2 reconcile loop (this is its boot-gate arm); composes with B2's honest latch. Also closes wall-a's psyche_host_error gap (residency-confirm does not run at boot tick-1, livehost.rs:395-441 / 257-263). (v0.12.0)
       The brain's daemon-hosted Psyche lifecycle surfaces a host-FAILURE on the live perch (harness-diagnosable) and runs net-INDEPENDENTLY. When reconcile_once→host_one→spawn_psyche fails for a state=live_agent+status=online endpoint (e.g. the adapter's psyche binary absent from its install dir, REQ-INSTALL-11), the failure MUST be written to the perch info.json as a CURRENT-STATE field (reason + ts + attempt count; overwritten each 5s retry, CLEARED on successful host) and surfaced by `spt endpoint list`/status — never left as an eprintln on the brain's invisible stderr where a harness reading only perch state is blind. status=online stays authoritative (agent reachable; only the Psyche is missing — brain-restart rehydrate legitimately has online-without-Psyche windows), so this is a SEPARATE psyche-host-health field, never a status de-stamp. Net-independence is a locked-in invariant: spawn_live_host (brainproc.rs:230) reaches the reconcile and hosts the Psyche on a net-less/unpaired/peer-pump-STALLED node, proven by a REAL detached-daemon E2E (real broker→brain-child, real api seed+listen, real install-dir psyche binary). spt-core SURFACES the failure; the adapter owns fixing its packaging.
       A daemon-hosted Psyche that spawns then EXITS IMMEDIATELY is a host failure, surfaced like a spawn failure (closes the v0.8.1 residual masking): the REQ-HAZARD-LIVEHOST-BOOT-RACE signal stamps `psyche_host_error` only when `spawn_psyche` returns Err, NOT when the detached spawn() returns Ok but the child dies within moments (e.g. a bad-argv child exiting 2 — the F-009 case). That leaves the residual 'online + no Psyche + no cause' gap: the nested `{id}-psyche` info.json is written status=online with a real-but-DEAD pid and the PARENT perch carries NO psyche_host_error (perri's F-010: tasklist showed 0 host procs across the window while info.json read online). The host MUST confirm RESIDENCY — a hosted child not alive (or whose `{id}-psyche` perch never re-registers / has a dead pid) within N seconds of spawn is treated as a host failure: stamp the parent perch `psyche_host_error{reason:"host not resident within <n>s (psyche perch missing/dead pid)"}` (and do not leave a phantom online nested perch). Closes the last masking gap the v0.8.1 fix left open. perri's F-010 (v0.8.1 dogfood). Sibling of REQ-HAZARD-LIVEHOST-BOOT-RACE.
       Pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type AND can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). ROOT (doyle /diagnose, code-grounded): the operator-keystroke path rc -> net-stream Input -> serve_attach (attach.rs:197 brain.send_effect) -> KIND_INPUT -> broker dispatch loop (broker.rs:1091) -> dispatch_input (broker.rs:1459) -> session.write_input(&bytes) runs SYNCHRONOUSLY on the broker request-handling thread. W1b (REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE) released the journal lock across the effect (fix 1) + made PtyWrite ephemeral/no-fsync (fix 3) but EXPLICITLY DEFERRED fix (2) — bound/fail-fast the PtyWrite itself. A single keystroke never fills the ConPTY input buffer; a PASTE BURST does -> write_input blocks -> the dispatch thread cannot service the next frame (a re-attach subscribe, a become_controller restore-write, an inject-floor flush) -> wedge. Not a bug-2 regression (the byte path funnels to the same write_input; paste just reliably fills the buffer). FIX (doyle design, V0.13.0-P0-PTY-INPUT-WRITER-DESIGN.md, CONTEXT L33 broker-owns-PTY/minimal + L435 SessionSurface + single-writer pattern): one dedicated per-session INPUT-WRITER THREAD = the SOLE caller of the blocking write_input, fed by a BOUNDED FIFO channel; every caller (dispatch_input, serve_attach->send_effect, inject-floor flush) ENQUEUES + returns immediately, never blocks. A blocked/slow harness blocks ONLY its own writer thread, never the broker dispatch. Backpressure (operator ruling): queue full => DROP excess input + stamp the session INPUT_BACKPRESSURE (visible health signal); the daemon NEVER wedges; a merely-slow harness self-heals as the writer drains. Exactly-once preserved (PtyWrite ephemeral: apply_once effect = the non-blocking enqueue => Applied; ack now means accepted+ordered, benign — rc does not gate on landing); order preserved (single FIFO + single writer); inject-floor (W2 Layer C) choreography moves to the lone writer. Completes the W1b-deferred fix (2), cross-platform (cfg(unix) forkpty park folds in). (v0.13.0)
---tail---
       A `rc --view` VIEWER that overflows its broker subscription queue and is EVICTED (OutputLog::append try_send Full → viewers.remove, REQ-HAZARD-VIEWER-ISOLATION session-protection) must SKIP TO LIVE, not die silently. ROOT (v0.13.0, b4 JIT item 2 = p0_paste + post-b4 a_journaled-Linux, ONE root): serve_attach forwards each frame (read_event→b64decode→re-encode AttachRecord→net_stream_send) SLOWER than the drain fans out under flood → its VIEWER_CHANNEL_DEPTH(256) channel overflows → the drain evicts (viewers.remove drops the ViewerSink → drops tx → viewer_writer's rx.recv() Err → the writer returns WRITING NOTHING) → serve_attach's brain.read_event() just STOPS getting Output (no EOF, no error) → serve_attach blocks forever → the operator receives nothing (attach_received_output=FALSE). Eviction-of-a-hopelessly-behind-viewer is CORRECT session-protection (keep it); SILENT+PERMANENT eviction is the bug. VIEWER-only → B2-SAFE (a viewer never advances delivered_through / is not authoritative / exposes no resume cursor). FIX (doyle-gated, skip-to-live = tail -f reconnect): (1) explicit broker→viewer EVICTION SIGNAL (KIND_VIEWER_EVICTED, written in the viewer_writer thread OFF the log lock, DISTINCT from session-exit EOF so serve must NOT tear down on it); (2) serve_attach re-subscribes from the CURRENT ring floor (skip-to-live, replays nothing, sees the next live burst) — resetting the cold serve-brain's next_seq so the post-eviction forward-jump replay is accepted (the legacy reject-gap path, brain.rs:618-626, would otherwise FATAL the forward jump); (3) HARD constraint NO evict→resubscribe busy-loop: serve_attach rate-limits re-subscribes (RESUBSCRIBE_INTERVAL) so under max-flood the operator sees intermittent LIVE bursts, never a CPU spin. (v0.13.0)
  [OK] REQ-WHOAMI-1  required: [doc, impl, unit]  stages: +doc +impl +unit -int
       `spt whoami` is a thin ALIAS for `spt endpoint list` (full output: the SELF pin + the subnet roster) — the standalone bare-id command is dropped (the `id=$(spt whoami)` capture was never a real pattern: env vars don't persist between agent tool calls). The one new render: the `endpoint list` SELF pin carries the Self endpoint's authored `endpoint description` (info::read_info(...).resources) when present, inline after the liveness state. whoami stays a top-level hot-path verb (parse unchanged, REQ-MSG-9).


yes we can switch. note that `spt-discord` info/docs should go in a new sister directory `../spt-discord/`. only information directly relevant to spt-core should go here.


Organize spt-discord documentation into sister directory


Gate green — `EXIT=0`, both `REQ-EP-8`/`REQ-EP-9` `[OK]` (required `[]`, `+doc` from the ADR tags). Now the `../spt-discord/` sister dir for the adapter-side design (per your directory split):


Write

# spt-discord

A Discord-substrate **forum / group-messaging** adapter for the SPT ecosystem: it bridges a Discord channel into the subnet so multiple agents (and humans) hold one shared conversation, and it can draw an offline agent online when addressed.

> **Status:** design only (ratified 2026-06-21 via grill sessions). Not yet built. Lives in its own repo — spt-core contains **zero** Discord conventions.

## How it sits on spt-core

spt-discord is an **always-on endpoint adapter** (spt-core `kind`, see spt-core `ADR-0023`, `REQ-EP-8`/`REQ-EP-9`). The single core mechanism it depends on:

- **`[always-on]` manifest section** — spt-core boot-launches and supervises **one bot binary per adapter-option** (`spt-discord` or `spt-discord:<profile>`), running it continuously and independent of any agent's liveness.
- **`#`-sigil addressing** — each bridged channel is an spt always-on endpoint addressed `#<channel>` (`[subnet:]#id[@node]`). `#general` ⟺ an always-on endpoint; bare `general` ⟺ an agent. The bot self-registers these via the existing `api bind` — one connection fronts many `#`-endpoints.
- **`endpoint wake <id>`** — target-side authorized (no caller-ownership gate), so the bot may revive an offline agent it's drawing into the conversation.

Everything below is **adapter-internal** — none of it belongs in spt-core.

## Architecture

```
Discord channel #general  ─────────────────  spt always-on endpoint #general
   per-agent threads                              (bound via api bind)
   ┌─ #"doyle"  (thread)                      one bot connection / adapter-option
   ├─ #"deployah"
   └─ #"todlando"
```

One bot application per adapter-option (one Discord gateway connection). Each Discord channel the bot bridges = one `#`-endpoint. Per-agent **threads** inside the channel are each agent's durable inbox.

## Agent identity — webhooks

One bot + channel **webhooks** post messages with a per-message `username` + `avatar_url` override → N agent identities with **zero per-agent bot registration** (the matterbridge pattern).

- **Outbound** (agent → Discord): webhook, per-message identity.
- **Inbound** (Discord → agents): one bot **gateway** connection with the `MESSAGE_CONTENT` privileged intent.
- Caveat: webhook identities aren't real Discord users (no @mention-as-user, no presence). Discord rate-limits webhooks (~tens/min per channel — confirm exact).

## Inbound routing (structural, via Discord threads)

Discord thread-mentions are real channel mentions (`<#thread_id>` → renders `#doyle`), so routing keys on a **thread ID, not a parsed string** — no typo/collision risk.

| Where the message is posted | Routes to | Semantics |
|---|---|---|
| main channel, plain text | all members (minus sender) | **ambient** (the forum) |
| main channel, `#"doyle"` | doyle | **directed-public** (others see it; doyle replies in channel) |
| inside doyle's thread | doyle | **directed-private** (DM; doyle replies in thread) |
| any agent posting `#"deployah"` | deployah | agent↔agent direct (same mechanism) |

## Delivery model (hybrid, per-member)

- **directed** (thread / `#mention`) → **push** to the live session **+ on-demand catch-up digest + reset the cadence timer** (a pinged agent needs surrounding context to respond).
- **ambient** (channel) → **digest** at the member's interval.
- interval is **fully configurable**, `0` = no auto-digest.
- **`mute` is a separate knob from `interval=0`**: `interval=0` suppresses ambient auto-digest but a directed ping still pushes; `mute` suppresses even directed pushes (queues silently in the thread).
- Reuses spt-core's digest pull/subscribe cadence machinery.

## Thread lifecycle

- **join** → auto-create a thread named for the agent.
- **leave** → **lock** the thread (read-only, history preserved = durable inbox).
- **offline agent** → messages sit in its Discord thread; on wake it reads the unread backlog (**Discord is the spool**).
- **gotchas**: Discord auto-archives idle threads (1h/24h/3d/7d) — posting unarchives if unlocked; needs the bot's *Manage Threads* permission; rejoin = unlock the existing thread (keep history) vs mint new (open).

## Open adapter-level items

- **Echo-loop prevention** — the bot must filter its own webhook posts (classic bridge bug).
- **Bot-token secrets** — store via spt-core adapter strings / a secret store; never in the manifest.
- **Single-bridge-node SPOF** — the Discord connection lives on one node; cross-node agents join via Iroh, but the bot host is a single point.
- **Payload types** — text + Discord attachments → spt-core's text+file channel.
- **Human authority** — a human posting in Discord is a human-backed origin; whether/how their messages carry `user-msg` authority, and the fact that Discord-channel membership ⊄ subnet trust boundary, needs a decision (who's in the channel = the trust gate).

## Provenance

Distilled from two grill sessions (2026-06-19/21). The core-side decisions (always-on endpoint kind, `#` sigil, generalized wake authorization) are recorded in the **spt-core** repo: `CONTEXT.md`, `docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md`, and `REQ-EP-8`/`REQ-EP-9`.


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

# spt-discord

A Discord-substrate **forum / group-messaging** adapter for the SPT ecosystem: it bridges a Discord channel into the subnet so multiple agents (and humans) hold one shared conversation, and it can draw an offline agent online when addressed.

> **Status:** design only (ratified 2026-06-21 via grill sessions). Not yet built. Lives in its own repo — spt-core contains **zero** Discord conventions.

## How it sits on spt-core

spt-discord is an **always-on endpoint adapter** (spt-core `kind`, see spt-core `ADR-0023`, `REQ-EP-8`/`REQ-EP-9`). The single core mechanism it depends on:

- **`[always-on]` manifest section** — spt-core boot-launches and supervises **one bot binary per adapter-option** (`spt-discord` or `spt-discord:<profile>`), running it continuously and independent of any agent's liveness.
- **`#`-sigil addressing** — each bridged channel is an spt always-on endpoint addressed `#<channel>` (`[subnet:]#id[@node]`). `#general` ⟺ an always-on endpoint; bare `general` ⟺ an agent. The bot self-registers these via the existing `api bind` — one connection fronts many `#`-endpoints.
- **`endpoint wake <id>`** — target-side authorized (no caller-ownership gate), so the bot may revive an offline agent it's drawing into the conversation.

Everything below is **adapter-internal** — none of it belongs in spt-core.

## Architecture

```
Discord channel #general  ─────────────────  spt always-on endpoint #general
   per-agent threads                              (bound via api bind)
   ┌─ #"doyle"  (thread)                      one bot connection / adapter-option
   ├─ #"deployah"
   └─ #"todlando"
```

One bot application per adapter-option (one Discord gateway connection). Each Discord channel the bot bridges = one `#`-endpoint. Per-agent **threads** inside the channel are each agent's durable inbox.

## Agent identity — webhooks

One bot + channel **webhooks** post messages with a per-message `username` + `avatar_url` override → N agent identities with **zero per-agent bot registration** (the matterbridge pattern).

- **Outbound** (agent → Discord): webhook, per-message identity.
- **Inbound** (Discord → agents): one bot **gateway** connection with the `MESSAGE_CONTENT` privileged intent.
- Caveat: webhook identities aren't real Discord users (no @mention-as-user, no presence). Discord rate-limits webhooks (~tens/min per channel — confirm exact).

## Inbound routing (structural, via Discord threads)

Discord thread-mentions are real channel mentions (`<#thread_id>` → renders `#doyle`), so routing keys on a **thread ID, not a parsed string** — no typo/collision risk.

| Where the message is posted | Routes to | Semantics |
|---|---|---|
| main channel, plain text | all members (minus sender) | **ambient** (the forum) |
| main channel, `#"doyle"` | doyle | **directed-public** (others see it; doyle replies in channel) |
| inside doyle's thread | doyle | **directed-private** (DM; doyle replies in thread) |
| any agent posting `#"deployah"` | deployah | agent↔agent direct (same mechanism) |

## Delivery model (hybrid, per-member)

- **directed** (thread / `#mention`) → **push** to the live session **+ on-demand catch-up digest + reset the cadence timer** (a pinged agent needs surrounding context to respond).
- **ambient** (channel) → **digest** at the member's interval.
- interval is **fully configurable**, `0` = no auto-digest.
- **`mute` is a separate knob from `interval=0`**: `interval=0` suppresses ambient auto-digest but a directed ping still pushes; `mute` suppresses even directed pushes (queues silently in the thread).
- Reuses spt-core's digest pull/subscribe cadence machinery.

## Thread lifecycle

- **join** → auto-create a thread named for the agent.
- **leave** → **lock** the thread (read-only, history preserved = durable inbox).
- **offline agent** → messages sit in its Discord thread; on wake it reads the unread backlog (**Discord is the spool**).
- **gotchas**: Discord auto-archives idle threads (1h/24h/3d/7d) — posting unarchives if unlocked; needs the bot's *Manage Threads* permission; rejoin = unlock the existing thread (keep history) vs mint new (open).

## Open adapter-level items

- **Echo-loop prevention** — the bot must filter its own webhook posts (classic bridge bug).
- **Bot-token secrets** — store via spt-core adapter strings / a secret store; never in the manifest.
- **Single-bridge-node SPOF** — the Discord connection lives on one node; cross-node agents join via Iroh, but the bot host is a single point.
- **Payload types** — text + Discord attachments → spt-core's text+file channel.
- **Human authority** — a human posting in Discord is a human-backed origin; whether/how their messages carry `user-msg` authority, and the fact that Discord-channel membership ⊄ subnet trust boundary, needs a decision (who's in the channel = the trust gate).

## Provenance

Distilled from two grill sessions (2026-06-19/21). The core-side decisions (always-on endpoint kind, `#` sigil, generalized wake authorization) are recorded in the **spt-core** repo: `CONTEXT.md`, `docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md`, and `REQ-EP-8`/`REQ-EP-9`.


Grill-with-docs complete. Design captured, gate green, split honored.

## What landed where

**spt-core (core-relevant only):**
- `CONTEXT.md` — two new glossary terms: **`#` always-on sigil** + **AlwaysOnEndpoint**; fixed the now-false "the one class of boot-launched binary" claim → "two classes" (cross-ref discipline).
- `docs/adr/0023-always-on-endpoints-resident-supervised-binary-sigil-addressing.md` — the decision + 4 rejected alternatives (Shell / generalize-watcher / faceless-service / bake-in-Discord) + consequences.
- `traceable-reqs.toml` — **REQ-EP-8** (always-on endpoint kind + supervision) and **REQ-EP-9** (`#` sigil), both inactive (`required_stages = []`, rule 5 — design ratified, unbuilt). `traceable-reqs check` → **EXIT=0**.

**../spt-discord/ (new sister dir):**
- `README.md` — all adapter-land design: webhooks, per-agent threads, hybrid delivery, thread lifecycle, echo-loop/secrets/SPOF/authority open items.

## Resolved facts
- **Residual verify DONE**: `cmd_rest` (cli.rs:1850) — wake is target-side gated only (local ungated; remote = target node's access gate). No caller-ownership guard. No new auth needed.
- **`#` sigil does NOT touch REQ-HAZARD-ID-CHARSET** — sits above the id validator; bare ids stay charset-clean.

## Core change, sized (for whenever a milestone picks it up)
1. `[always-on]` manifest section + register-triggered supervision (reuse `shellwake.rs` scaffolding minus the offline flip).
2. `#` sigil in `spt_proto::addr::Address::parse` + class-discriminated resolve.
3. Wake targeting already works — no change.

Nothing staged, nothing committed (you didn't ask). Want me to commit these doc changes, or keep poking the open adapter-level items (human-authority/trust is the meatiest)?


please commit, and also add the spt-core items to DEFERRED.md


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


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