---
phase: 25.3-project-context-envelope-persistence-encoding-defects
plan: 03
subsystem: owl
tags: [defect-fix, encoding-contract, llm-stdin-boundary, event-envelope, cycle-5-high-3]
requires:
  - 25.3-01 (Defect B2 resolver refactor)
provides:
  - event_body_unescape (pub(crate) — inverse of event_body_escape)
  - compose_llm_prompt_from_envelope (pub(crate) — wrapper LLM-stdin boundary helper)
  - parse_event_attrs (private — attr-extraction state machine)
  - ENCODING CONTRACT doc block (single source of truth at src/owl/poll.rs top of module)
affects:
  - src/owl/poll.rs (event_body_unescape + contract doc + 9 INLINE tests)
  - src/live/wrapper/mod.rs (compose_llm_prompt_from_envelope + parse_event_attrs + 7 INLINE tests)
  - src/live/wrapper/claude.rs (boundary helper applied inside resume_session_with_exit + final_session)
tech_stack:
  added: []
  patterns:
    - "encode-once at producer; decode-once at LLM-stdin boundary AFTER body extraction"
    - "natural-language LLM prompt (header + decoded body) replaces EVENT envelope at prompt layer"
    - "amp-last invariant on every unescape pass (Pitfall 3 inverse)"
    - "defensive idempotent fallthrough on non-envelope inputs (raw PULSE_TRIGGER passes through unchanged)"
key_files:
  created: []
  modified:
    - src/owl/poll.rs
    - src/live/wrapper/mod.rs
    - src/live/wrapper/claude.rs
decisions:
  - "Option A LOCKED (cycle-3): decode at wrapper LLM-stdin boundary AFTER body extraction; NOT in process_file_drop"
  - "Cycle-5 HIGH 3 LOCKED: new pub(crate) helper compose_llm_prompt_from_envelope returns natural-language prompt; LLM stdin contains zero `<EVENT>` framing tokens at the prompt layer"
  - "Call-site conversion applied INSIDE resume_session_with_exit + final_session (not at every call site) — single edit point catches all current and future callers; helper's idempotent fallthrough makes raw PULSE_TRIGGER paths safe"
  - "ENCODING CONTRACT doc lives at top of src/owl/poll.rs as a single 80-line block (within plan's extraction threshold; no separate encoding.rs needed)"
  - "End-to-end test placed INLINE in src/live/wrapper/mod.rs `mod tests` per cycle-4 PARTIAL fallback — no clean public seam exists to capture LLM stdin from a subprocess; the helper-driven INLINE test is the pure-function equivalent"
metrics:
  duration: ~40min
  completed: 2026-05-23
---

# Phase 25.3 Plan 03: Defect C — encoding contract + LLM-stdin boundary helper Summary

Defect C from `.planning/debug/todlando-project-context-not-persisted.md` closed by introducing
the canonical body-decode helper (`event_body_unescape`) and a new wrapper LLM-stdin boundary
helper (`compose_llm_prompt_from_envelope`) that extracts attrs + body from incoming EVENT
envelopes and emits a natural-language prompt — eliminating `<EVENT>` framing tokens at the
prompt layer entirely.

## Asymmetric Site Root-Cause Statement

The user screenshot showed asymmetric encoding within a single payload: outer
`<EVENT type="commune">` and `<br>` tokens UNENCODED, while inner `&lt;live-context&gt;` was
ENCODED — all in the same body. Under a consistent contract, that mixed state is impossible.

**Pre-fix asymmetric site (verified at src/live/wrapper/mod.rs:1318):**
`process_file_drop` composes a commune EVENT envelope via `compose_commune_payload` (escaping
the body via `event_body_escape`), then passes the full envelope string directly as stdin to
the spawned `claude -p --resume` subprocess via `resume_session_with_exit`. The LLM saw:

```
<EVENT type="commune" timestamp="..." machine="..." project="...">&lt;live-context&gt;...&lt;/live-context&gt;</EVENT>
```

Outer framing tokens visible to the LLM, inner body encoded as entities — the exact asymmetric
state Defect C documented.

## Chain Trace Table

Inbound commune end-to-end (Self → Self listener → Psyche wrapper → LLM stdin):

