# Quick-task RESEARCH — Option D: single-writer file_drop contract (signoff + commune)

**Researched:** 2026-05-23
**Confidence:** HIGH (code-trace verified across all five files; commune half already implemented; signoff side is the remaining gap)
**Specialist Hint:** rust

## Summary

Option D is **half-already-landed**. The commune side of the contract was completed at Phase 25.3-05 D-E-01 — `src/live/context.rs::append_pending_sections` is already read-only over `.claude/{id}-commune.md` and `.claude/{id}-signoff.md` (commit comment at `src/live/context.rs:620-630` explicitly references this debug file). The wrapper is already the sole deleter for commune. The remaining work is on the **signoff branch of `$LIVE start`**:

- `src/live/start.rs::drain_stale_signoff_file` is the last surviving non-wrapper deleter. Two delete sites (lines 166, 232) consume `.claude/{id}-signoff.md` at `$LIVE start` time, BEFORE the wrapper boots. This is what dropped the signoff signal in the debug just resolved.
- Operator constraint #3 (wrapper SHARES signoff with Psyche but does NOT EXIT) requires a carve-out at `src/live/wrapper/mod.rs:1915-1921` and `:1954` so the signoff branch no longer routes through `final_session` + `FileDropOutcome::BreakLoop`. Instead route through `resume_session_with_exit` like commune does, then return `Continue`.

The operator's no-generation-marker constraint (#4) is workable because the commune side has been running for a week without one — the helper's idempotency comes from "first-attempt-wins on disk write; drop file is re-read on next call, gets a NEW mtime header, gets re-appended" — which is fine because `psyche-download` is operator-initiated, not auto-fired. The signoff side has a stricter constraint (the listener auto-fires file_drop events every poll iteration) but producer-side `dedupe_drops` already collapses repeats per-file-lifecycle.

**Primary recommendation:** Three surgical edits — (a) strip the two `fs::remove_file` calls from `drain_stale_signoff_file`, (b) replace the post-forward delete with a snapshot status line, (c) rewire wrapper's signoff branch in `process_file_drop` to use `resume_session_with_exit` + `Continue` instead of `final_session` + `BreakLoop`. No generation marker. No info.json sentinel. The wrapper's exit_code==0 delete at `src/live/wrapper/mod.rs:1925` already exists for both kinds.

---

## 1. Deletion-site enumeration (CONFIRMED)

Authoritative grep over `src/` for `remove_file`, filtered to commune/signoff drop-file paths:

| File:Line | Path | Branch | Status under Option D |
|-----------|------|--------|----------------------|
| `src/live/start.rs:166` | `.claude/{id}-signoff.md` | `drain_stale_signoff_file` empty-body branch | **MUST REMOVE** |
| `src/live/start.rs:232` | `.claude/{id}-signoff.md` | `drain_stale_signoff_file` post-forward branch | **MUST REMOVE** |
| `src/live/wrapper/mod.rs:1925` | both kinds (`path` arg) | `process_file_drop` exit_code==0 branch | **KEEP — sole deleter** |
| ~~`src/live/context.rs:615`~~ | (formerly commune drop) | `append_pending_sections` | Already removed at 25.3-05 D-E-01 |

`src/owl/poll.rs::scan_drop_files` (line 1208) is **read-only** (verified — only emits envelopes via `compose_file_drop_event`). `src/owl/resume.rs:203`/`:331` SessionStart hook variants reach the drop file ONLY via `download_payload_for_injection → download_payload → append_pending_sections` — which is already non-destructive. No listener-side, no hook-side deletion. **Enumeration is complete: 2 sites to remove, 1 site to keep.**

## 2. Snapshot-non-destructive shape for `drain_stale_signoff_file`

Current shape (`src/live/start.rs:143-247`):

