---
quick_task: 260523-opn-option-d-file-drop-single-writer-snapsho
type: execute
status: complete
completed_date: 2026-05-23
commit: 7f819f5, 4b3c56f
files_modified:
  - src/live/start.rs
  - src/live/wrapper/lifecycle.rs
  - src/live/wrapper/mod.rs
  - src/owl/poll.rs
  - tests/file_drop_integration.rs
  - tests/native_latent_signoff_deliver_then_die.rs
requirements_satisfied:
  - OPN-D-SIGNOFF-01
  - OPN-D-SIGNOFF-02
  - OPN-D-SIGNOFF-03
  - OPN-D-SIGNOFF-04
  - OPN-D-SIGNOFF-06
  - OPN-D-SIGNOFF-07
  - OPN-D-SIGNOFF-08
tags: [option-d, file-drop, signoff, single-writer, wrapper, init-stale, discriminator, mtime]
---

# Quick Task 260523-opn: Option D File-Drop Single-Writer (Snapshot + Discriminate) Summary

One-liner: Make the wrapper the sole deleter of `.claude/{id}-signoff.md`, and have it discriminate init-stale signoffs (file mtime pre-dates wrapper `gen_start`) from normal-flow signoffs (file mtime post-dates `gen_start`) using filesystem mtime only — no sidecar, no in-file metadata.

## What Shipped

### 1. `src/live/start.rs::drain_stale_signoff_file` — read-only (D-01)

The listener-side stale-signoff drain is now snapshot+forward only. Both pre-existing `fs::remove_file` calls (empty-body branch and post-forward delivered-true branch) are removed. The latent-signoff envelope forward to the `{id}-psyche` spool is preserved unchanged — Psyche still absorbs the FINAL COMMUNE body as inbound context. The wrapper's `process_file_drop` becomes the sole deleter.

Doc comment updated to describe the new contract and explicitly note that the wrapper's init-stale branch consumes-and-deletes files left over by drain.

### 2. `src/live/wrapper/lifecycle.rs::WrapperState::new` — `gen_start` initialized (D-08)

Both cold-start and handoff-rehydration paths initialize `gen_start: SystemTime::now()`. The plan locked this contract explicitly: handoff is a fresh OS process at a fresh wall-clock instant; carrying forward the prior gen's `gen_start` would defeat the discriminator and let prior-gen signoffs fire teardown on the new gen.

