# Known Hazards

> A **conformance checklist, not advice.** Each hazard below is a first-class
> `REQ-HAZARD-*` requirement in `traceable-reqs.toml`, and is **not "covered" until a test tags
> it** (`unit`, plus `int` where the failure is cross-process / cross-node). This file exists to
> make "we won't re-break X" mechanical: an entry without a passing tagged test is an open risk,
> and `traceable-reqs check` will say so once the hazard is activated.

A hazard earns a place here when it is an invariant you have *paid for once* (a real bug, an
incident) or one you have *committed never to introduce*. State it so a test can prove it.

## Entry format

Each entry is one numbered subsection with these fields:

- **Failure** — the concrete bad behavior: what goes wrong, under what sequence / timing / input.
- **Invariant** — the property that MUST hold, phrased so a test can assert it (the thing the
  `REQ-HAZARD-*` requires).
- **Mapping / notes** — where this lives in *this* project, and anything that changes the shape
  of the test (e.g. "in-process now, so use a lock instead of racing on disk").
- **cite** — where the failure / fix is evidenced (an incident, a prior commit, a source path);
  reference only — the binding evidence is the tagged test.

Mirror each entry as a requirement:

```toml
[[requirements]]
id = "REQ-HAZARD-EXAMPLE"
title = "The invariant, stated so a test can prove it"
required_stages = []   # activate (["unit"] or ["unit","int"]) when you cover it
```

---

## 1. Windows / MSYS shell environment

### 1.1 MSYS `/`-prefix path conversion mangles slash-leading arguments

- **Failure:** On Windows under Git-Bash / MSYS, any **command-line argument** beginning with `/`
  is silently rewritten to an absolute Windows path before the target binary sees it. Observed
  2026-06-15 during the UPS-fires validation: invoking `claude -p "/send hi"` from Git-Bash
  delivered the prompt to Claude Code as `C:/Program Files/Git/send hi` — the `/send` token was
  path-converted. Anything that passes a `/sptc:…` (or other `/`-leading) token as a **positional
  argument** through a Git-Bash layer is corrupted the same way. (Legacy `claude_skill_owl`
  documents the identical hazard in `new-alarm`.)
- **Invariant:** sptc adapter glue MUST NOT depend on receiving `/`-leading content as a
  Git-Bash positional argument. Message/prompt content is read from the **hook stdin JSON**
  (`prompt`, message bodies), never reconstructed from a `/`-prefixed argv; any helper that must
  take such an argument uses a stdin/`--message-file` transport or `MSYS_NO_PATHCONV=1`.
- **Mapping / notes:** the hook wrappers (`plugin/sptc/hooks/*.sh`) are immune by construction —
  they parse the CC hook payload from stdin (`json_str`), not from argv. The invariant is the
  *commitment* to keep it that way (and to apply it to any future `/sptc:*` arg-taking surface).
  A test asserts the stdin path is honored (no argv `/`-token dependency).
- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

---

## 2. Live-agent perch / CI orchestration

### 2.1 Nested-`claude` perch collision tears down the live agent's poll stream

<!-- [doc->REQ-HAZARD-PERCH-COLLISION] -->

- **Failure:** The CI acceptance harness spawns a real `claude` session as the system-under-test.
  If that nested session loads the spt plugin (whose SessionStart establishes a perch) and resolves
  the **same perch id as the live operator agent** (e.g. `perri`), the nested establish **displaces
  the operator's perch** — perches are name-keyed, last-establish-wins — killing the operator's
  `api poll` / live stream. Observed 2026-06-15 as the live poll Monitor exiting `exit-1` plus a
  `sessions log seal failed: git failed` on revive (the collision teardown). Diagnosed by the
  operator as self-inflicted, NOT a legacy-substrate bug.
- **Invariant:** Every nested SUT the acceptance harness spawns MUST run under a **disposable
  identity** distinct from any live agent — `SPT_AGENT_ID=sptc-ci-<n>` (and the matching
  `OWL_SESSION_ID`), never a live agent name. The harness MUST set this for every spawn; it MUST
  NOT inherit the operator's `SPT_AGENT_ID`/`OWL_SESSION_ID`. A test asserts the harness always
  overrides both to a `sptc-ci-` id and never emits a live id.
- **Mapping / notes:** `ci/acceptance/lib.sh` `sptc_ci_identity` mints the disposable id and
  exports it into the SUT env; `ci/acceptance/run-acceptance.sh` spawns `claude -p` only through
  that env. The deterministic guard lives in `tests/acceptance-harness.sh` (no real `claude`
  needed — it asserts the env the harness would hand a spawn). Identity is the documented
  name-keyed knob (`spt whoami` resolves from `$OWL_SESSION_ID`/`$SPT_AGENT_ID`); a separate data
  dir is not part of the public surface, so isolation rides on identity.
- **cite:** Operator diagnosis 2026-06-15 (perch collision, self-inflicted via nested `claude -p`
  loading the spt plugin). Reference only — binding evidence is the tagged test under
  `REQ-HAZARD-PERCH-COLLISION`.

### 2.2 A non-interactive spt-spawned CC deadlocks on a permission prompt

<!-- [doc->REQ-HAZARD-PSYCHE-PERMS-DEADLOCK] -->

- **Failure:** spt-core spawns Claude Code **non-interactively** on two paths — the Psyche runner
  (`claude-spt psyche`, launched **detached** with `Stdio::null`) and the `[session.self]` bringup
  (broker-held **PTY**, no operator attached at spawn). If such a CC turn hits a tool-use that
  requires permission approval, the interactive permission gate has **no operator / no stdin** to
  approve it — the turn blocks indefinitely. For the Psyche this is silent (detached, stdio
  discarded): the daemon believes it hosts a working companion that never produces a commune.
- **Invariant:** Every CC process spt-core spawns non-interactively MUST bypass the permission gate
  with `--dangerously-skip-permissions`. Concretely: each `claude-spt psyche` turn (seed **and**
  every pulse) carries it, and **both** `[session.self]` bringup commands (base `claude`, the `ccs`
  profile) carry it. The Psyche additionally runs **inside** a Read/Edit/Write tool sandbox
  (`--tools Read,Edit,Write --disable-slash-commands`), so auto-approve is bounded, not blanket
  trust (see `docs/adr/0003-*`).
- **Mapping / notes:** `tools/claude-spt/src/psyche.rs` `sandbox_flags()` appends the flag (and
  the tool cap) to both `seed_cmd` and `pulse_cmd`; the unit test
  `every_turn_is_sandboxed_to_legacy_owl_parity` asserts it on every turn. The bringup leg is a
  manifest property — `adapter/claude-spt.toml` `[session.self].command` and
  `[profiles.ccs.session.self].command` must both contain `--dangerously-skip-permissions`; a unit
  assertion over the manifest covers it. The legacy sibling `claude_skill_owl`
  (`src/live/wrapper/claude.rs`) applies the identical flag to init/resume/final, which is why this
  is a *committed-never-to-reintroduce* invariant rather than an observed incident here.
- **cite:** `claude_skill_owl/src/live/wrapper/claude.rs` (init/resume/final all pass
  `--dangerously-skip-permissions`); identified 2026-06-18 while reaching psyche parity. Reference
  only — binding evidence is the tagged tests under `REQ-HAZARD-PSYCHE-PERMS-DEADLOCK`.

### 2.3 A missed `/clear` boundary rotation pins the perch to a dead session id — silently and permanently

<!-- [doc->REQ-HAZARD-SESSION-PIN-WEDGE] -->

- **Failure:** On `/clear` (or `/compact`) CC rotates to a NEW session id, and the SessionStart
  boundary hook must re-pin the perch to it. The rotation verb is auth-gated on the **departed**
  session's proof (`AUTH_REFUSED … (need --token or matching --session-id)` without it), and the
  departed session's env file is already gone when the hook fires. The pre-v0.10.4 hook had two
  stacked defects: it resolved the perch id via `whoami --json` keyed by the NEW sid — a catch-22
  (the new sid is unregistered until the rotation succeeds) that resolved `self:null` and **skipped
  the rotation with zero diagnostics** — and even when the id resolved it presented **no auth
  proof**. Either way the perch stays pinned to the dead sid, after which EVERY id-scoped api call
  (`poll`/`state`/`boundary`) from the live session is silently `AUTH_REFUSED` (stderr-only): the
  session looks perfectly healthy (brief renders, turns run) while local AND cross-node delivery is
  permanently dead. **No self-heal exists**: later boundary attempts present the new sid and refuse
  too, and spt-core's dead-owner re-pin correctly does NOT cover a live harness pid (squat
  protection). Observed live: the ENLYZEAM `ball-b` wedge (F-024, root-caused 2026-07-02) —
  operator clean-room repro: fresh session + `/clear` wedges with zero hook errors.
