# Phase 25.4 — CONTEXT (Locked Decisions)

**Captured:** 2026-05-23
**Source:** Operator input collected before planning + RESEARCH.md (.planning/phases/25.4-.../25.4-RESEARCH.md)
**Reference fix shape:** commits `c8a5535`, `bc71575` (`.more-done` reader dual-path); `src/common/wrapper_state.rs::wrapper_state_path_resolved`; `src/live/wrapper/echo_fire.rs::pick_fresher_more_done`

---

## Decisions (Locked — Non-Negotiable)

### D-01 — Direction: Option ALPHA (finish D-01 nested migration)

Of the three end-states in RESEARCH §4 (ALPHA = finish nested-canonical, BRAVO = walk back to flat-canonical, CHARLIE = minimal Class-E patch), **ALPHA is locked**. Every psyche and worker runtime writer migrates to the nested layout `owlery/<parent>/nested/<child>/`. Flat paths become legacy-fallback only, served by reader-side dual-path resolvers during a one-deploy-cycle migration window.

Rationale (operator): user observed flat psyche perches still actively written despite Phase 25 D-01; the original D-01 intent (nested = single owner, easy parent-cascade cleanup) is the right end-state. BRAVO would defeat D-01 and CHARLIE leaves the system permanently inconsistent.

### D-02 — Single Source of Truth (CRITICAL ARCHITECTURAL DIRECTIVE)

Operator's exact language: **"ENSURE that all reader and writer functions don't have hardcoded paths, but determine their paths by referencing a single source."**

Concrete shape:
- Introduce a **central path resolver** in a new module `src/common/perch_path.rs` (sibling of `owlery.rs`; promoted because the surface is large enough to warrant its own module while still being colocated with `owlery_dir()`/`perch_dir()`/`nested_perch_dir()`).
- The resolver exposes a single function — `resolve_perch_path(child_id: &str, parent_hint: ParentHint) -> PathBuf` — plus the four child-path helpers (`resolve_ready_file`, `resolve_info_file`, `resolve_inbox_dir`, `resolve_perch_root`) that compose on top of it.
- A `PerchKind` enum classifies ids: `Self_`, `Psyche`, `Worker`, `Spine`, `Touch` — derived from id suffix conventions (`-psyche`, `-w{N}`, `-spine`, `-touch`).
- A `ParentHint` enum lets callers pass `Explicit(parent_id)` (preferred; zero ambiguity) OR `Infer` (resolver derives parent by stripping the kind suffix). **Explicit is the default; Infer is provided as a fallback for legacy call sites where the parent isn't reachable in scope.**
- All readers and writers in Wave 2/3 plans route their path composition through this module. Direct calls to `owlery::perch_dir(id)` for non-Self ids are eliminated outside the resolver module itself and the legacy-fallback reader pattern.

The resolver is the SINGLE place that encodes "psyche/worker live nested under their parent." If the layout ever changes again, exactly one file is touched.

### D-03 — Pre-25.2 Wrapper Binaries: NONE in flight — migration window can close

Operator confirmed all currently-running Psyche wrappers are post-25.2. Per RESEARCH Open Q3, this unlocks:
- The `wrapper-state.json` writers at `src/common/wrapper_state.rs:124`, `src/live/wrapper/claude.rs:201`, `src/live/wrapper/lifecycle.rs:103`, `src/live/wrapper/mod.rs:1685` flip from FLAT to NESTED.
- `wrapper_state_path_resolved` (the nested-first / flat-fallback READER) STAYS for one deploy cycle so the deploy itself (running wrapper hands off to new binary) is covered. It is ripped out in a follow-up phase (TBD; not Phase 25.4).
- This change is coordinated with the binary-handoff state file (`perform_wrapper_handoff` at `wrapper/mod.rs:1685`) — both writer sites flip together. `#[serde(default)]` already present on `WrapperHandoffState` keeps the existing on-disk file forward-compatible.