| Step | File:line | Operation | Input state | Output state |
|------|-----------|-----------|-------------|--------------|
| 1 | poll.rs:1149 | file_drop EVENT compose (`compose_file_drop_event`) | raw path attr | attr-escaped path; body N/A |
| 2 | wrapper/mod.rs (process_file_drop, ~1247) | std::fs::read_to_string on `.claude/{id}-commune.md` | raw bytes on disk | raw body in memory (DECODED — literal `<`/`>`) |
| 3 | wrapper/mod.rs:474 | `compose_commune_payload(ts, stamp, body)` | raw body | EVENT envelope with body escaped via `event_body_escape` |
| 4 | **(NEW) wrapper/mod.rs** | **`compose_llm_prompt_from_envelope(envelope)`** | EVENT envelope (encoded body) | natural-language prompt (decoded body; no EVENT framing) |
| 5 | wrapper/claude.rs:286 | `stdin.write_all(timestamped.as_bytes())` | natural-language prompt | LLM stdin |

The new step 4 is applied INSIDE `resume_session_with_exit` (claude.rs) so every caller routes
through it transparently. The boundary helper's defensive fallthrough makes the conversion safe
to apply uniformly (raw PULSE_TRIGGER text passes through unchanged).

## Producer Emit / Decode Site Inventory

| Site | File:line | Encode/Decode | Notes |
|------|-----------|---------------|-------|
| compose_echo_commune_payload | src/owl/echo_commune.rs:74-83 | attrs + body escape | producer |
| compose_file_drop_event | src/owl/poll.rs:1139-1151 | attrs escape only | producer |
| alarm + plain msg EVENT compose | src/owl/poll.rs:1058-1067 | attrs + body escape | producer |
| init/listener composition | src/live/start.rs:183-186 | attrs escape | producer |
| commune body escape | src/live/commune.rs:30 | body escape | producer |
| compose_init_signoff_payload | src/live/signoff.rs:33-61 | attrs + body escape | producer (inbound rewrap) |
| compose_commune_payload | src/live/wrapper/mod.rs:474 | attrs + body escape | producer (inbound rewrap) |
| Stamp::event_attrs | src/common/git.rs:165-194 | attrs escape | producer-side stamping |
| parse_event_from_attr | src/owl/poll.rs:720 | attr decode (`from=` only) | consumer (amp-last) |
| wrapper from-attr decoder | src/live/wrapper/mod.rs:347 | attr decode | consumer (amp-last) |
| **event_body_unescape (NEW)** | **src/owl/poll.rs (post-685)** | **body decode** | **consumer (amp-last, br-first)** |
| **compose_llm_prompt_from_envelope (NEW)** | **src/live/wrapper/mod.rs (post-485)** | **attr + body decode at LLM-stdin boundary** | **consumer (SOLE LLM-stdin decode site)** |

## Cycle-5 HIGH 3 Call-Site Audit Table

Every caller of `resume_session_with_exit` and `final_session`, with msg type and conversion:

| Call site | File:line | Current msg type | Cycle-5 HIGH 3 conversion |
|-----------|-----------|------------------|---------------------------|
| process_file_drop commune arm | wrapper/mod.rs:1318 | EVENT envelope (compose_commune_payload) | Routed through compose_llm_prompt_from_envelope inside resume_session_with_exit |
| process_file_drop signoff arm | wrapper/mod.rs:1315 (via final_session) | EVENT envelope (compose_init_signoff_payload) | Routed through compose_llm_prompt_from_envelope inside final_session |
| INIT_SIGNOFF inner-poll arm | wrapper/mod.rs:738 (via final_session) | EVENT envelope (matched is_init_signoff_envelope) | Routed through compose_llm_prompt_from_envelope inside final_session |
| Main MSG dispatch | wrapper/mod.rs:813 (via resume_session_checked → resume_session_with_exit) | EVENT envelope (post-aba13d9 typed) OR raw PULSE_TRIGGER | Routed through compose_llm_prompt_from_envelope inside resume_session_with_exit; helper falls through unchanged on non-envelope |
| handle_pulse_trigger_recovery tier 1 | claude.rs:394 (via resume_session_checked) | raw PULSE_TRIGGER text | Helper falls through unchanged (no `<EVENT` opener) |
| handle_pulse_trigger_recovery tier 2 | claude.rs:408 (via resume_session_checked) | raw PULSE_TRIGGER text | Helper falls through unchanged |
| resume_session (legacy thin wrapper) | claude.rs:205 (delegates to resume_session_checked) | varies | Inherits resume_session_with_exit behavior |

