---
status: accepted
---

# The owner's `io` frames are an additive scan hint: they can ask for a scan sooner, never for a different one

<!-- [doc->REQ-TAG-SCAN-IO-HINT] -->

Extends [ADR-0011](0011-owner-activity-gates-the-scanner-poll.md); builds the hint
[ADR-0010](0010-tag-scanner-polls-on-a-tick-activity-frames-never-drive-it.md) reserved
("an *additive* low-latency hint that triggers an extra scan … must never be able to
replace, throttle, or gate the tick"). Accepted 2026-09-09 (W50 grill, Operator ruling).
Request [#80](https://github.com/BigscreenVR/spt-progress-tool/issues/80); the scoping
comments on it (2026-09-05/06) are the evaluation record this ADR condenses.

## Context

spt-core 0.67 publishes the owner's session IO as durable `io` frames on the shell's
relay drain — `USER_INPUT`, `AGENT_OUTPUT` (with `mid="1"` for a mid-turn span, absent
for the turn's close), `MSG_IN`/`MSG_OUT`, `COMMUNE`/`COMMUNE_FAIL`, a reserved
`TOOL_USE` — MAC-stamped like every spooled channel, bodies capped at 16 KB with a
`truncated` flag and an optional `seq` pointing at the digest **entry** holding the whole
payload. Measured on this node 2026-09-05: the funnel already arrives at alchemy-0/1 and
`frames::parse` skips it per line as an unknown type, never wedging the drain.

Today the scanner's worst-case tag latency while the owner is working is the tick (~5s),
and on the idle edge it is one activity-drain read. The funnel offers something the
activity slot cannot: an **event** the instant the owner's input reaches the agent — the
same instant the previous turn seals — and, when the adapter reports one, the turn's
close.

Two facts from the published contract bound what the hint may be:

1. **Publish and seal are unordered, with a named consequence.** A turn closes when the
   endpoint goes idle, and the `USER_INPUT` frame publishes on the same `api state busy`
   report that flips the sentinel — so a scan racing it can see the trailing turn as
   `partial` for one blink and then reclosed with the same `input_seq`
   (`REQ-DIGEST-SEAL-ON-IDLE`, ADR-0048 decision 4 upstream). Nothing already read moves.
2. **A non-`mid` `AGENT_OUTPUT` at idle is the adapter's choice, not a promise.** An
   adapter that has streamed the whole turn as spans reports `idle` without a payload,
   and a payloadless `idle` emits no io event at all. This node's window (claude-spt,
   spans only) is consistent with exactly that.

Evaluated and declined on the Request, recorded here so they are not relitigated:
reading tag bodies from `AGENT_OUTPUT` and retiring the digest pull (the frame's `seq` is
an entry seq — `REQ-HAZARD-TAG-SEQ-DRIFT` forbids anchoring on it; bodies truncate; `mid`
spans can cut a tag across payloads; two authorities double-fire in every transition);
capturing Seeds from `USER_INPUT` (a Seed exists only in Discord by design); relaying
`COMMUNE_FAIL` (the harness adapter's channel, not a shell's); `MSG_IN`/`MSG_OUT` for
dispatch or watches (they already read `spt send`'s exit status); an `[io] compliance`
manifest section (governs harness adapters whose ingest core parses; alchemy's `#<`
grammar is its own); `boundary` frames for the cursor (the digest already spans `/clear`).

## Decision

**`io` frames are an additive, single-flight scan hint. Nothing else about the scanner
changes.**

| frame | action | `scan.log` |
|---|---|---|
| `io` `USER_INPUT` | scan now | `scan:io-input` |
| `io` `AGENT_OUTPUT`, no `mid` | scan now | `scan:io-close` |
| `io` `AGENT_OUTPUT`, `mid="1"` | `note_inbound` only — the turn is open by construction, so a scan is the guaranteed-empty pull the gate exists to stop | — |
| `io`, any other kind | parsed, ignored | — |
| `activity` idle edge | scan once, then quiesce — **unchanged** (ADR-0011) | `scan:idle-edge` |
| tick, heartbeat | **unchanged**; the tick stays the authority | as before |

- **Single-flight, no debounce.** The gate holds one pending hint. A trigger arriving
  while one is pending is absorbed into it — the scan it asks for is the same scan — and
  none is queued. The drain loop is synchronous, so every trigger in one drained batch
  collapses into that slot. A hint coinciding with the cadence is paid by the cadence's
  own decision, once, and never ahead of a pending idle edge (ADR-0011 constraint 1).
- **A hint never quiesces or resumes the poll.** The `activity` frame riding the same
  report does that, exactly as before. The three liveness floors of
  `REQ-HAZARD-SCAN-GATE-DEAF` are untouched.
- **The digest cursor stays the sole replay authority.** The hint never reads a tag body
  from a frame and never anchors on the frame's `seq`. A hint scan that lands in the
  idle-seal blink sees a `partial` trailing turn, fires nothing, moves nothing; the tick
  or the next hint fires the reclosed turn once. `REQ-HAZARD-TAG-SEQ-DRIFT` reasoning is
  unchanged.
- **Never dependent on a close frame existing.** `USER_INPUT` is the trigger the design
  leans on; `io-close` is a bonus when an adapter reports one.
- **Parsing posture.** `frames::parse` decodes `type="io"` on the stamped drain: `kind`
  from the closed vocabulary (unknown → the line is skipped, as today), `mid` and
  `truncated` present-only, `seq` optional and never defaulted, body amp-last. The
  existing MAC check is the check.

Rejected: a debounce timer (adds a knob and a latency floor to buy nothing — the slot
already collapses bursts); scanning on `mid` spans (an open turn cannot fire).

## Consequences

- Tag latency after the owner's next prompt drops from up to one tick to one drain pass
  (~250 ms) — for a Liaison, the difference between a tag firing before and after the
  agent's next turn begins. No change to latency bounds while the owner is working
  without prompting, and none to the idle edge.
- One more frame type the shell understands; one more reason in `scan.log`. The relay
  drain now carries the owner's IO in volume, all of it verified and mostly skipped —
  measured harmless today, and the contract says a consumer counts, never blocks, on it.
- Mints **`REQ-TAG-SCAN-IO-HINT`** (the additive hint, its trigger table, single-flight,
  the untouched authorities), per ADR-0010's rule that the hint "mints its own".
- Forecloses nothing: if core ever publishes a seal-ordered close event, the same slot
  takes it as another reason.