### D-04 — Parent inference strategy: Explicit-first, Infer as documented fallback

Per RESEARCH Section 4 + operator concern that id-suffix parsing is brittle:
- `ParentHint::Explicit(parent_id)` is the default for all new call sites (poll.rs accepts `--parent`, spool/inbox surfaces accept perch-path-aware variants, wrapper threads `self.self_id` explicitly).
- `ParentHint::Infer` is provided for legacy reader call sites (send.rs/ring.rs/commune.rs/signoff.rs) where the receiver only has the target id (e.g., `is_alive("dunsen-psyche")` with no Self context). Inference: strip `-psyche` / `-w{N}` from the id to recover the parent.
- A unit test fixture covers every Infer rule (`-psyche` → strip suffix; `-w42` → strip last `-w\d+`; `-spine`/`-touch` → Self-level (no parent); plain Self id → Self-level (no parent)).

### D-05 — Spool/inbox path semantics: spool data is a "perch property" (Q5 resolution)

Per RESEARCH Open Q5: spool.db follows the perch path, NOT the perch-id. A nested psyche perch keeps its spool.db at `owlery/<self>/nested/<self>-psyche/spool.db`; a flat Self perch keeps its spool at `owlery/<self>/spool.db`. The new spool helpers (`open_spool_at(perch_path)`) make this explicit by taking a path argument; the legacy `open_spool(id)` surface is preserved for Self callers (which are correct flat) but every psyche/worker caller switches to the path-aware variant.

Existing on-disk flat psyche/worker `spool.db` files become orphaned by the writer migration. They are left in place — a future cleanup migration (out of scope for 25.4) sweeps them; the on-disk-smoke probe in Plan 25.4-07 documents their post-deploy presence so the operator knows what to expect.

### D-06 — Latent ready-file landmine: pin invariant with regression test BEFORE writer changes

Per RESEARCH Open Q7: `wrapper/mod.rs:1168` reads a ready file that nothing actively refreshes. Today it survives only because no code path deletes it. Once we start changing nested writers (Wave 2), the risk of accidentally unlinking the nested ready file rises. **Plan 25.4-02 lands a regression test pinning the invariant BEFORE Wave 2 writer migrations begin.**

The test asserts: after `start.rs` creates the nested psyche perch and writes the ready file, the wrapper's loop-exit predicate (`ready_file_at(nested_perch_dir(self, psyche)).exists()`) returns true; and after a simulated `poll.rs` iteration writes (which Wave 2 makes nested-canonical), the predicate STILL returns true.

### D-07 — Worker-perch mystery (Q1): fold into resolver unit-test coverage, not a separate investigation

Per RESEARCH §3 + Open Q1: w85/w87 got nested perches; w81-w84/w86 only got flat spool side-effect dirs. Operator chose NOT to spawn a dedicated investigation task — instead, the resolver's unit tests cover the `spawn_worker_perch` code path (`src/owl/hook_subagent_start.rs:128`) and Plan 25.4-07's on-disk smoke probe will confirm whether the writer-migration sweep itself resolves the asymmetry (the flat side-effect dirs disappear when spool/inbox auto-create routes nested per D-02/D-05). If the asymmetry persists after Phase 25.4 ships, file a follow-up debug.

### D-08 — `.more-done` writer: flip to nested in this phase (drop dual-path reader in follow-up)

Per RESEARCH §2 B/C-5: `hook_idle.rs:142` is the last B/C-class writer. Flip to nested in Plan 25.4-04 (writer migration). The `echo_fire.rs::pick_fresher_more_done` reader STAYS as the reference dual-path pattern for one deploy cycle; the nested branch will eventually become unconditional. Cleanup of the dual-path reader is a follow-up (TBD).

### D-09 — `live/stop.rs:18` vs `wrapper/lifecycle.rs:127` asymmetry: resolve to symmetric nested-cleanup

