---
phase: 25.4-perch-path-single-source-of-truth-finish-d-01-nested-migrati
plan: 03
subsystem: runtime
tags: [rust, perch-path, poll, psyche, wrapper, cli, resolver, single-source-of-truth, nested-migration]

requires:
  - phase: 25.4
    provides: "Plan 01 perch_path resolver module (resolve_perch_path / resolve_ready_file / resolve_info_file / resolve_inbox_dir); Plan 02 wrapper ready-file landmine regression test"
provides:
  - "`owl poll` accepts an optional `--parent <self_id>` CLI flag"
  - "`poll::run` signature extended with `parent: Option<&str>` (additive — old call sites take `, None`)"
  - "`poll::run` perch-path composition routed through `perch_path::resolve_*` for info.json + ready + status + inbox via shared `perch` value"
  - "Psyche wrapper `poll_psyche` argv now carries `--parent <self_id>`, routing every inner-poll iteration into the NESTED path"
  - "Integration test `tests/poll_nested_psyche.rs` pinning nested-only side effects of the migrated writer"
affects: [25.4-04, 25.4-05, 25.4-06, 25.4-07]

tech-stack:
  added: []
  patterns:
    - "Additive CLI/function-signature evolution: new optional `--parent` flag + trailing `Option<&str>` parameter preserve all existing call sites (Self listener at `src/live/start.rs:502`, `tests/mid_iteration_alarm.rs` callers) with a single `, None` append per site"
    - "Resolver-routed perch-path composition: every flat `owlery::perch_dir(id)/ready_file(id)/info_file(id)` call inside the migrated writer replaced with `perch_path::resolve_*(id, hint)` driven by `ParentHint::Explicit(parent)` or `ParentHint::Infer`. The `perch` value alone propagates to siblings (`perch.join(\"inbox\")`, `perch.join(\"status\")`) — no second composition site needed."
    - "Integration test sandbox via per-spawn `Command::env`: SPT_HOME + SPT_TRAMPOLINE_GUARD + HOME/USERPROFILE all redirected into the tempdir so neither the outer trampoline (`src/main.rs:7-28`) nor the inner handoff path (`src/owl/poll.rs:532`) can intercept the test binary. Matches the integration-test convention in `tests/native_owl.rs::owl_cmd` + `tests/mid_iteration_alarm.rs::init_sandbox`."

key-files:
  created:
    - tests/poll_nested_psyche.rs
  modified:
    - src/cli.rs
    - src/owl/mod.rs
    - src/owl/poll.rs
    - src/live/start.rs
    - src/live/wrapper/mod.rs
    - tests/mid_iteration_alarm.rs

key-decisions:
  - "`parent` placed as the LAST positional parameter of `poll::run` (after `once`). Trailing-Option ordering keeps all 3 existing call sites (`src/owl/mod.rs`, `src/live/start.rs:502`, two `tests/mid_iteration_alarm.rs` sites) trivially updatable with `, None` and preserves source-control diff legibility."
  - "Wrapper argv inserts `--parent <self_id>` IMMEDIATELY after `--psyche` (before the optional `--pulse-interval` block). This places the new flag adjacent to the other always-present flags and keeps the `period > 0`-gated `--pulse-interval` block contiguous, which the existing test fixture `poll_psyche_argv_contains_once_flag` reads sequentially."
  - "Self listener call site (`src/live/start.rs:502`) passes `parent=None`. `ParentHint::Infer` returns flat via `PerchKind::Self_` classification (Plan 01 contract) — preserving the Self listener's flat layout per CONTEXT D-04 without any branching in `poll::run`."
  - "Integration test `tests/poll_nested_psyche.rs` redirects HOME/USERPROFILE to the tempdir (in addition to SPT_HOME + SPT_TRAMPOLINE_GUARD). Without HOME redirection, `handoff::resolve_installed_target` at `src/common/handoff.rs:67-83` reads the developer's REAL `~/.claude/plugins/installed_plugins.json` and the inner handoff path at `src/owl/poll.rs:532` spawns the installed cache binary as a stdio-relay child — which doesn't have `--parent` and writes flat artifacts. Discovered empirically (first test run failed with both flat + nested artifacts on disk + `HANDOFF:dunsen-psyche target=...` in stderr)."
  - "Assertion surface in the integration test scoped to `info.json` + `ready` (Plan 03's writer surface) — NOT `spool.db` or inbox sentinels. Plan 25.4-04 migrates `spool.rs::open_spool` and `inbox.rs::set_has_messages`, so the flat side-effect dirs may still appear from those auto-create paths until Plan 04 lands. Documenting this scope keeps the test honest about what Plan 03 alone can deliver."