**Decision (LOCKED):** apply the boundary helper INSIDE `resume_session_with_exit` and
`final_session` rather than at every call site. Rationale:

1. **Single edit point** — every current AND future caller is automatically routed through
   the boundary. Eliminates the "did we convert this site too?" maintenance burden.
2. **Idempotent fallthrough** — the helper's malformed-input defensive fallback means it's
   safe to call on raw PULSE_TRIGGER text, plain strings, and other non-envelope inputs;
   they pass through unchanged.
3. **No call-site churn** — the 5+ existing callers of `resume_session_checked` /
   `resume_session_with_exit` are untouched, preserving their semantics.

## Final Test Placement Table (cycle-4 HIGH 4 audit)

| Helper | File | Visibility | Existing `mod tests` | Tests landed |
|--------|------|------------|----------------------|--------------|
| event_body_escape | src/owl/poll.rs:685 | pub(crate) | line 1266 | (covered via round-trip tests with new event_body_unescape) |
| **event_body_unescape (NEW)** | **src/owl/poll.rs (post-685)** | **pub(crate)** | **line 1266** | **4 INLINE round-trip tests + 2 dual-`<br>` tests + 3 EVENT-PART chunk-boundary tests** |
| compose_commune_payload | src/live/wrapper/mod.rs:474 | pub(crate) | line 1390 | 1 INLINE exactly-once-encoding test |
| **compose_llm_prompt_from_envelope (NEW)** | **src/live/wrapper/mod.rs (post-485)** | **pub(crate)** | **line 1390** | **4 INLINE cycle-5 HIGH 3 tests + 2 end-to-end Scenario A/B tests** |
| chunk_if_oversized | src/owl/poll.rs:901 | pub(crate) | line 1266 | 3 INLINE chunk-boundary tests (encoded body + reassembly + decode) |
| parse_event_attrs | src/live/wrapper/mod.rs (NEW) | private | line 1390 | exercised transitively via compose_llm_prompt_from_envelope tests |

**Public-API end-to-end test placement (cycle-4 PARTIAL fallback):** The plan's preferred
target (`tests/native_owl.rs`) required capturing LLM stdin from a subprocess. The wrapper's
`resume_session_with_exit` writes stdin to a spawned `claude` child process and logs via
`log_block` — no clean public seam exists to inspect the exact bytes written to stdin
without spinning up the full wrapper + a stub `claude` binary. Per cycle-4 PARTIAL clause
(c), the end-to-end test moved **INLINE** alongside `compose_llm_prompt_from_envelope` in
`src/live/wrapper/mod.rs::mod tests` as
`encoding_chain_scenario_a_envelope_tag_body_no_transport_encoded_envelope_tags_in_prompt`
and `encoding_chain_scenario_b_user_prose_entities_pass_through_untouched`. These exercise
the full producer-encode → boundary-decode contract via the pub(crate) helpers; the
behavioral guarantee (no transport-encoded envelope tags survive into the LLM prompt; user
prose entities pass through byte-equal) is verified at the same semantic layer.

## The Fix (file:line + diff summary)

### `src/owl/poll.rs`

1. **Top-of-module ENCODING CONTRACT block** (~80 lines) — single doc surface covering:
   producer encode sites; transport invariants (encoded body throughout); consumer decode
   sites (attrs at framing-parse boundary, body AFTER body extraction); LLM-stdin boundary
   (sole decode site = `compose_llm_prompt_from_envelope`); Plan 04 handoff state
   (DECODED body at `process_file_drop`).

2. **`event_body_unescape(s: &str) -> String`** (pub(crate), inserted post-685) — inverse
   of `event_body_escape`. Decode order: `<br>`→`\n` FIRST, then `&lt;` `&gt;` `&quot;`,
   then `&amp;`→`&` LAST (amp-last invariant; Pitfall 3 inverse).