Per RESEARCH Open Q4: today `wrapper/lifecycle.rs:127` cleans nested; `live/stop.rs:18` cleans flat. Both flip to use the central resolver in Plan 25.4-06 — symmetric nested-cleanup. The flat path becomes obsolete (post-migration nothing writes flat psyche state), and the resolver naturally returns nested for psyche ids.

---

## Deferred Ideas

These are intentionally OUT of scope for Phase 25.4:

- **Dropping `wrapper_state_path_resolved` / `pick_fresher_more_done` dual-path readers entirely.** Stays one more deploy cycle for the migration handoff window. Follow-up phase tears them out.
- **Walking back the migration if it fails.** ALPHA is locked; if Wave 2 breaks a wrapper, fix forward (regression test, follow-up plan), don't revert direction.
- **Migrating away from id-suffix conventions (`-psyche`, `-w{N}`) to explicit metadata.** The kind-by-suffix classifier is the line of least resistance; id suffix is a stable convention since Phase 9.1. A schema redesign is out of scope.
- **`enumerate_perches` skipping flat-spool-only orphan dirs.** RESEARCH Open Q6 — cosmetic, addressed in a future `live doctor` extension, not Phase 25.4.
- **Cleaning up existing on-disk flat psyche/worker orphan files.** Post-migration sweep is a separate follow-up phase. Plan 25.4-07's probe documents the post-deploy state.

---

## Claude's Discretion

These choices are Claude's to make within the locked decisions:

- **Module layout**: New module `src/common/perch_path.rs` (vs. extending `owlery.rs`). Operator confirmed "central path-resolver in `src/common/owlery.rs` (or sibling module)". Claude picks sibling module — `owlery.rs` is already 700+ lines, and the resolver's clear single-responsibility justifies its own file.
- **Test pattern**: Reuse the `ENV_LOCK: Mutex` + `SPT_HOME` env-override pattern from `owlery.rs::tests`, `wrapper_state.rs::tests`, `hook_idle.rs::tests`. No new test infra.
- **Naming**: `PerchKind` enum variants (`Self_`, `Psyche`, `Worker`, `Spine`, `Touch`); `ParentHint::{Explicit, Infer}`; resolver function `resolve_perch_path`.
- **`open_spool_at` shape**: Take `&Path` for the perch dir (not `&str` for the id). Internal `create_dir_all` stays — caller's responsibility to pass the correct (nested or flat) path. Legacy `open_spool(id, owlery)` deprecated via doc comment, kept compiling for Self callers (which are correct).
- **Inbox `*_at` surface**: Parallel `set_has_messages_at(perch_path)`, `clear_has_messages_at(perch_path)`, `set_idle_ready_at(perch_path)`, `clear_idle_ready_at(perch_path)`, `is_idle_ready_at(perch_path)`, `has_messages_at(perch_path)`, `write_to_inbox_at(perch_path, from, body)`, `drain_inbox_at(perch_path)`. Legacy id-based fns delegate to the `*_at` variants via `owlery::perch_dir(id)` (correct for Self).
- **`poll.rs` `--parent` flag**: Optional `--parent <self_id>` CLI flag (default None = Self mode; flat path stays correct). Wrapper at `mod.rs:1527` passes `--parent <self_id>` when invoking psyche poll. `find_working_perch_with_parent` upgrade returns `(owl_id, parent_id, perch_path)` (currently `(owl_id, parent_id)`); `hook_check.rs:141` uses the returned `perch_path` instead of recomposing via `perch_dir(&owl_id)`.

---

## Source Audit (Coverage Map)

