---
name: v0330-io-funnel-hold
description: "claude-spt v0.33.0 SHIPPED the IO funnel's USER_INPUT edge + the now-signal migration, but HELD [io] compliance — mid-turn tags are an accepted loss under compliance and a split parser is forbidden."
metadata: 
  node_type: memory
  type: project
  originSessionId: 74b2cad7-79eb-47d8-8b70-7d2c3648da94
  modified: 2026-08-29T02:31:26.043Z
---

2026-08-28. Request #22 ("IO parser compliance") asked for one release: declare `[io] compliance =
true` and delete our own `@<…@>` parser. **v0.33.0 shipped the funnel work and HELD the declaration.
#22 stays open, blocked on `spt-releases#238`.** Published + mirrored + installed on this node.
Plan: `IO-COMPLIANCE-PLAN.md`.

⭐ **Shortform is parsed off the STATE PAYLOAD, not off now-signal.** `state busy --payload-stdin` =
USER_INPUT, `state idle` = AGENT_OUTPUT; `now-signal --user-input` feeds only
ENDPOINT_MENTIONS/HINTS/MONICS. The one-line proof is in `harness-contract/api`, under the state
verb: *"Parsing this payload for shortform happens only when the manifest declares [io]
compliance."* v0.32.0 shipped the idle half alone, so declaring compliance would have silently
killed every **operator-typed** tag — core owning the grammar but never seeing the edge the operator
types on. **Find which CALL carries the text before building on a feature that parses "your ingest".**