| Step | Current | Option D |
|------|---------|----------|
| Step 3 (empty body, line 164-168) | Delete + return | **No-op: return without delete.** File is left for the wrapper to re-process; wrapper's `process_file_drop` reads empty body, composes envelope with empty FINAL COMMUNE slot, routes through resume, deletes on exit==0. |
| Step 6 (forward, line 198-208) | Compose `<EVENT type="latent signoff" ...>` envelope, `deliver_body_anonymous` to `{id}-psyche` spool, gate status/delete on `delivered` | **Keep the forward UNCHANGED.** This pre-emits the signoff to the Psyche spool so the wrapper (when it boots) finds it already queued. |
| Step 7+8 (status + delete, line 224-247) | If delivered: log `LATENT-SIGNOFF-FORWARDED:{id}`, delete file | **Keep the status line; remove the `fs::remove_file` block.** File persists for the wrapper's `process_file_drop` to consume + delete via the exit_code==0 path. |

**Feedback-loop risk analysis:**

- `$LIVE start` calls `drain_stale_signoff_file` once at boot (line 525 + line 846 — but the line 846 call is in `live_start_result` which does NOT enter `poll::run`, so only the line 525 call matters for the listener-spawn path). **NOT a loop within `start.rs`.**
- After `$LIVE start` returns, the listener's `scan_drop_files` (poll iteration body) WILL see the persistent file and emit a fresh `<EVENT type="file_drop" kind="signoff">` envelope. **This is the double-forward risk** — drain already delivered one `latent signoff` envelope, listener will deliver one `file_drop` envelope, both to the wrapper's spool.
- Mitigation already in place: `dedupe_drops` (`src/owl/poll.rs:1265`) emits each file at most ONCE per file lifecycle. So the listener emits 1 file_drop envelope.
- Net: wrapper sees **2 envelopes** for the same signoff: 1 `latent signoff` (from drain) + 1 `file_drop kind=signoff` (from listener). They have DIFFERENT wire shapes — `latent signoff` reaches `is_init_signoff_envelope` predicate at line 1260? **NO** — `is_init_signoff_envelope` matches the literal `<EVENT type="init_signoff"`, NOT `<EVENT type="latent signoff"` (per the D-12 invariant guard at `src/live/start.rs:193-196`). The `latent signoff` envelope passes through `resume_session_checked` as if it were prose, gets composed into a claude `--resume` stdin — Psyche sees the latent signoff body but does NOT teardown. The `file_drop` envelope hits the file_drop arm at line 1306 → `process_file_drop` → reads file → routes via `resume_session_with_exit` → deletes on exit 0.
- **Double-process risk:** Psyche will see the FINAL COMMUNE content TWICE (once as latent_signoff body, once as file_drop body). Acceptable per operator constraint #4 (no generation tracking) but worth flagging.
- **Mitigation option (NOT in scope unless operator asks):** Drop the entire `latent signoff` forward from `drain_stale_signoff_file`. The wrapper-file_drop path is now the sole route, drain becomes a no-op when signoff file exists (matching empty case). This is the **cleanest single-writer** shape and what I'd actually recommend — but it changes behavior the operator might rely on (latent signoff delivers context even if wrapper never starts). **Decision point for the operator** — flagged in §9.

**Smallest defensive shape: keep the forward, drop both deletes.** No info.json sentinel, no mtime epoch — relying entirely on `process_file_drop`'s exit_code==0 delete + `dedupe_drops` listener-side suppression.

## 3. Snapshot-non-destructive shape for commune `append_pending_sections` — ALREADY DONE

`src/live/context.rs:567-657` already implements the read-only contract. Inline comments at `src/live/context.rs:618-630` and the doc-comment at `:506-559` explicitly cite `.planning/debug/file-drop-file-gone-todlando.md` as the closing change. **No commune changes needed.**