The `mk_wrapper_state` test helper (`lifecycle.rs::tests::mk_wrapper_state`) was also updated to initialize the new field — discovered during the file-search enumeration (not in the plan's 7-site list but required by the new struct shape).

### 3. `src/live/wrapper/mod.rs::WrapperState` — new `gen_start: SystemTime` field (D-08)

Field added after `last_fresh_launch` with a doc comment describing the construction-time invariant (cold + handoff both `SystemTime::now()`) and its use as the discriminator in `process_file_drop`. All 7 in-mod.rs test-site struct literals updated to initialize the field via `SystemTime::now()`.

### 4. `src/live/wrapper/mod.rs::process_file_drop` — discriminator + empty-body skip + ENOENT cleanup (D-02, D-03, D-04, D-08)

Three changes layered on top of the existing flow:

- **ENOENT diagnostic cleanup (D-04):** the kind-aware `let note = if kind == "signoff" { " (stale - likely consumed by drain_stale_signoff_file..." }` ternary is replaced with a single bare log line: `"[FILE-DROP] dropped kind={} path={} (file gone — consumed prior iteration or stale spool duplicate)"`. Identical for commune and signoff kinds, since the listener-side drain no longer deletes and the kind-specific failure mode no longer exists. The multi-paragraph diagnostic comment is compressed.

- **Empty-body signoff skip (D-03):** inserted after body is bound and before envelope composition. If `kind == "signoff" && body.trim().is_empty()`, log the skip, delete the file (wrapper is sole deleter), return `FileDropOutcome::Continue`. Applies to BOTH init-stale and normal-flow paths (the discriminator is only consulted below this short-circuit).

- **Discriminator + envelope routing (D-02):** the previous `let exit_code = if kind == "signoff" { final_session(...); 0 } else { resume_session_with_exit(...) }` block is replaced with a three-way match on `(kind, is_init_stale)`:
    - `signoff + init-stale` → log marker, `resume_session_with_exit`, outcome `Continue` (NO teardown — shares the envelope with Psyche but continues polling).
    - `signoff + normal-flow` → log marker, `final_session`, outcome `BreakLoop` (graceful-shutdown semantic preserved unchanged).
    - `commune` → unchanged (`resume_session_with_exit`, outcome `Continue`).

  `is_init_stale` computed as `fs::metadata(path).and_then(|m| m.modified())` comparing to `self.gen_start`. On Err, defaults to init-stale per the operator's defensive default (better to over-share than wrongly tear down a fresh agent).

- **Shared delete + return:** the existing `exit_code == 0 → remove_file else log retained` block stays, and the function returns `outcome_after_delete` (computed by the match above) instead of the hard-coded `if kind == "signoff" { BreakLoop } else { Continue }`.

### 5. `is_init_signoff_envelope` predicate path UNCHANGED (D-06)

Confirmed by inspection: the `run()` branch at `mod.rs::1260` that fires `final_session` on cross-agent `<EVENT type="init_signoff">` inbox messages is untouched. The asymmetry — file-drop discriminates, inbox envelope always tears down — is intentional and locked.

### 6. Test surface

Inside `src/live/wrapper/mod.rs::file_drop_handler_tests`:
- **Renamed + rewrote** `process_file_drop_enoent_signoff_logs_drain_stale_note` → `process_file_drop_enoent_signoff_logs_bare_file_gone`. Asserts the bare `(file gone` wording; negatively asserts the prior `drain_stale_signoff_file` / `Phase 30 defense` mentions are GONE.
- **Updated** `process_file_drop_enoent_logs_dropped_not_retaining` to tolerate the widened `"(file gone — ..."` wording (changed `contains("(file gone)")` to `contains("(file gone")`).
- **Added** `file_drop_signoff_init_stale_returns_continue` — sets `gen_start` to `SystemTime::now() + Duration::from_secs(60)` so file mtime is guaranteed `< gen_start`; asserts outcome is `Continue` and the init-stale discriminator log line was emitted.
- **Added** `file_drop_signoff_normal_flow_returns_break_loop` — sets `gen_start` to `SystemTime::now() - Duration::from_secs(60)` so file mtime is guaranteed `>= gen_start`; asserts outcome is `BreakLoop` and the normal-flow discriminator log line was emitted.
- **Added** `file_drop_signoff_empty_body_skips_and_deletes` — pins D-03 (empty body skips LLM round-trip, deletes file, returns Continue, never reaches discriminator).

The discriminator tests use `cli_binary: "claude-nonexistent-for-test"` and rely on log-scrape of the markers emitted BEFORE dispatch — `final_session` / `resume_session_with_exit` spawn-failures are silently tolerated. SPT_HOME is set to a tempdir so `psyche_dir()` resolves to a valid `current_dir` for the spawn.

Inside `tests/native_latent_signoff_deliver_then_die.rs`:
- **Updated Test A** `drain_stale_signoff_file_forwards_latent_signoff_envelope` to assert `sp.exists()` (was `!sp.exists()`) — file persists post-drain under Option D.
- **Renamed + updated Test B** `drain_stale_signoff_file_deletes_empty_signoff` → `drain_stale_signoff_file_preserves_empty_signoff` — asserts file persists.
- **Updated Test D** `drain_stale_signoff_file_no_stop_loop_regression` to add the persistence assertion alongside the existing D-12 STOP-loop landmine guard.

Inside `tests/file_drop_integration.rs`:
- **Updated Test 9** `stale_signoff_md_does_not_kill_fresh_live_start` to assert file persists post-startup (the "no kill" property still holds via the wrapper's init-stale Continue path).
- **Added Test 10** `signoff_drain_does_not_delete_file_persists_for_wrapper_consume` — drives `owl live start` against a non-empty pre-staged signoff drop, asserts file persists AND the latent-signoff envelope reached the `{id}-psyche` spool.
- **Added Test 11** `signoff_drain_empty_body_does_not_delete` — drives `owl live start` against an empty pre-staged signoff drop, asserts file persists AND no spool row was created.

## Verification

```
cargo build --release            → OK (8.27s)
cargo test --lib -- file_drop_signoff process_file_drop_enoent
                                → 6 passed; 0 failed
cargo test --test file_drop_integration
                                → 7 passed; 0 failed (4 pre-existing #[ignore])
cargo test --test native_latent_signoff_deliver_then_die
                                → 3 passed; 0 failed
cargo test --lib -- --test-threads=1
                                → 855 passed; 0 failed; 5 ignored
```

Grep verification per plan:

```
grep -c "fs::remove_file" (inside drain body)        → 0 (only comment ref)
grep -c "gen_start" src/live/wrapper/mod.rs          → 34
grep -c "gen_start" src/live/wrapper/lifecycle.rs    → 2
grep -c "final_session" src/live/wrapper/mod.rs      → 29 (D-06 inbox path preserved)
```

## Deviations from Plan

### Auto-fixed Issues

1. **[Rule 1 - Bug] Updated commune-kind ENOENT test to tolerate widened wording**
   - **Found during:** test run after rewriting the ENOENT arm.
   - **Issue:** `process_file_drop_enoent_logs_dropped_not_retaining` asserted `log_content.contains("(file gone)")` (with closing paren), but the new wording is `(file gone — consumed prior iteration or stale spool duplicate)` (em-dash before closing paren).
   - **Fix:** Changed assertion to `contains("(file gone")` (open paren only). Both kinds emit the same string under Option D so the assertion is still meaningful.
   - **Files modified:** `src/live/wrapper/mod.rs` (test only).

2. **[Rule 1 - Bug] Updated Tests A/B in native_latent_signoff_deliver_then_die.rs**
   - **Found during:** plan-walkthrough — plan explicitly called out rewriting Test D, but Test A asserted `!sp.exists()` (file gone) and Test B asserted `!sp.exists()` (empty file deleted).
   - **Issue:** Under the new contract (D-01) drain never deletes, so both tests would fail.
   - **Fix:** Flipped both assertions to `sp.exists()`. Renamed Test B to `drain_stale_signoff_file_preserves_empty_signoff` to match the new semantic.
   - **Files modified:** `tests/native_latent_signoff_deliver_then_die.rs`.

3. **[Rule 1 - Bug] Updated Test 9 in file_drop_integration.rs (stale_signoff_md_does_not_kill_fresh_live_start)**
   - **Found during:** scan of all `signoff_path.exists()` assertions across the test surface.
   - **Issue:** Test 9 asserted `!signoff_path.exists()` post-startup (was testing the prior delete-on-drain contract).
   - **Fix:** Flipped to `signoff_path.exists()`; updated the comment to explain that the "no kill" property still holds via the wrapper's init-stale Continue path (not via deletion).
   - **Files modified:** `tests/file_drop_integration.rs`.

4. **[Rule 2 - Missing Functionality] Added empty-body skip test (file_drop_signoff_empty_body_skips_and_deletes)**
   - **Found during:** the plan's <interfaces> section described D-03 (empty-body skip) as a contract change to `process_file_drop` but did not enumerate a test for the in-mod.rs path. The integration tests (Test 11 / Test B) cover the listener-side drain empty-body case, but not the wrapper-side empty-body skip after a non-empty file becomes empty mid-flight (or whitespace-only).
   - **Fix:** Added `file_drop_signoff_empty_body_skips_and_deletes` in-mod test that writes a whitespace-only signoff file and asserts the wrapper deletes it + returns Continue + never reaches the discriminator.
   - **Files modified:** `src/live/wrapper/mod.rs`.

5. **[Rule 3 - Blocking] Extended mk_wrapper_state helper in lifecycle.rs**
   - **Found during:** grep enumeration of `WrapperState\s*\{` literal sites turned up 9 sites (struct decl + 8 literals) instead of the 7 the plan listed. The 8th literal was in `src/live/wrapper/lifecycle.rs::tests::mk_wrapper_state`.
   - **Fix:** Added `gen_start: std::time::SystemTime::now(),` to that helper too. Per the plan's judgment-authority guidance ("If a test in the existing test module uses a WrapperState builder/helper instead of a struct literal, prefer extending the helper..."), this was the cleanest path — the helper is the construction site, not a struct-literal callsite that the discriminator tests would need to override.
   - **Files modified:** `src/live/wrapper/lifecycle.rs`.

### No Architectural Changes

No Rule 4 events. All decisions were locked in the plan; no new schema, no new DB tables, no new infrastructure.

## Decisions Made

- **gen_start default for in-mod test helpers**: `SystemTime::now()` (controlled-timestamp variants only used in the two discriminator tests where the branch decision is load-bearing).
- **mtime fetch Err defaults to init-stale**: honored per plan judgment authority. Logged via the init-stale branch's marker line so the operator can see why the branch fired.
- **Empty-body delete + Continue in BOTH branches**: honored uniformly. The empty-body skip lives before the discriminator so it does not matter which sub-branch the file would have taken.
- **Test placement for discriminators**: chose in-mod.rs `file_drop_handler_tests` over MockFileDropDispatcher. Rationale: `process_file_drop` is a method on `WrapperState`, not parameterized — there is no clean trait seam to mock. Placing the tests in-mod.rs lets the discriminator log markers be inspected via `log_path` read-back, which is more authoritative than a mock that re-implements the branch logic. Tests intentionally tolerate downstream `final_session` / `resume_session_with_exit` spawn-failures (claude not on PATH) — the load-bearing assertion is on the discriminator log marker, which is emitted BEFORE dispatch.

## Pre-existing Failures (Out of Scope)

Per the executor judgment authority ("Pre-existing parallel-test worktree failures from main baseline are OK to surface but do not block — document them in SUMMARY.md and proceed"):

1. **`tests/native_wrapper_state_retry.rs` — compile errors (pre-existing)**: 3 sites use `WrapperHandoffState { ... }` literal without the `pulse_psyche` field that commit `3616ed1 feat(live): default --period 480 + new --pulse-psyche flag` added 6 commits ago. Pre-existing main-branch breakage; not caused by this work. Recommended follow-up: extend each literal site with `pulse_psyche: false` to match the other test-site idioms.

2. **`tests/native_owl.rs::deferred_send_does_not_wake_idle_poll` — flaky/pre-existing**: asserts that on wake, the poll should drain all messages including deferred ones. Got only `REAL_MESSAGE`, missing `DEFERRED_NOTICE`. Last touched 11 commits ago by a Phase 25.3-05 change unrelated to file_drop / signoff / wrapper-state. Likely a parallel-test timing flake or environment race; deterministically passes serially.

3. **Parallel-test worktree races in `cargo test --lib` (multiple tests)**: ~30 test failures appear under default parallel execution due to shared `psyches/tracked/seed/` worktree state. ALL pass under `--test-threads=1` (verified: 855 passed; 0 failed; 5 ignored). Pre-existing environment hazard, not caused by this work.

None of these block landing — none are caused by changes in this commit, and the 6 new tests + 3 rewritten tests added by this work all pass under both parallel and serial execution.

## Self-Check: PASSED

- `src/live/start.rs` modified — FOUND (drain body has 0 `fs::remove_file` calls; only a comment reference).
- `src/live/wrapper/lifecycle.rs` modified — FOUND (`gen_start: std::time::SystemTime::now()` in both `WrapperState::new` and `mk_wrapper_state`).
- `src/live/wrapper/mod.rs` modified — FOUND (`pub gen_start: std::time::SystemTime` field + all 7 in-mod literal sites + discriminator + empty-body skip + ENOENT cleanup + 3 new tests + 2 rewritten tests).
- `tests/file_drop_integration.rs` modified — FOUND (Test 9 updated + Tests 10 + 11 added).
- `tests/native_latent_signoff_deliver_then_die.rs` modified — FOUND (Tests A/B/D updated; Test B renamed).
- Commit `7f819f5` exists in `git log --oneline -1` — FOUND.
- `cargo build --release` succeeds — VERIFIED.
- `cargo test --lib -- --test-threads=1` → 855 passed; 0 failed — VERIFIED.
- `cargo test --test file_drop_integration` → 7 passed; 0 failed — VERIFIED.
- `cargo test --test native_latent_signoff_deliver_then_die` → 3 passed; 0 failed — VERIFIED.

---

## Follow-up commit 4b3c56f (drain removed + listener-side discriminator)

After operator review of 7f819f5, two follow-up corrections shipped under
the same quick task slug:

### Why a follow-up was needed

7f819f5 added the wrapper-side discriminator (mtime vs `WrapperState.gen_start`)
but left two latent bugs:

1. **`drain_stale_signoff_file` was redundant.** Operator confirmed that
   `psyche-download` already absorbs pre-existing signoffs into psyche
   context (the offline path), and the wrapper's own `process_file_drop`
   init-stale branch picks them up live. The `$LIVE start`-side
   snapshot+forward was load-bearing under the OLD contract (it deleted
   the file pre-listener-scan to suppress listener teardown). Under the
   new contract it just added noise.

2. **Listener-side soft-stop still fired on stale signoffs.** Removing
   drain alone re-opened the bug via a different path: listener's
   `scan_drop_files` finds leftover signoff → emits envelope AND
   soft-stops + exits(0) → wrapper's `check_orphan` (parent_pid liveness)
   detects Self gone → composes INIT_SIGNOFF → `final_session` → wrapper
   exits → live agent dead. The wrapper-side mtime discriminator only
   protects the file_drop envelope; it can't suppress orphan-cascade
   triggered by listener exit.

### What 4b3c56f shipped

- `src/live/start.rs`: `drain_stale_signoff_file` function + both callsites
  REMOVED. Unused `crate::owl::send` import dropped.
- `src/owl/poll.rs::run`: captures `listener_start_time: SystemTime` at
  poll-loop entry. In the scan_drop_files signoff branch, the mtime
  discriminator runs BEFORE the soft-stop/exit path:
    * mtime < listener_start_time (or mtime unavailable) → init-stale:
      emit `INIT-STALE-SIGNOFF:{id}` status line, `continue` polling.
      File_drop envelope already emitted by the earlier `deliver_body`
      call; the wrapper's init-stale branch (7f819f5) absorbs + deletes.
    * mtime >= listener_start_time → normal flow: preserve existing
      `STOP:{id} (signoff dropped)` + soft_stop_perch + exit(0).
- `tests/native_latent_signoff_deliver_then_die.rs`: DELETED (entire file
  was drain-specific).
- `tests/file_drop_integration.rs`:
  - Test 9 rewritten — switched from `live start` (needs wrapper
    subprocess spawn, brittle on Windows) to `poll listen` direct
    invocation. Asserts the listener does NOT print `signoff dropped`
    for a stale (pre-listener-boot) signoff.
  - Tests 10 + 11 (drain-specific) REMOVED.
  - New Test 10 `listener_emits_init_stale_signoff_status_for_stale_file`:
    drives `poll listen` against a pre-staged stale signoff, asserts the
    `INIT-STALE-SIGNOFF:{id}` line fires and `signoff dropped` does NOT
    (mutually exclusive).
  - `signoff_listener_exits_zero` updated to forward-date the signoff
    file mtime via `File::set_modified(now() + 60s)` so the discriminator
    treats it as normal-flow — preserves SC5/D12 exit-code coverage.
    Pre-staged signoffs are init-stale by definition; forward-dating is
    the right way to drive normal-flow from a test.

### Final shape (7f819f5 + 4b3c56f)

End-to-end single-writer contract:

- **`$LIVE start`**: no pre-listener-scan processing. Listener boots clean.
- **Self listener (`src/owl/poll.rs`)**: scan_drop_files discovers signoff,
  emits file_drop envelope, then mtime-discriminates:
    - init-stale → status line + continue. Listener stays alive.
    - normal-flow → soft-stop + exit(0). Listener exits.
- **Psyche wrapper (`src/live/wrapper/mod.rs::process_file_drop`)**:
  receives file_drop envelope, mtime-discriminates:
    - init-stale → share with Psyche via `resume_session_with_exit`,
      delete file, Continue polling. Wrapper stays alive.
    - normal-flow → compose INIT_SIGNOFF, run `final_session`, delete
      file, BreakLoop. Wrapper exits.
- **`psyche-download`** (`src/live/context.rs`): unchanged — read-only
  absorption path for offline operators (Phase 25.3-05 D-E-01).

The wrapper-side and listener-side discriminators are symmetric: same
mtime-vs-start-time comparison. Both must agree for either teardown or
absorption to complete cleanly. A normal-flow signoff (operator drops file
mid-session) drives BOTH paths to their teardown branches in parallel.
A stale signoff (file leftover at boot) drives BOTH to their absorb-and-
continue branches. No race, no orphan-cascade, no spurious teardown.

### Verification (4b3c56f)

- `cargo build --release` clean.
- `cargo test --lib -- --test-threads=1`: 854 passed / 1 pre-existing
  flaky (`commit_project_payload_omits_project_trailer_per_d08` — git
  timeout, unrelated).
- `cargo test --test file_drop_integration -- --test-threads=1`:
  6 passed, 4 #[ignore] (pre-existing harness gaps).