⭐⭐ **The blocker I was sent to find was not the only blocker.** Under compliance, AGENT_OUTPUT is the
turn's CLOSING output, so a tag written MID-TURN never reaches core's parser at all until #238.
doyle: a **split** parser (ours mid-turn, core's at the edges) is *forbidden* — the declaration means
the local parser is gone, full stop. I measured before escalating: `hook-trace.log`, ~25h, 726 lines,
file under its roll so the window was complete → **32 tag sends, 28 turn-end, 4 mid-turn (12.5%)**,
one node (mine), stated as such. Operator ruled HOLD. **A measurement turned "should I?" into a
decision the operator could actually make.**

⭐⭐⭐ **A guard must ship with the SEAM, not with the flip that makes it reachable.** Idle delivery
types into CC's input box, so peer messages/notifies/wakes arrive at UserPromptSubmit wearing a
prompt's clothes. Core never parses a *received* body for tags ("nothing you receive can make you
send") — but hand one back as our own USER_INPUT and it is **laundered into an ingest edge**: the
peer's tag dispatches FROM US. Nothing is exploitable while compliance is off; landing the guard with
the declaration would have left exactly the window the guarantee forbids.
`REQ-HAZARD-DELIVERY-AS-USER-INPUT`.

⭐⭐⭐⭐ **Deleting feature A can silently break feature B through an ORDERING dependency.** `handle_stop`
runs `scan_and_dispatch(settle = true)` before `turn_closing_output`, and that settle is what waits
out CC flushing the turn's final assistant message. The compliance release deletes that scan — and
would have taken the wait with it, still publishing AGENT_OUTPUT, just **one message stale**, which
under compliance drops every turn-final tag. Pinned with a test asserting the PAYLOAD is the late
closer. Both new guards **mutation-verified with the anchor asserted hit exactly once** before
believing the run.

⭐⭐⭐⭐⭐ `spt api hint` is a **thin alias over now-signal's HINTS category sharing its seen-set** — so
they may never both run, it is a REPLACEMENT not an addition. Trap: `now-signal` needs an endpoint id
as a positional and `hint` does not, and the hint call was deliberately never perch-gated ("an
unregistered session is exactly who a tip is for") — gating the whole funnel on an id would have
silently retired keyword hints for unregistered sessions. One verb or the other, chosen by id.
Also: now-signal takes turn text as **argv** (no stdin form), so it needs a char-boundary cap — safe
there because that text is SCANNED, never safe for the io payload, which is CONTENT core parses.

**FIELD-VERIFIED 2026-08-28, both arms** — `kinds: AGENT_OUTPUT 3, COMMUNE 4, MSG_IN 7, USER_INPUT 2`.
Positive: my inert isolation probe + one operator-typed prompt = exactly 2. Negative: **7 delivered
messages, several driving real UPS turns, produced ZERO extra USER_INPUT** — the laundering guard
proven in production, not just in tests. now-signal also verified live (the `<SPT-NOW-SIGNAL>` block
lands in context each UPS, delta-only; ENDPOINT_MENTIONS fired the turn I named doyle).

⭐⭐⭐⭐⭐⭐ **The verification dead-ended on MY OWN TOOL'S RENDERER, not on core or the adapter.** pacer
reported `io: N frame(s), M AGENT_OUTPUT` — every other kind pooled into N — so two populations
(one with a USER_INPUT, one without) rendered byte-identically, and no delta over that total could
isolate a kind on an endpoint where messages cross every turn. I nearly reported a `+7 frames` delta
as confirmation; it was a number that could not justify itself. **The kind was already on the wire**
(`kind="USER_INPUT"`) and `IoEvent.kind` already decoded it — the renderer was throwing away the one
discriminating field. Fixed as **pacer 0.6.1** (`REQ-PACER-IO-KIND-BREAKDOWN`, ordered map so an
unknown kind COUNTS rather than being dropped — a breakdown that silently drops what it does not
recognize looks complete because the pooled total still matches). Before blaming a producer, check
whether your instrument can even express the answer.

⭐⭐⭐⭐⭐⭐⭐ **State the prediction BEFORE the reading.** I did this twice; it failed the first time
(I said ΔAGENT_OUTPUT must be 0 and it was 1) — because pacer snapshots mid-turn, so the previous
turn's Stop fell inside my window. A mis-specified boundary, not a feature failure, and stating the
prediction first is the only reason I could tell those apart instead of retrofitting a story.
Corollary paid again: **absent and never-ran are identical in a zero count** — synthetic
task-notification turns produce no USER_INPUT because they carry no user text, which reads exactly
like a broken feature until you isolate with the real command by hand.

⭐⭐⭐⭐⭐⭐⭐⭐ **I generalized from a control that exercised HALF the population — again, same session.**
I claimed "core does not spend a mention on names I write in my own output", from a clean paired test
(sparrow in my output = nothing; sparrow in the operator's prompt = fires). doyle's census: a poll's
AUTHORED TEXT is `--user-input` **and** `--agent-output` COMBINED — and **my adapter passes no
`--agent-output` at all**, so the control was VACUOUS for that channel. I proved a property of my own
adapter and stated it as a property of the contract. The trap is that the test LOOKED perfectly
controlled — same name, two channels, opposite results.

**REAL GAP it exposed (v0.33.1 records it, does not fix it):** agent_output-triggered MONICS and HINTS
cannot fire for Claude Code sessions at all, because that channel is never fed. Feeding it belongs on
the deferred PreToolUse leg — the only injection point where the agent has authored text for the
CURRENT turn (at UPS the agent has not spoken yet). Deferring that leg silently deferred this too.

**Tokenizer (doyle, from source):** authored text splits on anything not alphanumeric / `-` / `_`,
lowercased. So `lia:`, `(lia)`, `"lia"`, `LIA` all match; `foo-lia` is ONE token and does not.

**v0.33.1 SHIPPED** — trace-gated capture of the authored text handed to each now-signal poll
(`REQ-NOW-SIGNAL-AUTHORED-TEXT-TRACE`), doyle's recommended instrument. Gated on `$SPTC_HOOK_TRACE`
because the `spt` seam does not log argv, so prompts have NEVER been written to `hook-trace.log` and
a default-on capture would change that. **Cannot be armed for an already-running session**: hook
children do not inherit CC's env-file exports (the same asymmetry the stamp probe measured), so the
var must be present in the environment CC itself was launched with.

**STILL OPEN — the lia anomaly.** `lia#desc` + `lia=Online` entered ENDPOINT_MENTIONS' seen-set at
20:49:24 on a turn whose prompt was an ADR-0007 stub with no such token; the operator then asked about
lia and the category was correctly SILENT (already seen), which reads exactly like the feature failing.
doyle's census: ONE writer, no seeding pass / description-fetch / state sync, and the `#desc`+state
pair is the signature of one real gather pass — so a real token matched, in text I cannot reconstruct.
Parked, not guessed at. Next occurrence self-documents if a session is launched with the trace flag.

**Defect-boundary nuance (doyle, closing the loop).** The `COMMUNE 4` in that same breakdown is
NOT in tension with this week's core commune defect: commune frames always reached shell-link
consumers like pacer, and what was broken — fixed in the v0.65.0 respin — was only the `api
io-events` POLL's view of them. **Two surfaces onto the same kind, and a healthy count on one says
nothing about the other.** Do not read my own frame counts as falsifying a core defect report, or
vice versa, without first checking which surface each was measured on.

doyle also placed my dead-end in a class worth naming: **a green that says what you expect gets read
past** — the `+7 frames` delta sitting there looking like confirmation is the same mechanism that
cost core a respin the same day.

Related: [[io-funnel-two-release-wave]] (v0.32.0, the AGENT_OUTPUT half),
[[node-local-book-gate]] (the schema URL correction taken this same session).

## 2026-08-29 — spt-core v0.65.0 landed. #22 STILL HELD. Do not re-litigate off the new flag.

- **`[io] shortform = false` is NOT an unblock, and it looks like one.** New manifest field: "the
  exotic-harness opt-out ... disables that reader and leaves the compliance declaration standing",
  one switch over BOTH markers (`@<...@>` and `;;` seal). Tempting read: declare
  `compliance = true, shortform = false`, keep `tag_scan`, done. It is wrong twice. (1) `compliance`
  MEANS "this adapter's local tag parsers are gone as of this release" — declaring it while ours
  runs is a false statement, not a clever gate. (2) It buys NOTHING: core's reader off ⇒ no core
  dispatch ⇒ DISPATCH_RESULTS reports nothing, which was the whole prize. Zero gain, one lie.
- **`spt-releases#238` moved BACKLOG → GREENLIT** (scheduled, still OPEN as of 2026-08-29). Mid-turn
  output is still explicitly the digest's job, not an ingest edge, so the 12.5% mid-turn loss stands
  and the HOLD stands.
- **`spt api io-events <id> --session-id <sid> | --after <seq> --json` is live** — kind-resolved io
  rows with a cursor; `--after` writes NO session cursor (use it to look without consuming), a new
  session's first poll seeds silently, `--json` emits even when empty. Use it as the VERIFICATION
  instrument. It confirmed `REQ-HAZARD-DELIVERY-AS-USER-INPUT` again in production through core's
  own eyes: operator's typed line = `USER_INPUT`, pacer delivery = `MSG_IN`, not laundered.
- **pacer needs NO change for it.** pacer reads io frames PUSHED on `drive-poll`
  (`<EVENT type="io" kind=... seq=... truncated=...>`), already kind-resolved; io-events is a
  separate pull path, and the v0.65.0 COMMUNE-row fix is in that poll, not in the pushed frames.
- claude-spt already declares `[io.now_signal] max_lines = 8`, so the per-poll budget covers the new
  PreToolUse leg with no manifest change.