3. **9 INLINE tests in `mod tests`** (line 1266):
   - `event_body_unescape_inverts_event_body_escape_for_html_content`
   - `event_body_unescape_inverts_event_body_escape_for_amp_first_content`
   - `event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags`
   - `event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body`
   - `event_body_round_trip_preserves_user_literal_br_text` (cycle-3 MEDIUM)
   - `event_body_round_trip_decodes_transport_br_to_newline` (cycle-3 MEDIUM)
   - `event_part_chunk_boundary_with_literal_end_event_in_body`
   - `event_part_chunk_boundary_with_br_token_at_split`
   - `event_part_chunk_boundary_with_amp_entity_at_split`

### `src/live/wrapper/mod.rs`

1. **`compose_llm_prompt_from_envelope(envelope: &str) -> String`** (pub(crate), inserted
   after `compose_commune_payload` at line 485) — extracts the outer EVENT opening tag,
   parses attrs via `parse_event_attrs`, finds the LAST `</EVENT>` closer, extracts the
   body between, decodes the body via `crate::owl::poll::event_body_unescape`, composes a
   natural-language prompt string. Defensive fallthrough on malformed input.

2. **`parse_event_attrs(opening_tag: &str) -> HashMap<String, String>`** (private,
   immediately following) — hand-rolled state machine that tokenizes `key="value"` pairs
   from an opening tag and applies attr-unescape semantics (`&lt;`, `&gt;`, `&quot;`,
   `&apos;`, then `&amp;` LAST).

3. **7 INLINE tests in `mod tests`** (line 1390):
   - `compose_llm_prompt_from_envelope_extracts_decoded_body_with_no_event_framing` (Test A)
   - `compose_llm_prompt_from_envelope_decodes_inner_envelope_tags_at_most_once` (Test B)
   - `compose_llm_prompt_from_envelope_surfaces_attrs_in_header` (Test C)
   - `compose_llm_prompt_from_envelope_malformed_input_falls_through_safely` (Test D)
   - `compose_commune_payload_encodes_inner_envelope_tags_exactly_once`
   - `encoding_chain_scenario_a_envelope_tag_body_no_transport_encoded_envelope_tags_in_prompt`
   - `encoding_chain_scenario_b_user_prose_entities_pass_through_untouched`

### `src/live/wrapper/claude.rs`

1. **`resume_session_with_exit`** (line 229) — added 5-line block immediately before
   the `[Current time: ...]` framing step:

   ```rust
   // 25.3-03 cycle-5 HIGH 3 — wrapper LLM-stdin boundary. Route the
   // incoming `msg` through `compose_llm_prompt_from_envelope` so that
   // if it is an EVENT envelope, the LLM sees a natural-language prompt
   // (header + decoded body) instead of `<EVENT>` framing tokens.
   // Non-envelope inputs (raw PULSE_TRIGGER, plain text) fall through
   // unchanged — the boundary helper is idempotent and defensive.
   let prompt = super::compose_llm_prompt_from_envelope(msg);
   ```

   The downstream `timestamped` format string uses `prompt` instead of `msg`.

2. **`final_session`** (line 423) — same 5-line conversion applied at the
   same anatomical position (before the `[Current time: ...]` framing).

## Contract Doc Block (verbatim — copy-paste from src/owl/poll.rs)