**Idempotency mechanism in the current helper (for the operator's awareness):**
- File mtime is read once and stamped into the section header (`## Pending Commune (written {mtime})`) — but mtime is NOT used as a dedupe key. Multi-invocation of `psyche-download` while the drop file persists WILL produce duplicate `## Pending Commune` sections in `agents/{id}/live_context.md`.
- Operator constraint #4 explicitly accepts this ("drift is a non-issue; if file exists it IS the latest"). Confirmed acceptable.

## 4. Wrapper "share signoff but don't exit" pathway

Current end-to-end signoff flow through wrapper (per `src/live/wrapper/mod.rs:1306-1323` + `:1785-1958`):

1. Inner poll surfaces msg containing `<EVENT type="file_drop" kind="signoff" path=...>`
2. `handle_file_drop_arm` parses + dispatches to `process_file_drop("signoff", path)`
3. `process_file_drop` reads file (line 1801), composes `compose_init_signoff_payload` envelope (`:1881-1888`)
4. **`process_file_drop` line 1915-1921 — THE PROBLEM:** signoff branch calls `self.final_session(&envelope)` instead of `resume_session_with_exit`. `final_session` (`src/live/wrapper/claude.rs:471-...`) is the EOL Psyche invocation that fires one last `claude --resume` and `return true` signals teardown.
5. `process_file_drop` line 1954 returns `FileDropOutcome::BreakLoop`
6. `run()` loop at line 1314-1322 receives `BreakLoop` and `break`s the inner-poll loop → wrapper teardown.

**Operator wants steps 4–6 replaced with the commune flow.** Required carve-out:

```rust
// src/live/wrapper/mod.rs:1915-1921 — REPLACE
let exit_code = if kind == "signoff" {
    let _break_signal = self.final_session(&envelope);
    0
} else {
    let (_response, code) = self.resume_session_with_exit(&envelope);
    code
};

// WITH:
let (_response, exit_code) = self.resume_session_with_exit(&envelope);
```

And:

```rust
// src/live/wrapper/mod.rs:1954-1958 — REPLACE
if kind == "signoff" {
    FileDropOutcome::BreakLoop
} else {
    FileDropOutcome::Continue
}

// WITH:
FileDropOutcome::Continue
```

The `compose_init_signoff_payload` envelope STILL gets routed (Psyche sees the FINAL COMMUNE + INIT_SIGNOFF body) — but Psyche just absorbs it as inbound context. Wrapper continues polling. Operator's "share final thoughts but keep running" semantics achieved.

**Side-effect to verify in implementation:** `compose_init_signoff_payload` produces `<EVENT type="init_signoff" ...>`. When that goes through `resume_session_with_exit`, the LLM prompt is composed via `compose_llm_prompt_from_envelope` (`src/live/wrapper/mod.rs::compose_llm_prompt_from_envelope` — same helper `final_session` uses at `claude.rs:477`). The natural-language prompt extraction strips the EVENT framing, so Psyche should not see literal `<EVENT type="init_signoff">` in its stdin. **VERIFY** this stripping works — if it doesn't, the LLM might interpret it as a directive to "sign off" textually. The existing `final_session` path uses the same helper, so behavior should be identical to status quo. Low risk.

**Disjointness consequence (locked invariant change):** The line-1260 `is_init_signoff_envelope(&msg)` predicate currently fires the OTHER teardown path. After Option D, the file_drop signoff branch NO LONGER calls `final_session` — but the line-1260 branch still does (for direct `<EVENT type="init_signoff">` inbox messages, e.g. cross-agent signoffs). **That branch remains untouched.** Verify the existing `file_drop_signoff_wire_form_disjoint_from_init_signoff` unit test (line ~3370) still locks the disjointness — yes, because the file_drop wire form contains `signoff` token but NEVER `init_signoff`, so changing the file_drop branch's behavior doesn't affect the predicate-based teardown route.

## 5. Wrapper sole-deleter contract — VERIFIED PRESENT

`src/live/wrapper/mod.rs:1923-1944` — both kinds already use the same delete path:

```rust
if exit_code == 0 {
    match std::fs::remove_file(path) {
        Ok(_) => self.log(... "(deleted)"),
        Err(e) => self.log(... "delete-failed err="),
    }
} else {
    self.log(... "retained kind={} ... (will retry on next consume)");
}
```

This block runs unconditionally for both `commune` AND `signoff` post-route. **No new delete code needs to be added** — the existing block is kind-agnostic and already provides the sole-deleter contract for both.

## 6. Spool-race re-verification (Option D timeline)

**Signoff timeline post-fix:**

| Step | Actor | Action |
|------|-------|--------|
| T0 | Operator | Writes `.claude/{id}-signoff.md` via Write tool |
| T1 | Self listener (alive) | `scan_drop_files` sees file → `dedupe_drops` admits emit-once → sends `<EVENT type="file_drop" kind=signoff>` envelope to `{id}-psyche` spool |
| T2 | Operator | Runs `$LIVE start <id>` (gen+1) |
| T3 | `$LIVE start` | `drain_stale_signoff_file` runs: reads file, composes `<EVENT type="latent signoff">`, delivers to spool, **does NOT delete** (Option D) |
| T4 | `$LIVE start` | Spawns wrapper detached |
| T5 | Wrapper | Polls spool, drains all envelopes |
| T6 | Wrapper iter 1 | Receives `latent signoff` envelope → falls through to `resume_session_checked` → Psyche reads body as context |
| T7 | Wrapper iter 2 | Receives `file_drop signoff` envelope → `handle_file_drop_arm` → `process_file_drop("signoff", path)` → reads file (STILL THERE) → routes → exit 0 → **deletes file** → returns `Continue` |
| T8 | Self listener next iteration | `scan_drop_files` no longer sees file → `dedupe_drops` removes path from `emitted` set → no more emits |

**No double-forward exposure for the file_drop envelope** — `dedupe_drops` collapses to 1 emit per lifecycle. The drain-side `latent signoff` is a different shape, so there's no race where both branches fight over the same envelope kind.

**Race that REMAINS (acceptable per operator):** If wrapper iter 1 (T6) and iter 2 (T7) overlap or reorder, Psyche sees the FINAL COMMUNE body twice (once embedded in `latent signoff`, once in `init_signoff`). Operator constraint #4 (no generation marker) accepts this.

**Commune timeline (already-shipped):**

| Step | Actor | Action |
|------|-------|--------|
| T0 | Operator | Writes `.claude/{id}-commune.md` |
| T1 | Self listener | Emits 1 file_drop envelope (dedupe enforces) |
| T2 | Operator (optional) | Runs `$LIVE psyche-download <id>` → `append_pending_sections` reads file, appends to `live_context.md`, does NOT delete |
| T3 | Wrapper | Drains spool, hits file_drop arm, reads file (STILL THERE) → routes → exit 0 → deletes |

Same shape. Already production-tested.

## 7. Test surface — proposed regression tests

**Existing test home:** `tests/file_drop_integration.rs` (Plan 04 Task 2 Tests 5+6 already cover commune `psyche-download` non-destructive path). New tests should land in the same file.

**Proposed test 1:** `signoff_drain_does_not_delete_file_persists_for_wrapper_consume`
- Setup: tempdir cwd; create `.claude/{id}-signoff.md` with non-empty FINAL COMMUNE body
- Action: call `drain_stale_signoff_file(id, &cwd)` directly
- Assert: file still exists post-call; spool has the `latent signoff` envelope queued

**Proposed test 2:** `signoff_drain_empty_body_does_not_delete`
- Setup: tempdir cwd; create `.claude/{id}-signoff.md` empty
- Action: call `drain_stale_signoff_file(id, &cwd)`
- Assert: file still exists post-call; spool has NO envelope (empty-body path returns without forwarding)

**Proposed test 3:** `file_drop_signoff_branch_returns_continue_not_break`
- Unit-level test in `file_drop_handler_tests` (mod.rs:~3241)
- Use the existing `MockFileDropDispatcher` pattern; assert signoff `dispatch_drop` now returns `FileDropOutcome::Continue`
- Locks the no-teardown invariant per operator constraint #3

**Existing test that needs update:** `process_file_drop_enoent_signoff_logs_drain_stale_note` (`src/live/wrapper/mod.rs:3815-3895`) — the diagnostic note "(stale - likely consumed by drain_stale_signoff_file...)" becomes IMPOSSIBLE after Option D (drain no longer deletes). Either remove the test, or repurpose it for the commune-equivalent ENOENT-via-cross-fire scenario. **Action: update test expectations.**

## 8. Phase 30 cold-start defense compatibility

Commit `063eedc` (kind-aware diagnostic) landed the message "not re-firing per Phase 30 defense" inside `process_file_drop`. **Option D inverts that assumption** — under Option D the file PERSISTS and the wrapper IS the canonical re-fire path. The diagnostic note is no longer accurate.

**Action:** When implementing Option D, remove the kind-aware "stale - likely consumed by drain_stale_signoff_file..." branch from `src/live/wrapper/mod.rs:1819-1823`. Restore the bare `(file gone)` log for both kinds — ENOENT under Option D only happens when wrapper itself already deleted (true stale spool duplicate from `dedupe_drops` slow propagation).

**Phase 30 cold-start defense itself is NOT violated** — the original defense was "prior-gen signoff must not auto-fire fresh gen's wrapper teardown." Under Option D the signoff branch no longer fires teardown AT ALL (regardless of gen). So the defense becomes moot rather than violated. The `latent signoff` forward path at `$LIVE start` time DOES still surface the signoff to Psyche as inbound context, which is the user-visible behavior the original drain helper was trying to preserve. Both behaviors survive.

## 9. Pitfalls / blockers (operator decision points + risks)

1. **DECISION: keep or drop the `latent signoff` forward in `drain_stale_signoff_file`?** Two viable shapes:
   - **(9a) Keep forward, drop deletes** (smallest delta, recommended-default): Psyche sees signoff body twice (latent_signoff + file_drop). Drift acceptable per constraint #4.
   - **(9b) Drop the entire forward, drain becomes no-op**: Psyche sees signoff body once (via file_drop). Cleaner single-writer. Risk: if wrapper crashes between drain and spool drain, the signoff context is lost — but that's also true under (9a) since `dedupe_drops` would mark the file as emitted before wrapper sees it… actually no, `dedupe_drops` lives in the LISTENER process and resets across listener restarts. If wrapper never starts, file persists and next listener invocation will emit. **(9b) is fine.** Operator picks.

2. **The `2026-04-13` open todos referencing `init_signoff` STOP loops** — `is_init_signoff_envelope`'s `lifecycle.rs::drain_stale_init_signoffs` path is OUT of scope for Option D (it operates on the wrapper's spool, not file_drop). No interference.