| Source item | Type | Covered by plan | Notes |
|---|---|---|---|
| D-01 (finish nested migration) | CONTEXT | 25.4-01..07 | architectural direction; all plans implement |
| D-02 (single source of truth) | CONTEXT | 25.4-01 (definition); 25.4-04/05/06 (consumption) | central resolver module is keystone |
| D-03 (close wrapper-state migration window) | CONTEXT | 25.4-05 | flat → nested writer flip |
| D-04 (explicit-first parent inference) | CONTEXT | 25.4-01 (defines), all others (consume) | |
| D-05 (spool data is perch property) | CONTEXT | 25.4-04 | `open_spool_at` shape |
| D-06 (landmine regression test) | CONTEXT | 25.4-02 | runs before Wave 2 writer changes |
| D-07 (worker mystery: fold into resolver tests) | CONTEXT | 25.4-01, 25.4-07 | resolver tests + smoke probe |
| D-08 (.more-done flip nested, keep reader) | CONTEXT | 25.4-04 | |
| D-09 (stop.rs/lifecycle.rs symmetry) | CONTEXT | 25.4-06 | |
| RESEARCH B/C-1 (poll.rs primary writer) | RESEARCH | 25.4-03 | |
| RESEARCH B/C-2 (spool auto-flat) | RESEARCH | 25.4-04 | |
| RESEARCH B/C-3 (inbox auto-flat) | RESEARCH | 25.4-04 | |
| RESEARCH B/C-4 (.owl-aware worker sentinel) | RESEARCH | 25.4-04 | via `find_working_perch_with_parent` perch_path return |
| RESEARCH B/C-5 (.more-done writer) | RESEARCH | 25.4-04 | |
| RESEARCH B/C-6 (wrapper-state writers) | RESEARCH | 25.4-05 | per D-03 |
| RESEARCH E-1 (commune.rs ready check) | RESEARCH | 25.4-06 | |
| RESEARCH E-2 (signoff.rs reachability) | RESEARCH | 25.4-06 | |
| RESEARCH E-3 (touch_loop.rs re-resolve) | RESEARCH | 25.4-06 | use `_perch_path` from enumerate |
| RESEARCH E-4 (boot_spine.rs revival check) | RESEARCH | 25.4-06 | |
| RESEARCH E-5 (stop.rs:18 asymmetry) | RESEARCH | 25.4-06 | per D-09 |
| RESEARCH E-6 (send.rs flat-only) | RESEARCH | 25.4-06 | resolver consumption |
| RESEARCH E-7 (ring.rs flat-only) | RESEARCH | 25.4-06 | resolver consumption |
| RESEARCH E-8 (cleanup.rs benign-but-fragile) | RESEARCH | 25.4-06 | resolver consumption |
| RESEARCH Q1 (w85/w87 mystery) | RESEARCH | 25.4-07 (smoke probe) | per D-07 |
| RESEARCH Q5 (spool path semantics) | RESEARCH | 25.4-04 | per D-05 |
| RESEARCH Q7 (ready-file landmine) | RESEARCH | 25.4-02 | per D-06 |
| Integration test: live start → no flat dirs | GOAL | 25.4-07 | smoke probe |
| Integration test: subagent spawn → no flat | GOAL | 25.4-07 | smoke probe |
| Regression test for wrapper/mod.rs:1168 | CONTEXT | 25.4-02 | per D-06 |

All items covered. No gaps. No PHASE SPLIT recommendation.

---

## Wave Structure

- **Wave 1** (parallel): 25.4-01 (resolver foundation), 25.4-02 (landmine regression test) — both independent, no shared files.
- **Wave 2** (sequential after Wave 1): 25.4-03 (poll.rs `--parent` migration), 25.4-04 (spool/inbox/hook_check/hook_idle writer migration), 25.4-05 (wrapper-state writer flip + handoff). 25.4-03 and 25.4-04 can run parallel (touch different files); 25.4-05 depends on 25.4-04 because `wrapper/mod.rs::perform_wrapper_handoff` shares file with the wrapper-state writer change.
- **Wave 3** (after Wave 2): 25.4-06 (Class-E reader migration), 25.4-07 (on-disk smoke probe + cleanup-asymmetry resolution).

Dependency rationale: Wave 1 lands the resolver module + the landmine test (defensive). Wave 2 consumes the resolver from writer sites. Wave 3 consumes the resolver from reader sites, then validates the steady-state on disk.