```
// ============================================================================
// ENCODING CONTRACT (Phase 25.3 Plan 03 — Defect C)
// ============================================================================
//
// One written-down rule for every emit/parse site in the producer → spool →
// poll → wrapper → Psyche chain. The chain agrees on:
//   - attrs decoded as attrs at framing-parse boundary;
//   - body decoded only AFTER body extraction;
//   - at most ONE body decode per transport traversal.
//
// PRODUCER ENCODE (single pass at the source):
//   - `event_body_escape(s)` — `&`→`&amp;`, `<`→`&lt;`, `>`→`&gt;`,
//     `"`→`&quot;`, `\n`→`<br>` (in that order; amp-first, br-last).
//   - `event_attr_escape(s)` — same as body escape but omits the `\n`→`<br>`
//     step (attr values are line-safe by construction).
//   Producer emit sites (one encode each — never double):
//     • src/owl/echo_commune.rs::compose_echo_commune_payload — attrs + body
//     • src/owl/poll.rs::compose_file_drop_event — attrs only
//     • src/owl/poll.rs alarm + plain msg EVENT compose — attrs + body
//     • src/live/start.rs — init/listener composition (attrs only)
//     • src/live/commune.rs — commune body escape
//     • src/live/signoff.rs::compose_init_signoff_payload — attrs + body
//     • src/live/wrapper/mod.rs::compose_commune_payload — attrs + body
//                                                          (inbound rewrap)
//     • src/common/git.rs::Stamp::event_attrs — attrs only
//
// TRANSPORT (spool / TCP / wrapper relay):
//   - Body stays ENCODED throughout transport. Spool + TCP + wrapper relay
//     do NOT decode in transit. EVENT-PART chunker splits encoded bytes;
//     reassembly is byte-exact.
//
// CONSUMER DECODE (single pass after framing-parse):
//   - attrs decoded at framing-parse boundary:
//       * `parse_event_from_attr(envelope)` — extracts `from=` attr,
//         applies attr-unescape: `&lt;`, `&gt;`, `&quot;`, then `&amp;`
//         LAST (amp-last invariant — Pitfall 3 inverse).
//       * `compose_llm_prompt_from_envelope` (in src/live/wrapper/mod.rs)
//         attr-extraction inner helper applies the same unescape semantics
//         with `&apos;` also handled.
//   - body decoded AFTER body extraction:
//       * `event_body_unescape(extracted_body)` — `<br>`→`\n` FIRST, then
//         `&lt;` `&gt;` `&quot;`, then `&amp;`→`&` LAST.
//       * SOLE LLM-stdin decode site: `compose_llm_prompt_from_envelope`
//         at src/live/wrapper/mod.rs. The LLM stdin contains a
//         natural-language prompt (header + decoded body); NO `<EVENT>`
//         framing tokens at the prompt layer.
//
// LLM-STDIN BOUNDARY (Option A LOCKED + cycle-5 HIGH 3 LOCKED):
//   - Every `resume_session_with_exit(envelope)` / `final_session(envelope)`
//     call routes through `compose_llm_prompt_from_envelope(envelope)` first
//     (applied INSIDE the wrapper helpers so every caller benefits — single
//     edit point).
//   - The boundary helper extracts attrs + body, decodes the body, and emits
//     a natural-language prompt. Malformed input (non-envelope, e.g. raw
//     PULSE_TRIGGER text) falls through unchanged — defensive fallback.
//   - User body containing literal `</EVENT>` becomes plain prose at the
//     prompt layer; reframing is structurally impossible because the prompt
//     contains no upstream framing token to reframe.
//
// PLAN 04 HANDOFF:
//   - `process_file_drop` reads the user-written commune file directly from
//     disk (filesystem read yields literal tags — DECODED state).
//   - Plan 04's `route_inbound_commune_body` sees DECODED body. NO decode
//     pass needed in Plan 04. The boundary helper runs DOWNSTREAM of
//     Plan 04's route.
//
// ============================================================================
```

## Test Names + Pass Evidence

| Test | Status |
|------|--------|
| event_body_unescape_inverts_event_body_escape_for_html_content | PASS |
| event_body_unescape_inverts_event_body_escape_for_amp_first_content | PASS |
| event_body_unescape_inverts_event_body_escape_for_nested_live_context_tags | PASS |
| event_body_unescape_inverts_event_body_escape_for_literal_end_event_in_body | PASS |
| event_body_round_trip_preserves_user_literal_br_text (cycle-3 MEDIUM dual-`<br>`) | PASS |
| event_body_round_trip_decodes_transport_br_to_newline (cycle-3 MEDIUM dual-`<br>`) | PASS |
| event_part_chunk_boundary_with_literal_end_event_in_body | PASS |
| event_part_chunk_boundary_with_br_token_at_split | PASS |
| event_part_chunk_boundary_with_amp_entity_at_split | PASS |
| compose_llm_prompt_from_envelope_extracts_decoded_body_with_no_event_framing (Test A) | PASS |
| compose_llm_prompt_from_envelope_decodes_inner_envelope_tags_at_most_once (Test B) | PASS |
| compose_llm_prompt_from_envelope_surfaces_attrs_in_header (Test C) | PASS |
| compose_llm_prompt_from_envelope_malformed_input_falls_through_safely (Test D) | PASS |
| compose_commune_payload_encodes_inner_envelope_tags_exactly_once | PASS |
| encoding_chain_scenario_a_envelope_tag_body_no_transport_encoded_envelope_tags_in_prompt | PASS |
| encoding_chain_scenario_b_user_prose_entities_pass_through_untouched | PASS |

**Full suite:** `cargo test --release --lib -- --test-threads=1` → **803 pass, 1 fail, 5 ignored.**
The single failure (`owl::version_changelog::tests::current_version_honors_override_in_debug_builds`)
is pre-existing and unrelated — same release-mode debug-build-override gate failure documented in
25.3-01 SUMMARY. Not a regression introduced by this plan.

**Build:** `cargo build --release` → PASS (3 pre-existing dead-code warnings, none new).

## Plan 02 Handoff Note (UPDATED per cycle-5 HIGH 3)

**The LLM no longer sees an EVENT envelope at the prompt layer.** Plan 02's psyche.md absorption
rules must reference the NEW prompt shape, NOT the EVENT envelope shape.

Prompt shape the LLM now sees:

```
Inbound commune envelope at 2026-05-22T19:00:00+00:00:
  machine: <machine>
  project: <project>
  branch: <branch>
  head_sha: <head_sha>

