# Plan 25.2-02 Task 1 Audit — Signoff Drain Consumers + D-12 Invariant

**Captured:** 2026-05-22 execution start
**Scope:** Inline deliverable per CONTEXT D-03. No code edits in Task 1. Documents the four findings that Task 2's `drain_stale_signoff_file` rewrite must honor.

## Finding 1 — `is_init_signoff_envelope` predicate confirmed

Verified at `src/live/wrapper/mod.rs:148-151`:

```rust
pub(crate) fn is_init_signoff_envelope(msg: &str) -> bool {
    msg.to_ascii_lowercase()
        .contains("<event type=\"init_signoff\"")
}
```

Literal case-insensitive substring is `<event type="init_signoff"`. A
`<EVENT type="latent signoff">` envelope lowercases to
`<event type="latent signoff"` — contains zero literal overlap with the
init_signoff substring (the only shared characters are `<event type="` and
the trailing `"`; the discriminator strings `init_signoff` and
`latent signoff` share NO substring relationship — `init_signoff` requires
the underscore, `latent signoff` requires the space).

**Conclusion:** D-12 invariant holds by construction. No predicate edit
needed. Task 2 produces envelopes that are structurally disjoint from the
init_signoff predicate.

## Finding 2 — Signoff drain consumers (D-12 coexistence proof)

Enumerated every consumer that reads signoff-shaped envelopes / files. Goal:
prove the latent-signoff envelope is consumed by exactly ONE consumer and is
NEVER double-eaten.

**Consumer A — `src/live/start.rs::drain_stale_signoff_file` (THIS PLAN'S rewrite target).**
Reads `.claude/<id>-signoff.md` from the user-project working tree (a
FILE source, NOT a spooled envelope). After Task 2 rewrites this function,
the consumed file body becomes the latent-signoff envelope's payload. The
file lives at `<cwd>/.claude/<id>-signoff.md` — NOT under SPT_HOME. Consumer
A cannot accidentally re-consume an envelope it itself wrote to spool,
because the spool destination is `SPT_HOME/owlery/<target>/spool.db` and
the source is `<cwd>/.claude/...`. Disjoint paths.

**Consumer B — `src/live/wrapper/lifecycle.rs::drain_stale_init_signoffs` (L116+).**
Wrapper-side; reads SPOOLED envelopes from the wrapper's own perch's spool.
Filters via `super::is_init_signoff_envelope(body)` (envelope-shape match —
see Finding 1). The latent-signoff envelope shape does NOT match this
predicate. **Confirmed safe by Finding 1's predicate disjointness.**

**Consumer C — `src/owl/poll.rs` normal-path consumer of spooled envelopes.**
The poll loop drains spool rows and dispatches by envelope type. Existing
recognized types include `file_drop`, `echo_commune`, `msg`, `init_signoff`,
etc. The envelope type `latent signoff` does NOT match any existing
dispatch. It falls through to the default "absorb silently" path
(psyche.md §52 rule — non-keyword envelopes are absorbed as context).
Psyche absorbs the body as informational context on the next poll. No
special handler needed.

**Plan 2's latent-signoff envelope is therefore consumed exactly once:**
- Produced by: Consumer A (this plan's rewrite, reading the FILE source).
- Consumed by: Consumer C (Psyche absorbs silently after spool delivery).
- NEVER consumed by: Consumer B (shape-disjoint from `is_init_signoff_envelope`).

## Finding 3 — `body_is_typed_event_envelope` passthrough requirement

Verified at `src/owl/poll.rs:782-796` (referenced by RESEARCH Pitfall 2).
The passthrough check requires the body to be a fully-formed typed envelope
starting at byte 0 with `<EVENT type=` — no leading whitespace, no UTF-8 BOM,
no leading comment.

**Constraint for Task 2:** The `format!` macro in the rewrite MUST produce a
string that starts at byte 0 with the literal `<EVENT type="latent signoff"`.
Do NOT prepend any text, whitespace, or BOM. The `format!` literal in
PATTERNS §S-3 satisfies this by starting the format string with
`<EVENT type=\"latent signoff\" ...`.

Verification path during Task 2: after constructing the envelope, the body
will be observed by `deliver_body_anonymous` → spool storage → next-poll
dispatcher. If the envelope literal starts at byte 0, passthrough fires and
no re-wrapping occurs. If it doesn't, the wrapper sees an HTML-escaped
inner envelope inside an outer `<EVENT type="msg" from="">` — Pitfall 2
warning sign.

## Finding 4 — STOP-loop landmine (D-12 invariant for Task 2)

Source: `.planning/debug/resolved/stale-signoff-fires-on-next-session-start.md`
asymmetry argument.

**The landmine:** if Task 2 accidentally types the envelope as
`<EVENT type="init_signoff">` (or any variant containing the literal
substring `<event type="init_signoff"` case-insensitively), the wrapper's
`drain_stale_init_signoffs` (Consumer B) would drain the spooled envelope
on next poll, fire `final_session`, and exit the wrapper milliseconds after
boot — exactly the regression the original wrapper-side drain was
introduced to prevent.

**Invariant Task 2 MUST honor:** the produced envelope string MUST NOT
contain the case-insensitive literal substring `<event type="init_signoff"`
anywhere. The literal `latent signoff` (lowercase, with space) is
required by D-11; the literal `init_signoff` (lowercase, with underscore)
MUST be absent.

**Mechanical guard:** Task 2's `format!` literal is the only producer of
the substring. Since it hard-codes `latent signoff` (NOT `init_signoff`),
the invariant holds by construction. Optional defense-in-depth:
`debug_assert!(!envelope.to_ascii_lowercase().contains("init_signoff"))`
inside the function after construction — guards against future
copy-paste regression. Low cost; add if convenient.

**Task 3 Test D codifies this as a runtime assertion at the envelope-string
level** (separate from Task 3 Test C, which asserts at the predicate level
inside the wrapper module's in-module test family).

## Verification grep (automated)

```
grep -n "is_init_signoff_envelope\|init_signoff\|latent signoff" \
    src/live/wrapper/mod.rs src/live/wrapper/lifecycle.rs \
    src/live/start.rs src/owl/poll.rs
```

Hits today:
- `src/live/wrapper/mod.rs:148-151` — predicate definition.
- `src/live/wrapper/mod.rs:2772+` — in-module test family for predicate.
- `src/live/wrapper/lifecycle.rs:116+` — Consumer B drain body.
- `src/live/start.rs:138-178` — Consumer A (current implementation; rewrite target).
- `src/owl/poll.rs` — no hits (poll loop is type-agnostic about
  `latent signoff` — Consumer C falls through to absorb-silently path).

NO occurrence of `latent signoff` exists today — Task 2 introduces it
exclusively at the rewrite site in `src/live/start.rs::drain_stale_signoff_file`.

## Constraints inherited by Task 2

1. Envelope literal MUST start at byte 0 with `<EVENT type="latent signoff"`
   (Finding 3 — no whitespace, no BOM, no leading comment).
2. The case-insensitive literal `init_signoff` MUST NOT appear anywhere in
   the produced envelope string (Finding 4 — STOP-loop landmine).
3. Delivery via `crate::owl::send::deliver_body_anonymous` wrapped in
   `std::panic::catch_unwind` (mirrors `src/live/signoff.rs:247-249`).
4. File deletion gated on `delivered` boolean — preserved on panic for next
   attempt (Plan D-10 deliver-then-die).

These constraints map 1:1 to Task 2's `<acceptance_criteria>` items.