patterns-established:
  - "Pattern: When migrating a writer to the resolver, ALSO update test fixtures that pin the wire-format of the writer's argv (`poll_psyche_argv_contains_once_flag` here). Argv tests are deliberately fragile so wire-shape changes surface immediately."
  - "Pattern: Wave 2 migrations preserve existing call-site semantics for Self/Spine/Touch ids by relying on `PerchKind::classify` + `ParentHint::Infer` returning flat — no new branching needed in callers."

requirements-completed: [PERCH-PATH-POLL-MIGRATION-01, PERCH-PATH-POLL-MIGRATION-02]

duration: ~25min
completed: 2026-05-23
---

# Phase 25.4 Plan 03: `poll.rs` → resolver migration via `--parent` Summary

**Migrated `src/owl/poll.rs` — RESEARCH B/C-1's primary flat-psyche-state writer — onto the central perch-path resolver via a new optional `--parent <self_id>` CLI flag. Psyche wrapper's `poll_psyche` now passes the flag on every inner-poll spawn, routing all `info.json` / `ready` / `status` / inbox writes into the NESTED `owlery/<self_id>/nested/<psyche_id>/` layout. Integration test pins nested-only side effects against an empty SPT_HOME; existing wrapper + landmine + poll unit tests stay green.**

## Performance

- **Duration:** ~25 min
- **Tasks:** 1
- **Files modified:** 6 (5 src + 1 existing test)
- **Files created:** 1 (`tests/poll_nested_psyche.rs`)

## Accomplishments

- `Commands::Poll` extended with `parent: Option<String>` (clap `#[arg(long)]`). Doc comment cites CONTEXT D-02/D-04 and the Wave 2 migration intent.
- `crate::owl::poll::run` signature gained `parent: Option<&str>` as the last positional parameter. The function body now opens with `let hint = match parent { Some(p) => ParentHint::Explicit(p), None => ParentHint::Infer };` and composes `perch` / `ready_path` / `info_path` via `perch_path::resolve_*(id, hint)`. Siblings (`perch.join("inbox")`, `perch.join("status")`) inherit the nested-or-flat shape from the composed `perch` value.
- `src/live/wrapper/mod.rs::poll_psyche` argv builder pushes `"--parent"` + `&self.self_id` between `"--psyche"` and the optional `--pulse-interval` block. The existing test fixture `poll_psyche_argv_contains_once_flag` updated to the new 9-slot shape (was 7-slot).
- `src/live/start.rs:502` Self-listener call passes `None` for the new parameter. Comment cites Plan 01's `PerchKind::Self_` classification: `ParentHint::Infer` for a Self id returns flat → behavior preserved byte-for-byte.
- `tests/mid_iteration_alarm.rs` updated at both poll::run sites with `, None` trailing.
- New integration test `tests/poll_nested_psyche.rs::poll_with_parent_writes_nested_only` spawns the built binary against an empty SPT_HOME tempdir and asserts: (a) NESTED `info.json` + `ready` exist at `owlery/<self>/nested/<psyche>/`; (b) FLAT `info.json` + `ready` do NOT exist at `owlery/<psyche>/`. Sandbox env covers SPT_HOME + SPT_TRAMPOLINE_GUARD + HOME + USERPROFILE.

## Task Commits

1. **Task 1: Extend Commands::Poll with --parent flag; thread through dispatch into poll::run; route all perch-path composition through perch_path resolver; wrapper passes --parent** — `36226f6` (feat)

