# IO-FUNNEL-PLAN — pacer reads its codes off the IO funnel (Release B of two)

Closes `BigscreenVR/spt-pacer-tool#14`: retire pacer's bespoke agent-output detection in favour of
the `AGENT_OUTPUT` io kind (spt-core 0.64.0, `shells/frames`). Release A shipped as
**claude-spt v0.32.0** on 2026-08-28 — the Stop hook now reports the turn's closing output as the
`AGENT_OUTPUT` payload, which is what makes this kind exist for a Claude Code owner at all.

## Verified before starting (not assumed)

- The emission surface is live on this node's core 0.64.0. Probed with a control:
  `--payload-stdin` parses and reaches auth (`NO_ENDPOINT:…`), while `--payload-bogus` is rejected
  by the argument parser — so the accepting answer is discriminating, not a shrug.
- claude-spt 0.32.0 is installed and re-registered on this node (`0.31.0 -> 0.32.0`).

## Operator decisions (2026-08-28) this plan implements

1. **Sequencing** — claude-spt first, then pacer. Done; A is published and installed.
2. **Coverage** — io frames are the primary code surface, and on `truncated="1"` pacer follows the
   frame's `seq` to the digest for the full payload. Chosen because the 16 KB cut lands at the
   *end* of the payload, which is exactly where pacer codes live.
3. **Retirement scope** — retire the CODE SCANNER only. The digest read stays for context
   boundaries (`clear`/`compact`/`boot`), which drive the education re-trigger and have no io kind;
   the closed vocabulary is `USER_INPUT`/`AGENT_OUTPUT`/`MSG_IN`/`MSG_OUT`/`COMMUNE`/
   `COMMUNE_FAIL`/`TOOL_USE`. The operator has slated **spt-bs-releases#239** to surface boundary
   events to shells as frames; when that lands, the digest read can go entirely.

## Contract notes that shape the code

- io frames are **command-class**: spooled, replayed if the binary was down, and MAC-stamped like
  `shell_command` — so they arrive on `api poll <id> --link`, the drain pacer already runs, and
  they get the same verify-before-parse treatment. They are NOT the activity frame's ephemeral
  latest-wins class, despite sharing a stream with it elsewhere.
- **"Each frame is its own event. Two frames with the same kind are two things that happened,
  never a resend."** Combined with a destructive drain, that means io-sourced codes need **no
  replay watermark** — the `acted_seq` dedup exists because the digest window is re-read on every
  poll, and nothing here is re-read.
- `seq` is **optional** ("present only when there is a digest entry to point at… treat the
  attribute as optional rather than defaulting it").
- Unknown kinds are passed over, not refused — the published forward-compatible posture. `TOOL_USE`
  is reserved and emits nothing today.
- **Never assume one `AGENT_OUTPUT` per turn.** `releases#238` will add mid-turn framed emission.
  Every frame is scanned on its own; nothing keys on "the turn's one output".

## Tasks

### B1 — decode the io frame (`frame.rs`)

`<EVENT type="io" from="<owner>" kind="…" seq="…" truncated="1">payload</EVENT>` → a typed value
carrying kind, optional `seq`, the `truncated` flag, and the body-unescaped payload. MAC-verified
first, entities decoded after, exactly as the existing `shell_command` path does. A frame whose MAC
does not verify is silently skipped — unchanged refusal boundary
(`REQ-HAZARD-PACER-FRAME-MAC`): an unstamped line is an attacker and earns no oracle.

### B2 — scan codes out of `AGENT_OUTPUT` (`main.rs`, `machine.rs`)

Run the EXISTING quotation-guard and code scanners over the frame body — `mask_quoted`,
`scan_code`, `scan_classifies` move with the requirement and are not rewritten. The guard is
load-bearing and stays exactly as it is (`REQ-HAZARD-PACER-CODE-FALSE-TRIGGER`): an agent that
writes about a code in backticks must still not fire it.

`Machine::ingest_codes` splits: the apply half (latest steer wins, every classify is additive)
becomes shared, and the io path calls it without the `acted_seq` watermark, per the drain-once
reasoning above.

### B3 — truncation follows `seq`

`truncated="1"` means the body was cut at 16 KB *at the end*. When a truncated frame carries a
`seq`, fetch the full entry via the digest and scan THAT instead. Truncated with no `seq` is a
recorded miss, not a silent one — it is counted and visible in `status`.

### B4 — the digest read keeps only its boundary job

`digest::parse_codes` leaves the poll loop; `digest::boundaries` stays. `DIGEST_LAST` keeps its
current window for boundaries only.

Incidental win worth naming: `spt endpoint digest --last N` is a **write** into shared per-endpoint
follow state, so the pre-#14 loop was mutating its own owner's digest state on every tick. Codes no
longer ride that call.

### B5 — the field instrument

A broker-launched resident's stderr is discarded by design, so counters are surfaced through the
existing `status` verb: io frames seen, `AGENT_OUTPUT` seen, codes ingested from io, truncated
frames followed, truncated-without-seq misses. This is what makes the end-to-end claim checkable on
a live node instead of asserted — `spt shell cmd PACER-0 status` after a real turn.

### B6 — floor + version

`min_spt_core_version` 0.44.0 → **0.64.0** (the release that added the io funnel). Version
0.5.1 → **0.6.0**.

## Gate

`cargo test` + `traceable-reqs check` exit 0, then the FIELD check: install, respawn `PACER-0`, end
a real turn, and read the counters back out of `status`. Green units are not the proof here — a
frame actually arriving is.
