---
phase: 25.2-doyle-cluster-fix-candidates
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  # Note: src/live/start.rs and src/live/wrapper/mod.rs are also listed in Plan 1.
  # Per CONTEXT D-02 ("keep edits in disjoint regions"): Plan 1 edits start.rs at
  # emit_boot_trigger_after_spawn (L583-630); Plan 2 edits drain_stale_signoff_file
  # (L138-178). Plan 1 edits mod.rs production code (L148, L629, L700, L859, L1100);
  # Plan 2 only appends to the #[cfg(test)] mod is_init_signoff_envelope_tests at
  # L2752+. Regions are disjoint by function.
  - src/live/start.rs
  - src/live/wrapper/mod.rs
  - plugin/spt/skills/signoff/SKILL.md
  - tests/native_latent_signoff_deliver_then_die.rs
autonomous: true
requirements: []

must_haves:
  truths:
    - "$LIVE start / $LIVE revive on an agent with a pending `.claude/<id>-signoff.md` forwards the body to the agent's psyche as a `<EVENT type=\"latent signoff\">` envelope via TCP-first-spool-fallback before deleting the file"
    - "The `.claude/<id>-signoff.md` file is deleted ONLY after the forward call returns (delivered=true); on panic the file is preserved for the next attempt"
    - "The forwarded envelope falls OUTSIDE `is_init_signoff_envelope`'s predicate — wrapper-side `drain_stale_init_signoffs` does NOT eat the latent-signoff envelope (D-12 invariant; no STOP-loop regression)"
    - "Psyche absorbs the latent-signoff envelope on next poll as informational context (no special handler; falls under psyche.md absorb-silently rule for non-keyword envelopes)"
  artifacts:
    - path: "src/live/start.rs"
      provides: "Rewritten drain_stale_signoff_file (deliver-then-die)"
      contains: "<EVENT type=\\\"latent signoff\\\""
    - path: "tests/native_latent_signoff_deliver_then_die.rs"
      provides: "Integration tests for #5 envelope shape + deliver-then-die ordering + D-12 invariant"
    - path: "plugin/spt/skills/signoff/SKILL.md"
      provides: "Updated user-facing signoff drain behavior description (if signoff doc surfaces drain behavior)"
  key_links:
    - from: "src/live/start.rs::drain_stale_signoff_file"
      to: "src/owl/send.rs::deliver_body_anonymous"
      via: "TCP-first-spool-fallback transport (D-10)"
      pattern: "send::deliver_body_anonymous"
    - from: "src/live/start.rs::drain_stale_signoff_file envelope"
      to: "src/owl/poll.rs event_attr_escape + event_body_escape"
      via: "EVENT envelope construction helpers (D-11)"
      pattern: "event_attr_escape|event_body_escape"
    - from: "<EVENT type=\"latent signoff\">"
      to: "src/live/wrapper/mod.rs::is_init_signoff_envelope predicate"
      via: "structural disjointness — latent-signoff substring does NOT match init_signoff predicate (D-12)"
      pattern: "is_init_signoff_envelope"
---

<objective>
Rewrite `src/live/start.rs::drain_stale_signoff_file` from "surface body to stdout then delete" to "forward body to psyche as `<EVENT type=\"latent signoff\">` commune then delete only after queue confirmed."

Purpose: Stop discarding fresh `.claude/<id>-signoff.md` payloads on `$LIVE start`/`revive` when the file was written by the previous session and not yet absorbed. The doyle case confirmed payload-loss in production. Deliver-then-die preserves the body via TCP/spool to the agent's psyche; psyche absorbs as context on next poll.

Output:
- Rewritten `drain_stale_signoff_file` building the `<EVENT type="latent signoff">` envelope inline.
- Delivery via `crate::owl::send::deliver_body_anonymous` wrapped in `std::panic::catch_unwind` (mirrors signoff.rs:247-249).
- File deletion only after `delivered == true`.
- `is_init_signoff_envelope` predicate regression test confirming D-12 invariant.
- Operator UAT smoke checkpoint per D-04.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-CONTEXT.md
@.planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-RESEARCH.md
@.planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-PATTERNS.md
@.planning/debug/resolved/init-signoff-substring-false-positive.md
@.planning/debug/resolved/stale-signoff-fires-on-next-session-start.md
@.planning/debug/resolved/wrapper-drains-stale-signoff.md
@plugin/spt/skills/signoff/SKILL.md
@CLAUDE.md