_Single commit: the CLI extension, dispatch threading, resolver routing, wrapper argv update, Self-listener trailing-None update, mid_iteration_alarm test updates, and the new integration test are all atomically dependent. A build attempted with any subset would fail (e.g., wrapper passes `--parent` but clap doesn't know the flag → runtime parse error). The TDD red/green cycle here is the integration test (failing-red until the writer migration is in place; green after) — committed alongside its driving code change rather than in a separate stub-then-fill iteration._

## Files Created/Modified

- `src/cli.rs` (modified): `Commands::Poll` gained `#[arg(long)] parent: Option<String>`.
- `src/owl/mod.rs` (modified): dispatch arm destructures `parent` and passes `parent.as_deref()` as the 10th arg to `poll::run`.
- `src/owl/poll.rs` (modified): added `use crate::common::perch_path::{self, ParentHint};` import; `run()` signature gains `parent: Option<&str>` last; opening block composes `let hint = ...` then routes `perch` / `ready_path` / `info_path` through resolver.
- `src/live/start.rs:502` (modified): Self-listener call site appends `, None` for the new parameter.
- `src/live/wrapper/mod.rs` (modified): `poll_psyche` argv builder pushes `--parent` + `&self.self_id`; the unit-test fixture at `poll_psyche_argv_contains_once_flag` updated to the new 9-slot argv shape.
- `tests/mid_iteration_alarm.rs` (modified): both `poll::run` callers updated with trailing `None`.
- `tests/poll_nested_psyche.rs` (created): integration test asserting nested-only side effects; sandbox env covers SPT_HOME + SPT_TRAMPOLINE_GUARD + HOME + USERPROFILE.

## Decisions Made

- **Trailing-Option parameter ordering**: `parent: Option<&str>` placed last (after `once: bool`) so call-site updates are mechanical (`, None` append). Trade-off: signature now has two trailing options (`once` is `bool`, `parent` is `Option<&str>`), but the call-site shape is unambiguous because positional types differ (`false, None` reads cleanly).
- **Wrapper argv: `--parent` adjacent to `--psyche`, before the optional `--pulse-interval` block**: groups the always-present flags together and keeps the conditional `--pulse-interval` block contiguous. The unit-test fixture reads sequentially, so adjacency simplifies the assertion update.
- **Integration test redirects HOME/USERPROFILE in addition to SPT_HOME + SPT_TRAMPOLINE_GUARD**: required because `src/common/handoff.rs:67-83::resolve_installed_target` reads `~/.claude/plugins/installed_plugins.json` independently of `SPT_HOME`, and the INNER handoff path at `src/owl/poll.rs:532` is NOT gated by `SPT_TRAMPOLINE_GUARD` (that env only stops the OUTER trampoline in `src/main.rs:12`). Discovered empirically — the first test run produced BOTH flat and nested artifacts because the test binary trampolined to the installed cache binary, which didn't have `--parent` and wrote flat. Solution mirrors `tests/native_owl.rs::owl_cmd`.
- **Test assertion surface scoped to `info.json` + `ready` only (NOT spool.db or inbox sentinels)**: Plan 25.4-04 migrates `spool.rs::open_spool` and `inbox.rs::set_has_messages` (their auto-create paths still touch flat). Plan 03's contract is the `info.json` / `ready` / `status` triple — keeping the test scoped here keeps it honest about what THIS plan alone delivers and avoids creating a false-positive failure when Plan 04 is the actual fix surface.

## Deviations from Plan

- **TDD cycle collapsed to single commit (vs `tdd="true"` task structure)**. The plan's task structure split into RED (failing test commit) → GREEN (impl commit). In practice, the test, the code change, the wrapper argv push, the Self call-site update, the mid_iteration_alarm updates, and the wrapper-mod test-fixture update are atomically dependent — any subset fails to build OR fails to test (because the build doesn't include the resolver routing). A RED-first commit would have left the codebase non-building, which violates `cargo build` invariants. Matches Plan 01 + Plan 02's same-pattern collapse rationale.
- **Auto-fix (Rule 3 — blocking issue)**: the first test run failed because `src/main.rs:7-28`'s OUTER trampoline + `src/owl/poll.rs:532`'s INNER handoff path both intercept the test binary. `SPT_TRAMPOLINE_GUARD=1` alone covers only the outer trampoline; the inner handoff resolves `~/.claude/plugins/installed_plugins.json` via `USERPROFILE`/`HOME`, so the dev's installed cache binary at the OLD version (without `--parent`) was spawned as a stdio-relay child and wrote flat artifacts. Fix: also redirect `HOME` + `USERPROFILE` to the tempdir so `handoff_available()` returns None. No plan changes needed (this is sandbox-isolation hygiene, not a behavior change).