<live-context>...literal tags decoded...</live-context>
<project-context>...literal tags decoded...</project-context>
```

Specifically: psyche.md absorption rules should pattern-match on `Inbound {type} envelope at`
as the framing marker, NOT on `<EVENT type="commune"`. The body content (everything after the
blank line following `head_sha:`) is the decoded payload — `<live-context>` and
`<project-context>` tags appear as literal `<` / `>` characters, NOT as `&lt;` / `&gt;` entities.

User-prose entities (`&amp;`, literal `<` in casual text, etc.) pass through byte-equal — they
are NOT structural.

## Plan 04 Handoff Note

**Option A LOCKED + cycle-5 HIGH 3 LOCKED.** `process_file_drop` receives a `<EVENT
type="file_drop" kind="commune" path="..." from="...">` envelope from the Self listener, parses
out `kind` + `path`, and reads the user-written commune file directly from disk via
`std::fs::read_to_string`. The filesystem-read body is in DECODED state (the file on disk
contains literal tags because the file was written by the user, never round-tripped through
`event_body_escape`).

Plan 04's `route_inbound_commune_body` therefore sees DECODED body. **NO decode pass is needed
in Plan 04.** The wrapper's NEW prompt-composition boundary
(`compose_llm_prompt_from_envelope`) runs DOWNSTREAM of Plan 04's route — Plan 04 hands the
decoded body to `route_two_slice`, then control returns to `process_file_drop`, which then
calls `resume_session_with_exit(&envelope)`, which internally routes the (re-encoded) envelope
through the boundary helper before feeding the LLM.

Plan 04 is UNAFFECTED by this plan's contract.

## Plan 04 Test-Placement Handoff Note

Per cycle-4 HIGH 4 (PRESERVED): tests targeting `pub(crate)` helpers MUST be INLINE in
`#[cfg(test)] mod tests` blocks. Only PUBLIC-boundary end-to-end tests live in
`tests/native_owl.rs`. Plan 04's `route_inbound_commune_body` is `pub(crate)` per Plan 25.3-01
visibility verification — its tests should land INLINE.

## Post-Deploy Smoke Command (for Plan 02 / Plan 04 deploy)

```powershell
# After Plan 02 / Plan 04 deploys via DEPLOY.ps1, send a commune to a live
# Psyche and inspect the next [STDIN] block in its log:
Get-Content "$env:LOCALAPPDATA\spt\logs_latest\todlando.log" -Tail 200

# Look for:
#  - Natural-language header:    `Inbound commune envelope at ...:`
#                                `  machine: ...`
#                                `  project: claude_skill_owl`
#                                `  branch: main`
#                                `  head_sha: ...`
#  - Decoded inner tags as LITERALS:
#                                `<live-context>marker-live-xyz</live-context>`
#                                `<project-context>marker-proj-xyz</project-context>`
#  - NO outer framing token at the start of the [STDIN] block content:
#                                must NOT contain `<EVENT type="commune"` as the
#                                opening line of the prompt body.
```

## Codex Review Resolution Table