3. **The line-1260 `is_init_signoff_envelope` predicate teardown route remains.** That's the cross-agent signoff path (someone else sends YOU an init_signoff). Under Option D, your OWN file-dropped signoff no longer triggers teardown, but a remote init_signoff message still does. **This is an intentional asymmetry the operator should confirm.** Likely desired — file-drop is "share my final thoughts and keep working"; remote-message init_signoff is "another agent told you to wind down."

4. **`compose_init_signoff_payload` body shape is unchanged.** The envelope produced for the wrapper-route path still says "Signoff initiated by Self" — semantically misleading now that signoff doesn't actually trigger signoff. Worth a rename / re-wording pass eventually (e.g. `compose_signoff_share_payload`), but not strictly required for the fix. Defer or include based on operator preference.

5. **Empty-body signoff (graceful-stop convention, line 165-168) becomes weird.** Under Option D, an empty `.claude/{id}-signoff.md` gets a file_drop envelope emitted, wrapper reads empty body, composes `init_signoff` envelope with empty FINAL COMMUNE, routes through Psyche, deletes. Psyche absorbs an empty-body init_signoff — semantically a no-op. **Operator should confirm this is acceptable** — alternative is a special-case skip in `process_file_drop` when both `kind=="signoff"` AND `body.trim().is_empty()` (delete + Continue without routing). Recommend the skip for cleanliness — costs ~3 lines.

