# Phase 25.2: Doyle Cluster Fix Candidates — Research

**Researched:** 2026-05-22
**Domain:** SPT live-agent wrapper + tracked-repo git-worktree paths
**Confidence:** HIGH (all findings sourced from direct file inspection of code anchors named in CONTEXT.md + CONTEXT.md decisions + resolved/* debug docs)

## Summary

Phase 25.2 fixes five blast-radius-audited defects from the doyle listener-poll session of 2026-05-22. All five fixes are concrete code edits against named anchors; no library research, no architecture exploration, no greenfield decisions. CONTEXT.md D-01..D-12 already lock the plan grouping (3 plans), retry budgets (80×250ms=20s), envelope shape (`<EVENT type="latent signoff">`), and contract semantics (commune not INIT_SIGNOFF; TCP-first spool-fallback; deliver-then-die never deletes before queue confirmation).

What the planner needs from research:

1. **Exact #4 call-site enumeration** under `src/live/wrapper/` so Plan 1 task 1 can list every flat-vs-nested swap point in one shot. Done below — 9 sites identified across 5 files.
2. **Reusable assets** for envelope construction, TCP-first-spool-fallback, best-effort cleanup, and retry loops — all already exist in tree; #5 and #1 just bolt on.
3. **Test harness conventions** — `tests/native_*.rs` + module-local `#[cfg(test)]` with `SPT_HOME` tempdir + ENV_LOCK mutex + `SptHomeSnapshot` RAII is the established pattern.
4. **Landmine confirmation** — the resolved/* debug docs prove that `<EVENT type="latent signoff">` falls outside `is_init_signoff_envelope` by construction, so Plan 2 cannot reintroduce the STOP-loop the asymmetry argument prevented.
5. **#2 idempotency** + **#4 migration direction** — recommended below with reasoning grounded in the debug doc §Optional hardening + Phase 25 D-01..D-04.

**Primary recommendation:** Plan 1 should bolt the #1 probe into `ensure_worktree` lines 363-366 (the fast-path return); the #3 budget should land as a single shared const in `src/common/wrapper_state.rs` consumed by all four call sites; the #4 sweep should adopt **try-nested-first, fall-back-flat** at every wrapper-side call (not at writer-side) because the writer fleet is mixed and the readers are the consumers losing data today. Plan 2's `latent signoff` envelope can be built inline by hand-formatting the `<EVENT type="latent signoff" ...>` string using existing `event_attr_escape` / `event_body_escape` helpers at `src/owl/poll.rs:685, 700` — no new shared builder is warranted for one call site.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Plan Granularity & Wave Structure:**
- **D-01: 3 plans matching ROADMAP groups.**
  - Plan 1 (`25.2-01`): #1 stale-lock probe + #3 wrapper-state read race + #4 flat→nested wrapper-path sweep. One coordinated wrapper-path-correctness plan.
  - Plan 2 (`25.2-02`): #5 deliver-then-die (forward latent signoff as commune). Standalone after #6 was dropped from scope.
  - Plan 3 (`25.2-03`): #2 ghost `tracked/.git/` cleanup. Standalone.
- **D-02: Parallel waves — all 3 plans independent.** No file overlap. Wrapper-path sweep touches `src/common/tracked.rs`, `src/live/wrapper/*`, `src/live/start.rs`, `src/live/signoff.rs`. DTD touches `src/live/start.rs` (`drain_stale_signoff_file`) plus a new commune-send call site. Ghost cleanup touches `src/common/tracked.rs::migrate_legacy_if_needed` only. Caveat: Plan 1 and Plan 2 both touch `src/live/start.rs` — keep edits in disjoint regions; merge-order them only if collision surfaces during execution.
- **D-03: Audit deliverables inline in each plan's task 1.** No separate AUDIT.md sibling doc.
- **D-04: Verification = operator UAT + targeted integration tests per candidate.**

**#3 Wrapper-State Read Race:**
- **D-05: Raise `MAX_ATTEMPTS` from 8 (2s) to 80 (20s).**
- **D-06: Single shared const `WRAPPER_STATE_MAX_ATTEMPTS = 80` everywhere.** Lives in `src/live/wrapper_state.rs` (or `src/common/`). All consumers import — boot (`start.rs:592–630 emit_boot_trigger_after_spawn`), signoff (`signoff.rs:225-228`), commune trigger, pulse trigger, init trigger.
- **D-07: On exhaustion (>20s no UUID), skip the row + structured warning.** Phase 30 invariant preserved.
- **D-08: Log `wrapper-state read: <elapsed-ms> (boot|signoff|commune|pulse|init)` on every successful read.**

**#5 Deliver-Then-Die Contract:**
- **D-09: Forward rescued signoff body as a regular commune, NOT as INIT_SIGNOFF.** INIT_SIGNOFF triggers wrapper shutdown.
- **D-10: TCP-first delivery with spool fallback.** Mirror existing `$LIVE commune` semantics. Delete `.claude/<id>-signoff.md` only after queue confirmed.
- **D-11: Envelope = `<EVENT type="latent signoff" ...>...</EVENT>`.** Name LOCKED as "latent signoff" (renamed from "rescued signoff").
- **D-12: No coordination needed with `drain_stale_init_signoffs`.** Wrapper's drain matches `is_init_signoff_envelope` predicate ONLY. `<EVENT type="latent signoff">` is a different envelope shape — wrapper-side drain ignores it by construction.

### Claude's Discretion

- Exact wording / line numbers for the per-plan audit task 1 deliverables.
- Test names + golden fixture layout for #1 stale-lock probe.
- Whether to introduce a shared helper for the new EVENT-envelope build in #5 vs inlining.
- Whether `D-08` elapsed-ms logging gets a threshold floor.
- For Plan 3 (#2 ghost cleanup): unconditional vs sentinel-guarded.

### Deferred Ideas (OUT OF SCOPE)

- **#6 Haiku signoff-absorption compression.** Dropped from phase scope by user.
- Migration coexistence policy refinement (project-wide convention beyond Phase 25.2).
- Cross-agent stale-lock sweep skill (`$OWL doctor --sweep-stale-locks`).
- `drain_stale_init_signoffs` deduplication (generalization to all deliver-then-die envelopes).
</user_constraints>

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Per-worktree git lock recovery (#1) | tracked-repo layer (`src/common/tracked.rs`) | — | Stale `index.lock` is a git-process-coordination artifact; recovery must run in the same code path that invokes `git -C <wt> add/commit`. Only `ensure_worktree` is upstream of every per-agent commit. |
| Ghost `tracked/.git/` cleanup (#2) | tracked-repo migration layer (`src/common/tracked.rs::migrate_legacy_if_needed`) | — | Symmetrical to existing legacy-flat-file migration; same call site, same idempotent posture, same stderr warning style. |
| Wrapper-state read budget (#3) | wrapper-state shared layer (`src/common/wrapper_state.rs`) | callers (`src/live/start.rs`, `src/live/signoff.rs`, `src/owl/echo_commune.rs`, pulse, init) | D-06 mandates a single shared const so drift is impossible. Caller-local copies were the original design and produced this bug. |
| Flat→nested wrapper-path sweep (#4) | wrapper module (`src/live/wrapper/`) | path resolver (`src/common/wrapper_state.rs`) | Phase 25 D-04 already path-aware at `enumerate_perches`; wrapper-side reader callers are the last consumers still on flat. Path resolver `wrapper_state_path` itself is the single decision point. |
| Latent-signoff forward (#5) | listener-spawn layer (`src/live/start.rs::drain_stale_signoff_file`) | TCP/spool transport (`src/owl/send.rs::deliver_body_anonymous` + `src/common/spool.rs`) | Function already exists; rewrite to deliver-then-die instead of surface-then-die. Transport already implements TCP-first-spool-fallback (D-10). |

## Phase Requirements

This phase carries **no formal requirement IDs** (urgent-insert phase). All work is governed by CONTEXT.md decisions D-01..D-12 plus the diagnostic source at `.planning/debug/doyle-sessions-seal-tracked-psyches.md`.

## Code Anchors — Verified

Every anchor named in CONTEXT.md was opened and inspected:

| Anchor | Verified contents | Plan |
|--------|-------------------|------|
| `src/common/tracked.rs:362-366` | `ensure_worktree` fast-path: `let dotgit = wt.join(".git"); if dotgit.exists() { return Ok(wt); }` — exactly the insertion site for #1. The probe must check `seed.join("worktrees").join(name).join("index.lock")` BEFORE the `return Ok(wt)`. [VERIFIED: code read] | 1 |
| `src/common/tracked.rs:1239-1269` | `seal_and_rotate_sessions_log` — `ensure_agent_worktree(agent_id)?` at L1247 routes through `ensure_worktree`, so a probe in `ensure_worktree` covers seal + every other `commit_agent_payload`/`commit_project_payload` caller. [VERIFIED: code read] | 1 |
| `src/common/tracked.rs:1458` | `migrate_legacy_if_needed(seed_existed_before: bool)` — the function with the re-entry guard (`MIGRATION_IN_PROGRESS` thread-local) and the soft-fail posture. Insertion site for #2 ghost cleanup. [VERIFIED: code read] | 3 |
| `src/live/start.rs:138-178` | `drain_stale_signoff_file(id: &str, cwd: &Path)` — current implementation reads body, surfaces to stdout wrapped in `<owl_pending_signoff id=... cleared_from=...>`, then `fs::remove_file`. The function is `pub(crate)` and called from two sites (start.rs:452 and start.rs:779). Rewrite target. [VERIFIED: code read] | 2 |
| `src/live/start.rs:583-630` | `emit_boot_trigger_after_spawn` — `const MAX_ATTEMPTS: u32 = 8; const RETRY_DELAY_MS: u64 = 250;` at L592-593 inside the function. The `_ if attempt < MAX_ATTEMPTS` loop at L618-620 is the retry. Warning emit at L621-627. #3 replaces both consts with a shared import. [VERIFIED: code read] | 1 |
| `src/live/signoff.rs:197-228` | `emit_signoff_trigger` — calls `crate::common::wrapper_state::read_wrapper_state(psyche_id)` ONCE (no retry). Warning emit at L224-228 reads `WARNING: wrapper-state.json missing or empty session_uuid for signoff of {}; sessions.log row skipped`. The signoff site currently has NO retry budget at all — #3 must add the same shared-budget retry pattern here. [VERIFIED: code read] | 1 |
| `src/live/wrapper/mod.rs:629` | `let ready_exists = owlery::ready_file(&self.psyche_id).exists();` — the anchor for the broader sweep. Five additional sites under `src/live/wrapper/` identified below. [VERIFIED: code read] | 1 |
| `src/live/wrapper/lifecycle.rs:116` | `drain_stale_init_signoffs` body — uses `super::is_init_signoff_envelope(body)` envelope-shape match (NOT bare substring). Confirms D-12 coexistence: `<EVENT type="latent signoff">` is not the canonical envelope substring `<event type="init_signoff"` so the wrapper drain ignores it by construction. [VERIFIED: code read] | 2 (coexistence audit) |
| `src/live/wrapper/claude.rs:155-178` | Post-`init_session` `write_wrapper_state(&self.psyche_id, &state)` — psyche_id format is `format!("{}-psyche", self_id)`. Writer side of the path agreement. Today resolves to flat `perch_dir(psyche_id)` via `wrapper_state_path`. [VERIFIED: code read] | 1 (#3 + #4 coherence) |
| `src/common/wrapper_state.rs` | `wrapper_state_path(agent_id) = owlery::perch_dir(agent_id).join("wrapper-state.json")` at L117-119. `read_wrapper_state(agent_id)` non-destructive read at L155-159. `load_and_delete` (destructive, handoff) at L65-79. Both share `wrapper_state_path` — single source of truth. The new `WRAPPER_STATE_MAX_ATTEMPTS` const lives here per D-06. [VERIFIED: code read] | 1 |
| `src/common/owlery.rs:161` | `nested_perch_dir(parent: &str, child_id: &str) = owlery_dir().join(parent).join("nested").join(child_id)`. Pure path composition, no side effects. Target path constructor for #4. [VERIFIED: code read] | 1 |
| `src/common/owlery.rs:351` (search returned 380) | `is_worker_perch(id: &str)` regex at L380 — id-suffix-only legacy path. Phase 25 D-03 introduced `is_worker_perch_path(perch_path: &Path)` at L397 (path-aware). Both still in tree; the id-form is "legacy flat fallback path" per L396 comment. Plan 1 #4 cross-check: do not remove either; the path-aware form is the right choice at nested-aware call sites. [VERIFIED: code read] | 1 |

## #4 Call-Site Enumeration (consumed verbatim by Plan 1 task 1)

Every `ready_file` / `perch_dir` / `info_file` / `inbox_dir` / `wrapper_state_path` caller under `src/live/wrapper/`. Each is classified as "needs nested swap" or "Self side, leave flat" per Phase 25 D-01 (`owlery/<self>/` stays flat; psyche/worker children live under `nested/`).

| File:Line | Call | Identity scope | Classification |
|-----------|------|----------------|----------------|
| `src/live/wrapper/mod.rs:629` | `owlery::ready_file(&self.psyche_id).exists()` (poll-loop guard) | psyche perch | **NEEDS NESTED SWAP** → `owlery::ready_file_at(&owlery::nested_perch_dir(&self.self_id, &self.psyche_id))` |
| `src/live/wrapper/mod.rs:700` | `owlery::ready_file(&self.psyche_id).exists()` (post-empty-poll re-check) | psyche perch | **NEEDS NESTED SWAP** |
| `src/live/wrapper/mod.rs:859` | `owlery::ready_file(&self.psyche_id).exists()` (end-of-loop guard) | psyche perch | **NEEDS NESTED SWAP** |
| `src/live/wrapper/mod.rs:1100` | `owlery::perch_dir(&self.psyche_id).join("wrapper-state.json")` (handoff writer) | psyche perch wrapper-state | **NEEDS NESTED SWAP** — but must go via `wrapper_state_path` (D-06 single-source-of-truth) NOT inline-joined. Recommendation: rewrite to `wrapper_state::wrapper_state_path(&self.psyche_id)`, then change `wrapper_state_path` itself to resolve nested-first-flat-fallback (see #4 migration direction below). |
| `src/live/wrapper/lifecycle.rs:23` | `owlery::perch_dir(&psyche_id).join("wrapper-state.json")` (handoff reader on rehydration) | psyche perch wrapper-state | **NEEDS NESTED SWAP** — same recommendation: route through `wrapper_state_path` instead of inline join. |
| `src/live/wrapper/lifecycle.rs:92` | `owlery::perch_dir(&self.psyche_id)` (cleanup soft-stop target) | psyche perch | **NEEDS NESTED SWAP** → use `owlery::nested_perch_dir(&self.self_id, &self.psyche_id)` |
| `src/live/wrapper/echo_fire.rs:111` | `owlery::perch_dir(&self.psyche_id).join(".more-done")` (echo sentinel) | psyche perch | **NEEDS NESTED SWAP** |
| `src/live/wrapper/echo_fire.rs:152` | `owlery::info_file(&self.self_id)` (Self info.json read) | **Self perch** | **LEAVE FLAT** — Self perch is `owlery/<self>/info.json` (D-01: "Self perch dir stays flat — it IS the parent"). |
| `src/live/wrapper/orphan.rs:59` | `owlery::info_file(self_id)` (Self info.json read for UUID) | **Self perch** | **LEAVE FLAT** — Self side. |
| `src/live/wrapper/orphan.rs:142` | `owlery::perch_dir(&self.self_id)` (Self perch path) | **Self perch** | **LEAVE FLAT** — Self side. |
| `src/live/wrapper/orphan.rs:242` | `owlery::ready_file(&self.psyche_id).exists()` (psyche-perch liveness pre-INIT_SIGNOFF deliver) | psyche perch | **NEEDS NESTED SWAP** |
| `src/live/wrapper/orphan.rs:309` | `crate::common::owlery::perch_dir(id)` (TEST helper `write_test_info_json`) | test fixture | **TEST-ONLY** — leave flat or update test as part of Plan 1 if it covers a nested-aware code path (planner discretion). |
| `src/live/wrapper/orphan.rs:321` | `crate::common::owlery::info_file(id)` (TEST helper) | test fixture | **TEST-ONLY** — same disposition. |

**Summary count:** 8 production-code sites need nested swap (psyche side); 3 production-code sites stay flat (Self side); 2 test-helper sites are at planner discretion. The asymmetry is structural: psyche perches moved to nested in Phase 25 D-01; Self perches did not.

## Standard Stack — N/A

This phase is internal Rust code edits against an existing crate. No new dependencies. `Cargo.toml` unchanged. Build via `cargo build --release` per CLAUDE.md.

## Package Legitimacy Audit — N/A

No external packages installed in this phase.

## Architecture Patterns

### Patterns to Reuse

#### Pattern 1: Best-effort cleanup with structured warning

Established in `src/live/start.rs::relocate_legacy_psyche_if_needed` (L196-229) and `src/common/tracked.rs::migrate_legacy_if_needed` (L1458+). Idiom:

```rust
// Plan 1 #1 stale-lock probe — INSIDE ensure_worktree, BEFORE `return Ok(wt)` at L365.
if let Ok(meta) = std::fs::metadata(&lockfile) {
    let stale = meta.len() == 0
        && meta.modified().ok()
            .and_then(|m| m.elapsed().ok())
            .map(|d| d.as_secs() > 60)
            .unwrap_or(false);
    if stale {
        match std::fs::remove_file(&lockfile) {
            Ok(_) => eprintln!(
                "tracked: removed stale index.lock at {} (>60s old, 0 bytes)",
                owlery::to_forward_slash(&lockfile)
            ),
            Err(e) => eprintln!(
                "WARNING: failed to remove stale index.lock at {}: {} (continuing)",
                owlery::to_forward_slash(&lockfile), e
            ),
        }
    }
}
```

Source: pattern lifted from `src/live/start.rs:208-227`. [VERIFIED: code read]

#### Pattern 2: TCP-first-with-spool-fallback transport

Already exists at `src/owl/send.rs::deliver_message` (L66-97). Public surface:
- `deliver_body(target, from, body)` — TCP-first, spool-fallback (named sender). [VERIFIED: send.rs:268-270]
- `deliver_body_anonymous(target, body)` — same transport, empty `from`. Used by INIT_SIGNOFF + commune-to-Psyche. [VERIFIED: send.rs:274-276]
- `deliver_body_deferred(target, from, body)` — spool only. Not the right choice for Plan 2 (we want timely delivery to live wrapper).

For Plan 2 D-10: call `crate::owl::send::deliver_body_anonymous(&psyche_id, &envelope)` — the function itself handles TCP-first + spool-fallback. Wrap in `std::panic::catch_unwind` per existing pattern at `src/live/signoff.rs:247-249`.

#### Pattern 3: EVENT envelope construction

Helpers at `src/owl/poll.rs:685, 700`:
- `pub(crate) fn event_attr_escape(s: &str) -> String` — HTML-entity escape for attribute values (T-30-01 mitigation).
- `pub(crate) fn event_body_escape(s: &str) -> String` — body escape (preserves multibyte UTF-8).

Reference call sites:
- `src/owl/echo_commune.rs:76-81` builds `<EVENT type="echo_commune" from="..." timestamp="..." note="...">body</EVENT>`. [VERIFIED: code read]
- `src/owl/poll.rs:1146-1152` `compose_file_drop_event(kind, path, from)` builds `<EVENT type="file_drop" kind="..." path="..." from="..."></EVENT>`. [VERIFIED: code read]

For Plan 2 D-11 envelope construction (inline at `drain_stale_signoff_file` rewrite site):

```rust
let envelope = format!(
    "<EVENT type=\"latent signoff\" from=\"{}\" written_at=\"{}\" cleared_from=\"{}\">{}</EVENT>",
    crate::owl::poll::event_attr_escape(id),
    crate::owl::poll::event_attr_escape(&written_at),
    crate::owl::poll::event_attr_escape(&owlery::to_forward_slash(&signoff_path)),
    crate::owl::poll::event_body_escape(trimmed),
);
```

**Recommendation:** inline the envelope construction at the single Plan 2 call site rather than introducing a `compose_latent_signoff_event` helper. There is exactly ONE producer (`drain_stale_signoff_file`). The two existing typed-envelope helpers (`compose_file_drop_event`, `compose_echo_commune_event`) earn their share of `src/owl/poll.rs` real estate by serving multiple callers OR by being load-bearing for test pinning. The latent-signoff path needs neither.

#### Pattern 4: Retry-on-empty wrapper-state read

Established at `src/live/start.rs:592-630`. Today: 8 attempts × 250ms = 2000ms. Plan 1 #3 dilates to 80 attempts × 250ms = 20000ms via a shared const. The pattern stays identical:

```rust
// In src/common/wrapper_state.rs (NEW shared budget per D-06):
pub const WRAPPER_STATE_MAX_ATTEMPTS: u32 = 80;
pub const WRAPPER_STATE_RETRY_DELAY_MS: u64 = 250;

/// Polled read — retry until budget elapsed. Returns None on exhaustion (caller soft-fails per D-02).
/// `site` is one of "boot" / "signoff" / "commune" / "pulse" / "init" — used for the D-08
/// `wrapper-state read: <elapsed-ms> (<site>)` log emission.
pub fn read_wrapper_state_with_retry(agent_id: &str, site: &str) -> Option<WrapperHandoffState> {
    let start = std::time::Instant::now();
    for attempt in 1..=WRAPPER_STATE_MAX_ATTEMPTS {
        if let Some(state) = read_wrapper_state(agent_id) {
            if !state.session_uuid.is_empty() {
                eprintln!(
                    "wrapper-state read: {}ms ({})",
                    start.elapsed().as_millis(),
                    site
                );
                return Some(state);
            }
        }
        if attempt < WRAPPER_STATE_MAX_ATTEMPTS {
            std::thread::sleep(std::time::Duration::from_millis(WRAPPER_STATE_RETRY_DELAY_MS));
        }
    }
    eprintln!(
        "WARNING: wrapper-state.json missing or empty session_uuid for {} after {}ms ({}); skipping row",
        agent_id,
        WRAPPER_STATE_MAX_ATTEMPTS as u64 * WRAPPER_STATE_RETRY_DELAY_MS,
        site,
    );
    None
}
```

Then `emit_boot_trigger_after_spawn` collapses from L590-630 to a single call: `wrapper_state::read_wrapper_state_with_retry(psyche_id, "boot")`. Same for `emit_signoff_trigger` at `signoff.rs:196-231`. **CONTEXT D-06 explicitly mandates this single-shared-const consolidation.**

### Anti-Patterns to Avoid

- **Do NOT inline-join `perch_dir(psyche_id).join("wrapper-state.json")`** at any new call site. The path resolver `wrapper_state_path` is the single source of truth. Today there are TWO inline joins (`lifecycle.rs:23`, `mod.rs:1100`) — Plan 1 #4 should convert both to `wrapper_state_path(...)` calls AS PART of the nested swap, so the path resolver is the only place that needs the nested-first-fallback logic.
- **Do NOT introduce a separate `compose_latent_signoff_event` helper for one call site.** Inline construction at `drain_stale_signoff_file` is simpler and easier to audit.
- **Do NOT add a sentinel-file existence check to Plan 3.** See #2 idempotency below — unconditional best-effort `remove_dir_all` + existence check is cleaner.
- **Do NOT add a Plan 2 envelope predicate to `is_init_signoff_envelope`.** D-12 explicit: latent-signoff envelope shape MUST stay disjoint from init_signoff to preserve the asymmetry the wrapper-side drain relies on. The `is_init_signoff_envelope` predicate at `src/live/wrapper/mod.rs:148-151` matches the literal substring `<event type="init_signoff"` (case-insensitive); a `<EVENT type="latent signoff">` body does not contain that substring. Invariant holds by construction.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Build the latent-signoff envelope manually | A new `compose_*` helper | Inline `format!` with `event_attr_escape` + `event_body_escape` | One call site; helper overhead not warranted. The two existing typed-envelope helpers earn their keep by multi-call-site reuse or test pinning; neither applies here. |
| TCP send + spool fallback for latent-signoff delivery | Hand-rolled `TcpStream::connect_timeout` + spool fallback | `send::deliver_body_anonymous(&psyche_id, &envelope)` | The transport already handles TCP-first, registry lookup, stale-PID cleanup, and spool fallback. Mirror `signoff.rs:247-249` (catch_unwind wrap). |
| Wrapper-state retry budget per call site | Local consts at each site | `wrapper_state::WRAPPER_STATE_MAX_ATTEMPTS` + shared `read_wrapper_state_with_retry` | D-06 explicitly forbids drift. Today's 8-attempt local consts at `start.rs:592` and missing-retry at `signoff.rs:197` are the exact divergence D-06 closes. |
| Nested-vs-flat decision at every wrapper call site | If-let chain per site | Centralize in `wrapper_state_path` resolver (and a sibling `psyche_perch_dir(self_id, psyche_id)` helper if more than one call needs the perch dir directly) | Per #4 recommendation below: try-nested-first-fall-back-flat at the resolver layer. Call sites stay 1-line. |

## Common Pitfalls

### Pitfall 1: Plan 2 STOP-loop regression

**What goes wrong:** If Plan 2 rewrites `drain_stale_signoff_file` to forward latent-signoff as INIT_SIGNOFF (instead of commune), the next wrapper poll iteration immediately drains the envelope and `final_session` fires.

**Why it happens:** `is_init_signoff_envelope` at `src/live/wrapper/mod.rs:148` is envelope-shape-aware. INIT_SIGNOFF envelopes terminate the wrapper.

**How to avoid:** Strict adherence to D-09 + D-11: envelope MUST be `<EVENT type="latent signoff">`. Body MUST NOT contain the literal substring `<EVENT type="init_signoff"` (case-insensitive). [VERIFIED: predicate body at `src/live/wrapper/mod.rs:148-151`]

**Warning signs:** wrapper exits within seconds of `$LIVE start` after a stale `.claude/<id>-signoff.md` is present. If this happens during UAT, the envelope shape regressed.

### Pitfall 2: D-12 invariant violation via downstream re-wrapping

**What goes wrong:** If the latent-signoff envelope gets re-wrapped by the poll subprocess as `<EVENT type="msg" from="">...escaped body...</EVENT>`, the outer wrapper sees the escaped inner envelope.

**Why it happens:** `src/owl/poll.rs::body_is_typed_event_envelope` short-circuits pre-formed typed envelopes (passthrough at L1048-1050). A `<EVENT type="latent signoff">` body should NOT be re-wrapped. [VERIFIED: poll.rs:782-796 doc comment + L1048-1050]

**How to avoid:** Ensure the envelope literal starts at byte 0 with `<EVENT type="latent signoff"` (no leading whitespace, no UTF-8 BOM). The passthrough check at poll.rs:`body_is_typed_event_envelope` requires the body to be a fully-formed typed envelope.

**Warning signs:** wrapper log shows the latent-signoff event arriving HTML-escaped inside an outer `<EVENT type="msg" from="">` — that means re-wrapping happened and downstream consumers will see escape garbage.

### Pitfall 3: #3 retry-budget interaction with handoff rehydration

**What goes wrong:** A hydrated wrapper writes `wrapper-state.json` immediately during `lifecycle.rs::new` (L63-70). If the boot trigger fires before the hydrated write lands, the old 8-attempt budget reads the destructively-consumed pre-hydration file (`load_and_delete` already ran) and fails.

**Why it happens:** Order of operations in `lifecycle.rs::new`:
1. L22-24: `load_and_delete` consumes the previous file.
2. L63-70: re-publish on success.
3. Wrapper's `init_session` post-init also writes (`claude.rs:171-178`).

Between (1) and (2)/(3) the file is absent. The 8-attempt budget = 2s; cold `claude -p` p99 = 5-15s. Race window matches.

**How to avoid:** D-05 dilates the budget to 20s. Plus: `lifecycle.rs::new` already re-publishes on hydration (L63-70) — confirmed by `resolved/phantom-init-signoff-after-handoff.md` resolution. The handoff-rehydration write completes in microseconds; only cold init_session paths exceed 2s. 20s budget covers cold paths comfortably.

**Warning signs:** `WARNING: wrapper-state.json missing or empty session_uuid for <id> after 20000ms` continues to appear after Plan 1 lands. If it does, the budget needs to go higher OR the writer-side path is wrong (revisit #4 direction).

### Pitfall 4: #4 sweep regression for old wrappers writing to flat

**What goes wrong:** Phase 25 partial-landing means SOME running wrappers (gen-old, pre-handoff) still write to flat `perch_dir(psyche_id)`. SOME (gen-new, post-handoff) write to `nested_perch_dir(self_id, psyche_id)`. If Plan 1 #4 flips readers to nested-only, every gen-old wrapper-state read silently fails.

**Why it happens:** Bonus finding in the diagnostic source proves this: two `doyle-psyche` wrapper-state copies exist on disk today (`owlery/doyle-psyche/` + `owlery/doyle/nested/doyle-psyche/`) belonging to different psyche-wrapper processes. [VERIFIED: debug doc §"Bonus finding" + smoking gun]

**How to avoid:** Adopt try-nested-first-fall-back-flat at the resolver layer (`wrapper_state_path`). Migration coexistence is automatic. See #4 migration coexistence direction below.

**Warning signs:** sessions.log seal warnings re-appear for agents whose long-running wrappers haven't yet handed-off to the post-Plan-1 binary.

## #2 Idempotency Pattern — Recommendation

**Recommendation: unconditional best-effort `remove_dir_all` + existence-check.** No sentinel file.

```rust
// Inside migrate_legacy_if_needed, near the top after the re-entry guard fires.
// The ghost `tracked/.git/` is a Phase 23-era dormant repo with last commit
// May 20. Phase 24 deliberately stopped writing to it. Cosmetic noise only —
// no live reader. Cleanup is idempotent because `remove_dir_all` on a missing
// path returns Err, which we swallow.
let ghost = owlery::tracked_root().join(".git");
if ghost.exists() {
    match std::fs::remove_dir_all(&ghost) {
        Ok(_) => eprintln!(
            "tracked: removed Phase 23-era ghost .git/ at {}",
            owlery::to_forward_slash(&ghost)
        ),
        Err(e) => eprintln!(
            "WARNING: failed to remove ghost tracked/.git/ at {}: {} (continuing)",
            owlery::to_forward_slash(&ghost), e
        ),
    }
}
```

**Rationale:**
1. **Sentinel adds complexity for no gain.** Once the directory is gone, `ghost.exists()` returns false and the block is a single stat-syscall — same cost as reading a sentinel.
2. **Sentinel introduces a partial-state hazard.** If the sentinel is written but `remove_dir_all` partially fails (Windows file-locking), future invocations skip and the partial leak persists. Unconditional re-attempt heals automatically.
3. **Symmetric to the existing test posture.** The existing test at `src/common/tracked.rs:3110-3143` (`migrate_legacy_dot_git_left_in_place`) currently asserts the ghost is preserved (per Pitfall 6 historical posture). Plan 3 will INVERT this test to assert the ghost is removed. Inverting one test is cleaner than introducing a new sentinel-tracking test.
4. **Soft-fail posture matches the rest of `migrate_legacy_if_needed`.** Per CONTEXT.md §code_context "Phase 24 D-02 / Phase 32 D-05" — failed migration emits one stderr warning and boot continues.

**Tradeoff:** unconditional cleanup means we re-attempt every boot until the directory is gone. On a healthy install (post-first-success) the cost is one `Path::exists` syscall — negligible. The diagnostic source confirms the directory is small (Phase 23-era, last commit May 20). [VERIFIED: debug doc]

## #4 Migration Coexistence Direction — Recommendation

**Recommendation: try-nested-first-fall-back-flat at the READER layer (`wrapper_state_path` resolver + wrapper-side call sites).** Do NOT flip writers.

### Detailed direction

Modify `src/common/wrapper_state.rs::wrapper_state_path` to a fallback-aware variant. Add a new helper because the current `wrapper_state_path(agent_id: &str)` does not know `self_id`:

```rust
// In src/common/wrapper_state.rs:

/// Phase 25.2 #4: nested-first resolution with flat fallback.
///
/// Returns the on-disk path of the wrapper-state.json file. Tries the nested
/// layout (`owlery/<self_id>/nested/<psyche_id>/wrapper-state.json`) first
/// because Phase 25 D-01 moved the psyche perch there. Falls back to the
/// legacy flat path (`owlery/<psyche_id>/wrapper-state.json`) for wrapper
/// processes still running pre-Phase-25.2 binaries.
///
/// Returns the FIRST path whose parent directory exists, so the read does
/// not stat a non-existent nested directory and silently fall through to a
/// stale flat copy.
pub fn wrapper_state_path_resolved(self_id: &str, psyche_id: &str) -> PathBuf {
    let nested = owlery::nested_perch_dir(self_id, psyche_id).join("wrapper-state.json");
    if nested.exists() {
        return nested;
    }
    // Fallback for migration coexistence window.
    owlery::perch_dir(psyche_id).join("wrapper-state.json")
}
```

Then update `read_wrapper_state_with_retry` to take BOTH `self_id` and `psyche_id` and route through `wrapper_state_path_resolved`. Callers at `start.rs`, `signoff.rs`, `echo_commune.rs`, pulse trigger, and init trigger all already know `self_id` (it's the outer agent name) so the API change is mechanical.

Keep `wrapper_state_path(agent_id)` as-is for the existing handoff destructive-load path (`lifecycle.rs:23`) — that path runs INSIDE the wrapper and already knows `self.psyche_id`. But for the destructive load we also want nested-first: change `lifecycle.rs::new` to read via `wrapper_state_path_resolved(self_id, &psyche_id)` and `load_and_delete` on whichever path it found.

For the wrapper-side writer at `mod.rs:1100` (handoff write before exit) and `claude.rs:171` (post-init publish): WRITE to the nested path unconditionally. Old wrappers writing flat are gen-old processes that will hand off to a Plan-1-aware binary on the next deploy; their flat-write is irrelevant after that. The read-side fallback covers the window.

### Why reader-side, not writer-side

1. **The bug is read-side data loss.** sessions.log seal warning + wrapper-state.json 2000ms warning both fire on READ. The writer fleet is mixed but every writer is internally consistent (writes the same path it reads). Fixing the reader fixes the consumer; the writer is downstream of the consumer pain.
2. **Debug doc §Optional hardening recommends this direction.** "When Phase 25 actually executes and flips `read_wrapper_state` to the nested path, add a fallback chain: try `nested_perch_dir(parent, child)/wrapper-state.json` FIRST, fall back to `perch_dir(child)/wrapper-state.json` SECOND. This handles the migration overlap window where some wrappers still write to flat paths." [VERIFIED: debug doc final paragraph]
3. **Phase 25 D-04 `enumerate_perches` is already path-aware** (walks both layers). The plan-1 fallback chain is symmetric to that walk — both prefer nested but tolerate flat.
4. **Writer flip would break in-flight binary handoffs (Phase 18.4/18.5).** A Plan-1 wrapper writes nested; the in-flight handoff target is a gen-old wrapper that reads flat. The reader-side fallback covers both cases automatically.

### Open question for plan-1 task 2

How long to keep the flat-fallback in the resolver? My recommendation: **leave it indefinitely** — it costs one `Path::exists` syscall per read, and removing it would require a deploy-cadence promise that all running wrappers have handed off. For a zero-dependency, multi-machine plugin shipped via marketplace, that promise is unenforceable.

## Manual Unblock Command (Operator UAT)

**Confirmed Windows PowerShell path for current outage:**

```powershell
Remove-Item "$env:LOCALAPPDATA\spt\psyches\tracked\seed\worktrees\doyle\index.lock"
```

[VERIFIED: debug doc §"Manual recovery (unblock NOW, before fix lands)" + `tracked.rs:1318` Pitfall 6 comment confirming `$LOCALAPPDATA\spt` is `SPT_HOME` Windows default]

**Sweep for all known doyle-cluster agents (apply if any show seal warnings):**

```powershell
@('deployah','doyle','dunsen','executor','higsby','mica','todlando','webber','witty') | ForEach-Object {
    $lock = "$env:LOCALAPPDATA\spt\psyches\tracked\seed\worktrees\$_\index.lock"
    if (Test-Path $lock) {
        Write-Host "Removing $lock"
        Remove-Item $lock
    }
}
```

Document for D-04 operator UAT: this command must be run BEFORE the affected agent's next seal attempt. After Plan 1 lands and the next `$LIVE start <agent>` runs, `ensure_worktree` fast-path will probe and clean stale locks automatically; manual unblock becomes unnecessary on the next boot.

## Test Harness Conventions

### Existing patterns

Source: `tests/` directory listing + module-local `#[cfg(test)] mod tests` patterns at `src/live/wrapper/lifecycle.rs:273+`, `src/live/wrapper/orphan.rs:272+`, `src/common/wrapper_state.rs:161+`, `src/common/tracked.rs:3110+`.

**Two test-style families established in this repo:**

| Style | Location | When to use |
|-------|----------|-------------|
| **Module-local `#[cfg(test)] mod tests`** | Inside the source file under `src/` | Pure-function tests, source-order assertions (`include_str!`), helpers that don't need full binary process. |
| **Integration test in `tests/`** | Top-level `tests/<name>.rs` | Subprocess-based, full binary spawn, multi-process scenarios. Each file is a separate test binary. |

**Common scaffolding for env-mutating tests** (used in both styles):

```rust
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());

struct SptHomeSnapshot(Option<String>);
impl SptHomeSnapshot {
    fn capture() -> Self { Self(std::env::var("SPT_HOME").ok()) }
}
impl Drop for SptHomeSnapshot {
    fn drop(&mut self) {
        match &self.0 {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }
}

#[test]
fn my_test() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let _snap = SptHomeSnapshot::capture();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());
    // ... test body uses crate::common::owlery::* helpers, which resolve
    // under SPT_HOME thanks to the env var.
}
```

[VERIFIED: pattern repeated at `wrapper_state.rs:162-184`, `orphan.rs:280-304`, `lifecycle.rs:275+`, `tracked.rs:3110+`]

**Run command (CLAUDE.md mandate):** `cargo test --release -- --test-threads=1`.

### Test recommendations per candidate

#### #1 stale-lock probe — `tests/native_tracked_stale_lock.rs` (integration) OR module-local in `src/common/tracked.rs`

Module-local preferred (cheaper, faster). Pattern:

```rust
// In src/common/tracked.rs #[cfg(test)] mod tests
#[test]
fn ensure_worktree_removes_stale_index_lock() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let _snap = EnvSnapshot::capture();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());

    // First ensure to materialize the worktree.
    ensure_agent_worktree("doyle").expect("first ensure must succeed");

    // Plant a stale 0-byte index.lock with mtime 5 minutes ago.
    let lockfile = ensure_seed().unwrap().join("worktrees").join("doyle").join("index.lock");
    std::fs::write(&lockfile, "").unwrap();
    let five_min_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(300);
    filetime::set_file_mtime(&lockfile, filetime::FileTime::from_system_time(five_min_ago)).unwrap();

    // Second ensure must remove the stale lock.
    ensure_agent_worktree("doyle").expect("second ensure must succeed");
    assert!(!lockfile.exists(), "stale index.lock must be removed");
}
```

Note: this introduces a `filetime` dep — check if already present in `Cargo.toml`; if not, use raw `SystemTime::now() - Duration` math via the `set_modified` syscall workaround (the existing tests don't seem to manipulate mtime today, so verify in plan-1).

**Fresh-lock-preserved test** (companion): same setup but with current-mtime; assert lock is NOT removed.

#### #5 latent-signoff forward — module-local in `src/live/start.rs` OR `tests/file_drop_integration.rs` extension

Module-local with `SPT_HOME` sandbox + mock spool:

```rust
// In src/live/start.rs #[cfg(test)] mod tests
#[test]
fn drain_stale_signoff_file_forwards_latent_signoff_and_deletes_file() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let _snap = SptHomeSnapshot::capture();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());

    // Set up a psyche perch (so spool fallback has somewhere to land).
    let psyche_id = "doyle-psyche";
    let perch = crate::common::owlery::perch_dir(psyche_id);
    std::fs::create_dir_all(&perch).unwrap();
    // ... (write info.json + ready file)

    // Plant a stale .claude/doyle-signoff.md in a CWD-sandbox.
    let cwd = tempfile::tempdir().unwrap();
    let signoff_path = cwd.path().join(".claude").join("doyle-signoff.md");
    std::fs::create_dir_all(signoff_path.parent().unwrap()).unwrap();
    std::fs::write(&signoff_path, "gen-N final brief from prior session\n").unwrap();

    drain_stale_signoff_file("doyle", cwd.path());

    // (a) File deleted.
    assert!(!signoff_path.exists(), "signoff file must be deleted");
    // (b) Spool now contains a latent-signoff envelope (since no live TCP target).
    let rows = crate::common::spool::peek_all(psyche_id, &crate::common::owlery::owlery_dir()).unwrap();
    assert_eq!(rows.len(), 1, "expected exactly one queued envelope");
    let body = &rows[0].2;
    assert!(body.contains(r#"<EVENT type="latent signoff""#), "body must be latent signoff envelope: {}", body);
    assert!(body.contains("gen-N final brief"), "body must contain the surfaced content");
    // (c) Coexistence invariant: latent signoff envelope is NOT init_signoff envelope.
    assert!(!crate::live::wrapper::is_init_signoff_envelope(body),
        "latent signoff must NOT match is_init_signoff_envelope predicate (D-12 invariant)");
}
```

#### #4 path-resolution unit test — `src/common/wrapper_state.rs` module-local

```rust
#[test]
fn wrapper_state_path_resolved_prefers_nested_when_exists() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    let _snap = SptHomeSnapshot::capture();
    let tmp = tempfile::tempdir().unwrap();
    std::env::set_var("SPT_HOME", tmp.path());

    let nested = owlery::nested_perch_dir("doyle", "doyle-psyche");
    std::fs::create_dir_all(&nested).unwrap();
    std::fs::write(nested.join("wrapper-state.json"), "{}").unwrap();

    let resolved = wrapper_state_path_resolved("doyle", "doyle-psyche");
    assert_eq!(resolved, nested.join("wrapper-state.json"));
}

#[test]
fn wrapper_state_path_resolved_falls_back_to_flat_when_nested_missing() {
    // Setup: flat file present, nested absent. Resolver must return flat.
    // ...
}
```

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | `cargo test` (Rust stdlib `#[test]` + `tempfile` crate for sandboxes) |
| Config file | `Cargo.toml` (root) |
| Quick run command | `cargo test --release <test_name_substring> -- --test-threads=1` |
| Full suite command | `cargo test --release -- --test-threads=1` |

### Phase Requirements → Test Map

(No formal REQ-IDs — using fix-candidate numbering.)

| Candidate | Behavior | Test type | Automated command | File Exists? |
|-----------|----------|-----------|-------------------|--------------|
| #1 stale-lock probe (positive) | `ensure_worktree` removes 0-byte index.lock older than 60s | unit (module-local) | `cargo test --release ensure_worktree_removes_stale_index_lock -- --test-threads=1` | ❌ Wave 0 — to be added in Plan 1 |
| #1 stale-lock probe (negative) | Fresh index.lock (<60s) is preserved | unit | `cargo test --release ensure_worktree_preserves_fresh_index_lock -- --test-threads=1` | ❌ Wave 0 |
| #2 ghost cleanup (positive) | `migrate_legacy_if_needed` removes `tracked/.git/` | unit | `cargo test --release migrate_legacy_removes_ghost_dot_git -- --test-threads=1` | ❌ Wave 0 — INVERTS existing `migrate_legacy_dot_git_left_in_place` (tracked.rs:3110) |
| #2 idempotency | second `migrate_legacy_if_needed` after cleanup is no-op | unit | same as above + assertion that second call returns Ok | ❌ Wave 0 |
| #3 retry budget (success path) | `read_wrapper_state_with_retry` returns Some within budget | unit | `cargo test --release read_wrapper_state_with_retry_succeeds_after_delayed_write -- --test-threads=1` | ❌ Wave 0 |
| #3 retry budget (exhaustion) | `read_wrapper_state_with_retry` returns None + warning after 20s | unit (with shortened test-only budget) | `cargo test --release read_wrapper_state_with_retry_returns_none_after_budget -- --test-threads=1` | ❌ Wave 0 |
| #4 resolver direction | `wrapper_state_path_resolved` prefers nested | unit | `cargo test --release wrapper_state_path_resolved_prefers_nested_when_exists -- --test-threads=1` | ❌ Wave 0 |
| #4 fallback | `wrapper_state_path_resolved` falls back to flat | unit | `cargo test --release wrapper_state_path_resolved_falls_back_to_flat_when_nested_missing -- --test-threads=1` | ❌ Wave 0 |
| #5 latent-signoff envelope | `drain_stale_signoff_file` forwards envelope and deletes file | unit (module-local) | `cargo test --release drain_stale_signoff_file_forwards_latent_signoff -- --test-threads=1` | ❌ Wave 0 |
| #5 D-12 invariant | latent-signoff envelope does NOT match `is_init_signoff_envelope` | unit (added to `is_init_signoff_envelope_tests` mod at `mod.rs:2752`) | `cargo test --release latent_signoff_envelope_is_not_init_signoff_envelope -- --test-threads=1` | ❌ Wave 0 |
| #5 deliver-then-die ordering | file is NOT deleted until queue confirmed | unit | `cargo test --release drain_stale_signoff_preserves_file_on_transport_failure -- --test-threads=1` | ❌ Wave 0 (note: requires injectable transport — see Wave 0 gaps) |

### Sampling Rate

- **Per task commit:** `cargo test --release <name_substring> -- --test-threads=1` (single test or module).
- **Per wave merge:** `cargo test --release -- --test-threads=1` (full suite — required because cross-test env mutation is serialized via `--test-threads=1`).
- **Phase gate:** Full suite green AND operator UAT pass for each plan's checkpoint.

### Wave 0 Gaps

- [ ] Plan 1 must add `filetime` dep OR use a raw mtime-setting workaround for the stale-lock probe test. Confirm `Cargo.toml` state during plan task 0.
- [ ] Plan 2 `#5 deliver-then-die ordering` test requires injectable transport OR an end-to-end mock. Simplest: assert post-call state of (file presence, spool row count). The existing `drain_stale_signoff_file` already swallows transport errors, so a robust test of "file NOT deleted on transport failure" requires either (a) injecting a transport closure (refactor), or (b) accepting weaker assertion. Planner picks during Plan 2 task 2.
- [ ] No new test-helper file required — every test uses the established `SptHomeSnapshot` + `ENV_LOCK` pattern in-module.
- [ ] Framework install: not applicable (cargo + stdlib).

## Sources

### Primary (HIGH confidence — direct code/file inspection)

- `.planning/phases/25.2-doyle-cluster-fix-candidates-blast-radius-sanity-check-acros/25.2-CONTEXT.md` — locked decisions D-01..D-12.
- `.planning/debug/doyle-sessions-seal-tracked-psyches.md` — diagnostic source with smoking-gun evidence + proposed-fix sketches.
- `.planning/debug/resolved/init-signoff-substring-false-positive.md` — envelope predicate contract.
- `.planning/debug/resolved/stale-signoff-fires-on-next-session-start.md` — asymmetry argument; STOP-loop landmine prevention.
- `.planning/debug/resolved/phantom-init-signoff-after-handoff.md` — handoff-rehydration interaction with retry budget.
- `.planning/debug/resolved/wrapper-drains-stale-signoff.md` — predicate envelope-shape match (`is_init_signoff_envelope`).
- `src/common/tracked.rs` L335-433, L1239-1269, L1458-1535, L3110-3143 — direct read.
- `src/live/start.rs` L80-178, L430-460, L560-630 — direct read.
- `src/live/signoff.rs` L180-232 — direct read.
- `src/live/wrapper/mod.rs` L600-862, L1080-1118, L2740-2870 — direct read.
- `src/live/wrapper/lifecycle.rs` L1-150 — direct read.
- `src/live/wrapper/claude.rs` L130-185 — direct read.
- `src/live/wrapper/orphan.rs` L40-320 — direct read.
- `src/live/wrapper/echo_fire.rs` L100-170 — direct read.
- `src/common/wrapper_state.rs` (complete file) — direct read.
- `src/common/owlery.rs` L140-220, L340-410 — direct read.
- `src/owl/send.rs` L60-280 — direct read.
- `src/owl/poll.rs` references via grep (event helpers L685, L700; envelope composers L1146; `body_is_typed_event_envelope` L782-796).
- `src/owl/echo_commune.rs` L76-81 — envelope-build reference.
- `plugin/spt/skills/signoff/SKILL.md` L1-40 — user-facing contract.
- `.planning/phases/25-perch-nesting-psyche-workers-wire-psyche-download-to-forked-/25-CONTEXT.md` L1-100 — Phase 25 D-01..D-04 layout.
- `tests/` directory listing — established test families.

### Secondary (none — this phase is fully code-inspected)

### Tertiary (none)

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `filetime` crate is NOT already in `Cargo.toml` (referenced as Wave 0 gap for stale-lock mtime test) | Validation Architecture / Wave 0 Gaps | LOW — Plan 1 task 0 verifies; if already present, gap closes. |
| A2 | `tempfile` crate IS already present (used by every existing test we inspected) | Test Harness | LOW — visible in test code via `tempfile::tempdir()`. |
| A3 | `lifecycle.rs::new` re-publish at L63-70 completes within microseconds (so it doesn't trip the 20s budget) | Pitfall 3 | LOW — file-write of a small JSON; same-process, no LLM round-trip. Confirmed by `resolved/phantom-init-signoff-after-handoff.md` resolution which already lives with the timing. |

**All other claims are tagged `[VERIFIED: code read]` or `[VERIFIED: debug doc]` inline above.**

## Open Questions

1. **Plan 2 transport-failure test boundary.**
   - What we know: `drain_stale_signoff_file` currently swallows transport errors silently.
   - What's unclear: whether to refactor for injectable transport (clean test boundary) or accept weaker assertion ("file present after call with no live target").
   - Recommendation: defer to Plan 2 task 2 deliberation. The weaker form is sufficient for D-04 verification; injectable transport is gold-plating.
   - **RESOLVED (planner, 2026-05-22 plan-checker iter-2):** Plan 2 Task 2 accepts the panic-only-preserves-file form. `deliver_body_anonymous` ALWAYS reaches the spool fallback on TCP failure — spool is the durable destination, so D-10 deliver-then-die is satisfied without an injectable-transport refactor. Test D in Plan 2 (in-module STOP-loop regression guard at the envelope-shape level) provides the contract-level coverage; transport-failure file-preservation is exercised only by the `std::panic::catch_unwind` wrapper, not by injectable plumbing. No refactor of `deliver_body_anonymous` in this phase.

2. **Plan 1 `wrapper_state_path` migration of in-tree inline joins.**
   - What we know: `lifecycle.rs:23` and `mod.rs:1100` both do `owlery::perch_dir(&psyche_id).join("wrapper-state.json")` inline (not through the resolver).
   - What's unclear: whether to refactor both to call the new `wrapper_state_path_resolved` as part of #4, OR leave them on inline-flat (since they're wrapper-side and may be running on gen-old code in production).
   - Recommendation: refactor both. Both are inside the wrapper process — when a Plan-1-aware binary runs, both sites need to read the resolver result. The flat fallback in the resolver covers the case where the file is still flat.
   - **RESOLVED (planner, 2026-05-22 plan-checker iter-2):** Plan 1 Task 3 (the resolver-landing + call-site sweep task, renumbered per plan-checker Warning 3) routes the READER at `lifecycle.rs:23` through `wrapper_state_path_resolved(&self_id, &psyche_id)`. The returned `PathBuf` is passed unchanged to the existing `&Path`-keyed `load_and_delete(path: &Path)` primitive at `src/common/wrapper_state.rs:65` — no API surface change on the consumer. The WRITER at `mod.rs:1100` KEEPS its inline `owlery::perch_dir(&self.psyche_id).join("wrapper-state.json")` flat path and continues to call `wrapper_state::write_atomic(&state_file, &state)` unchanged — RESEARCH §"Why reader-side, not writer-side" mandates writers stay flat for the in-flight binary-handoff migration window. A code comment at mod.rs:1100 cites this rationale. Similarly, `claude.rs:155-178` post-init publish + `lifecycle.rs:69` rehydration republish KEEP `write_wrapper_state(&psyche_id, &state)` (which internally uses the flat `wrapper_state_path(agent_id)`) — same rationale, same comment treatment.

3. **D-08 elapsed-ms log threshold floor.**
   - What we know: D-08 mandates the log on every successful read; CONTEXT also says "Optionally suppress when elapsed_ms < 50 if log noise becomes an issue (defer to plan)".
   - What's unclear: whether to add the floor now or wait for noise feedback.
   - Recommendation: add NO floor in Plan 1. The log is at eprintln-level (stderr); operators can grep. If volume becomes a problem, a one-line `if elapsed_ms >= 50` gate is trivial to add later. Premature suppression risks losing the "p99 went from 200ms to 5000ms" diagnostic signal.
   - **RESOLVED (planner, 2026-05-22 plan-checker iter-2):** No floor. `read_wrapper_state_with_retry` emits `wrapper-state read: <elapsed-ms> (<site>)` on EVERY successful read regardless of elapsed time. Preserves the p99-drift diagnostic signal. If stderr volume becomes a problem in operation, gate via `if elapsed_ms >= 50 { eprintln!(...) }` as a one-line follow-up — not in scope for Phase 25.2.

4. **Cargo.toml `filetime` dependency for stale-lock test mtime manipulation.**
   - What we know: Phase 25.2 #1 stale-lock probe test (Plan 1 Task 5) needs to set an mtime 60+ seconds in the past on a planted `index.lock` file to exercise the stale-detection branch.
   - What's unclear: whether `filetime` is already in `Cargo.toml`.
   - **RESOLVED (planner, 2026-05-22 plan-checker iter-2 — Cargo.toml inspected):** `filetime` is NOT present in either `[dependencies]` or `[dev-dependencies]` of `Cargo.toml`. Current dev-deps: `assert_cmd`, `snapbox`, `predicates`, `tempfile`, `rusqlite`. Plan 1 Task 1 includes a pre-flight confirmation step. Plan 1 Task 5 (tests) uses the raw mtime workaround — either `std::fs::File::open(&lockfile)?.set_modified(SystemTime::now() - Duration::from_secs(300))` (stable Rust 1.75+) or platform-specific `libc::utimes` / Windows `SetFileTime` via `windows-sys` (already a target-dep). Do NOT add `filetime` — CLAUDE.md zero-external-runtime-dep posture (consistent with the Phase 18.3 `Duration::ZERO` workaround at `260418-nt3-PLAN.md`).

## Environment Availability

This phase requires no external runtime tools beyond the existing `cargo` + `git` toolchain already used by the project.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `cargo` | Build + tests | ✓ (project precondition) | per `rust-toolchain` (if pinned) | — |
| `git` | `ensure_worktree` + tests + #1 fix | ✓ (project precondition) | any modern git (worktree support added in 2.5+) | — |
| `filetime` crate | OPTIONAL — stale-lock probe test mtime manipulation | UNKNOWN | TBD | Use raw `set_modified` syscall OR test via planted-file-then-sleep approach (slower but no new dep) |

**Skip rationale not applicable — this phase has external git dep + crate deps to verify in Plan 1 task 0.**

## Metadata

**Confidence breakdown:**
- #4 call-site enumeration: HIGH — direct grep + per-site classification against Phase 25 D-01.
- Patterns to reuse (transport, envelope, retry, cleanup): HIGH — every pattern verified by direct code read at named anchors.
- Landmine confirmation (D-12 invariant, STOP-loop, handoff race): HIGH — each landmine cross-checked against the named resolved/* debug doc.
- #2 idempotency direction: HIGH — recommendation grounded in symmetric posture to existing soft-fail migration code + existing test inversion.
- #4 migration direction (try-nested-first-fall-back-flat): HIGH — recommendation explicitly endorsed by debug doc §Optional hardening.
- Test harness conventions: HIGH — pattern is repeated identically across 4+ in-tree test modules already.

**Research date:** 2026-05-22
**Valid until:** Phase 25.2 execution completes (this is a tactical research; findings reference frozen code anchors).