| Cycle | Review Note | Closed By | Verified By |
|-------|-------------|-----------|-------------|
| Cycle-2 HIGH | "attrs decoded as attrs at framing-parse boundary; body decoded only AFTER body extraction; at most one body decode per transport traversal" | event_body_unescape doc comment + ENCODING CONTRACT block; helper is body-only, never global string unescape | event_body_unescape_inverts_event_body_escape_for_* (4 tests) |
| Cycle-2 (chunk boundaries) | EVENT-PART chunk-boundary tests cover literal `</EVENT>`, `<br>`, and `&amp;` entities at split/reassembly | 3 INLINE chunk-boundary tests in poll.rs::mod tests | event_part_chunk_boundary_with_* (3 tests) |
| Cycle-3 HIGH (entity invariant reframed) | "NO TRANSPORT-ENCODED ENVELOPE TAGS at LLM-stdin; user-prose entities pass through" | Scenario A + B end-to-end tests assert forbidden-substring set absent + user-prose pass-through | encoding_chain_scenario_a_* + encoding_chain_scenario_b_* |
| Cycle-3 MEDIUM (`<br>` round-trip clarity) | TWO distinct tests | Dual-`<br>` tests INLINE in poll.rs::mod tests | event_body_round_trip_preserves_user_literal_br_text + event_body_round_trip_decodes_transport_br_to_newline |
| Cycle-3 MEDIUM (Option A LOCKED) | NO checkpoint:decision | Plan executed without checkpoint; Option A baked into ENCODING CONTRACT doc | Build + tests pass |
| Cycle-4 HIGH 4 (test placement) | INLINE for pub(crate) helpers | All 16 new tests INLINE in `mod tests` blocks (poll.rs:1266 + wrapper/mod.rs:1390); zero tests added to `tests/native_owl.rs` | `cargo test --release --lib` discovers and runs all 16 |
| Cycle-4 PARTIAL (public-API test placement) | Name concrete public fn OR fallback INLINE | NO clean public seam to capture LLM stdin from subprocess; FALLBACK INLINE per clause (c). End-to-end test exercises full producer-encode → boundary-decode contract via pub(crate) helpers | encoding_chain_scenario_a_* + encoding_chain_scenario_b_* INLINE in wrapper/mod.rs::mod tests |
| Cycle-5 HIGH 3 (decoded body at LLM stdin underspecified) | New helper `compose_llm_prompt_from_envelope` at the wrapper LLM-stdin boundary; LLM sees natural-language prompt, NOT EVENT envelope | New pub(crate) helper in wrapper/mod.rs (post-485) + applied INSIDE resume_session_with_exit + final_session | 4 cycle-5 HIGH 3 tests (Test A-D) all pass; encoding_chain Scenario A asserts NO `<EVENT type=` framing at prompt start |

## Task Commits

| Task | Description | Commit |
|------|-------------|--------|
| 1 | Full encoding chain trace + asymmetric site root-cause + cycle-5 HIGH 3 call-site audit | (audit recorded in this SUMMARY — chain table, producer emit/decode site inventory, call-site audit, test placement table) |
| 3 RED | INLINE failing tests (event_body_unescape + compose_llm_prompt_from_envelope) | `558248a` |
| 3 GREEN | event_body_unescape + compose_llm_prompt_from_envelope + parse_event_attrs + ENCODING CONTRACT doc + call-site conversion inside resume_session_with_exit/final_session | `6d1c3ea` |
| 4 | Full test suite + cargo build witness; NO deploy this plan | (witness in this SUMMARY) |

## Self-Check: PASSED

Files verified:
- `src/owl/poll.rs` — ENCODING CONTRACT block at top of module (post-line 5), `event_body_unescape` post-`event_body_escape`, 9 new INLINE tests in `mod tests` (line ~1340-onwards).
- `src/live/wrapper/mod.rs` — `compose_llm_prompt_from_envelope` + `parse_event_attrs` post-`compose_commune_payload` (line ~487-onwards), 7 new INLINE tests in `mod tests` (line ~1396-onwards).
- `src/live/wrapper/claude.rs` — boundary helper applied INSIDE `resume_session_with_exit` (line ~245) AND `final_session` (line ~427).

Commits verified:
- `558248a` — test(25.3-03): add failing tests for event_body_unescape + compose_llm_prompt_from_envelope (RED)
- `6d1c3ea` — feat(25.3-03): add event_body_unescape + compose_llm_prompt_from_envelope LLM-stdin boundary (GREEN)

Build witness: `cargo build --release` PASS. Test witness: 803/804 pass (1 unrelated pre-existing failure documented in 25.3-01 SUMMARY).