6. **Doc surface drift:** `plugin/spt/skills/signoff/SKILL.md` lines 39-43 currently say "Self listener detects the file ... Psyche wrapper independently consumes the file, composes an INIT_SIGNOFF envelope ... runs its final Psyche session, and tears itself down." After Option D, "tears itself down" is FALSE. SKILL.md needs updating. The operator-visible semantic change should be reflected in the skill teaching so future operators don't expect teardown. **Include skill edit in the plan.**

7. **No new `info.json` schema fields needed.** Per operator constraint #4, we do NOT add `last_consumed_mtime` or generation markers. The wrapper's exit_code==0 delete + listener's `dedupe_drops` are sufficient.

8. **`drain_stale_signoff_file` rename consideration:** "drain stale" implies destructive consumption. Post-Option D the helper merely SNAPSHOTS + forwards. Optional rename to `forward_pending_signoff_to_psyche_spool` or similar. Defer unless operator wants the cleanup pass.

9. **Test currently passing that should fail post-Option D:** `tests/native_latent_signoff_deliver_then_die.rs::drain_stale_signoff_file_no_stop_loop_regression` (referenced at `src/live/wrapper/mod.rs:4814`). Need to read that test and adjust — likely the "die" part was asserting file-deletion as proof of delivery. With Option D the delivery proof becomes "envelope queued in spool", not "file gone".