- **Invariant:** (1) The boundary branch resolves the endpoint id from `$SPT_ENDPOINT_ID` FIRST
  (the brief path's proven preference) — never whoami-by-new-sid alone. (2) The hook persists the
  CURRENT session id at EVERY SessionStart to an adapter-owned state file
  (`{adapter_dir}/state/session/<endpoint_id>.sid`) and presents it as `--session-id` proof at the
  next rotation — NOT via `CLAUDE_ENV_FILE`, whose per-session lifetime is the exact failure mode.
  (3) A rotation that cannot run (no id) or is refused (auth) logs LOUDLY to stderr with the
  reason; a silent skip is forbidden.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `sid_state_rel` + the boundary branch +
  the post-registration persistence; `HookEnv::{spt_strict,log,read_adapter_state,write_adapter_state}`
  seams (the plain `spt` seam discards stderr and exit — the refusal reason NEEDS the strict seam).
  Recovery for an already-wedged perch: relaunch the endpoint WITHOUT `/clear` (resume re-binds the
  recorded sid; live-proven on ENLYZEAM 2026-07-02).
- **cite:** F-024 correction arc, `docs/SPT-CORE-FINDINGS.md` (empirics: whoami-by-dead-sid →
  `self:null`; `AUTH_REFUSED:ball-b (need --token or matching --session-id)` shell-verified); doyle
  ruling `F024D-BOUNDARY-RULING` 2026-07-02 (adapter-owned state file = the contract-clean carrier;
  the reference pattern for the harness-contract boundary doc). Binding evidence = the tagged tests
  under `REQ-HAZARD-SESSION-PIN-WEDGE`. Fix live-validated on-node: E2E-0104 GREEN (doyle verdict
  2026-07-02 — rotation authenticates with the prior sid, `.sid` rolls forward in lockstep,
  post-rotation delivery lands; the `[<e2e>->REQ-DIST-BOUNDARY-ROTATE]` record in
  `docs/SPT-CORE-FINDINGS.md` F-024).

### 2.4 The checkpoint wake races the `/clear` and lands in the OLD session

<!-- [doc->REQ-HAZARD-CHECKPOINT-CLEAR-RACE] -->

- **Failure:** The agent-driven checkpoint submits `/clear` then a wake directive. CC's `/clear` is
  ASYNC — it tears down and rebuilds the session, re-running SessionStart (whoami, psyche-download,
  brief assembly — network I/O, seconds). The pre-fix macro emitted both in ONE inject sequence with a
  fixed `500ms` gap between the `/clear` submit and the wake. Two stacked problems: (1) 500ms is a
  blind guess — a slow clear routinely exceeds it, so the wake is typed/submitted BEFORE the clear
  settles and lands in the OLD (about-to-be-wiped) session, or the ordering inverts entirely (the
  wake hits first). (2) A hard ceiling: the whole inject sequence must `{commit}` within the 5s
  `INJECT_COMMIT_DEADLINE`, so the delay CANNOT be widened to cover a genuinely slow clear — a single
  inject sequence is architecturally incapable of straddling a `/clear`. Field-observed: the followup
  wake hit first.
- **Invariant:** The clear and the wake are SPLIT into two inject sequences, synchronized on CC's own
  clear-done signal (SessionStart firing with `source == clear`). (1) ARM: a `{"checkpoint":"v1",…}`
  envelope stashes the wake in the translation binary's in-memory `pending_wake` and emits CLEAR-ONLY
  (`ctrl+s · 50ms · /clear · 50ms · enter · commit`); the wake is withheld. (2) FIRE: the SessionStart hook,
  on a `clear` boundary, UNCONDITIONALLY self-sends `{"checkpoint_fire":"v1"}`; the binary emits
  WAKE-ONLY (`ctrl+s · 50ms · wake · 50ms · enter · commit`) iff a wake is armed, else no-ops. State lives
  ONLY in translate memory (no marker file; the hook is stateless — it fires on every clear and the
  binary decides). BOTH self-sends use `--force-native` so the signal is delivered through the
  translation binary's stdin (where the markers parse), never spooled to the active-poll channel
  (which surfaces the envelope as a plain message the agent reads instead of firing — the ENLYZEAM
  misdelivery mode). Ordering is guaranteed by construction: the wake can only emit after SessionStart,
  which only fires after `/clear` completes. Fires ONLY on `clear`, not `compact` (no checkpoint
  variant leverages `/compact`).
- **Accepted residual:** if the checkpoint's own clear fails to fire the signal (hook error), the
  armed wake fires on the NEXT clear — possibly a slightly-wrong context. The every-clear-fire model
  self-limits it (it won't sit forever); a translate-side generation/timeout could bound staleness if
  it ever bites. Accepted for v1.
- **Mapping / notes:** `tools/claude-spt/src/translate.rs` — `commands_for_clear` (ARM) +
  `commands_for_wake` (FIRE) + `is_checkpoint_fire` + the stateful `dispatch_event` threading
  `pending_wake` (the binary is one persistent process per endpoint, reaped on shutdown NOT on
  `/clear`, so the armed wake survives the clear). `tools/claude-spt/src/hook.rs` —
  `handle_post_tool_use` (`--force-native` on the arm self-send) + `handle_session_start` (the
  `clear`-boundary fire: mark idle + self-send `checkpoint_fire` `--force-native`).
- **cite:** `CHECKPOINT-CLEAR-RACE-PLAN.md`; supersedes the single-sequence macro described in
  `REQ-DIST-CHECKPOINT-COMMUNE`. Binding evidence = the tagged tests under
  `REQ-HAZARD-CHECKPOINT-CLEAR-RACE` (crate units: `arm_then_fire_orders_clear_before_wake`,
  `fire_emits_wake_only_and_disarms`, `fire_with_no_armed_wake_is_a_noop`; hook units:
  `clear_boundary_fires_checkpoint_fire_force_native`, `non_clear_starts_do_not_fire_checkpoint`; int:
  the ARM-emit block in `ci/idle-translate/translate-proof-int.sh`).
- **Second leg (v0.15.1, paid 2026-07-06): everything that fires POST-clear must ride ONE inject
  sequence.** The boundary rename (v0.14.1) originally rode its own sequence immediately before the
  wake's — two back-to-back sequences at the just-rebuilt boundary RACED (CC input processing lags
  the PTY stream: the rename's enter registered as a soft newline, the next sequence's ctrl+s failed
  to stash the residue, and the wake text submitted INSIDE the `/rename` argument — flynn: session
  titled with the wake, NO wake turn, agent dormant ~9.5h; ~33% observed, 1 of 3 checkpoints).
  Invariant: the fire emits rename + wake as ONE combined sequence with an explicit enter→ctrl+s
  bridge delay between the halves (`commands_for_boundary`, `BOUNDARY_BRIDGE_MS`); the rename
  delivery only STASHES. Never reintroduce a second post-clear inject sequence. Regression lock:
  `checkpoint_boundary_is_one_combined_sequence_rename_then_wake` (crate unit).

---

### 2.5 A spawned Psyche inherits its parent's identity env and rebinds a FOREIGN perch

<!-- [doc->REQ-HAZARD-PSYCHE-IDENTITY-ENV] -->

- **Failure:** The `[session.psyche_init]` child inherits the spawning session's environment —
  including `OWL_SESSION_ID` / `SPT_AGENT_ID`, the parent's identity env. The Psyche's own harness
  hooks then resolve "self" to the PARENT (or, worse, an unrelated top-level perch) and stamp/rebind
  THAT perch's records. Field-observed on HFENDULEAM (F-028 C2 evidence): `f015b-probe-psyche`'s hook
  rebound `hall-a`'s `info.json.session_id` to the psyche's own session via `authenticate()`'s
  dead-owner re-pin — cross-perch identity contamination, silent.
  **Second leg (2026-07-09, seat theft):** `SPT_ENDPOINT_ID` is identity env too, and the F-028
  scrub set missed it. The daemon itself can carry a stray value (restarted from doyle's session,
  it inherited `SPT_ENDPOINT_ID=doyle`) and passes it to every psyche spawn; each psyche claude
  turn fires a NORMAL SessionStart (`startup` first turn, `resume` EVERY later turn — `claude -p
  --resume` re-fires it per pulse), `register_verb` sees the endpoint id → bind path → rotates
  that endpoint's perch to the psyche's own sid with a VALID `state/session/<id>.sid` proof.
  Every psyche on the node stole the SAME victim's seat (doyle) regardless of parent: deliveries
  drained into psyche transcripts (37 messages in lia's psyche alone; 1963 wrong-perch briefs in
  doyle's own psyche), echo communes/pulses silent, the real session's sends downgraded to
  `cli@node`. Trace pin: `hook-trace.log` `bind(resume) re-pin FAILED for doyle:
  CONFLICT:doyle is live under session <deployah-psyche-sid|lia-psyche-sid>`.
- **Invariant:** A Psyche's (and echo-commune's) inner claude NEVER carries ANY identity env and
  NEVER runs live hook logic. Three belts: (1) all three role tables (`[session.psyche_init]`,
  `[session.psyche_resume]`, `[session.echo_commune]`) declare
  `env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID", "SPT_ENDPOINT_ID"]`, and both psyche roles
  declare `recursion_guard_env = "SPT_PSYCHE_TURN"` (doyle F-036 ruling 2026-07-10: core honors
  the guard on ALL session roles — the schema's "summarizer children" wording is a core doc gap);
  (2) the shims scrub the same three vars again at their own claude spawn (LAST HOP — catches
  daemon-env leaks core's role-level scrub can't see) and set the recursion-guard marker
  themselves (`SPT_PSYCHE_TURN` / `SPT_ECHO_COMMUNE` — belt against a core regression);
  (3) the hook binary bails (exit 0, zero api calls) under either marker.
  spt-core's halves (nested-inclusive self-resolve + cross-perch bind-honesty refuse) guard the
  daemon side, but the adapter must not hand the child a foreign identity in the first place —
  and the daemon's own env hygiene at role spawn is F-036 (core-side, filed to doyle).
- **Mapping / notes:** `adapter/claude-spt.toml` `env_remove` on all three roles (the
  declarations); `tools/claude-spt/src/psyche.rs` `PSYCHE_GUARD_ENV` + `PSYCHE_IDENTITY_SCRUB` +
  `claude_command` (shim-side scrub, unit-locked by
  `psyche_turn_sets_the_hook_guard_and_scrubs_identity_env`); `hook.rs` `shim_guarded` bail;
  `echo_commune.rs` `run_claude` (belt). Binding evidence for the manifest = the env-scrub block
  in `tests/manifest-shortcut.sh`. If a future role spawns any other detached child that runs
  harness hooks, it needs the same scrub + guard pair.
- **cite:** doyle's F-028 C2 adapter change brief (2026-07-03); the 2026-07-09 seat-theft RCA
  (this repo, F-036 in `docs/SPT-CORE-FINDINGS.md`); spt-core runtime `env_remove` support is
  public contract (`manifest.schema.json` `SessionRole.env_remove`).

### 2.6 The psyche host loops silently at ~3 boots/sec when claude dies at spawn

<!-- [doc->REQ-HAZARD-PSYCHE-HOST-THRASH] -->

- **Failure:** The psyche host's resident loop had no pacing: each iteration polls the perch
  (`spt ready --once`) then maybe runs a claude turn. A healthy iteration BLOCKS (the poll waits for
  a pulse; a model turn takes seconds) — but when claude dies instantly at spawn (canonically: an
  untrusted `{psyche_dir}` cwd puts CC at a workspace-trust prompt with null stdin, F-h/F-026) or
  the poll returns success-instantly-empty, the loop spun ~3 iterations/sec, silently, forever.
  Field-observed on HFENDULEAM: ordinal 5358 boot rows in ~30min. Doubly invisible to spt-core:
  its residency pacing keys on wrapper DEATH (the wrapper stays resident), and its
  `psyche_host_error` surface sees wrapper EXITS only (nothing exits). Even `spt endpoint shutdown`
  left the looping wrapper running (core-side hardening for that is C3(b)/(c), theirs).
- **Invariant:** The loop is PACED and TERMINAL under persistent instant cycles: a cycle finishing
  sub-2s (`INSTANT_CYCLE_MS`) increments a streak — exponential backoff (500ms doubling, 5s cap) —
  and at 8 consecutive (`GIVE_UP_AFTER`) the host prints `PSYCHE_HOST_GIVE_UP:<id>` to stderr and
  exits **nonzero** (3), converting the invisible loop into the wrapper death spt-core's existing
  `psyche_host_error`/residency machinery already handles. Any healthy cycle resets the streak
  (recovery mid-thrash — e.g. the operator trusts the dir — must not accumulate toward give-up).
- **Threshold coupling (doyle-ruled, binding):** spt-core's C3(b) backstop trips at ≥10 ledger
  boundaries per 60s window. For the pure instant-death class the adapter always fires first —
  ≤8 boundaries, <60s worst-case wall clock, strictly inside the core trip on both axes. Failure
  modes that dodge the streak (e.g. just-over-threshold cycles resetting it — the reset
  deliberately forgives) legitimately reach the core backstop: a core trip on a guarded wrapper is
  CORRECT backstop behavior, not a bug. Do not retune these constants except in step with the
  ruling (the coupling is asserted by `give_up_stays_inside_the_core_backstop_window`, derived
  from the real constants).
- **Second leg (v0.13.4, field 2026-07-04) — a REFUSED poll must die loud, not exit clean:** the
  runner's per-pulse `spt ready <id> --once` can fail for CONFIG/ENV reasons the runner can never
  satisfy — canonically the multi-subnet home guard (`READY_FAIL: … pass --subnet`; the daemon
  fills carry no subnet, a core seam, F-017 sibling). Treating any non-success as "perch gone"
  exited 0 silently and moved the invisible loop ONE LEVEL UP: the daemon rehosted the runner
  ~every 5s forever (hall-bf-psyche, ordinal 6491, since the node's second subnet joined —
  invisible to C3(b), see doyle's boot-records-vs-ledger item). Invariant: a poll failure whose
  stderr carries `READY_FAIL` exits LOUD + NONZERO (`PSYCHE_POLL_FAIL`, exit 4) so
  psyche_host_error stamps a real fault; a plain non-success (perch closed at session end) stays
  the quiet lifecycle exit. Asserted by `ready_fail_refusal_is_loud_not_perch_gone` +
  `plain_poll_death_stays_the_clean_perch_gone_exit`.
- **RESOLVED-BY-DESIGN (v0.14.0, F-030 psyche-ephemeral redesign):** the resident loop this hazard
  guards — and its second-leg poll-fail — are DELETED. The shim is run-to-completion (one `claude`
  spawn per invocation, then exit): there is no loop and no per-pulse `spt ready --once`, so neither
  the instant-cycle class nor the multi-subnet `READY_FAIL` churn can occur adapter-side. Crashloop is
  now repeated ephemeral deaths, which core C3(b)'s ledger-rate backstop + the daemon's
  `psyche_host_error` surface own by design (an ephemeral death is exactly what that surface consumes).
  The subnet-less nested-resolution seam (a runner could never know its subnet) folds into the F-030
  core wave. Kept for history per KH convention; the successor invariant is the shim's pure planner.
- **Mapping / notes:** `tools/claude-spt/src/psyche.rs` — the ephemeral shim's stateless `plan()`
  (successor evidence: `plan_is_stateless_one_turn_no_residency` — plans exactly one turn, nothing to
  thrash), replacing the retired `thrash_guard` module + `poll_refused` classifier (both deleted with
  the resident loop). The deferred give-up/poll-fail live ints die with the loop (nothing to trigger).
- **cite:** doyle's C3 adapter-half brief (2026-07-03), formalizing our F-h finding; spt-core
  halves = C3(b) ledger-rate trip @b2351a0 + shutdown tree-kill hardening; second leg = the
  hall-bf field residual (doyle confirm run, 2026-07-04) + perri's multi-subnet READY_FAIL repro;
  RESOLVED-BY-DESIGN = the v0.14.0 ephemeral redesign (PSYCHE-EPHEMERAL-PLAN.md, F-030 adapter half).

### 2.7 A delivery answered with ZERO records faults + terminates the translation binary

<!-- [doc->REQ-HAZARD-EMPTY-RESPONSE-COMMIT] -->

- **Failure:** The broker opens an inject sequence for every event delivery it feeds the
  translation binary and ends it ONLY on an explicit `{commit}` — a delivery the binary answers
  with zero output lines leaves the sequence unterminated, the 5s `INJECT_COMMIT_DEADLINE`
  expires, and the broker faults + TERMINATES the binary (permanently until respawn; the
  published contract's "raw-inject fallback" consequence is stale — removed core-side v0.14.3).
  The adapter had exactly this zero-record path: a `checkpoint_fire` with nothing armed answered
  `Vec::new()`. Since the SessionStart hook self-sends `checkpoint_fire` on EVERY clear
  (stateless by design, REQ-HAZARD-CHECKPOINT-CLEAR-RACE), every non-checkpoint `/clear`
  killed the binary at the boundary — the deterministic half of the "force-native decay" ghost
  (B6). Pinned 2026-07-04: iso captured stderr shows the ARM's commit ACCEPTED and the
  `TRANSLATION_FAULT` following the unarmed duplicate fire ~5s later; whole-sequence-deadline
  and parse-drop mechanisms were excluded by measurement (sequence budgets ≤~150ms vs 5s;
  zero `TRANSLATION_BIN_BAD_LINE`; commit line bytes exact).
- **Invariant:** EVERY event-typed delivery answers at least a bare `{"commit":true}` — a
  deliberate no-op delivery still terminates its inject sequence. Scope is event-typed responses
  ONLY: `init`/`input`/unknown stdin lines are protocol lines, not inject deliveries, and must
  stay silent (an unsolicited `{commit}` outside a sequence is protocol noise; doyle-confirmed
  scoping).
- **Mapping / notes:** `tools/claude-spt/src/translate.rs` — `dispatch_event`'s unarmed-fire
  branch and `dispatch_line`'s event-without-envelope branch each emit the bare commit. Binding
  evidence = the three `REQ-HAZARD-EMPTY-RESPONSE-COMMIT` unit tests (two bare-commit asserts +
  the no-stray-commit scoping assert). The live boundary-survival int graduates with the
  wake-survival e2e once spt-core's C-1 (miss≠fault deadline semantics) lands. This was a JOINT
  contract gap: the published contract nowhere states the empty-response rule and understates
  the missed-commit penalty — both docs fixes ride core's C-1 chunk.
- **cite:** doyle 3-discriminant collab + verdict (2026-07-04, work order @2915eb8); iso wtrace
  captured stderr (4 injects, fault after the duplicate fire); operator's two-commit premise
  check that reframed the hunt to the receive side.

### 2.8 Raw PreToolUse stdout is DISCARDED by CC — a mid-turn drain becomes a silent message black hole

<!-- [doc->REQ-HAZARD-PRETOOL-CONTEXT-ENVELOPE] -->

- **Failure:** CC injects a hook's additionalContext from PLAIN stdout only on
  `UserPromptSubmit`. On `PreToolUse`, plain stdout is logged and DISCARDED — context reaches
  the agent only inside the `{"hookSpecificOutput":{"hookEventName":"PreToolUse",
  "additionalContext":…}}` JSON envelope. The adapter's mid-turn delivery leg (F-021) emitted
  raw text on both events, so every message drained during a busy turn was TAKEN from the spool
  (`delivered=1`, honest core-side) and then thrown away by CC — invisible to the agent,
  invisible to the sender, invisible in the spool. Field 2026-07-07 (doyle RCA): every
  claude-spt session on the node was a silent message black hole; three doyle→perri messages
  (including the outage evidence itself) were eaten mid-turn. The busy-path eat also MASKS
  itself: a busy agent's rows are consumed within seconds by its constant PreToolUse stream,
  so the idle-path/UPS legs never get a chance to surface them.
- **Invariant:** Every PreToolUse emission — message drains, nudges, overflow pointers — rides
  the `hookSpecificOutput` JSON envelope, never raw stdout. The cap decision is made on the
  WRAPPED byte size (JSON escaping can double newline-heavy drains), and an over-cap drain
  spills the RAW text with a WRAPPED pointer — a taken message must always surface somewhere
  the agent can read.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `wrap_pretool_context` +
  `emit_capped_pretool`, wired in `handle_pre_tool_use`. UserPromptSubmit keeps raw-stdout
  emission (that channel is the documented raw-context path and is live-proven).
  `~/.claude/reference_docs/claude-code-hooks.md` is the per-event contract source — check it
  BEFORE wiring any new hook output, per event, never by analogy from another event.
- **cite:** doyle node-wide RCA + self-probe (2026-07-07); perri repro: the installed binary
  driven directly emits correctly (binary exonerated), spool rows delivered=1 with zero
  surfaced `<sptc_messages>`; recovered bodies read straight from `spool.db`.

### 2.9 A poll frame truncated mid-frame is silently dropped WHOLE by the drain renderer

<!-- [doc->REQ-HAZARD-DANGLING-FRAME-LOUD] -->

- **Failure:** `render_frames` walks `spt api poll` output frame by frame (`<EVENT …>body</EVENT>`).
  If the poll output ends mid-frame — opening tag present but no `>`, or a body with no closing
  `</EVENT>` — the original loop `break`s and the dangling frame vanishes: the message was taken
  from the spool (`delivered=1`) but nothing surfaces to the agent. Same silent-loss family as
  §2.8, one seam upstream. Never observed in the field (the 2026-07-07 seed-#9 head-truncations
  were core-side idle-inject pacing, spool rows complete, and could NOT have produced this shape),
  but the drop path existed and was invisible by construction — hardened 2026-07-07 (doyle-ACKed)
  before it could cost a real message.
- **Invariant:** A dangling frame is never dropped silently. The renderer emits a loud
  `<sptc_frame_integrity>` marker carrying the surviving partial content and a spool-recovery
  pointer (`owlery/<id>/spool.db`, `messages` table) — a taken message must always surface
  somewhere the agent can read (the §2.8 rule, applied to the framing seam).
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — the dangling-frame arm in `render_frames`.
  Rides both drain paths (UserPromptSubmit + PreToolUse) and the existing capped emits unchanged.
- **cite:** perri code-walk during the seed-#9 seam exoneration (2026-07-07); doyle "ship it" ACK
  same night.

### 2.10 A completed `/compact` leaves the endpoint stuck ACTIVE — parked messages wait for a human

<!-- [doc->REQ-HAZARD-COMPACT-STUCK-BUSY] -->

- **Failure:** SessionStart(source=`compact`) rebound the perch boundary but asserted `state idle`
  only for `clear` — a compact fell through with no idle transition and no park re-drive. Field
  (deployah 2026-07-21, hertz RCA): doyle's RELEASE GO arrived `SENT` 79–88ms before the operator
  submitted `/compact`; the typed stub did not survive the compact collision, the body stayed
  safely committed in adapter msgpark (custody model held), and with the endpoint still ACTIVE and
  no further traffic, the GO sat **46m45s** until the operator's next UserPromptSubmit happened to
  drain it. Fourth entry point into the stuck-ACTIVE black-hole class (never-first-turn, API-error
  turn-end, Esc-interrupt, now `/compact`); the interrupt watcher cannot heal it — no marker.
- **Invariant:** A completed MANUAL compact transitions the endpoint idle and, when msgpark holds
  committed bodies, self-sends a redrive (`--force-native`) whose stub opens the turn that drains
  them — automatically, never waiting on a human prompt. An AUTO compact does neither (it fires
  the same SessionStart **mid-turn**; idle/redrive there is busy-injection, and the continuing
  turn's next PreToolUse drains the park anyway). Trigger read from the transcript's last
  `compact_boundary` record (`compactMetadata.trigger`); undeterminable → heal (the black-hole
  direction costs 46 minutes, the noise direction one queued stub turn). An empty park never
  redrives (no dead prompt). SessionStart itself never drains or commits the park — custody stays
  with the UPS/PreToolUse drain → post-emit commit path.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — the compact arm of `handle_session_start`,
  `compact_trigger`, `HookEnv::msg_park_pending` (non-destructive peek).
- **cite:** hertz RCA + timeline amendment (2026-07-21/22, deployah SID 0cc427d2); stub-collision
  half is bounded inference, the missing-idle half source-certain.

### 2.11 Internal sessions (psyche, echo-commune) load the user's foreign hooks and plugins

<!-- [doc->REQ-HAZARD-PSYCHE-FOREIGN-CONFIG] -->

- **Failure:** The claude sessions the adapter spawns for itself — psyche turns and the
  echo-commune summarizer — inherited the user's full config root. Every user-installed hook and
  plugin loaded and fired inside them: users saw their hooks triggered by sessions they never
  started, and a prompt-injecting hook (observed live: a persona-restyling UserPromptSubmit hook)
  corrupted the very summaries the psyche exists to write. The recursion-guard env silences only
  the adapter's own hook; foreign config had no guard. CLAUDE.md memory and MCP servers leaked in
  the same way.
- **Invariant:** Every internal claude spawn is config-isolated: `--safe-mode` when the installed
  binary supports it (probe-gated), else the disable-all-hooks fallback. Auth must survive — the
  isolation lever that drops OAuth is forbidden (internal turns bill like the user's own).
  Operator-owned live/ready sessions are never isolated — user hooks belong in sessions the user
  owns. Any profile overlaying an internal-session command must still route through the shim
  (predicate-guarded; a rerouted command silently bypasses the isolation).
- **Mapping / notes:** `tools/claude-spt/src/isolation.rs`; spawn paths in `psyche.rs` /
  `echo_commune.rs`; the manifest predicate test.
- **cite:** operator report 2026-07-22 (user hooks firing from adapter-spawned sessions; caveman
  persona hook observed restyling psyche turns on HFENDULEAM).

### 2.12 A session-limit 429 leaves the endpoint stuck ACTIVE — the limit banner reads as agent activity

<!-- [doc->REQ-HAZARD-RATE-LIMIT-STUCK-BUSY] -->

- **Failure:** A turn that dies on the account session limit gets a SYNTHETIC assistant message in
  the transcript (`model: "<synthetic>"`, `isApiErrorMessage: true`, `error: "rate_limit"`, text
  `You've hit your session limit · resets <time>`). The digest extractor renders assistant text as
  an ordinary `agent` record, so the banner surfaces as an `Agent` entry on the latest digest turn
  — and the interrupt watcher reads any `Agent` entry as the agent producing output, i.e.
  "genuinely resumed": re-arm, never heal. `StopFailure` does fire on the first hits and heals,
  but each drained delivery re-marks the endpoint busy and re-429s; once CC stops attempting API
  calls for the limited session it fires no further hook, so the LAST delivery leaves the endpoint
  ACTIVE with the banner as its newest turn. The watcher's misread makes the black hole permanent:
  messages arriving after the limit resets can never wake the session.
- **Field (2026-07-28, operator-reported):** lia hit the session limit ~00:59 local. hook-trace
  shows 30+ `RECEIVE_HEAL (StopFailure)` marks between 01:03 and 03:17, then silence — every heal
  drained a delivery that immediately re-429'd, until CC went quiet. The limit reset at 04:20;
  lia stayed dark until the operator woke her by hand.
- **Invariant (`REQ-HAZARD-RATE-LIMIT-STUCK-BUSY`):** a digest turn carrying an `Agent` entry whose
  text STARTS WITH `You've hit your session limit` is a stuck marker, not activity. Such a turn is
  never read as a resume — whatever else it carries (a pre-limit `Agent`/`ToolSprint` entry on the
  same turn must not defeat the heal; same misread class as the echo_mirror RCA in §7-adjacent
  interrupt history). A limit marker anywhere in the scan window with no real agent activity on the
  latest turn heals exactly once, sharing the interrupt marker's heal-once latch and re-arm rules.
  Start-anchored match only: an agent QUOTING the phrase mid-text is not stuck; the false-positive
  cost of a start-anchored collision is one idempotent idle mark, self-corrected at the next busy
  transition.
- **Mapping / notes:** `tools/claude-spt/src/interrupt_watch.rs` (the same hook-independent
  watcher as the Esc-interrupt heal — this is its second marker vocabulary). The hook-side layer
  (`StopFailure` → `state idle`) is necessary but insufficient: it cannot fire when CC no longer
  attempts the API call. Refined by `REQ-LIMIT-RESET-DEFERRED-HEAL` (operator-directed,
  2026-07-28): the banner names its own reset time, so the heal is DEFERRED to reset+60s — the
  watcher holds the idle mark (deliveries wait in the durable spool instead of churning into the
  limited session), and a hosted `StopFailure` on the banner skips its immediate heal in favor of
  the watcher's deadline. Every fallback direction (unparseable clock, missing offset, implausible
  delay, non-hosted session) is heal-NOW, never hold-forever. v0.25.27 closed the two field
  residuals of v0.25.26 (lia, same evening): the hook gate keyed on `error_type == "rate_limit"`
  but CC fires StopFailure with `error_type=""` even on a session-limit 429 — the banner is now
  the sole discriminator; and the hold became ACTIVE — the watcher re-asserts `state busy` every
  tick while the hold is live, because a passive hold is defeated by any other idle source (a hook
  miss, an idle_prompt Notification) marking idle underneath it. v0.25.28 closed the last stray
  idle source: a daemon restart resuming a session mid-window asserted the bringup idle
  (REQ-HAZARD-STUCK-ACTIVE-NO-IDLE's own protection) and the resulting drain beat the 15s busy
  re-assert (field lia 2026-07-29 23:12, 2 spooled messages spent into the limited session). The
  bind arm and the idle_prompt heal now both yield inside a LIVE limit window — keyed on the
  transcript's newest assistant record, so a recovered session's stale banner never suppresses the
  protections those idle marks exist for.
- **cite:** operator report 2026-07-28 (lia); transcript specimen
  `owlery/lia/nested/lia-psyche/d42c9d7f….jsonl` (the synthetic-message shape, verbatim);
  hook-trace.log 01:03–03:17 heal burst.

### 2.13 A `clearing` latch whose `/clear` never arrives strands the endpoint BUSY forever

<!-- [doc->REQ-HAZARD-QUIET-LATCH-STRANDED] -->

- **Failure:** The across-clear quiet window (REQ-ACROSS-QUIET-WINDOW) keeps an arming endpoint
  BUSY on purpose — `arm_wake` writes `state/clearing/<eid>.latch` instead of marking idle, and Stop
  suppresses its turn-end idle mark — so that inbound SPOOLS instead of being delivered to a session
  whose `/clear` is already queued. That suppression is only safe while the `/clear` actually
  arrives. If it does not (the boundary is lost, the translate leg never types it, CC drops the
  queued command, the process dies mid-window), nothing else ever marks the endpoint idle: it stays
  ACTIVE forever and every inbound message spools unseen. That is the **stuck-ACTIVE black-hole
  class** — the one this repo has already paid for through four other entry points (never-completed
  first turn, API-error turn-end, Esc-interrupt, completed `/compact`) — reached by a fifth door
  that we would have built ourselves.
- **Invariant:** The latch is always falsifiable and always expires. **(1) New-turn falsifier
  (primary):** the latch records whether the arming turn's `Stop` was seen. A `UserPromptSubmit`
  ALWAYS proves a new turn started; a `PreToolUse` proves it once a Stop has been seen (a PreToolUse
  *before* the Stop is the window itself). Either proof means the `/clear` never fired — drop the
  latch and log loudly. **(2) Stamp backstop:** a wall-clock stamp on the latch, 15 minutes, so a
  crash that fires no hook at all heals on the next hook of ANY kind. Additionally **any**
  `SessionStart` source drops the latch (`clear` is the success path; `startup`/`resume`/`compact`
  are the stale heal), and any path that asserts idle for other reasons — the receive-heal on
  StopFailure/PostToolUseFailure — drops it too, so *latched* and *idle* can never both hold. An
  unparseable latch is treated as NO latch: fail open, because the black hole is always the worse
  direction. **The heal never asserts idle from a hook that cannot know the turn state:** it drops
  the latch and lets the normal machinery resume — a UserPromptSubmit/PreToolUse heal continues into
  its busy-mark and poll, which delivers the spooled bodies safely into the running turn, and a
  `Notification(idle_prompt)` heal falls through to that handler's own idle mark, which *is* a
  proven-idle moment. Asserting idle from a mid-turn hook is the busy-injection hazard (§2.10).
- **Rejected:** a short time cap alone. The quiet window is "the rest of the current turn", which is
  unbounded, so any single N either expires MID-window (re-opening the delivery bug the latch
  exists to close) or leaves a real black hole standing for N minutes. The falsifier is what makes
  the window bounded by *events* rather than by a guess; the stamp is only the crash backstop.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `clearing_latch_rel`, `ClearingLatch` /
  `parse_clearing_latch`, `clearing_verdict` (the pure decision), `HookPoint`, `clearing_quiet`
  (read → heal → verdict), `mark_clearing_stop_seen`, `drop_clearing_latch`, and the call sites in
  `arm_wake`, `handle_pre_tool_use`, `handle_stop`, `handle_notification`, `handle_session_start`
  and `mark_idle_receive_heal`.
- **cite:** operator report + grill 2026-08-04 (the noisy-window symptom: a peer message answered by
  an agent whose `/clear` was already queued, then a second across-commune written and lost at the
  boundary). The hazard itself is **anticipated, not yet field-observed** — it is the cost of the
  fix, identified at design time and closed in the same change.
- **Field-verify — COMPLETE 2026-08-04 (was deferred; the deferral is now discharged).** The quiet
  window was driven on a real endpoint across a real across-commune, and all three legs are observed
  on `perri`'s own id in `hook-trace.log` — the arm (`PreToolUse inbound SUPPRESSED`), the Stop leg
  (`Stop idle mark SUPPRESSED`), and the leg the previous session ended before reaching: `across-clear
  latch for perri released by SessionStart(clear)`, a 23.8s window (armed `…747621`, released
  `…771458`). `state/clearing/perri.latch` is 0 bytes (released, not stranded) and the endpoint shows
  reachable, not stuck BUSY. Independently, five peer endpoints have since armed and released the same
  latch and **all six latch files are 0 bytes** — so the heal is exercised node-wide, not just once.
  Each leg was confirmed against `perri`'s own id specifically: peer endpoints logging the same lines
  would look identical in the trace and prove nothing about ours (REQ-TAG-SEND-PRETOOL-TRACE).

### 2.14 A version gate that cannot read its instrument reports "too old" and passes by skipping

- **Failure:** `ci/subnet/multi-subnet-bringup-int.sh` decided which spt contract it faced with
  `spt endpoint run --help | grep -q -- '--subnet'`, skipping with "needs v0.14.0" when the grep
  missed. spt-core 0.54.0 **retires** `endpoint run`, so the command refuses, the grep misses for a
  reason having nothing to do with v0.14.0, and the int **exits 0 announcing it skipped for being too
  OLD** — the whole multi-subnet suite passing by skipping on the exact version it exists to test,
  while stating the opposite of the truth. Its three sibling ints call `endpoint run` too, but they
  announce themselves by failing; only this one went quiet.
- **cite:** deployah, 2026-08-04, flagged at the v0.54.0 tag before the fleet reached it — so this was
  caught by review, not by a red suite, which is precisely the point: a fail-open gate has no red
  suite to catch it.
- **Invariant (`REQ-HAZARD-VERSION-GATE-FAIL-CLOSED`):** a feature that is **absent** and a feature
  that **moved** are byte-indistinguishable in a grep count. Before a zero-match may mean "feature
  absent", the probe must prove it read a real body — a positive control that MUST match (here,
  `--help` exiting 0 *and* carrying `usage`). When the shape cannot be established, the gate **fails
  and names the port it needs**; it never skips. A zero from a command that refused describes the
  probe, not the contract. **This generalizes: any version-sniffing gate in `ci/` owes a positive
  control and a fail-closed default.** Same family as §7.1's silent blanking, REQ-TAG-SEND-PRETOOL-TRACE
  (an instrument covering one of two dispatch legs), and F-044 (a zero read off a version-lagged docs
  surface). Guarded by a unit that DRIVES the int with a stubbed `spt` rather than grepping its text —
  a shape-check would reproduce the very defect — and that keeps the honest pre-v0.14.0 skip working,
  since failing closed must not degrade into failing always.
- **Swept, not asserted (2026-08-04).** Claiming the rule "generalizes" while fixing one instance is
  the same list-shaped thinking the rule condemns, so every version gate in `ci/` was audited:
  - `ci/idle-translate/translate-proof-int.sh` — **same defect, fixed.** It grepped
    `adapter translate-proof --help` for `--dir` and skipped with "needs v0.13.2" on a miss; retire or
    rename that verb and it would have skipped for a reason it never measured.
  - `ci/psyche-download/psyche-download-int.sh` — **sound.** It tests the verb's own `--help` exit
    status, which is a real signal about presence, not a body scraped for a substring.
  - `ci/hooks/poll-int.sh` — **acceptable.** It version-parses against a list of OLD releases, so an
    unknown or empty version falls through and RUNS the test. Unknown proceeding into a loud failure
    is the safe direction; unknown skipping is not.
  The positive control now lives once, in `ci/lib/spt-probe.sh` (`sptc_flag_supported`), which returns
  three DISTINCT outcomes — flag present / flag absent / **probe failed** — because collapsing the
  third into the second is precisely the bug. `ci/lib/spt-bringup.sh` builds its shape detection on it.
<!-- [doc->REQ-HAZARD-VERSION-GATE-FAIL-CLOSED] -->

### 2.15 A re-render that keeps only `from` + body strips the envelope of the meaning core put on it

- **Failure:** spt-core rides a matched monic on the delivery envelope as `mnemonics-json`, and
  **suppresses the stranger trust-warning on the strength of that same match**. Our renderer
  (`hook::render_frames`) kept `from` and the body and dropped every other attribute, so the session
  got **no warning** (core correctly suppressed it) and **no monic** (we dropped it): a peer the agent
  had classified arrived reading as an ordinary sender. The published invariant is explicit — "the two
  never disagree, so you can never be warned about a stranger whose monic you were handed in the same
  envelope" — and an adapter transform that re-renders a delivery without the attribute manufactures
  exactly that disagreement. Nothing errored; the loss is only visible if you already know a monic
  existed.
- **cite:** hertz field RCA 2026-08-05 (lia ↔ ball-b, both on claude-spt 0.26.1, both directions
  symmetric); core half + contract verified by doyle in source and published docs; routed to this lane
  as an adapter defect.
- **Not custody.** `translate::park_envelope` writes the RAW envelope and both drains concat it
  verbatim — the stub/park pipeline was innocent. The single drop site was the renderer, which is also
  the reason the fix is cheap: `render_frames` serves BOTH the busy poll drain and the idle stub/park
  drain, so parity is structural rather than a second thing to keep in step.
- **Invariant (`REQ-MONIC-REVEAL`):** an envelope attribute that carries **recipient-facing meaning**
  must survive our re-render. The renderer names what it drops and why; when spt-core's grammar gains
  an attribute, THIS is the seam that decides whether an agent ever sees it. Today `type`, `notif_id`,
  `subnet` and `path` are dropped deliberately (no recipient-facing meaning on a rendered delivery) and
  `mnemonics-json` is revealed as a `<sptc_monics>` block leading its message, verbatim. The general
  shape of the bug — **a re-render is a lossy copy, and what it loses is invisible at the far end** —
  is the thing to keep in mind, not the one attribute.
- The revelation half is **interim** (operator ruling 2026-08-05): the now-signal funnel's MONICS
  category replaces it at milestone D. It is deliberately one helper, one call site and one digest
  entry so that migration is a removal. The custody half is durable and outlives it.
- **Accepted bounded risk — body-authored imitation.** Message bodies are unescaped on render, so a
  peer can type the literal text `<sptc_monics …>` into their own message and have it render. The
  **structural discriminant** is placement, and it is the same one core uses for the trust warning's
  reserved author: a genuine reveal is emitted by us at the FRAME level, ahead of and outside the
  peer's `<sptc_messages>` block, while anything a sender writes can only ever appear INSIDE their own
  block. Bounded, and accepted for that reason: the surface predates this fix (a body can equally type
  `</sptc_messages>` or a `<sptc_frame_integrity>` marker), it grants no new capability, and the
  discriminant is positional rather than a string a sender could match. spt-core carries the same
  obligation for every adapter in the published custody guidance (docs-site `networking/monics.md`,
  main @27d40b9) — our frame-level placement is what satisfies it. Revisit if a reveal ever needs to
  sit inside a rendered body: at that point the discriminant is gone and the risk is no longer bounded.
- **The together-assertion — DISCHARGED 2026-08-05, cross-node.** Suppression and revelation had to be
  measured in ONE delivery, or the split-brain could recur under a later renderer refactor with every
  local test still green. It is not buildable node-locally (same-node AND posture-open arrivals are
  both ratified never-warning, so a local probe has no warning whose suppression it could observe), so
  it was run ENLYZEAM → HFENDULEAM against a DISPOSABLE perch carrying an access entry for the sender —
  never by restricting a live perch, which would deny every unlisted peer for the window. Same peer
  both legs: **leg 1** (no monic) rendered the `from="spt-access"` TRUST WARNING and no block; **leg 2**
  (monic held) came back as ONE frame on the wire — no warning frame at all — carrying
  `mnemonics-json`, and rendered the block at frame level, text verbatim. Leg 2 was drained through the
  PARK leg, so cross-node custody rode the same measurement. Leg 1 is the arm that makes leg 2's
  absence a SUPPRESSION rather than a warning that never fires. Full procedure + evidence table:
  `docs/plans/MONIC-REVEAL-PLAN.md`. The rendering half is pinned locally by a unit so a refactor
  cannot undo it between cross-node runs.
<!-- [doc->REQ-MONIC-REVEAL] -->

### 2.16 A carried trust warning that our re-render drops is a security caution that never happened

- **Shape:** spt-core now composes the stranger trust warning onto the delivery envelope itself, as a
  `trust-warning` attribute written by the RECEIVING node — the same construction as `mnemonics-json`,
  and one arrival where there used to be two (releases #170, gated PASS, routed by doyle 2026-08-21). Our `render_frames` keeps `from` + body and drops every other attribute, so without a
  carry-through the caution vanishes exactly the way the monic did in v0.26.2. Same seam, same class.
- **This attribute deliberately INVERTS the `mnemonics-json` precedent.** The published guidance there
  is that unknown envelope attributes are safely ignored. For this one, **being ignored IS the
  failure** — a silently dropped trust warning is a caution about an admitted stranger that no agent
  ever read, while everything downstream still reads as a normal delivery.
- **Loss scope, measured rather than assumed (doyle, Q2 2026-08-21):** the once-per-session-per-peer
  cadence mark is made by the receiving node when the caution enters the delivery channel — the same
  act that hands the enveloped message over — and there is **no adapter-side acknowledgement in that
  loop**. So an adapter that drops the attribute silences that `(session, peer)` pair for the
  **remainder of that session**, not permanently: the dedup marker lives in the per-session scratch,
  so any reset that mints a new session owes the warning again, the marker is best-effort and fails
  toward warning again, and a warning that could not be delivered at all claims nothing. Scoped —
  and still a real loss, which is why carriage is verbatim rather than best-effort.
- **Invariant (`REQ-TRUST-WARNING-CARRY`):** the attribute survives the re-render and is surfaced as a
  `<sptc_trust_warning from="…">` block at OUR frame level, LEADING the message it is about, text
  verbatim. **The pairing is part of the contract** — the block rides the SAME delivery as its
  message, never batched with another's and never reordered across deliveries. The digest carries the
  span for the same reason: a record that shows an admitted stranger's message with the caution
  stripped off reproduces the loss for whoever reviews it later.
- **PRESERVE, DO NOT INTERPRET** — the value is the fully composed block (identifying spine,
  advisory, how-to-silence line); there is nothing to expand adapter-side, ever.
- **Newlines ride as `&#10;`, and THE DECODE ORDER IS LOAD-BEARING.** The envelope codec is
  LINE-FRAMED by design, so a literal newline in an attribute value tears the envelope at any
  line-based reader. The attr rule therefore carries exactly one representable entity beyond the
  four: escaping normalizes CRLF and CR to LF, then maps LF → `&#10;` **after** the
  `&` → `&amp;` step, so our decode must run **before** `attr_unescape`'s amp-LAST step. That
  ordering is precisely what lets literal `&#10;` CONTENT round-trip (it rides as `&amp;#10;`, the
  newline decode does not touch it, and the amp step returns it as the text `&#10;`) — decode in
  the other order and an operator's advisory override saying `&#10;` silently becomes a line break.
  `<br>` is NOT decoded in an attribute anywhere: the body rule and the attr rule stay distinct.
  The decode stays scoped to THIS attribute until the docs-site attr-rule section publishes the
  fifth entity; widening it before that would be reading a rule that has not landed. **Re-checked
  2026-08-21 against the published page at core v0.59.0: it has NOT landed** — the escape table
  still lists four characters and still says attribute values are “line-safe by construction”.
  So the scoping stands, and the trigger stays a trigger. Record the check, not just the answer:
  a re-check that is never written down decays into an assumption that it published.
- **How this was settled — keep the sequence, it is the lesson.** Asked whether the composer emits
  literal newlines, numeric entities, or `<br>`, the first ruling was *literal, guaranteed*, and this
  decode (written defensively) was REMOVED on it. Re-measured at the lane tip, literal newlines
  proved to be what the composer does **today** but a **latent defect** rather than the contract: the
  "one line, always" pin only ever covered the monic attribute, whose newlines are a two-character
  JSON escape and never a real newline, so nothing had ever exercised a genuine newline through the
  attribute layer. The codec extension rides the same unlanded lane, ahead of this feature ever
  publishing. **An adapter-side question about how a value is escaped found a core-side defect** —
  worth remembering the next time a codec question looks like mere adapter housekeeping. It also
  means a first answer measured against *current behaviour* can differ from the contract; ask what
  the rule IS, not only what the bytes do today.
- **A literal `>` inside an attribute value would terminate the opening tag** for our frame scan.
  That is not a defect to harden against but a load-bearing reason the codec escapes those four
  characters — noted here so nobody "fixes" the scanner to tolerate what can never arrive.
- **Warning + monic legitimately CO-OCCUR** (doyle, 2026-08-21) — a CONTENT-triggered monic matches a
  stranger's message, since classification is a sender question and content matching is not. The
  forbidden direction is narrower: a SENDER-classifying monic riding beside a warning about that same
  sender, which core suppresses at the source. Our ordering (warning, then monic, then the message) is
  therefore a live path, not a defensive one.
- **Imitation surface:** identical to 2.15's and bounded the same way. A peer can type the marker into
  their own body; the discriminant is STRUCTURAL — a genuine block sits at our frame level, ahead of
  and outside the peer's `<sptc_messages>` block, where nothing a sender writes can appear. Anything a
  SENDER writes into `trust-warning` is stripped at ingress, so the value read here is always the
  receiving node's own.
- **Section PUBLISHED and re-checked 2026-08-21** (`networking/monics` → “The warning rides the
  message”, landed with the PORTER core cut, v0.59.0). The attribute shape matches what was built,
  and the page states the obligation in the same inverted terms this entry does: *“this attribute
  MUST be surfaced … a dropped monic costs you a note you wrote, while a dropped trust warning is a
  security caution that silently did not happen”*. Nothing here needed amending.
- **Owed: the cross-node int — and the deferral now rests on a PUBLISHED RULE, not on my inference.**
  The page ratifies **three arrivals that never warn**: `same-node` (“inside your node's own trust
  unit”), a correlated reply, and `posture-open`. So no node-local rig can manufacture a
  warning-eligible delivery — which is the same wall the v0.26.2 suppression measurement hit.
- **Measured rather than assumed, 2026-08-21.** A rig was built anyway, because the cheap way to be
  wrong here is to call an absence a defect: an access rule was written admitting this agent to a
  disposable receiver holding no monics, a message was sent and drained, and the **raw** `spt api
  poll` envelope carried no `trust-warning` attribute. That locates the absence in core's ratified
  same-node pass and NOT in our re-render. **The discriminating step is the raw poll**, and it is
  the step worth repeating: had the wire carried the attribute while our render dropped it, that
  would have been the v0.26.2 defect a second time, and the rendered output alone looks identical
  either way. Rig torn down to its exact prior state (rule removed, receiver returned offline).
<!-- [doc->REQ-TRUST-WARNING-CARRY] -->

### 2.17 A perch claim gated on the VERB instead of on the registration, and a refusal nobody reads

- **Failure:** SessionStart already tracked `registered` — the id whose STRICT registration succeeded
  this SessionStart, documented as the sole license to advance the session carrier — but the brief
  below it branched on the **verb** (`bind` | `boundary`). A REFUSED bind therefore still emitted the
  full `<sptc-active-perch>` identity brief: the agent told, in its own opening context, that it holds
  a live perch it does not hold. Measured byte-for-byte in the field (PORTER milestone #205, member #204 — todlando's ER
  acceptance measurement on the private release tracker, v0.58.0).
  Meanwhile the daemon's refusal — already in hand, since `spt_strict` returns stderr — went only to
  `env.log`, i.e. the spill file nobody reads. Measured case: `ANCHOR_REFUSED` asking for `--subnet`
  on a 3-subnet node.
- **Two halves of ONE defect.** A claim that outran its evidence, and evidence that reached nobody.
  Fixing only the first would trade a false claim for a silence, which is why
  `REQ-PORTER-PERCH-CLAIM-GATED` and `REQ-PORTER-BIND-REFUSAL-SURFACED` land in one change and are
  never split across releases.
- **Why THIS surface lied and the every-turn one did not.** The brief arm took its id from
  `$SPT_ENDPOINT_ID` **unverified**. The reachability notice resolves through `self_id` → `spt whoami`
  keyed on THIS payload's session, so a failed bind yields no id and it self-gates. Verified by
  inspection 2026-08-21 before touching anything — the fix needs no widening beyond the brief, and
  the general lesson is that **an env var is a request, not a registration**.
- **Invariant (`REQ-PORTER-PERCH-CLAIM-GATED`):** the identity brief's perch claim is emitted only
  after a registration that SUCCEEDED this SessionStart. The bringup idle assert is gated with it — a
  refused bind leaves the perch on its PRIOR sid, so `state idle … --session-id <this sid>` can only
  AUTH_REFUSE; skipping it costs the STUCK-ACTIVE protection nothing (there is no perch on this
  session to protect) and removes a call that cannot do anything but fail.
- **Invariant (`REQ-PORTER-BIND-REFUSAL-SURFACED`):** a refused bind/boundary emits the **no-perch
  shape carrying the refusal** — `<sptc_perch_refused verb="…" endpoint="…">` stating that this
  session has no perch and is not reachable, quoting the daemon's own bytes, followed by the ordinary
  `<sptc-reach>` ring brief.
- **A verdict may not assert more than it measured** (the standing rule, applied here at authoring
  time rather than after): the block reports that registration was refused and quotes the refusal. It
  does NOT diagnose the node, does not claim the endpoint is gone or was never created, and **invents
  no remedy** — the measured refusal already names what it wants. When no diagnostic was captured it
  says exactly that, rather than supplying a plausible cause; a blank diagnostic is the same fact as
  none and may not render as an empty quotation.
- **Contract — PUBLISHED and re-checked 2026-08-21** (harness-contract API page, landed with core
  v0.59.0). It reads: *“an adapter must therefore treat a session brief that asserts perch ownership
  as conditional on the bind's exit, never on having attempted it: on a refused bind, emit the
  no-perch shape and carry the refusal into it so the agent learns it is unreachable and why”*, and
  on the second half, *“the refusal text is the diagnosis; putting it only in an adapter-side log
  leaves the one party who needs it without it”*. Built from the routed statement before the page
  existed; the published wording matches and **no amendment was needed**. The core half (bring-up
  anchors the endpoint skeleton) rode the same lane and this half stood independently of it.
- **Int: DONE — `ci/hooks/bind-seam-int.sh`**, driving the built binary against a REAL spt-core
  0.59.0. Three arms: a refused registration claims no perch and quotes the daemon; a successful one
  still claims its perch (the guard against over-gating — a fix that silenced the true case too
  would also have gone green on arm 1 alone); and the measured `ANCHOR_REFUSED` field case itself.
- **The multi-subnet rig turned out to be the node this was written on** (3 subnets), so arm 3 drives
  the real field refusal rather than a stand-in. Arm 1 deliberately uses a refusal that does NOT
  depend on node shape (`INVALID_ID`) so the proof still runs on a single-subnet node, and arm 3
  **says out loud when it did not run** — a silent skip is indistinguishable from a pass, which is
  exactly how an earlier version-gated check in this repo passed by skipping the case it tested.
- **The green run has been shown failing.** `$SPTC_HOOKBIN` aims the proof at another build; against
  the pre-fix 0.26.2 binary every arm-1 and arm-3 assertion FAILS (it emits the full
  `<sptc-active-perch>` brief for a bind that was refused) while arm 2 stays green, because the
  successful case was never the defect. An all-green instrument nobody has watched fail is not
  evidence, and the before/after was taken on the two SHIPPED binaries, same input, same daemon.
<!-- [doc->REQ-PORTER-PERCH-CLAIM-GATED] -->
<!-- [doc->REQ-PORTER-BIND-REFUSAL-SURFACED] -->

### 2.18 A dropped seal token deletes the sender's citation at the last hop before the agent

- **Failure:** a sealed message carries its wax-seal token as a `seal="…"` attribute on the delivery
  envelope — the sender's own durable proof that a human authorized exactly these bytes. Our
  `render_frames` keeps `from` + body and drops every other attribute, so the token never reached the
  agent. **Third time on this seam** (`mnemonics-json` v0.26.2, `trust-warning` v0.29.x, `seal` here);
  the pattern is now the hazard, not the individual attribute.
- **Field (doyle, 2026-08-25):** the network's FIRST sealed exchange, core v0.62.0 — lia → doyle,
  same node, token `n2czzem8hc`. doyle's injected surface read `<sptc_messages from="lia">` and no
  token. doyle pinned the core half before filing: the attribute is test-pinned through compose,
  ingress, spool and re-emit at v0.62.0, including the local live-relay leg that exchange rode, which
  forwards the typed envelope verbatim to the adapter boundary. Core delivered it to our side of the
  seam; we dropped it. Clean boundary, established by the filer rather than argued afterwards.
- **The obligation is published, in the inverted form** (`messaging/overview.md`, "The seal attribute
  — sender-authored, and it must be surfaced"): *"A pipeline that re-renders a delivery has to carry
  the token through and show it, or the receiving agent loses the pointer to evidence the sender
  deliberately attached."* Same obligation `trust-warning` carries.
- **Sender-authored, and that is the whole security difference.** The two attributes we already carry
  are composed by the RECEIVING node — anything a sender writes into them is stripped at ingress — so
  unescaping them into a block body is safe. This one is the peer's own bytes and rides end-to-end
  through that strip *by design*. Unescaped into a block body, a hostile value could forge
  `</sptc_seal>` and put attacker text at OUR frame level, where a spoofed `<sptc_messages from="…">`
  is indistinguishable from a real delivery — defeating the structural discriminant every one of these
  blocks depends on.
- **Invariant (`REQ-SEAL-ATTR-SURFACE`):** the attribute survives the re-render and is surfaced as a
  `<sptc_seal from="…" token="…">` block at OUR frame level, LEADING the message it binds, ordered
  behind the trust warning and the monic (the caution keeps its lead; the citation sits closest to its
  body). **The token rides as an attribute carrying the RAW, still-attr-escaped wire value, exactly as
  `from` does** — the wire form cannot contain a raw `"`, `<` or `>`, and a real token is 8–10
  characters of narrow lowercase, for which escaped and unescaped are byte-identical. Faithful for
  every legal value, unforgeable for every illegal one. A blank `seal=""` renders loudly; the digest
  carries the span.
- **A citation, never an authorization — and this binds the WORDING, not just the plumbing.** Nothing
  may branch authority on the token's presence or value; the only citable fact is a BOUND verdict from
  `spt api seal verify` recomputed over the delivered body. The block therefore hands over the token
  and the two verbs (`seal describe`, `seal verify`) and asserts nothing about the message — a unit
  test pins the absence of authorization language, because the failure mode of a well-meant block is
  an agent reading "sealed" as "approved". A forged token is harmless by construction: verify rehashes.
- **We never validate the shape.** A verdict about a token belongs to `seal verify`, not to a
  renderer — the standing rule that a verdict may not assert more than it measured, applied at
  authoring time.
- **Int deferred, and NAMED rather than skipped:** `--seal` gates dispatch on a human-presence TOTP
  ceremony at an attached controller (a ceremony that does not admit sends nothing), so no CI arm can
  mint a sealed delivery. The units drive the exact wire shape the published contract prints; the
  field proof is the next sealed send landing with its token visible.
<!-- [doc->REQ-SEAL-ATTR-SURFACE] -->

### 2.19 A re-render built from a list of known attribute names deletes every attribute added after the list was written

- **Failure:** `render_frames` kept `from` + body and dropped every other envelope attribute. Each new
  attribute therefore had to be caught BY NAME, always after a field loss — `mnemonics-json`
  (v0.26.2), `trust-warning` (v0.29.x), `seal` (v0.30.0). Three losses at ONE seam is a recurrence
  class, and a by-name fix cannot close it: **the next attribute is unnamed by construction**, and the
  deletion is invisible at the site that does the dropping, because nothing there ever names what it
  dropped. §2.16 and §2.18 are instances of this entry, not siblings of it.
- **Published as a class rule** (core `REQ-EVENT-ATTR-PASSTHROUGH`, riding v0.63.0's docs publish,
  read on this node's own book before building — a quotation, even an exact one, is not the page):
  *"The attribute set is open — re-render by pass-through, never by allowlist… Carry through every
  attribute you do not yourself consume… The only closed list in this contract is the
  receiver-composed strip class (`trust-warning`, `mnemonics-json`), and it is a strip list, not a
  render list; everything else rides."* It binds THE ADAPTER RE-RENDER explicitly — our three by-name
  cases do not discharge it.
- **"Consume" is the DROP TEST, not "we read it."** A pipeline consumes an attribute when the value's
  distinction is re-expressed at the agent surface; reading a value and then emitting output that two
  sender-distinct deliveries would share is *peeking*. If omitting the attribute makes two deliveries
  the sender distinguished indistinguishable at the agent surface, it must ride. This definition was
  reader-derived until our own question exposed it, and the filer amended the PAGE rather than
  answering only us — which is the difference between a ruling and a contract.
- **`type` was the case that proved it, and the census is the method.** A complete enumeration of
  every non-test attribute read in `hook.rs` — not a sample — found `from`, `seal`, `trust-warning`,
  `mnemonics-json`, and the update nudge, which keys on `from == "spt-update"` and never on `type`. So
  `<EVENT type="notify" from="x">b</EVENT>` and `<EVENT type="msg" from="x">b</EVENT>` rendered
  **byte-identically**: the drop test failing exactly as written. `type` now rides. The idle/live arm
  is separately type-agnostic by design and carries `type` only by retyping the opening tag verbatim —
  **carried, never consumed**, so it never rescued the render path.
- **Invariant (`REQ-EVENT-ATTR-PASSTHROUGH-RENDER`):** every attribute on the EVENT opening tag that
  is not in the consumed set rides onto our re-emitted tag, **in wire order**, with its value RAW and
  still attr-escaped, in ATTRIBUTE POSITION. The consumed set is exactly `from` (re-emitted),
  `trust-warning`, `mnemonics-json` and `seal` (each re-expressed as a block of ours). **That set may
  never grow by one without proof** — adding a name without re-expressing its distinction rebuilds the
  forbidden allowlist one entry at a time.
- **Values stay escaped or the seam becomes a forgery.** An attribute value is attr-escaped for
  exactly one context; unescaped into a body or frame context, a crafted value could close our
  re-rendered tag and land attacker text at OUR frame level, where a spoofed `<sptc_messages from="…">`
  is indistinguishable from a real delivery. This is §2.18's `seal` mechanism, unchanged and widened.
- **Names are tokens, not text.** The envelope's name grammar is `[a-z0-9_-]+`, and names are
  hostile-reachable — envelopes arrive from peer NODES, not only from this binary. A tag-position span
  that does not match is framing damage, not an attribute: it is refused and **never re-emitted**, and
  the refusal is loud IN THE AGENT'S CONTEXT (`<sptc_frame_integrity>`), never a log line nobody reads
  (§2.17). The damaged span is displayed escaped — showing attacker bytes raw at our own frame level
  is the forgery being refused. Parsing continues past the damage, so one bad span cannot silently
  delete the good attributes behind it.
- **Framing damage is ordered ahead of even the trust warning** — the only thing that outranks it.
  Every other block on the delivery is derived FROM that opening tag, `from` included, and the warning
  names that same sender: a caution read out of a damaged frame must not be believed before the notice
  that the frame is damaged.
- **This changes the bytes of every delivery** (`type` now rides), which is why the no-regression
  control is written on an envelope of ONLY consumed attributes. A byte-identity control written on a
  `type`-bearing envelope would assert the defect back.
<!-- [doc->REQ-EVENT-ATTR-PASSTHROUGH-RENDER] -->

## 3. Distribution split (adapter × plugin version skew)

### 3.1 A hook_cmd shape change with a stale dispatch.sh bricks the whole CC session

<!-- [doc->REQ-HAZARD-HOOKCMD-DISPATCH-LOCKSTEP] -->

- **Failure:** `spt adapter update` refreshes the adapter (manifest + binary) **immediately**, but
  the cplugs plugin's `dispatch.sh` on disk only refreshes after a plugin reconcile + CC
  `/reload-plugins`. Observed live 2026-07-01 (perri, the v0.9.0→0.9.2 on-node update): the stale
  0.1.8 `dispatch.sh` execs `"$bin" <event>` (expecting the legacy ` hook`-suffixed hook_cmd) while
  the new manifest's `[strings].hook_cmd` is a **bare** binary path → it ran `claude-spt PreToolUse`
  → `unknown subcommand` → **nonzero exit on every hook event** → CC blocked EVERY tool call
  (Bash/Read/Write/Glob/AskUserQuestion) **and looped the Stop hook** (the agent could not even end
  its turn). Zero self-repair is possible from inside the session — every repair lever is itself a
  blocked tool call; only the operator's `/reload-plugins` recovered it. The v0.9.1 dispatch fix
  (strip legacy ` hook` suffix) protects the OTHER direction only (new dispatch × old manifest).
- **Invariant:** a `hook_cmd` SHAPE change in either layer with the other layer stale MUST
  **degrade, never brick**: (a) `dispatch.sh` tolerates BOTH shapes in BOTH directions (append the
  `hook` token iff missing, strip iff doubled), and (b) `claude-spt` exits **0** (pass-through with
  a stderr note) on an unrecognized event/subcommand token so a stale dispatch produces a noisy
  no-op instead of a tool-blocking failure. Equivalently: pin the dispatch↔binary contract so the
  shape can never change unilaterally.
- **Mapping / notes:** the two halves live in `plugin/sptc/hooks/dispatch.sh` (cplugs-shipped,
  slow channel) and `tools/claude-spt/src/{main,hook}.rs` (adapter-shipped, fast channel) — the
  asymmetric ship cadence IS the hazard. A `claude-spt post-update` reconcile of the LIVE plugin
  cache dir (including ccs instance roots `~/.ccs/instances/<x>/plugins/…`, suspected missed today —
  unconfirmed) closes the window but cannot cover a mid-session CC that has already snapshotted its
  hooks; the degrade-not-brick exit-0 half covers that remainder.
- **cite:** live incident 2026-07-01 (perri session, this node; todlando concurs adapter-side —
  off the spt-core triage doc). Memory: `v092-onnode-update-skew-brick.md`. Reference only —
  binding evidence lands with the tagged tests under `REQ-HAZARD-HOOKCMD-DISPATCH-LOCKSTEP`
  (registry entry present, stages `[]` until the guard slice activates).

### 3.2 CC can boot a session on a STALE cached plugin version despite a newer one installed

<!-- [doc->REQ-HAZARD-STALE-PLUGIN-RESOLUTION] -->

- **Failure:** the harness's plugin resolution picked an OLD cached copy at session boot even
  though the newer version was already installed. Observed live (F-024 domino #1, ENLYZEAM):
  the node's plugin cache held BOTH sptc 0.1.8 and 0.1.9; `installed_plugins.json` showed 0.1.9
  installed **24 minutes before** the 2026-06-29 boot of session `22ce8af0…` — yet that session
  verifiably ran the **0.1.8** dispatch (the unquoted-`SPTC_HOOK_BIN` v0.9.0 regression, fixed in
  0.1.9). The stale pick then compounds: the buggy dispatch corrupted the per-session
  `CLAUDE_ENV_FILE`, which **survives every resume of that session** (the env file lives with the
  session, so one bad boot poisons the session's whole lifetime), hooks died silently, and the
  dead-hook era swallowed the `/clear` boundary — the first domino of the ball-b wedge. WHY CC's
  resolver picked the stale copy is **unconfirmed** (CC-internals: resolution lag, a pin, or an
  orphaned-dir artifact — cf. the orphaned old install dir left by the v0.9.2 update). The
  adapter cannot control the harness's picker; it CAN control how survivable and how visible a
  stale pick is.
- **Invariant (defense-in-depth — the adapter stays safe under a stale pick):
  (a) degrade-not-brick:** any shipped dispatch version × any adapter version must degrade,
  never brick (§3.1's invariant is the binding half; a stale-RESOLVED plugin is simply another
  way to arrive at the same skew).
  **(b) minimize the stale-pickable set:** the post-update plugin reconcile must reach EVERY
  plugin root the node's CLIs actually read — the `claude` config root AND each ccs instance root
  (`~/.ccs/instances/<x>/…`, the v0.10.3 follow-up) — and superseded cached copies should be
  purged (or at minimum flagged) rather than left resolvable; a picker cannot choose a stale copy
  that no longer exists.
  **(c) observability:** which plugin version actually ran must be a log line, not an ssh dig —
  the dispatch (or the hook binary it execs) should surface its plugin version at least once per
  session so version-of-truth checks can catch a stale pick immediately.
- **Mapping / notes:** reconcile halves live in `tools/claude-spt/src/post_update.rs` (primary
  CLI reconcile + the best-effort ccs follow-up, v0.10.2/v0.10.3 — REQ-DIST-CCS-PLUGIN-FOLLOWUP);
  the degrade half is §3.1 / `REQ-HAZARD-HOOKCMD-DISPATCH-LOCKSTEP`; the purge and
  version-surfacing halves are **unbuilt** (guard-slice work, activate on start). Operator
  guidance meanwhile: after any adapter/plugin update, purge stale plugin-cache copies of sptc
  from every plugin root on the node (doyle's F-024 advisory — a latent re-brick hazard while any
  pre-0.10.2 copy remains), and treat "version installed" as unproven until the running session
  demonstrates it.
- **cite:** F-024 auth-wedge arc, `docs/SPT-CORE-FINDINGS.md` (doyle receipt
  `F024-AUTHWEDGE-CONFIRMED`; perri ssh-verified the 0.1.8-dispatch-at-boot on ENLYZEAM). The
  CC-side residual question (why the resolver lagged/pinned) stays open — perri court. Doc
  evidence = this entry; impl/unit land with the guard slice.

---

## 4. Skill-injection delivery (skeleton × adapter split)

### 4.1 A slash-command form the hook does not parse silently no-ops skill injection

<!-- [doc->REQ-HAZARD-SKILL-INJECTION-SILENT-NOOP] -->

- **Failure:** `/sptc:<skill>` skeletons are thin by design — the operative body arrives as
  UserPromptSubmit `additionalContext`. The UPS parsers matched ONLY the fully-qualified
  `/sptc:<skill>` form, but the form users actually type is the **bare shortname** (`/ready
  terminus`, `/live perri`), which Claude Code resolves whenever the name is unambiguous across
  installed plugins. CC then records the CANONICAL `<command-name>/sptc:ready</command-name>` in
  the transcript while the hook — which sees the RAW prompt — matched nothing and injected
  nothing. The transcript therefore looks perfectly healthy at the exact moment delivery failed,
  which is what makes this class expensive to diagnose. A second, latent route to the same no-op:
  the parsers trimmed only `' '` and `'	'`, so any other leading whitespace (a newline from a
  multi-line input box or a paste with a blank first line, a NBSP) also failed to match.
  Downstream, the agent is left holding a skeleton with no body, and the skeleton's own fallback
  told it to go check the install via `sptc:setup` — turning a one-tool-call bringup into a
  multi-call environment hunt (2026-07-19, session `27458245`: `/ready terminus` → 5+ tool calls,
  `spt --version`, `adapter status/list`, settings greps, a hook replay, then `sptc:setup`).
  Signature in `hook-trace.log`: `TRACE UserPromptSubmit ... skill=0ms` — 0ms means no
  `get-string` subprocess ran at all, i.e. the key never matched (a real resolve costs ~20ms).
- **Invariant:** (a) **Every form CC dispatches, the hook resolves** — qualified `/sptc:X`, the
  bare `/X` shortname for every shipped skill, and either preceded by ANY leading Unicode
  whitespace. The bare arm is gated on the shipped skill set (a whole-token match, so `/review`
  and `/readyish` never resolve) so an unrelated plugin's `/version` cannot pull an sptc body in,
  and a drift test binds that set to `plugin/sptc/skills/` — a new or renamed skill whose
  shortname would silently stop injecting fails the build instead. (b) **No silent no-op** — a
  prompt that mentions `/sptc:` but yields no key MUST log its leading bytes
  (`SKILL_KEY_MISS ... head=...`), so the next occurrence is one grep rather than an RCA.
  (c) **Skeleton fallback is one call** — when the injected block is absent, a skeleton must carry
  the single operative call for its own skill and must NOT route the agent to `sptc:setup`
  (setup is reserved for "spt itself is missing"). A skeleton that costs more than one tool call
  on injection failure is the hazard, not the mitigation.
- **Mapping / notes:** parsers in `tools/claude-spt/src/hook.rs` (`skill_key` — both arms —
  `is_live_invocation`, `role_args`) with the `SPTC_SKILLS` set and its drift guard; the miss-log
  sits in `handle_user_prompt_submit`'s skill stage; skeleton fallbacks in
  `plugin/sptc/skills/*/SKILL.md` (skeleton text ships via cplugs ONLY — the adapter cannot
  deliver it, so this half is a PLUGIN release, not an adapter one). Note the empty
  `[strings.skills]` bodies for `commune`/`signoff`/`send` are INTENTIONAL (those skills carry
  full skill docs and natural-language triggers) — an empty body is not this failure.
- **cite:** operator report + RCA 2026-07-19 (perri) off transcript
  `27458245-febf-44f4-b5b9-8e2165de0149` + `hook-trace.log` `[1784509735531]`. Diagnostic note:
  the first RCA pass pinned the whitespace route because it reproduces the trace signature
  byte-identically; the operator supplied the actual trigger (`/ready terminus`). Both routes are
  real and both are now covered — but "a mechanism reproduces the signature" is not "this is what
  happened", and the transcript could not distinguish them because CC stores the canonical form.

### 4.2 A skill Claude Code routes to by DESCRIPTION never reaches the injection point

<!-- [doc->REQ-SKILL-TOOL-INJECTION] -->

- **Failure:** 4.1 covers the forms of the typed slash-command. But CC reaches a skill a second
  way entirely — a **description match through its `Skill` tool** (`Skill(skill: "sptc:knock",
  args: …)`), which the model issues on its own and which never produces a user prompt. All skill
  injection hung off UserPromptSubmit, so this whole route injected NOTHING for every skill we
  ship, from the day the skeletons went thin. The agent got the skeleton and fell back to `--help`
  or the skeleton's one-call fallback — and because 4.1's invariant (c) makes that fallback
  deliberately graceful, nothing ever looked broken. Field-observed 2026-08-04: a live agent's
  prose ask for a knock code routed by description to `sptc:knock` bodyless, and local transcripts
  carried four bodyless model-invoked `sptc:commune` calls — commune's body being exactly where the
  across/wake mechanics live.
- **Invariant:** **every route CC uses to reach a skill resolves the body.** The `Skill` tool route
  is served at PreToolUse (which does fire on that tool — MEASURED, not assumed). The name on
  `tool_input.skill` must be PLUGIN-QUALIFIED (`sptc:<id>`) and a whole member of `SPTC_SKILLS`: a
  bare `send` or `version` may be the user's own project skill, and pulling an sptc body into a
  foreign skill is a worse failure than the one being fixed. The body LEADS the mid-turn envelope,
  and it is resolved AHEAD of both of PreToolUse's early returns — a subagent gets the body for the
  skill it itself invoked (see 5.1: that gate is about the parent's MAIL, not about bodies), and a
  perchless session gets it too, since `/sptc:version` and `/sptc:setup` are valid with no perch.
- **Mapping / notes:** `skill_key_from_tool` (pure) + the injection leg at the top of
  `handle_pre_tool_use` in `tools/claude-spt/src/hook.rs`. NO cross-hook suppression state guards
  against a double injection with the UPS path: a typed command still arrives as a prompt and was
  never observed routing through the tool, and a stale suppression record would recreate the exact
  silent-no-op class this section exists to close.
- **cite:** the private backlog, item #8 (filed 2026-08-04 while diagnosing the inert `[[hints]]`
  table), fixed 2026-08-21. Measurement note: the first probe — a bare tag whose confirm rides
  PreToolUse — showed nothing on a `Skill` call and confirmed on the next `Bash` call, which READS
  like "PreToolUse does not fire on Skill" but cannot distinguish that from "it fired before the
  tag reached the transcript". The discriminating probe used a SPOOLED inbound message (no
  transcript timing anywhere in the path): it was delivered ON the `Skill` call, under a
  `PreToolUse:Skill` header. An absence measured through a lagging instrument is not an absence.

### 4.3 A release body carried through a temp file publishes someone else's notes

<!-- [doc->REQ-RELEASE-NOTES-FROM-CHANGELOG] -->

- **Failure:** v0.28.0's GitHub release body was **spt-core's release notes** — engine-room roles,
  `knock --subnet` lists, an access-rule fix — on a claude-spt release that ships none of it. The
  changelog section was correct; the transport was not. The slice was written by python to
  `/tmp/notes.md` and published by `gh --notes-file /tmp/notes.md` invoked from MSYS sh, and those
  are **two different files on this platform**: python resolves a leading `/tmp` drive-relative
  (`C:\tmp`), MSYS maps it to `%LOCALAPPDATA%\Temp` — where a stale notes file from an unrelated
  release was sitting. The slice was checked by printing it back, and that check passed, because
  the file printed was the one WRITTEN and not the one gh CONSUMED. Found by the operator reading
  the published page, not by any step of the release.
- **Invariant:** (a) **no shared-filesystem path between the changelog and the release** — the body
  comes from `ci/publish/release-notes.sh <version>` on stdout and is PIPED into
  `gh release create|edit --notes-file -`; the script refuses to emit a missing or empty section
  rather than publishing a blank body, and the `## [<version>]` header is not part of the body.
  (b) **the last act of a release is a read-back** — `ci/publish/verify-release-notes.sh <tag>`
  fetches the PUBLISHED body from GitHub and diffs it against the changelog section. (c) The
  general form, which is what makes this a hazard and not a runbook nit: **verifying the artifact
  you produced instead of the artifact the consumer received cannot detect a substitution anywhere
  in between** — the same shape as reading a rendered copy instead of the wire (§ the v0.26.2
  monic loss), reached through a filesystem instead of a renderer.
- **Mapping / notes:** `ci/publish/release-notes.sh`, `ci/publish/verify-release-notes.sh`,
  `tests/release-notes.sh`, and the publish step of `docs/RELEASE-RUNBOOK.md`. Windows/MSYS trap
  worth stating on its own: **never hand a bare `/tmp/...` path between a python step and a sh
  step** — they are different directories, and the failure is silent in both.
- **cite:** v0.28.0, 2026-08-21 (perri), corrected within the hour on the operator's report. Audit
  of the four preceding releases at the same time: v0.28.0 was the ONLY content-wrong body; v0.26.2
  and v0.25.32 differed from their sections cosmetically (a retained header line, a leading blank).

---

## 5. Mid-turn delivery routing (subagent context)

<!-- [doc->REQ-HAZARD-SUBAGENT-DRAIN-STEAL] -->
### 5.1 A subagent's tool calls drain the parent's inbound mail

- **Failure:** the PreToolUse hook fires the mid-turn drain (`api poll` + msg-park) on EVERY tool
  call — including tool calls made INSIDE a subagent the session spawned. CC fires PreToolUse in
  the **subagent's** context, not the parent's (the parent fires no PreToolUse while it waits), and
  because the subagent shares the parent's `session_id` the drain resolves the PARENT's endpoint and
  consumes the parent's inbound messages into the subagent's context — where they are relayed as an
  unverifiable claim, summarized, distorted, or dropped. The parent's main context never receives
  the message. Observed 2026-07-24 (HFENDULEAM, todlando's live session): a peer message queued to
  todlando drained into a running `spt-test-engineer` subagent, which reported it to todlando as a
  relayed ruling; todlando's main context got nothing. Impact class: operator/peer mail silently
  lost from the main agent; authority attribution unverifiable (a subagent relaying a ruling is
  indistinguishable from one hallucinating it); worst case a subagent ACTS on a directive meant for
  the main agent.
- **Invariant:** inbound perch messages surface ONLY in the endpoint's MAIN conversation context. A
  PreToolUse firing in a subagent context (CC sets `agent_type` / `agent_id` in the payload) MUST NOT
  drain the parent's inbox or message-park; the mail stays parked for the parent's own next
  main-context PreToolUse / UserPromptSubmit. A test drives `handle_pre_tool_use` with `agent_type`
  present and asserts zero park drain, zero `poll`, no busy-mark, and no injected output; a
  main-context call (no `agent_type`) still drains. SCOPE (amended 2026-08-21, §4.2): what this gate
  withholds from a subagent is the PARENT's mail. The operative body of a skill the SUBAGENT ITSELF
  invoked through the `Skill` tool is not the parent's mail and is injected — the tool is the only
  route a subagent has to an sptc skill at all — and a test asserts the body lands while the parent's
  park stays untouched.
- **Mapping / notes:** `handle_pre_tool_use` in `tools/claude-spt/src/hook.rs` — an early
  `is_subagent(agent_type)` guard, mirroring the SessionStart brief skip
  (REQ-DIST-SESSIONSTART-BRIEF), which already uses the same discriminator. In-process, so the
  binding evidence is a unit test on the handler. `agent_type`/`agent_id` are the same correlation
  identifiers spt-core records on a worker perch (`api worker-start --agent-id/--agent-type`), so a
  subagent has its own `<parent>-w<N>` endpoint identity — a later enhancement could route a
  subagent's PreToolUse to `worker-poll <worker-id>` so it receives its OWN mail, but the incident
  fix is the defer guard.
- **cite:** field incident 2026-07-24 (doyle relaying operator; todlando/HFENDULEAM). Reference
  only — binding evidence is the tagged test under `REQ-HAZARD-SUBAGENT-DRAIN-STEAL`.

---

<!-- [doc->REQ-HAZARD-TAG-BODY-CODE-SPAN] -->
### 5.2 A shortform message whose body quotes a command is dropped whole, silently

- **Failure:** the quotation guard was implemented by SPLITTING the output on code delimiters
  (```` ``` ```` then `` ` ``) and scanning each piece for `@<…@>`. A tag whose body quoted anything
  put its opener in one piece and its close in another, so no piece contained a complete section and
  the tag was dropped entirely — no send, no error, no diagnostic. Agents quote commands constantly,
  which made this fire on ordinary messages. Field 2026-07-25: perri's request to doyle for a ledger
  item, a body full of backticked commands, was never delivered; the resulting silence read as an
  unresponsive peer rather than a lost message, which is the expensive part. A single unterminated
  backtick had the same effect for the rest of the output, since the parity walk treated it as an
  open code span.
- **Invariant:** the guard is **positional** — it decides whether the OPENER is quoted — and the body
  between opener and close is taken **verbatim**, delimiters and all. A `@>` inside code is quoted
  punctuation, not a terminator (so an opener whose only close sits inside code still does not fire);
  an unterminated backtick is literal text, not an open span; a fenced block inside a body survives.
  Never re-implement the guard by splitting the text and scanning the pieces: that shape cannot
  express "the body may contain a delimiter."
- **Mapping / notes:** `tools/claude-spt/src/tag_scan.rs` — `code_ranges` (fences first, then inline
  spans, unterminated tick excluded), `in_code`, the positional walk in `parse_tag_sections`, and
  `push_section` for the verbatim body. The old `prose_segments` / `scan_prose_segment` pair was
  deleted rather than patched. The false-fire guard it originally earned (v0.17.0: release notes
  whose own `@<alice …@>` examples sent real messages) is preserved by the retained tests.
- **cite:** first-hand 2026-07-25 (perri), with the adjacent class flagged by flynn from emphasys's
  omp-spt 0.3.27 mask-slice fix — a different mechanism, same silent-drop family.
- **MOVED, not solved (2026-08-29, v0.36.0):** this adapter no longer parses shortform at all — the
  suppression grammar is spt-core's now, and `REQ-HAZARD-TAG-BODY-CODE-SPAN` is retired with the code
  it guarded. The hazard is kept here because the *shape* is what matters and it is not specific to
  tags: a guard implemented by SPLITTING text on delimiters and scanning the pieces will drop any
  construct that straddles a piece boundary, silently, and quoting is the common case rather than the
  exotic one. Match ranges and test the position; never split and scan. If a future adapter-side
  parser of any kind is written here, this is the mistake it must not repeat.

---

## 6. Start-transition context injection (additionalContext limit)

<!-- [doc->REQ-HAZARD-SESSIONSTART-CONTEXT-TRUNCATION] -->
### 6.1 A SessionStart brief over CC's additionalContext limit is silently truncated

- **Failure:** the SessionStart brief (identity + durable resume — role / live-context /
  project-context) was emitted RAW (`emit_additional_context` + bare `env.emit`), the ONLY adapter
  emit path that skipped the cap. CC SILENTLY TRUNCATES injected additionalContext at ~2 000 chars
  (measured 2026-07-25: a 16KB brief surfaced as ~38 lines; a 1 819-char brief surfaced whole —
  no spill, no pointer, the tail just vanishes; the documented "10 000 chars → saved to file" does
  NOT hold for injected context). So a resume-bearing brief over ~2KB lost its role/status/resume
  tail with no signal, and the reborn agent proceeded amnesiac (field: took a wake's "build"
  literally because the whole role/status payload was in the cut portion).
- **Invariant:** the SessionStart brief is emitted UNDER CC's real limit (`DEFAULT_CAP` <
  `CC_CONTEXT_LIMIT`). Fits → inline verbatim. Over → the FULL brief is spilled to an
  agent-readable file and the emit inlines the essentials PREFIX (the brief leads with identity)
  plus a forced-read directive naming the file, the whole emit kept under the cap so CC never
  truncates it. Nothing is lost silently: it is either inline, or in the spill behind an
  unmissable read directive.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `emit_capped_sessionstart` +
  `cut_at_line_boundary` at the SessionStart brief emit site; `DEFAULT_CAP` lowered 9000→1800
  (also tightens the UPS/PreToolUse `emit_capped*` paths — they spill earlier now, correct, since
  CC's real limit is ~2KB not 9KB). Tests: under-cap verbatim; over-cap spills full + inlines
  essentials + forced-read + stays under the cap; `DEFAULT_CAP < CC_CONTEXT_LIMIT`.
- **cite:** operator field report + graduated-marker experiments 2026-07-25 (perri). Reference
  only — binding evidence is the tagged tests under `REQ-HAZARD-SESSIONSTART-CONTEXT-TRUNCATION`.

<!-- [doc->REQ-HAZARD-SPILL-CLOBBER] -->
### 6.2 A second oversized delivery destroys the first agent's spilled brief, unread

- **Failure:** the spill path was fixed for the whole session (`sptc-drain-<sid>.txt`), so every
  oversized delivery wrote the SAME file. Field 2026-07-25 (lia): their SessionStart brief overflowed
  and spilled; a peer broadcast then overflowed into the same path and overwrote it **before the
  agent had read it**. Identity, role, live-context and the resume tail were destroyed unread — the
  agent spent the session running on what peers happened to tell it. The loss is silent by
  construction: no error, no marker, nothing that says "you had a brief and it is gone", so the
  agent behaves exactly like one that was never briefed and no observer can tell the difference.
  Blast radius is wider than a single message: an agent gathering field evidence through spills has
  that evidence silently degraded, which cost a peer the strength of an observation already reported
  to spt-core.
- **Invariant:** a spill NEVER overwrites another spill — each gets its own path — and a new spill's
  `<sptc_overflow>` pointer NAMES the session's earlier spills, so one that was not read yet surfaces
  in the very next pointer instead of sitting unmentioned. Uniqueness alone is not sufficient: an
  un-destroyed file nobody is told about is still an unread file.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `spill_path` (unique: session + monotonic ms
  + pid, so two hook processes racing inside one millisecond still separate), `spill_stem` (pure,
  session-scoped prefix — it must survive a uuid's four dashes or it would either miss real priors or
  advertise another session's spills), `prior_spill_notice`, and `HookEnv::{now_ms, pid,
  list_prior_spills}`. **No pruning of old spills, deliberately** — deleting one is precisely the act
  this hazard forbids; the disk cost is bounded and an operator can clear the directory.
- **cite:** lia's field report relayed by flynn 2026-07-25, plus the same clobbering observed
  first-hand in perri's own session that day. Reference only — binding evidence is the tagged tests
  under `REQ-HAZARD-SPILL-CLOBBER`, and since 2026-07-26 the `int` stage too: `ci/hooks/poll-int.sh`
  step 4 drives three oversized deliveries through the shipped binary against a real perch and
  asserts three distinct files, all present, the first still holding its own body after the two
  later ones. Red-proven first — a binary rebuilt with the old fixed per-session path fails exactly
  those assertions.

<!-- [doc->REQ-HAZARD-SPILL-NOTICE-LOST] -->
### 6.3 The spill POINTER goes missing — and a taken message reads as a non-delivery

- **Failure:** §6.1 and §6.2 make the spilled CONTENT safe (under the cap, never clobbered). The
  notice that tells the agent the content exists was not safe, on three routes, each of which
  reproduces the same observable as a lost message — sender says delivered, agent saw nothing.
  **(a) A failed spill write.** `write_spill` returning `false` emitted a pointer whose
  `spilled_to` read `(spill failed; content too large to inline)`, nothing was logged, and
  `handle_pre_tool_use` committed the msg-park **unconditionally straight after** — so a body that
  reached no file and no context was destroyed anyway. **(b) Only the UPS path named priors.**
  §6.2's second half (a new pointer NAMES the session's earlier spills, so an unread one resurfaces)
  was implemented in `emit_capped` alone; `emit_capped_pretool` — the mid-turn drain, the
  highest-volume spill path there is — and the SessionStart directive named only their own file, so
  the canonical unread spill (a SessionStart brief) was never mentioned again. **(c) An unbounded
  pointer.** The earlier-spills line listed every prior path (~90 bytes each) with no bound, so a
  long session's pointer grew past CC's ~2 000-byte limit and CC silently truncated the very notice
  whose job is to prevent silent truncation — measured in the probe at **4 310 bytes with 40 priors**,
  and over the limit from ~18 spills onward.
- **Invariant:** the pointer always reaches the agent, always names a file that exists, and always
  fits. Concretely: (1) a failed spill write is LOUD in the hook log, emits a distinct
  `<sptc_spill_unwritable>` marker that inlines as much of the body as CC's real limit allows and
  names the spool as the remainder's only recovery route, and does **not** commit the msg-park —
  staged bodies redeliver on the next drain, exactly as the deadline-killed case does; (2) every
  spill-emitting path (UPS, PreToolUse, SessionStart) carries the earlier-spills line; (3) the line
  is bounded — oldest names kept (an old spill's own pointer has scrolled away; a recent one was
  pointed at recently), the rest COUNTED with a glob, and the count survives even a budget too small
  to name anything.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `prior_spill_notice_part` (named + counted),
  `priors_within` / `fit_with_priors` (the two bounding shapes), `spill_failed_emit` + `cut_head`
  (the last-resort inline; a hard cut is permitted ONLY here, because this marker states the
  delivery is partial), the `bool` return of `emit_capped` / `emit_capped_pretool`, and the
  `surfaced` gate on `commit_msg_park`. `emit_capped_sessionstart` gets the failure-branch wording
  that routes to durable state instead of a Read directive for a file that was never written.
- **cite:** hazard raised by todlando 2026-07-24 ("a spill notice that itself went missing would
  look like a non-delivery") after two field spills that behaved correctly; the three routes were
  found by code-walk and each PROBED to failure before the fix (2026-07-26, perri) — no field
  incident. Route (b) is additionally LIVE-VERIFIED on v0.25.17 (HFENDULEAM, same day): a 3 658-byte
  self-send drained mid-turn spilled and its PreToolUse pointer named this session's earlier
  SessionStart-brief spill — the exact file v0.25.16 would never have mentioned again. Binding
  evidence is the tagged tests under `REQ-HAZARD-SPILL-NOTICE-LOST`, plus the `int` stage (green
  since 2026-07-26 with §6.2's): `ci/hooks/poll-int.sh` step 4 drives three oversized deliveries
  through the shipped binary against a real perch — the second UPS pointer names the first spill as
  an earlier one, the PreToolUse pointer carries the same line (route (b)), and with 40 seeded
  sibling spills the pointer still fits under CC's 2 000-byte limit and counts what it cannot name
  (route (c)). Both legs were red-proven against rebuilt pre-fix binaries (a 5 539-byte unbounded
  pointer) before they were believed.

---

## 7. Digest extraction (transcript root resolution)

<!-- [doc->REQ-HAZARD-DIGEST-ROOT-BLIND] -->
### 7.1 A stale config-root capture blanks the whole digest, silently

- **Failure:** the projects-root chain in the extractor was FIRST-RUNG-WINS: it picked the first
  candidate that was a real directory and asked `locate()` for `<sid>.jsonl` only there; a miss
  returned `Ok(())` with empty stdout and exit 0. The perch's `{CLAUDE_CONFIG_DIR}` capture is
  taken at bind time, so it goes stale the moment a later session boots under a different config
  root. Field 2026-07-25 (perri): a perch captured `C:\Users\decid\.ccs\instances\bigscreen`
  from earlier ccs-launched sessions, then a plain `~/.claude` session bound to it. The daemon ran
  `claude-spt digest --session <sid> --config-dir <ccs-root>`, the ccs tree held no `<sid>.jsonl`,
  and extraction returned empty-and-quiet. For ~20 minutes the endpoint's digest carried
  spt-injected `Context/echo_mirror` rows ONLY, its newest `Agent` entry frozen at the
  PRE-reboot session, tail turn stuck `"partial": true` — no error in the daemon log, none in
  `hook-trace.log`, exit 0 everywhere. Peer endpoints on the same node were unaffected (their
  captures matched their sessions), which makes it read as a one-agent oddity rather than a bug.
- **Invariant:** the root chain is **session-aware** — every rung is tried in the documented
  precedence order and the first rung that actually CONTAINS the requested session's transcript
  wins; a rung that resolves to a real directory *without* the session must not end the search.
  When no rung holds it, the extractor is **loud** (stderr names the session id and every root
  tried) rather than exiting 0 with empty stdout.
- **Mapping / notes:** `tools/claude-spt/src/digest.rs` — `projects_root_candidates` (ordered,
  pure) + `locate_in_candidates` + the no-rung-held diagnostic in `execute()`.
  `resolve_projects_root` survives as the first-candidate helper so the precedence contract keeps
  its existing tests. Empty stdout stays a non-error (spt-core reports the empty digest) — the
  change is that it can no longer be *unexplained*. The capture staleness itself is spt-core's
  read-var lifecycle, not ours: the adapter's obligation is to find the transcript anyway and to
  narrate the failure when it cannot.
- **cite:** field diagnosis 2026-07-25 (perri), reproduced by running the daemon's exact extractor
  command by hand against the stale root. Reference only — binding evidence is the tagged tests
  under `REQ-HAZARD-DIGEST-ROOT-BLIND`.

<!-- [doc->REQ-HAZARD-DIGEST-ZERO-INPUT-SILENT] -->
### 7.2 Finding the transcript is not the same as getting turns out of it

- **Failure:** §7.1's diagnostic fires on ONE branch — no rung holds the session. A transcript that
  IS located, parses fine, and yields zero `role="input"` records still returned `Ok(())` with exit
  0 and nothing on stderr. That is the same observable the field fault wore (entries accreting,
  `input_seq` null forever, no error anywhere), so fixing only the locator branch left the hazard
  half-closed: doyle's regression hold on the zero-rows-exit-zero class was explicitly NOT satisfied
  by 0.25.10, and this is the gap it named.
- **Why it matters beyond an empty digest:** sealing a turn takes its identity from an extracted
  Input record. Zero input IS zero seal — ONE cause wearing two symptoms (`SEAL-DUAL-TRIGGER-ABSENCE`,
  liam 2026-07-24: one open turn since ~05:20, `input_seq=null`, alchemy's skip-partial scanner
  correctly refusing forever, no tag ever firing). RCA the extraction, never the seal effect;
  heuristics stay banned from driving sealing (ADR-0048) — the fix is observability, not a timeout.
- **Invariant:** the extractor reports what an extraction **yielded**, not merely whether it found a
  file. Typed user turns present with zero inputs emitted is named a **fault** (the filter ate the
  session's own turns). No typed turn at all is reported as *what was seen*, because an unstarted
  session and a mis-read transcript are indistinguishable from here and the caller cannot tell them
  apart unless told which it is. A healthy extraction (inputs > 0) stays **quiet** — a diagnostic
  that fires on healthy runs is one people learn to skip past — and so does a genuinely empty file,
  which is self-explaining. Exit stays 0 and stdout is untouched: spt-core owns "the digest is
  empty", this owns "and here is why".
- **Mapping / notes:** `tools/claude-spt/src/digest.rs` — `Tally` (entries / typed user turns /
  inputs, counted at every emission site including the stub flush), `zero_input_report` (pure, so
  the wording is unit-assertable), called from `execute()` after extraction. Tool-result user
  entries are NOT typed turns and must not inflate the discriminator, or a busy session would
  accuse itself.
- **cite:** doyle's binding guard on `SEAL-DUAL-TRIGGER-ABSENCE` (2026-07-25) — the item does not
  close on a heal. Reference only — binding evidence is the tagged tests under
  `REQ-HAZARD-DIGEST-ZERO-INPUT-SILENT`.

<!-- [doc->REQ-HAZARD-LATE-ACTIVATION-DARK] -->
### 7.3 A session activated mid-flight binds a perch to a session id it does not own

- **Failure:** the sptc plugin (or its adapter registration) arrives AFTER Claude Code started, so
  no SessionStart ever ran under this hook. `/reload-plugins` brings the surface back — hooks fire,
  `/sptc:*` skills inject, `/sptc:setup` reports the adapter active — but it never re-runs
  SessionStart, and neither does `/clear` (the boundary path logs `no endpoint id resolvable` and
  skips). The session is therefore never seeded, so `/sptc:ready` binds a perch whose recorded
  session id is a core-MINTED `sess-<pid>-<nanos>`, with no `adapter` and no `{CLAUDE_CONFIG_DIR}`
  capture. Everything reads healthy; the perch is simply pinned to a session that does not exist.
- **Why it matters beyond one bad field:** the digest fetcher is fed that id, so it can never locate
  a transcript — `spt endpoint digest <id>` answers `NO_DIGEST` for the session's whole life, and the
  §7.1/§7.2 diagnostics fire into an stderr nobody is reading. Same terminal observable as the field
  fault those two closed: an endpoint that looks online and says nothing. Liam's instance ran two
  days undetected.
- **Probe (2026-07-26, perri):** rigged with an isolated `CLAUDE_CONFIG_DIR`, sptc absent at launch,
  session launched DETACHED. Mid-session activation → `"session_id":"sess-43676-…"`, extractor:
  `no transcript for session … under any projects root`. Control with the plugin present at launch →
  the real CC sid and full NDJSON records. The published fallback
  (`api listen --session-id`, harness-contract/api.md — "binding when the seed is gone") produced a
  perch indistinguishable from a seeded one, which is what makes an in-place heal possible at all.
- **A seed alone does NOT fix it (probe, same day):** after the heal seeded the session, the same
  `spt ready` still bound the SYNTHETIC id — and the same command with `OWL_SESSION_ID=<sid>` in
  front of it bound the REAL one. Core takes the session id from the BRINGUP PROCESS's env, and that
  env can never be repaired mid-session: `$CLAUDE_ENV_FILE` is a SessionStart/CwdChanged/FileChanged
  -only channel. Anything designed off "re-seed and it heals" is wrong at the step that matters.
- **Invariant:** an unregistered session is loud ONCE (first prompt: seed on the resolved claude
  anchor + the notice naming what is empty and what to substitute), and operative EVERY time it
  could do lasting damage — each `/sptc:ready`|`/sptc:live` turn carries the explicit bringup command
  that passes the session id, overriding a skill body written for registered sessions. When no anchor
  resolves, nothing is sent to core and the notice is the RESTART-REQUIRED one instead, naming
  `/reload-plugins` as explicitly NOT the cure (it is what makes the session look healthy). Never
  silent, and never a banner on a registered session.
- **Bringup forms, both probe-verified 2026-07-26:** ready →
  `OWL_SESSION_ID=<sid> spt ready <id> --subnet <name>` (real sid, stays a ready_agent); live →
  `spt api seed --pid <hpid> --session-id <sid> && spt api --adapter claude-spt listen <id>
  --parent-pid <hpid> --session-id <sid> --subnet <name>` (real sid, live_agent, `adapter` field and
  the `{CLAUDE_CONFIG_DIR}` capture the digest fetcher needs). `spt ready` has no `--session-id`; the
  bare `spt api listen` refuses `ADAPTER_UNRESOLVED` under the Monitor's `bash.exe` child, hence the
  group-level `--adapter`.
- **Mapping / notes:** `tools/claude-spt/src/hook.rs` — `is_unregistered_session` (shared detector),
  `late_activation_heal` (seed + once-guard) with `late_activation_healed_notice` /
  `late_activation_restart_notice` / `late_activation_bringup_override` pure for assertable wording,
  called first in `handle_user_prompt_submit`. The detector keys on `$OWL_SESSION_ID` being UNSET,
  never on a mismatch: a subagent inherits its parent's export with its own sid, and healing off that
  mismatch would seed the parent's perch onto a nested session's id. The session env cannot be
  repaired mid-session — `$CLAUDE_ENV_FILE` is a SessionStart/CwdChanged/FileChanged-only channel —
  so `$OWL_SESSION_ID` and `$SPT_HOST_PID` stay empty for that session's life and the notice hands
  the values over verbatim instead. One `state/late-heal.sid` file makes the notice once-per-session.
- **Adjacent observation, NOT covered here:** `spt whoami` resolves identity by process LINEAGE
  (observed: same binary, same instant — from a perched session's tree `{"id":"perri"}`, from a
  WMI-parented process `{"id":null}`). An unregistered session that is a DESCENDANT of a perched one
  therefore adopts that ancestor's identity on the core-fallback path; during the first probe run it
  moved `perri`'s own session pin onto the probe session's sid. Probe rigs must launch detached; the
  hijack itself wants its own requirement and test.
  **CORRECTED 2026-07-26 — see §7.4:** the lineage reading above is wrong. Probed on the live node
  from a genuine descendant of the perch's host process, ancestry with the environment scrubbed
  resolves `{"id": null}`; what actually carries the ancestor's identity into the child is the
  INHERITED `$SPT_ENDPOINT_ID`, which beats an explicit session id that matches no perch. The
  requirement is open as `REQ-HAZARD-INHERITED-IDENTITY-ADOPTION`, and rig discipline is
  detached **and** env-scrubbed — the scrub is the part that prevents the adoption.

<!-- [doc->REQ-HAZARD-INHERITED-IDENTITY-ADOPTION] -->
### 7.4 A descendant session adopts a perched ancestor's identity through inherited env

- **Failure:** any process launched from a perched agent's own tool call inherits that agent's
  `$SPT_ENDPOINT_ID` (and `$OWL_SESSION_ID` / `$SPT_HOST_PID`). When a Claude Code session is what
  gets launched, the hook binary's identity fallback (`self_id` → `spt whoami --json`) sets ONLY
  `OWL_SESSION_ID` on the child call and lets the rest of the environment through — so core answers
  with the ANCESTOR's endpoint id even though the caller handed it a session id that belongs to no
  perch. The descendant then acts as the ancestor: `state busy|idle <ancestor-id> --session-id <its
  own sid>` re-points the ancestor's session pin at a session the ancestor does not own. Observed
  in the field: during the §7.3 probe's first rig (a probe CC launched from perri's own Bash tool,
  env unscrubbed) **perri's own session pin moved onto the probe session's sid**, and was repaired
  only by re-binding with the recorded sid as proof.
- **Probe (2026-07-26, perri; live node, read-only `whoami` calls from a genuine descendant of
  perri's host process):**
  - **all `SPT_*`/`OWL_*` scrubbed →** `{"id": null}`, exit 1. **Process ancestry resolved
    NOTHING** — so the adoption is NOT a lineage walk, which is what the §7.3 adjacent note
    assumed. (Not a disproof of a lineage path in general: this perch's recorded `pid` is not in
    the current process chain, so a walk would have had nothing to match here.)
  - **`SPT_ENDPOINT_ID=perri` inherited + `OWL_SESSION_ID=<sid matching no perch>` →**
    `{"id":"perri","state":"live_agent","ready":true,…}`, exit 0. The inherited endpoint id **wins
    over an explicit non-matching session id**.
  - **real `OWL_SESSION_ID`, endpoint id scrubbed →** `perri`. The legitimate path is unaffected;
    any fix must keep it working.
- **Why it matters:** this is a WRITE-CAPABLE identity error, unlike §7.1–§7.3 which lose reads. A
  hijacked descendant does not merely read the wrong agent's state — it moves the pin the digest
  and every seal depend on, so the *ancestor* goes dark while the descendant looks healthy. It also
  survives the §7.3 fix: `verified_env_id` correctly REJECTS the inherited `$SPT_ENDPOINT_ID`
  (carrier sid ≠ payload sid), and then the whoami fallback re-adopts the very identity the
  fastpath just refused.
- **Two routes survived that scrub, found 2026-07-26 while building the spill int probe — whose own
  runs tripped them (perri):**
  - **(d) The session leg is itself an inheritance leg.** `self_id` asked whoami with the AMBIENT
    `$OWL_SESSION_ID` whenever it was set, using the payload's `session_id` only as a fallback. Every
    earlier probe assumed a descendant carrying a sid matching NO perch; the real one carries its
    ancestor's REAL sid, so the descendant never asked about its own session at all. **Observed:**
    the hook binary, handed a payload `session_id` belonging to a different live perch, answered as
    `perri`, marked it busy, and ran its poll and msg-park drain. With the endpoint and agent legs
    scrubbed and the inherited real sid left in place, `spt whoami --json` still returns
    `{"id":"perri",…}`. **Closed:** identity resolves from the session the PAYLOAD names; the ambient
    value is never an identity input (it is still SET on the child call — with the resolved value),
    and an empty payload sid resolves EMPTY rather than reaching for an inherited one. Nothing
    legitimate is lost: `$CLAUDE_ENV_FILE` is per session and SessionStart re-exports into it, so for
    any session the launcher spawned the ambient value IS the payload sid.
  - **(e) The ambient endpoint id was read raw at every side-effecting site.** `verified_env_id` is
    the only reader that proved custody; the msg-park drain and commit, the wake-park read and clear,
    the digest cursor, the role-edit round trip, the SessionStart bind and the idle heals all took
    `$SPT_ENDPOINT_ID` directly and then WROTE against it — consuming the ancestor's parked message
    bodies, clearing its wake directive, re-pinning its perch. **Closed:** each handler resolves ONCE
    and threads that proved id to every side effect. Where no session id exists to prove custody with
    (the bind, the idle heals, the commune-write checkpoint), `$SPT_HOST_PID` is the discriminator —
    it names the claude process the export belongs to, and a descendant's own anchor differs.
    Three-way on purpose: ABSENT is unknown, not foreign, or the first SessionStart of a fresh spawn
    (whose export has not landed yet) would refuse the bind that establishes custody. Presence-only
    reads that pick WORDING (skill arms, `register_verb`) still use the ambient variable — they write
    nothing.
  - **(f) The env guards only hold when the SPAWNER cooperates — the cwd does not need it to.**
    Route (e)'s discriminator is `$SPT_HOST_PID`, and its deliberate three-way reading (ABSENT is
    unknown, not foreign) leaves one shape open: a session launched by hand from a shell that
    exports `$SPT_ENDPOINT_ID` but no host anchor. **Observed 2026-07-26 (doyle's own diagnostic,
    the fourth artifact incident of the day and the only one that broke something real):** he ran a
    bare `claude.exe -p` inside `owlery/doyle/nested/doyle-psyche` to reproduce something. Its
    SessionStart routed to `bind` and re-pinned HIS perch onto the probe session; the probe also
    drained a delivery of his mail, then exited 3 seconds later. His perch stayed pinned to a dead
    session for an hour: core's last `ENDPOINT_INJECT:doyle` is at that same second, after which his
    spool shows no inject decision of either shape and every take is `hook-poll` — his messages all
    still arrived, just at his next hook instead of on arrival, with the endpoint still reading
    ONLINE + CONTROLLED. Note what did NOT cause it: core's `[session.psyche_*]` and
    `[session.echo_commune]` roles all carry `recursion_guard_env` + `env_remove` of the identity
    vars, and both shims additionally spawn hook-isolated (`--safe-mode`, else
    `--settings {"disableAllHooks":true}`) and self-set the guard — those inner sessions are inert
    three ways over. A hand-rolled spawn simply gets none of it. **Closed:** the refusal is
    STRUCTURAL — `nested_perch_owner` reads the session's cwd, and a session inside
    `owlery/<id>/nested/…` never seeds, binds, rotates a boundary, stamps state or receives a brief.
    Scoped to the owner (claiming to be the endpoint whose tree you are sitting in), so an ordinary
    project cwd and an unrelated endpoint's session are untouched. The whole SessionStart handler
    returns early rather than just refusing the id, because `register_verb` routes on PRESENCE — a
    refused id would have silently re-routed a `bind` into a `seed`.
  - **Operator/agent rule this leaves behind:** do not run diagnostics inside an endpoint's identity
    blast radius. The guard now converts that mistake into a no-op, but the class is worth naming.
  - Route (f) is red-proven too: `a_nested_perch_dir_names_the_endpoint_that_owns_it` (separators,
    casing, and the two near-misses — the endpoint's own home dir, and `nested` appearing somewhere
    other than right after the id) and `a_session_inside_an_endpoints_nested_tree_registers_nothing`
    (the field shape verbatim: ambient id, NO host anchor, cwd in the psyche dir — it emits no call
    at all, no brief, and leaves the sid carrier holding the OWNER's sid). Both fail against a
    binary whose `nested_perch_owner` returns None; `the_nested_gate_only_refuses_the_endpoint_that_owns_the_tree`
    bounds the blast radius and stays green either way, which is what makes the red meaningful.
  - (d) and (e) are red-proven and covered: unit (`a_descendant_turn_writes_nothing_against_its_ancestor`,
    `an_inherited_host_anchor_is_what_marks_the_env_foreign`,
    `identity_asks_about_the_payloads_own_session_not_the_inherited_one`) and int — `ci/hooks/poll-int.sh`
    step 5 runs the hook with the ambient env of a REAL perch and a foreign payload session, and
    asserts it drains nothing while the message stays there for the session that owns it. Against the
    pre-fix binary that assertion fails with the stolen body printed in full.
- **Invariant (CLOSED 2026-07-26 — `IDENTITY_ENV_SCRUB` in `hook.rs`):** the hook's identity fallback resolves
  by SESSION, never by inheritance. The `spt whoami` call must be made with `$SPT_ENDPOINT_ID` and
  `$SPT_AGENT_ID` explicitly cleared, so a descendant with no perch of its own resolves EMPTY and
  no-ops rather than acting as its ancestor. The registered-session path (real `$OWL_SESSION_ID`,
  probe G) must be unchanged, and the `verified_env_id` fastpath must keep its own custody proof.
  Shipped as a child-environment scrub on the `spt whoami` call: `HookEnv::spt_with_env` carries an
  `unset_env` list (`Command::env_remove`), because removal cannot be expressed through the existing
  set-only `extra_env` — core's identity legs test PRESENCE, and a var set empty is not an absent
  one. Tests assert the child env directly, since the scrub never appears in argv.
- **Core's half, ruled by doyle the same day (`REQ-WHOAMI-EXPLICIT-SID-REFUSAL`, inactive):** an
  explicit non-empty `$OWL_SESSION_ID` that matches no perch will REFUSE, poisoning the weaker legs
  (agent-id, endpoint-id, ancestry) instead of falling through; sid-ABSENT keeps today's chain, so
  the unexported-shell-var flow that leg exists for still works. Our scrub does not wait on it and
  is not superseded by it — one layer's refusal must not be another layer's grant.
- **Kin, measured the same day and worse than the endpoint leg:** `$SPT_AGENT_ID` is returned
  UNCONDITIONALLY, with no perch check at all — `SPT_AGENT_ID=nobody-xyz spt whoami --json` answers
  `{"id":"nobody-xyz","ready":false,"alive":true,"unbound":false}`, exit 0. Note the shape: no
  `state` key but `"id"` populated, and `parse_whoami_self` takes `.id` first — so an adapter that
  trusts whoami is handed a PHANTOM id it will then write state under. Same write-capable class,
  sourced from a made-up name rather than a real ancestor. Scrubbed by the same list; core-side it
  is bundled into doyle's milestone.
- **Lane:** adapter-side. Core's documented resolution order is
  `$OWL_SESSION_ID / $SPT_AGENT_ID / process ancestry` (`spt whoami --help`), and passing a
  non-matching session id alongside an inherited endpoint id is our call site's choice, not core's
  defect. Whether core should ALSO refuse when a caller hands it a session id that matches no perch
  is a separate question filed with doyle; the adapter must not wait on it.
- **Rig discipline (consequence, already in force):** a probe session must be launched DETACHED —
  `Invoke-CimMethod -ClassName Win32_Process -MethodName Create` so the parent becomes `WmiPrvSE` —
  AND with the environment scrubbed (`CLAUDECODE`, `CLAUDE_CODE_*`, `SPT_*`, `OWL_*`). Scrubbing
  alone is what actually prevents the adoption; detaching alone is not enough.

### 7.5 The foreign-host refusal reads a resumed session as a foreign one, node-wide

- **Failure:** §7.4's route (d)/(e) gate compared `$SPT_HOST_PID` against the hook's own claude
  anchor as bare pids. A daemon restart resumes every hosted session by re-applying its SAVED
  environment verbatim, so every resumed session carries the pid of a claude host that died with
  the old daemon — and the bare compare read every real agent on the node as a descendant of
  "another claude process". Every ambient-gated leg (the sites with no session id to prove custody:
  commune parking, idle heals, boundary rotation) refused at once, while the sid-custody fastpath
  kept UserPromptSubmit/Stop/PreToolUse working — so every agent LOOKED healthy while its
  across-commune ceremony, idle heals and clear boundaries were silently dead.
- **Field (2026-07-27, operator-reported twice in one evening):** after the 18:09 daemon bounce,
  `psutil environ()` over every live claude on the node showed the SAME stale `SPT_HOST_PID=40152`
  (a dead pid) in every resumed session, and hook-trace.log logged the (doyle) refusal every few
  minutes from 18:22 — before adapter 0.25.21 existed on the node, exonerating that day's other
  suspects. doyle's across-commune at 19:27 parked nothing and armed no wake (he sat un-cleared
  until found); the operator's manual /clear at 19:32 logged `boundary(clear) SKIPPED — the perch
  stays pinned to the departed session id and will strand`, and the post-clear rename never fired.
- **Invariant (CLOSED 2026-07-27 — `REQ-HAZARD-STALE-ANCHOR-REFUSAL`):** a mismatched anchor is
  foreign only while its pid names a LIVE claude host. Inheritance means a live ancestor's claim —
  a dead anchor is resume residue and reads as unknown, not foreign. Liveness is the one
  discriminator a resumed session and a borrowed descendant do not share, and it cannot be forged:
  a dead pid protects nothing. Pid reuse by a non-claude process reads dead (that process holds no
  perch); reuse by an unrelated claude keeps the refusal — rare, and the protective direction.
- **Lane:** adapter-side gate, adapter-side fix. Whether core's resume leg should REFRESH
  `$SPT_HOST_PID` (or scrub it) when it respawns a session under a new daemon is filed with doyle
  as an adjacent hardening; the adapter does not wait on it — a liveness-blind refusal is wrong
  regardless of who wrote the stale value.

## 8. IO funnel (what we report as our own ingest)

<!-- [doc->REQ-HAZARD-DELIVERY-AS-USER-INPUT] -->
### 8.1 Reporting a delivered message as USER_INPUT launders a peer's tag into our own send

- **Failure shape:** idle delivery types into Claude Code's input box, so an inbound peer message,
  a notify and a checkpoint wake all arrive at `UserPromptSubmit` wearing a prompt's clothes —
  today as ADR-0007 stubs (`<msg from="…"/>`, `<wake/>`), and from a stale resident translation
  binary possibly still as a whole `<EVENT …>` envelope. A busy report that passes the prompt
  through as its `USER_INPUT` payload therefore publishes, as the user's words, text the user never
  typed.
- **Why it is more than a counting error:** spt-core parses shortform out of the payloads an
  adapter reports at its ingest edges, and it deliberately never parses a RECEIVED message body —
  *"a peer can write a tag at you all day; it is text. Nothing you receive can make you send."*
  That guarantee is core's to keep only over text core itself handles. Hand a delivered message
  back to core as OUR user input and it is laundered into an ingest edge: the peer's `@<…@>` tag is
  now inside our own payload and dispatches FROM US, to targets the peer chose. It is invisible at
  both ends — the peer sees an ordinary delivery, we see a dispatch we did not author.
- **Invariant (`REQ-HAZARD-DELIVERY-AS-USER-INPUT`):** a delivery is never reported as user input.
  `user_input_payload` returns `None` for an empty prompt, for every ADR-0007 stub shape, and for
  an `<EVENT` envelope; those turns take the plain, payload-free busy call, which core documents as
  emitting nothing.
- **Timing is part of the invariant, not an implementation detail.** The guard ships in the release
  that starts reporting the busy payload (v0.33.0), NOT in the later release that declares `[io]`
  compliance. Core parses nothing of our ingest until we declare, so nothing is exploitable today —
  but landing the guard with the declaration would make it a fix rather than an invariant, and the
  window between the two releases would be exactly the shape the guarantee forbids. Add the guard
  when you add the seam, not when the seam becomes reachable.
- **Lane:** adapter-side entirely. Core is keeping its half of the contract; this hazard is only
  reachable by us handing it text that is not ours.

<!-- [doc->REQ-HAZARD-IO-SPAN-OVERLAP] -->
### 8.2 Two readers of one transcript publish the same text twice

- **Failure shape:** the adapter reports the agent's output to spt-core at two points in a turn —
  mid-turn spans at `PreToolUse` (`state busy --payload-stdin --mid`) and the remainder at `Stop`.
  If those two reports overlap, the same assistant text is published twice. spt-core's rule is
  closed and deliberate: *"Report every span EXACTLY ONCE across the turn. spt-core does not
  deduplicate and never will... Overlap is not refused; it is delivered twice."* Core declines the
  dedup on purpose — knowing that two payloads are the same span means modelling how a harness
  assembles a turn, which is the adapter's business, not core's.
- **How it was already latent here:** the retired `turn_closing_output` read the transcript from
  **offset 0** and returned the last non-empty assistant text, while `scan_and_dispatch` runs first
  in `handle_stop` and had already advanced the byte cursor past that same text. Two readers of one
  transcript, only one of them holding the cursor. Two reachable shapes: (a) the agent writes text,
  calls a tool, and ends the turn with nothing further — that text is published as a span and again
  as the closer; (b) a turn that produces no assistant text at all walks *backwards* into the
  previous turn's closing message and republishes it.
- **Why the cost is not constant, which is what makes it a hazard rather than a bug:** before
  `[io] compliance = true` an overlap is a duplicated record — visible in `spt api io-events`,
  costing nobody anything. After the flip, core parses the payload for shortform, so a re-reported
  `@<…@>` **dispatches twice** and the duplicate is discovered by the recipient rather than by us.
  The release ordering follows from that: spans ship with compliance OFF so the first field test of
  disjointness happens on the near side of the flip that makes a mistake externally visible.
- **Invariant (`REQ-HAZARD-IO-SPAN-OVERLAP`):** both payload legs read the SAME cursor-guarded
  batch, `ScanOutcome.authored`. Disjointness then holds by construction — from the same byte
  cursor that has kept `PreToolUse` and `Stop` from double-dispatching a tag since v0.17.x — rather
  than from two call sites agreeing to stay in step.
- **Do not "fix" this with a dedup check.** Comparing the closing text against the spans re-derives
  at every `Stop` what the cursor already knows, and passes silently the day the two texts differ by
  one byte. The defect is the second reader; delete the second reader.
- **A test whose fixture cannot reach the leg is not a guard.** The pre-existing phantom-payload
  test passed `transcript_path: ""`, so the scan bailed, the batch was empty, and the span leg was
  unreachable — it kept passing when the span leg landed, proving nothing about it. Give io-payload
  tests a real transcript and assert a positive control that the leg was actually reached.
- **Lane:** adapter-side entirely. Core states the exactly-once rule and declines to enforce it;
  keeping it is ours.

<!-- [doc->REQ-HAZARD-ACROSS-CLEAR-DISPATCH-DROP] -->
### 8.3 Handing the dispatch to core makes every un-reported turn a silently unsent one

- **Failure shape:** the across-clear quiet window returns from `handle_stop` before the idle
  report, so that turn reports no payload. While this adapter owned the parser that cost nothing —
  `scan_and_dispatch` had already dispatched, higher up the same function, and the call site said so:
  *"The dispatch above already ran — outbound is never suppressed."* Under `[io] compliance` the
  dispatch is **core's**, and core only ever sees what we report. The same early return therefore
  stops dispatching every shortform tag written in that turn, with nothing anywhere recording it.
- **Why that turn in particular:** the quiet window is armed by a commune-across. It is the turn an
  agent writes its handoff in — often naming peers, often the last thing it says before the session
  is rebuilt. It is the worst turn in the system to lose a message on, and the loss is silent at both
  ends.
- **The general rule, which outlives this instance:** when a capability moves from the adapter to
  core, every path that *skips reporting* silently becomes a path that *skips the capability*. An
  early return that was previously about one concern (here: not marking idle) inherits every concern
  that now rides the report. Re-walk the early returns when you hand something over — the compiler
  cannot, because nothing about them changed.
- **Invariant (`REQ-HAZARD-ACROSS-CLEAR-DISPATCH-DROP`):** the quiet-window arm still reports the
  turn's batch, as a mid-turn span on the **busy** arm (`state busy --payload-stdin --mid`). That is
  compatible with the window rather than a hole in it: the window exists to keep the endpoint BUSY so
  inbound spools for the post-clear session, and `--mid` marks busy (idempotent) rather than idle. The
  text reaches core's parser, the endpoint stays ACTIVE, and the window keeps doing its job.
- **Accepted and named:** a bare (odd, unpaired) `;;` seal marker in that turn is refused as
  `SEAL_BARE_MIDTURN` — mid-turn, *"through the end of your output"* names text core has not been
  given yet, and a short seal looks exactly like a correct one. Seal pairs mint normally. Recorded
  rather than worked around: closing the pair is the author's fix.
- **Lane:** adapter-side. Core's rule is that it parses what it is given; choosing to give it
  nothing is ours.

<!-- [doc->REQ-HAZARD-COMMUNE-DROP-REARM] -->
## 9. Commune-drop detection (a trigger that is a state, not an event)

### 9.1 Trading an event trigger for a state trigger re-fires it until something remembers

- **Failure shape:** the across-commune boundary used to be triggered by a **Write tool call** —
  `tool == "Write" && file_path.ends_with("<id>-commune.md")` — which is an event: it happens once,
  and being observed once is a property of the event itself, not of anything we wrote. Keying the
  trigger on the **drop file** instead (so that a commune written with Bash, `Edit`, python or
  anything else fires the boundary at all — `REQ-COMMUNE-DROP-DETECT`) replaces that event with a
  **state**: the drop sits on disk until spt-core's watcher ingests it, which the operator measures
  at >15 seconds and many tool calls. A state-shaped trigger re-fires on every hook that reads it.
- **What re-firing costs:** a second wake-ARM self-send, a rewritten clearing latch, and ultimately
  two post-clear sequences — which **blend** rather than fail loudly (§ the v0.14.1 boundary-rename
  lesson: never emit two post-clear sequences). Nothing errors; the agent just gets a mangled
  boundary.
- **The path that can double-fire *through* the fix rather than around it:** our own `>>commune<<`
  shortcut writes the drop and arms in the same breath, so its own write is a live trigger for the
  detector on the very next hook. It has to stamp what it wrote.
- **Invariant (`REQ-HAZARD-COMMUNE-DROP-REARM`):** `state/commune-seen/<eid>.stamp` records the
  **last examined** drop identity (`<mtime_ms>:<len>`). An unchanged identity is a no-op that does
  **not read the file**; a changed or missing one triggers read → scan → arm → restamp; the drop's
  absence clears the stamp, so a stamp never outlives the file it describes.
- **Why *examined* and not *armed*:** a commune with no wake marker is the common case. Recording
  only arms would leave a 20–100KB file being re-read on every tool call for the whole ingest window
  while never arming — correct, and a cost regression nothing downstream would ever notice. Both
  halves are tested, including one test that asserts the file is stat'd and not read.
- **Lane:** adapter-side, and self-inflicted by the fix above — the price of making the detector ask
  about the commune instead of about the tool call.

<!-- [doc->REQ-COMMUNE-FRAME-BACKSTOP] -->
### 9.2 A detector that reads the disk loses a race it was never told it was in

- **Failure shape:** § 9.1's detector stats the commune drop at each hook. That is correct only
  while the drop is still *there* — and spt-core's watcher ingests and **deletes** it. The design
  was built on an ingest time of ">15 seconds"; measured on the field node it is **2.8s / 3.8s /
  5.8s**. A hook cycle is routinely longer than that, because the gap between hooks includes the
  agent's own thinking time. So the commune written as a turn's *final act* — the handoff, the exact
  case the feature exists for — is typically gone before that turn's `Stop` runs, and every
  file-stat leg sees nothing at all.
- **How it was found:** by field-testing the release. Two real drops (`cp`, then `>`) fired no
  boundary, and `state/commune-seen/` stayed empty, proving no leg had even *examined* them. The
  binary was correct the whole time — driven with a synthetic payload it detects, logs and arms
  exactly as designed. The logic worked; the window it needed did not exist.
- **The general rule, which outlives this instance:** *naming* a load-bearing assumption is not
  *testing* it. The requirement said, in as many words, "this holds only while ingest is slower than
  a hook cycle" — phrased to be falsifiable, and never falsified. The cheapest measurement available
  decided the whole design, and it was taken after the release instead of before it.
- **Invariant (`REQ-COMMUNE-FRAME-BACKSTOP`):** once ingested, the `COMMUNE` io frame is the only
  surviving evidence, and `Stop` reads it — `api io-events <id> --session-id <sid>`, authenticated
  by the sid every hook already carries. The disk leg wins before ingest, the frame leg after; they
  are complementary, not redundant, and between them the window has no gap.
- **Never clear on a guess:** a `truncated` frame is refused **by name** rather than armed or
  silently skipped. The payload caps at the 16KB class and the marker conventionally sits at the end
  of a commune — precisely what a cap cuts — so arming would reset a session that never asked for
  it. These frames carry no `digest_seq` to follow (28 observed), so there is no adapter-side
  recovery; the gap is filed upstream and the refusal names the manual recovery.
- **Lane:** adapter-side. The timing is core's; building on an unmeasured number was ours.

## 10. CI process cleanup (a kill on a shared runner)

### 10.1 An unscoped `taskkill` by adapter name wall-a's every agent on the box

<!-- [doc->REQ-HAZARD-CI-KILL-SCOPING] -->

A test's `cleanup()` that enumerates processes by the `claude-spt` **image name** and force-kills
them (`tasklist | grep claude-spt | ... taskkill //T //F`) is not scoped to its own run. On the
shared HFENDULEAM runner every live agent's adapter is a `claude-spt.exe launch --id <agent>`
process, so that one line tree-force-kills **every** peer's adapter — deployah, doyle, hertz, and
the session running the test itself — not only the disposable this test spawned. Found by hertz
during doyle's fleet kill-census (2026-09-07), one unguarded site at `ci/psyche/live-relay-int.sh`;
the repo already carried the correct idiom three times.

**Invariant.** A CI kill selects its targets by **this run's unique id in the command line**
(`wmic process where "name like 'claude-spt%' and commandline like '%$ID%'"`, or PowerShell
`CommandLine -match '$ID'`), never by image name alone. Pin an image name in the `wmic` predicate
so the `wmic` process cannot match its own command line. A kill of a **remembered pid**
(`taskkill //PID "$RUNPID" //T //F`) re-verifies that pid is still a `claude` process at kill time
(`wmic process where "processid=$RUNPID" get name | grep -qiE claude`), because a remembered pid
can be recycled by the OS between capture and cleanup and a tree-force kill would then take an
unrelated tree. Enforced by `tests/ci-kill-scoping.sh` (a repo scan over `ci/` and `tests/`).
Presence/count probes (`tasklist | grep -qi claude-spt` with no kill) are exempt — they take no
life.

### 9.3 A stale-high session cursor goes blind to every fresh commune after a seq restart

<!-- [doc->REQ-HAZARD-COMMUNE-FRAME-CURSOR-BLIND] -->

The commune FRAME backstop (§9.x, the leg that arms an across-commune boundary from the ingested
`COMMUNE` frame once the drop is off disk) originally rode the per-session io cursor: SessionStart
seeded the cursor to the log tip, and each `Stop` polled `--session-id <sid>` for events *after*
it. That assumes `seq` is monotone. It is not — the core iolog `seq` RESTARTS and the old blocks
stay (the UTF-8 tail-window reset, filed separately). So a session seeds its cursor high (e.g.
370), the log restarts below that, every fresh commune mints a low `seq` (e.g. 30), and the
cursored poll — asking "after 370" — returns **nothing**, on every turn. The across-commune
boundary silently never fires; the agent never resumes on the commune it just wrote, and its
resume brief reads stale. Field-measured on perri 2026-09-07 (cursored poll = 0 events at cursor
370; the live authored+marker commune sat at seq 30).

**Invariant.** The frame backstop re-reads the whole log (`--after 0`, which writes no cursor;
`--session-id` remains as proof) and relies on the **age guard** — the session-start `at_ms` floor
plus newest-wins — to reject replays and pick the handoff. `at_ms` is monotone where `seq` is not,
so this is both blind-proof (a fresh low-seq frame is always returned) and replay-proof (a
pre-session frame is always dropped). The SessionStart cursor-seed is retired; its guarantee was
false under a restart. This is §9.2's sibling and REQ-HAZARD-COMMUNE-FRAME-REPLAY's opposite face:
a LOW cursor replays ancient frames, a stale-HIGH cursor hides fresh ones, and the cursor is the
wrong instrument in both directions.