## Issues Encountered

- **Integration-test sandbox completeness**: the first test run produced both flat AND nested artifacts on disk because the inner handoff path at `src/owl/poll.rs:532` was not gated by `SPT_TRAMPOLINE_GUARD`. The stderr capture (`HANDOFF:dunsen-psyche target=C:\Users\decid\.claude\plugins\cache\cplugs\spt\1.11.10\owl.exe`) made the diagnosis unambiguous. Resolved by adding HOME/USERPROFILE redirection — `resolve_installed_target` returns None when there's no `installed_plugins.json` under HOME, so the inner handoff is skipped. Documented inline in the test sandbox-env comments.
- **Parallel-test flakiness in `cargo test --lib`**: 44 failures in parallel mode, 2 in single-threaded mode. The 2 single-threaded failures (`owl::version_changelog::tests::current_version_honors_override_in_debug_builds`, `common::tracked::tests::doctor_status_local_only_count`) are pre-existing and unrelated to this plan's changes (the version test only works in debug builds; the tracked test is a known flake). Not in scope per the per-task fix-attempt-limit; logged here for visibility.

## Verification

- `cargo build --release` → succeeds (6 pre-existing warnings, none introduced by this plan).
- `cargo test --release --lib owl::poll` → **40 passed, 0 failed** (all poll unit tests including the new argv-shape fixture).
- `cargo test --release --test poll_nested_psyche` → **1 passed, 0 failed** (`poll_with_parent_writes_nested_only`).
- `cargo test --release --test wrapper_ready_file_landmine` → **2 passed, 0 failed** (Plan 25.4-02 invariant holds under the migrated writer — Success Criterion #5).
- `cargo test --release --lib live::wrapper -- --test-threads=1` → **129 passed, 0 failed** (wrapper argv-shape test passes with the new 9-slot vector).
- `cargo test --release --test cli_parse` → **48 passed, 0 failed**.
- `grep -n "owlery::perch_dir(id)\|owlery::ready_file(id)\|owlery::info_file(id)" src/owl/poll.rs` → **0 matches** (verification spec #3: all direct id-based composition gone).
- `grep -n "perch_path::" src/owl/poll.rs` → **4 matches** (1 import + 3 resolves: ≥3 required by verification spec #4).
- `grep -n '"--parent"' src/live/wrapper/mod.rs` → **3 matches** (argv builder at line 1539 + 2 test-fixture assertions).
- `grep -rn "poll::run(" src tests` → every call site passes 10 args (the new `parent` slot).

## Self-Check: PASSED

- File `tests/poll_nested_psyche.rs` exists (verified — test discovered and passed in the cargo run above).
- All modified source files compile cleanly (`cargo build --release` succeeded).
- Commit `36226f6` present on main branch (verified via `git rev-parse --short HEAD` post-commit).
- Test `poll_with_parent_writes_nested_only` passes against an empty SPT_HOME — verified above.
- `wrapper_ready_file_landmine` (Wave 1 invariant) still passes under the migrated writer — verified above.

## Next Phase Readiness

- Plan 25.4-04 (spool/inbox/hook_check/hook_idle writer migration) can proceed in parallel with this plan's commit (no file overlap — Plan 03 touches `src/cli.rs`, `src/owl/mod.rs`, `src/owl/poll.rs`, `src/live/start.rs`, `src/live/wrapper/mod.rs`; Plan 04 touches `src/common/spool.rs`, `src/common/inbox.rs`, `src/owl/hook_check.rs`, `src/owl/hook_idle.rs`).
- Plan 25.4-05 (wrapper-state writer flip + binary handoff) depends on Plan 25.4-04 because `wrapper/mod.rs::perform_wrapper_handoff` shares a file with the wrapper-state writer change.
- The `--parent` flag is now production-wired. Once Plans 25.4-04/05/06 land, every psyche poll iteration writes only nested artifacts; the flat psyche perch dirs become writer-orphaned (spool.db side effects sweep in a follow-up phase per CONTEXT Deferred Ideas).
- No blockers.

---
*Phase: 25.4-perch-path-single-source-of-truth-finish-d-01-nested-migrati*
*Completed: 2026-05-23*