---

## Recommended implementation outline (for the plan-phase)

**Wave A — code edits (4 sites):**
1. `src/live/start.rs:166` — remove `let _ = fs::remove_file(&signoff_path);` (empty-body branch); replace with comment + bare `return`.
2. `src/live/start.rs:224-247` — gate the status line on `delivered` (keep), remove the `fs::remove_file` block entirely. Update doc comment.
3. `src/live/wrapper/mod.rs:1915-1921` — collapse the signoff/commune branch split; always use `resume_session_with_exit`.
4. `src/live/wrapper/mod.rs:1819-1823 + :1954-1958` — remove kind-aware ENOENT diagnostic (no longer accurate); collapse signoff outcome to `Continue`.
5. **Optional empty-body skip:** `src/live/wrapper/mod.rs::process_file_drop` post-read, add `if kind == "signoff" && body.trim().is_empty() { delete + return Continue; }` per §9 item 5.

**Wave B — tests:**
- Update `process_file_drop_enoent_signoff_logs_drain_stale_note` (mod.rs:3815-3895) — diagnostic note is gone; either delete or rewrite for new semantics.
- Update `tests/native_latent_signoff_deliver_then_die.rs::drain_stale_signoff_file_no_stop_loop_regression` — file is no longer deleted.
- Add the 3 new tests proposed in §7.

**Wave C — docs:**
- `plugin/spt/skills/signoff/SKILL.md` lines 38-43 — replace "tears itself down" with "shares the final commune body with Psyche; wrapper continues running. Use /spt:force-stop to actually terminate."
- Add an entry in `.planning/STATE.md` (or wherever) noting Option D landed; close the carry-forward note in `.planning/debug/resolved/signoff-file-gone-todlando.md`.

**Build verification:** `cargo build` then `cargo test --test file_drop_integration` then `cargo test`. Targeted regression — no full deploy needed for a quick-task fix unless operator specifies.

---

## Sources

- `src/live/start.rs:107-247` (`drain_stale_signoff_file`)
- `src/live/wrapper/mod.rs:1284-1323` (`run` loop file_drop arm), `:1746-1958` (`process_file_drop`), `:1801-1837` (ENOENT arm + kind-aware diagnostic)
- `src/live/wrapper/claude.rs:471-...` (`final_session` — the kill-switch we're routing around)
- `src/live/context.rs:480-657` (`download_payload` + `append_pending_sections` — already Option D)
- `src/owl/poll.rs:1208-1285` (`scan_drop_files`, `compose_file_drop_event`, `dedupe_drops`)
- `.planning/debug/resolved/signoff-file-gone-todlando.md` (parent diagnosis; Option D deferred from there)
- `.planning/debug/file-drop-file-gone-todlando.md` (commune-side companion; Option D approach explicitly preferred)
- Commit `063eedc` — kind-aware ENOENT diagnostic (the just-landed Option C predecessor)
- `plugin/spt/skills/signoff/SKILL.md`, `plugin/spt/skills/commune/SKILL.md` (operator-facing teaching that needs an update)