<interfaces>
<!-- Key types and contracts. -->

From src/owl/send.rs:
- `pub fn deliver_body_anonymous(target: &str, body: &str)` (L272-276) — TCP-first, spool-fallback, empty `from`. Used by INIT_SIGNOFF + echo-commune. Returns nothing (errors logged to stderr; spool fallback always reached).

From src/owl/poll.rs:
- `pub(crate) fn event_attr_escape(s: &str) -> String` (L700) — HTML-entity escape for attribute values; replaces `&`, `<`, `>`, `"`.
- `pub(crate) fn event_body_escape(s: &str) -> String` (L685) — body escape; same as attr + `\n -> <br>` last.
- `pub(crate) fn body_is_typed_event_envelope(body: &str) -> bool` (L782-796) — passthrough check; envelope literal MUST start at byte 0 with `<EVENT type=` (no leading whitespace/BOM) to short-circuit re-wrap.

From src/live/wrapper/mod.rs:148-151:
- `pub(crate) fn is_init_signoff_envelope(msg: &str) -> bool` — literal case-insensitive substring check on `<event type="init_signoff"`. Latent-signoff envelope (lowercase `<event type="latent signoff"`) does NOT match.

From src/live/start.rs (current — to be rewritten):
- `pub(crate) fn drain_stale_signoff_file(id: &str, cwd: &Path)` (L138-178) — called from start.rs:452 and start.rs:779. Read body, surface via println, fs::remove_file.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: AUDIT (inline deliverable) — confirm is_init_signoff_envelope predicate + enumerate signoff drain consumers</name>
  <files>(no edits; inline deliverable in plan transcript)</files>
  <read_first>
    - .planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-RESEARCH.md (sections "Pitfall 1: STOP-loop regression" + "Pitfall 2: D-12 invariant violation" + "Anti-patterns to avoid")
    - .planning/debug/resolved/init-signoff-substring-false-positive.md
    - .planning/debug/resolved/stale-signoff-fires-on-next-session-start.md
    - .planning/debug/resolved/wrapper-drains-stale-signoff.md
    - src/live/start.rs (lines 138-178 — drain_stale_signoff_file body + lines 440-470, 770-790 — call sites)
    - src/live/wrapper/mod.rs (lines 148-151 — is_init_signoff_envelope; lines 2752+ — is_init_signoff_envelope_tests mod)
    - src/live/wrapper/lifecycle.rs (lines 116-140 — drain_stale_init_signoffs body)
    - src/owl/poll.rs (lines 780-800 — body_is_typed_event_envelope rule + line 1146 — compose_file_drop_event reference)
  </read_first>
  <action>
    Document four findings inline in plan transcript (or as inline comment block in task 2 code prefixed `// AUDIT 25.2-02 task 1`).

    Finding 1 — is_init_signoff_envelope predicate confirmed: literal case-insensitive substring `<event type="init_signoff"` at src/live/wrapper/mod.rs:148-151. Body lowercased before contains-check. A `<EVENT type="latent signoff">` envelope (lowercased: `<event type="latent signoff"`) does NOT contain the substring `<event type="init_signoff"`. The substring `latent signoff` shares zero literal overlap with `init_signoff`. Conclusion: D-12 invariant holds by construction. No predicate edit needed.

    Finding 2 — Signoff drain consumers (D-12 coexistence): enumerate every consumer that reads signoff-shaped envelopes and prove no double-eat.

      Consumer A: `src/live/start.rs::drain_stale_signoff_file` (this plan's rewrite target) — reads `.claude/<id>-signoff.md` from disk (a FILE, not a spooled envelope). Cannot accidentally re-consume an envelope written to spool by itself, because spool envelopes don't pass through `.claude/`.

      Consumer B: `src/live/wrapper/lifecycle.rs::drain_stale_init_signoffs` — wrapper-side; reads SPOOLED envelopes; matches via `is_init_signoff_envelope`. Latent-signoff envelope shape does NOT match this predicate. Confirmed safe.

      Consumer C: `src/owl/poll.rs::scan_drop_files` (or equivalent normal-path consumer of spooled envelopes) — these consume by envelope type via dispatch (file_drop, echo_commune, msg, etc.). Latent-signoff envelope type does NOT match any existing dispatch — it falls through to the default "absorb silently" path per psyche.md:52. Confirmed safe.

      Document explicitly: "Plan 2 latent-signoff envelope is consumed by Consumer A (this plan, file-source) and absorbed silently by Psyche (Consumer C fall-through). It is NEVER consumed by Consumer B because shape-disjoint."

    Finding 3 — body_is_typed_event_envelope passthrough requirement: envelope literal MUST start at byte 0 with `<EVENT type=`. Task 2's rewrite must NOT prepend whitespace, BOM, or comment text. Verify with grep `body_is_typed_event_envelope` to confirm src/owl/poll.rs:782-796 is the contract source.

    Finding 4 — STOP-loop landmine check: the asymmetry argument from `resolved/stale-signoff-fires-on-next-session-start.md` says: the wrapper-side `drain_stale_init_signoffs` only fires on INIT_SIGNOFF-shaped envelopes (Consumer B). If task 2 accidentally typed the envelope as `<EVENT type="init_signoff">` instead of `<EVENT type="latent signoff">`, the wrapper would drain it on next poll, fire `final_session`, and exit. Document this as the invariant task-2 implementation MUST not violate. Verify with grep that no occurrence of `"init_signoff"` is created by task 2's code path.
  </action>
  <verify>
    <automated>
      grep -n "is_init_signoff_envelope\|init_signoff\|latent signoff" src/live/wrapper/mod.rs src/live/wrapper/lifecycle.rs src/live/start.rs src/owl/poll.rs
    </automated>
  </verify>
  <acceptance_criteria>
    - All four findings recorded in plan transcript / source comment.
    - Finding 1 cites exact predicate line + literal substring.
    - Finding 2 enumerates all 3 consumers (A, B, C) with double-eat proof.
    - Finding 3 cites `body_is_typed_event_envelope` rule.
    - Finding 4 documents the STOP-loop landmine invariant for task 2 to honor.
  </acceptance_criteria>
  <done>Audit committed. Task 2 has explicit constraints to honor: envelope starts at byte 0, type literal is `latent signoff` (lowercase compare-disjoint from `init_signoff`).</done>
</task>

<task type="auto">
  <name>Task 2: Rewrite drain_stale_signoff_file to deliver-then-die latent signoff</name>
  <files>src/live/start.rs</files>
  <read_first>
    - Task 1 audit findings
    - .planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-RESEARCH.md (Patterns 2/3/4 + Pattern S-3/S-4 + Don't Hand-Roll table + "Architectural Responsibility Map" row for #5)
    - .planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-PATTERNS.md (section "Plan 25.2-02: Latent-Signoff Forward (#5)" full code block)
    - src/live/start.rs (lines 138-178 — current drain_stale_signoff_file)
    - src/live/signoff.rs (lines 241-249 — canonical catch_unwind + deliver_body_anonymous shape)
    - src/owl/echo_commune.rs (lines 60-90 — compose_echo_commune_payload envelope-build reference)
    - src/owl/poll.rs (lines 685-705 — event_attr_escape + event_body_escape)
    - src/owl/send.rs (lines 65-97, 268-276 — transport surface)
  </read_first>
  <action>
    Rewrite the body of `pub(crate) fn drain_stale_signoff_file(id: &str, cwd: &Path)` at src/live/start.rs:138-178. Keep the signature unchanged so the two existing call sites (start.rs:452 and start.rs:779) continue to work without modification.

    New body responsibilities (in order):

    Step 1 — Compute `signoff_path = cwd.join(".claude").join(format!("{}-signoff.md", id))`. If `!signoff_path.exists()` return early (no work). Match existing behavior.

    Step 2 — `fs::read_to_string(&signoff_path)` to get the body. On Err: emit `output::owl_err(&format!("drain_stale_signoff_file: failed to read {}: {} (continuing $LIVE start)", owlery::to_forward_slash(&signoff_path), e))` and return. Do NOT delete the file on read failure (preserves the body for the next attempt).

    Step 3 — `let trimmed = body.trim_end();`. If `trimmed.is_empty()`, the file holds only whitespace — just delete it (`fs::remove_file` best-effort) and return. Nothing to forward.

    Step 4 — Build the envelope INLINE per CONTEXT D-11 and RESEARCH "Don't Hand-Roll" rationale (one call site; no helper function). The envelope MUST start at byte 0 with `<EVENT type="latent signoff"`. Construct via `format!`:

      - Attributes: `from` (the agent id), `written_at` (mtime of signoff_path formatted via `chrono::DateTime<chrono::Local>` as `%Y-%m-%dT%H:%M:%S%:z`; on mtime read failure use empty string), `cleared_from` (the forward-slash path of signoff_path).
      - Body: `event_body_escape(trimmed)`.
      - All attribute values escaped via `crate::owl::poll::event_attr_escape`.
      - Use the exact literal `<EVENT type="latent signoff"` (lowercase `latent signoff` per D-11 envelope-name lock).

      Reference shape (canonical from echo_commune.rs):
        envelope = format!(
          "<EVENT type=\"latent signoff\" from=\"{}\" written_at=\"{}\" cleared_from=\"{}\">{}</EVENT>",
          event_attr_escape(id),
          event_attr_escape(&written_at),
          event_attr_escape(&owlery::to_forward_slash(&signoff_path)),
          event_body_escape(trimmed),
        )

    Step 5 — Determine `psyche_id = format!("{}-psyche", id)` (matches the convention used by signoff.rs:241).

    Step 6 — Deliver via TCP-first-spool-fallback wrapped in `std::panic::catch_unwind` per `src/live/signoff.rs:247-249`. Move the envelope + psyche_id into the closure (use `.clone()` then move). Capture the `is_ok()` result as `delivered: bool`. RESEARCH §"Note" + Open Question 1: accept the panic-only-preserves-file form (D-10 deliver-then-die is satisfied because `deliver_body_anonymous` ALWAYS writes to spool fallback on TCP failure — spool is the durable destination). Do NOT refactor `deliver_body_anonymous` for an injectable success/failure return (gold-plating per RESEARCH Open Question 1).

    Step 7 — Emit a status line via `output::live_status(output::S_READY, ...)` indicating the forward occurred. Suggested literal: `LATENT-SIGNOFF-FORWARDED:{id} (body queued to {psyche_id} via TCP/spool)`. This tag should NOT collide with the existing READY/SENT/STOPPED set in CLAUDE.md §Conventions — adding a new descriptive tag value is acceptable since it ships via `output::live_status` (status output to stderr, not a wire protocol).

    Step 8 — File deletion gated on `delivered`. If `delivered == true`: `fs::remove_file(&signoff_path)` best-effort; on Err emit `output::owl_err` and note the file will be picked up next time (NOT a regression — `delivered==true` means body is durably queued, so next-time forward will spool a duplicate but psyche absorbs silently per Finding 2C). If `delivered == false`: emit `output::owl_err` indicating panic preserved the file; do NOT delete; rely on next-time retry.

    Step 9 — REMOVE the old `println!(<owl_pending_signoff ...>...)` stdout surface emission from the old implementation. That was the lossy stdout-surface behavior; the new behavior delivers to wire.

    Constraints inherited from task-1 audit:
    - Envelope literal MUST start at byte 0 with `<EVENT type="latent signoff"` (no leading whitespace, no BOM, no leading comment).
    - The literal `init_signoff` (lowercase, case-insensitive) MUST NOT appear anywhere in the envelope output. Search the envelope string after construction in debug builds via `debug_assert!(!envelope.to_ascii_lowercase().contains("init_signoff"))` — protects against future copy-paste regressions. Optional; add if low-cost.
  </action>
  <verify>
    <automated>
      cargo build --release 2>&1 | tail -20 && grep -n "owl_pending_signoff\|latent signoff" src/live/start.rs
    </automated>
  </verify>
  <acceptance_criteria>
    - `drain_stale_signoff_file` signature unchanged; two existing callers compile.
    - New body builds `<EVENT type="latent signoff" from="..." written_at="..." cleared_from="...">BODY</EVENT>` inline.
    - Envelope literal starts at byte 0 with `<EVENT type="latent signoff"` (no leading whitespace).
    - Delivery via `send::deliver_body_anonymous` wrapped in `std::panic::catch_unwind`.
    - File deletion gated on `delivered` boolean.
    - Old `owl_pending_signoff` stdout surface removed (grep returns 0 hits in src/live/start.rs).
    - `cargo build --release` succeeds.
  </acceptance_criteria>
  <done>drain_stale_signoff_file rewritten; D-09 (commune, not INIT_SIGNOFF), D-10 (TCP-first, delete after queue), D-11 (envelope shape), D-12 (predicate disjoint) all satisfied.</done>
</task>

<task type="auto">
  <name>Task 3: Integration tests + in-module D-12 invariant unit test</name>
  <files>tests/native_latent_signoff_deliver_then_die.rs, src/live/wrapper/mod.rs</files>
  <read_first>
    - .planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-RESEARCH.md (sections "Test Harness Conventions" + "Phase Requirements → Test Map" + "Wave 0 Gaps")
    - .planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-PATTERNS.md (Pattern S-2 scaffold + "#5 Tests" section)
    - src/common/wrapper_state.rs (lines 162-184 — canonical SptHomeSnapshot + ENV_LOCK)
    - src/common/spool.rs (any peek_all / read-spool surface for assertion)
    - src/live/wrapper/mod.rs (lines 2752+ — is_init_signoff_envelope_tests mod for extending OR regression case in this test file)
  </read_first>
  <action>
    Create `tests/native_latent_signoff_deliver_then_die.rs`. Each test uses `ENV_LOCK` + `SptHomeSnapshot` from RESEARCH Pattern S-2.

    Test A `drain_stale_signoff_file_forwards_latent_signoff_envelope`:
      Setup — SPT_HOME tempdir; cwd tempdir; plant `.claude/doyle-signoff.md` with body `"gen-N final brief from prior session\nline 2\n"`. Plant a psyche perch at the appropriate path (call `owlery::nested_perch_dir("doyle", "doyle-psyche")` and `fs::create_dir_all` it; write a placeholder `info.json` if needed for spool lookup — verify by reading src/common/spool.rs to determine minimum required setup; if a registry entry is required, plant via `crate::common::registry::register_address` or skip if `deliver_body_anonymous` falls through to spool unconditionally on missing target).
      Action — call `drain_stale_signoff_file("doyle", cwd.path())`.
      Assertions:
        (a) `.claude/doyle-signoff.md` was deleted.
        (b) Spool contains exactly one row whose body starts with `<EVENT type="latent signoff"`.
        (c) That body contains `gen-N final brief from prior session` (with `\n` converted to `<br>` by event_body_escape).
        (d) The body's `from` attribute equals `"doyle"`.
        (e) The body's `cleared_from` attribute equals the forward-slash path of the signoff file.

    Test B `drain_stale_signoff_file_preserves_file_when_no_body`:
      Setup — plant empty `.claude/doyle-signoff.md` (zero bytes or only whitespace).
      Action — call `drain_stale_signoff_file("doyle", cwd.path())`.
      Assertions: file is deleted (empty case); no spool row created.

    Test C `latent_signoff_envelope_is_not_init_signoff_envelope` (D-12 regression guard) — IN-MODULE UNIT TEST (per checker Warning 6).
      `is_init_signoff_envelope` at `src/live/wrapper/mod.rs:148` is `pub(crate)`. Integration tests (`tests/native_*.rs`) see only `pub` symbols — `pub(crate)` is invisible. Therefore: do NOT add this test to `tests/native_latent_signoff_deliver_then_die.rs`. Instead, EXTEND the existing in-module test family at `src/live/wrapper/mod.rs::is_init_signoff_envelope_tests` (L2752+).
      Add one new `#[test]` function named `latent_signoff_envelope_is_not_init_signoff_envelope` inside that mod (placement: at the end of the existing test family, before the closing brace of the mod). Test shape (described, not fenced): the test binds a `payload` local of type `&str` to a raw-string literal of an `<EVENT type="latent signoff" from="doyle" written_at="2026-05-22T12:34:56-07:00" cleared_from="/foo/.claude/doyle-signoff.md">prior session brief body</EVENT>` envelope (use Rust raw-string `r#"..."#` syntax to avoid escape-hell on the inner quotes). The test body then asserts `!is_init_signoff_envelope(payload)` with the failure message `"D-12 invariant: latent-signoff envelope must NOT match init_signoff predicate"`. The mod already imports `use super::is_init_signoff_envelope;` at its top so the predicate is in scope.
      This is the ONLY edit to `src/live/wrapper/mod.rs` that Plan 2 makes — a single appended `#[test]` function inside the existing `#[cfg(test)] mod is_init_signoff_envelope_tests` at L2752+. Region is disjoint from every Plan 1 edit point (L148/L629/L700/L859/L1100).

    Test D `drain_stale_signoff_file_no_stop_loop_regression` (STOP-loop landmine guard):
      Setup — same as Test A.
      Action — call `drain_stale_signoff_file("doyle", cwd.path())` then read the forwarded envelope from spool.
      Assertion — the forwarded body does NOT contain (case-insensitive) the literal `<event type="init_signoff"`. Equivalent contract guarantee to Test C but at the produced-envelope level (not the predicate level). If the implementation accidentally typed `init_signoff` into the envelope, this test catches it before wrapper code does.

    For all tests: use `cargo test --release -- --test-threads=1` for run. The test file is its own binary. If any test needs registry/info.json setup that's non-trivial, document the simplifying choice (e.g., "deliver_body_anonymous falls through to spool on missing registry entry — minimal setup OK") in a file-top comment.
  </action>
  <verify>
    <automated>
      cargo test --release --test native_latent_signoff_deliver_then_die -- --test-threads=1 2>&1 | tail -30 && cargo test --release --lib is_init_signoff_envelope_tests::latent_signoff_envelope_is_not_init_signoff_envelope -- --test-threads=1 2>&1 | tail -10
    </automated>
  </verify>
  <acceptance_criteria>
    - File `tests/native_latent_signoff_deliver_then_die.rs` exists.
    - Test A asserts file deleted + spool row contains `<EVENT type="latent signoff"` + body content preserved.
    - Test B handles empty-file edge.
    - Test C exists as an in-module unit test inside `src/live/wrapper/mod.rs::is_init_signoff_envelope_tests` at L2752+ (NOT in the integration tests file — `pub(crate)` visibility constraint per checker Warning 6); asserts D-12 invariant via direct predicate call.
    - Test D asserts envelope does not contain `init_signoff` literal (STOP-loop regression guard).
    - All tests pass under `cargo test --release -- --test-threads=1`.
  </acceptance_criteria>
  <done>Test coverage in place. D-11 envelope shape pinned, D-12 invariant pinned, deliver-then-die ordering pinned.</done>
</task>

<task type="auto">
  <name>Task 4: SKILL.md doc — update signoff drain behavior description (if user-visible)</name>
  <files>plugin/spt/skills/signoff/SKILL.md</files>
  <read_first>
    - plugin/spt/skills/signoff/SKILL.md (entire file)
  </read_first>
  <action>
    Read `plugin/spt/skills/signoff/SKILL.md`. Search for any mention of `drain_stale_signoff_file`, `pending signoff`, `owl_pending_signoff`, or behavior on `$LIVE start` discovering a `.claude/<id>-signoff.md`. If the doc surfaces drain behavior to users / agents:

    - Replace any "surfaced to stdout" / "discarded" wording with "forwarded to your psyche as a `<EVENT type=\"latent signoff\">` envelope via TCP-first-spool-fallback; the file is deleted only after the queue is confirmed."
    - Note that the envelope is absorbed silently by Psyche (no special handler).
    - Cite the new semantic guarantee: deliver-then-die — a `.claude/<id>-signoff.md` written while the agent is offline is preserved across the $LIVE start gap.

    If the SKILL.md does NOT mention drain behavior (it surfaces only the user-facing `$LIVE signoff` command), SKIP this task entirely. Document the skip with `git log -1 --format=%B` rationale in the commit message: "signoff/SKILL.md does not surface drain behavior; no user-facing change needed."

    Do NOT touch the user-facing `$LIVE signoff` command contract — that path is `src/live/signoff.rs::run` (separate code path; not changed by Plan 2).
  </action>
  <verify>
    <automated>
      grep -n "latent\|drain_stale" plugin/spt/skills/signoff/SKILL.md ; echo "(empty grep result is acceptable iff task skipped — verify in commit message)"
    </automated>
  </verify>
  <acceptance_criteria>
    - Either: SKILL.md surfaces drain behavior and has been updated to describe deliver-then-die latent-signoff forwarding; OR commit message explicitly notes the skip with rationale.
  </acceptance_criteria>
  <done>User-facing contract aligned with new code behavior, or skip recorded.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 5: Operator UAT smoke — write a stale signoff while offline, revive, observe envelope arrives at psyche</name>
  <what-built>
    - `drain_stale_signoff_file` rewritten: builds `<EVENT type="latent signoff">` envelope inline; forwards via TCP-first-spool-fallback; deletes file only after queue confirmed.
    - D-12 invariant: latent-signoff envelope shape disjoint from `is_init_signoff_envelope` predicate (no STOP-loop regression).
  </what-built>
  <how-to-verify>
    1. Build release: `cargo build --release` (must be green).
    2. Run full suite: `cargo test --release -- --test-threads=1`. All passing.
    3. Deploy: `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1`.
    4. Find a sandbox agent (use `doyle` or any test agent). Stop it cleanly first: `$LIVE stop doyle` and wait for STOPPED. Confirm no live wrapper.
    5. While doyle is offline, write a fresh signoff body to its `.claude/doyle-signoff.md`: pick the project root where doyle would launch from and write `Plan-2 UAT test signoff body — verify this reaches psyche as latent signoff.`
    6. `$LIVE revive doyle` (or `$LIVE start doyle`).
    7. Observe stderr / daemon.log: expect a `LATENT-SIGNOFF-FORWARDED:doyle` status line + no panic.
    8. Verify the file was deleted: `Test-Path "<cwd>/.claude/doyle-signoff.md"` returns `False`.
    9. Verify the envelope arrived at psyche: inspect the psyche perch spool OR wait for the next psyche poll and inspect the wrapper's `live_context.md` / commune log for the absorption — exact mechanism depends on whether psyche was started fresh or was already running. Acceptable evidence: spool row visible in the psyche's owlery dir containing `<EVENT type="latent signoff"`, OR psyche absorbs and emits an absorb-notice in the next commune.
    10. Negative test (STOP-loop guard): after step 6, observe the wrapper does NOT exit immediately (no `final_session` fire, no `STOPPED` from wrapper poll iter 1). Wrapper should continue polling normally.
  </how-to-verify>
  <resume-signal>Type "approved" when UAT passes, or describe issues (e.g., "envelope was not delivered" or "wrapper exited iter 1" — would indicate STOP-loop regression).</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| `.claude/<id>-signoff.md` content | Locally-written by Self (this binary); never crosses untrusted input. Body is escaped via `event_body_escape` before envelope construction (mitigates T-25.2-02-01). |
| Spool message → wrapper consumer | Same-machine IPC; spool is filesystem under SPT_HOME. Envelope shape gates which wrapper handler picks up the body. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-25.2-02-01 | Tampering / Injection | Signoff body interpolated into XML-like envelope | mitigate | All attribute values pass through `event_attr_escape`; body passes through `event_body_escape`. No raw concatenation. |
| T-25.2-02-02 | Denial of Service | STOP-loop regression if envelope mis-typed as `init_signoff` | mitigate | Task 1 audit Finding 4 + Task 3 Test D regression guard + D-11 envelope-name lock. Optional debug_assert in task 2 step 9. |
| T-25.2-02-03 | Information Disclosure | Latent-signoff body forwarded over TCP to localhost psyche | accept | TCP target is the agent's own psyche on localhost; same trust boundary as INIT_SIGNOFF (which is already delivered over the same path). Spool fallback writes to local filesystem under SPT_HOME. |
| T-25.2-02-04 | Tampering | File deleted before queue confirmed | mitigate | Step 8 of task 2: deletion gated on `delivered` boolean. Panic preserves file for next attempt. |
</threat_model>

<verification>
- `cargo build --release` succeeds.
- `cargo test --release -- --test-threads=1` — full suite green (existing tests + Plan 2's 4 new tests).
- `grep -n "owl_pending_signoff" src/` returns 0 hits (old stdout-surface emission removed).
- `grep -nE "type=\"latent signoff\"" src/` returns hits only in the rewritten drain_stale_signoff_file region.
- Operator UAT: stale signoff written offline → forwarded as latent-signoff envelope → file deleted → wrapper does NOT exit.
</verification>

<success_criteria>
- All must_haves from frontmatter observed.
- Task 1 audit findings (4 of them) committed.
- Task 5 operator UAT marked "approved" by user.
- Phase 25.2 #5 closed; latent signoff bodies preserved across $LIVE start gap.
</success_criteria>

<output>
Create `.planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-02-SUMMARY.md` when done.
</output>
