---
slug: working-perch-notice-priority-leak
status: fixed
created: 2026-05-21T05:46:28Z
trigger: doyle live observation — WORKING_PERCH_NOTICE leaked through $OWL poll listener to Monitor stream consumer as `<EVENT type="msg">`, bypassing priority="info" gating
goal: find_and_fix
tdd_mode: false
specialist_dispatch_enabled: true
---

# Debug: WORKING_PERCH_NOTICE priority gating broken for Monitor stream

## Symptoms

- Priority-attr gating contract broken for Monitor stream consumers.
- Low-priority WORKING_PERCH_NOTICE messages leaking through `$OWL poll` listener as `<EVENT type="msg" from="...">...</EVENT>` — should be suppressed entirely (info-level, deferred-only delivery).
- Live repro (doyle session): `doyle-w278` notice surfaced via Monitor stream; parallel `doyle-w277` notice came via PreToolUse hook drain CORRECTLY tagged `priority="info"`.

## Repro Steps

1. Start an agent with active poll listener (Monitor stream OR `$OWL poll --once`).
2. Spawn a subagent from that perch (writes deferred WORKING_PERCH_NOTICE to spool).
3. Any subsequent TCP message arrival OR the 5-min D-09 timeout triggers an unfiltered `spool::drain_all` which emits the deferred row to the listener.

## Hypothesis (CONFIRMED)

Two-layer regression — both layers verified by code reading:

**Layer 1 (PRIMARY, code-confirmed): partial deferred-skip coverage in poll loop.**
The quick-260418-n7k fix added `drain_non_deferred` and patched ONE of FOUR spool-drain sites in `src/owl/poll.rs`. Three other sites still call unfiltered `drain_all` / `drain_all_with_metadata` and feed `emit_event_line`, leaking deferred rows:

| Site | poll.rs line | Function called | Filters deferred? |
|---|---|---|---|
| Startup drain (P2P-05/D-11) | 155 | `drain_all_with_metadata` | NO |
| TIMEOUT 5-min drain (D-09) | 353 | `drain_all` | NO |
| Idle-mode TCP wake drain | 567 | `drain_all` | NO |
| Periodic idle-drain | 625 | `drain_non_deferred` | YES (n7k fix) |

In doyle's repro the leaking path was almost certainly line 567 — listener was idle, real TCP message arrived from another sender, `is_idle_ready` true, code flushed entire spool (including the queued WORKING_PERCH_NOTICE) before emitting the legitimate message.

**Layer 2 (CONTRACT GAP): `emit_event_line` is priority-blind.**
`src/owl/poll.rs:996-1034` emits `<EVENT type="msg" from="...">body</EVENT>` with no priority attr. Even if a deferred row legitimately reaches the listener, Monitor consumers have no signal to filter info-level traffic. Contrast with `src/common/hook_output.rs:336-345` (`format_owl_messages`) which already classifies via `is_informational` and stamps `priority="info"`.

## Evidence

- timestamp: 2026-05-21T05:46:28Z
  source: `src/common/spool.rs:67-81`
  finding: `spool_message_deferred` sets `deferred=1` correctly. Sender-side mechanism is intact — the leak is on the receiver side.

- timestamp: 2026-05-21T05:46:28Z
  source: `src/owl/hook_subagent_start.rs:114-117`
  finding: `deliver_body_deferred` is called for WORKING_PERCH_NOTICE — correct intent.

- timestamp: 2026-05-21T05:46:28Z
  source: `src/owl/poll.rs:155, 353, 567`
  finding: Three spool drain sites that emit to Monitor stream use unfiltered drain. Only line 625 (periodic idle-drain) honors deferred=1. This is the leak.

- timestamp: 2026-05-21T05:46:28Z
  source: `src/common/spool.rs:227-250`
  finding: `drain_all_with_metadata` (used at poll.rs:155) is unfiltered. Need a `drain_non_deferred_with_metadata` mirror.

- timestamp: 2026-05-21T05:46:28Z
  source: `src/common/hook_output.rs:321-323`
  finding: `is_informational(body)` already exists, `pub(crate)`, matches `[WORKING_PERCH_NOTICE]`. Reusable from poll.rs.

- timestamp: 2026-05-21T05:46:28Z
  source: `src/common/spool.rs:571-583`
  finding: Existing test `test_drain_all_still_returns_deferred` pins `drain_all` semantics ("hook-path semantics — unfiltered"). Hook consumers (`hook_check.rs`, `hook_prompt.rs`) call `spool::drain_all`/`peek_all` directly to render the priority-stamped XML. We must NOT change `drain_all` itself — only swap the call sites in poll.rs to the filtered variant.

## Resolution

**Fix (defense in depth — two layers):**

1. **Layer 1 — close all poll.rs drain leaks:**
   - Add `spool::drain_non_deferred_with_metadata` (mirror of `drain_all_with_metadata` with `AND deferred = 0`).
   - Convert poll.rs:155 → `drain_non_deferred_with_metadata`.
   - Convert poll.rs:353 → `drain_non_deferred`.
   - Convert poll.rs:567 → `drain_non_deferred`.

2. **Layer 2 — belt-and-suspenders at emit site:**
   - Short-circuit `emit_event_line` to skip emission when `body` is informational AND `from != self_id`. Deferred-leak rows become harmless: a future regression that reintroduces an unfiltered drain still cannot leak info-only notices to the Monitor stream.
   - Self-originated bodies (`from == self_id`) bypass the filter (alarms / typed envelopes preserve their existing semantics).

**Root cause (one sentence):** The deferred-skip rule landed in quick-260418-n7k only patched the periodic idle-drain in `poll.rs`; the startup, TIMEOUT, and idle-mode-TCP-wake drain sites still call unfiltered `drain_all`, which emits deferred WORKING_PERCH_NOTICE rows to the Monitor stream whenever any real TCP traffic or the 5-minute timeout triggers them.

**Fix (one sentence):** Convert the three remaining poll.rs drain sites to the `drain_non_deferred*` variants and add a hard-gate in `emit_event_line` that drops informational bodies from non-self senders.
