# IO-parser compliance — JIT plan (claude-spt-bs#22)

Declaring `[io] compliance = true` and deleting our local `@<…@>` parser must happen in ONE change,
per the published ordering rule: *"Declare compliance in the release that removes your local parser,
not before."* No version may exist in which both parsers run.

**Sequenced as two releases** since #238 landed (see TWO RELEASES, NOT ONE below):
**A** = mid-turn spans + the disjointness fix, compliance still off; **B** = the flip and the
deletion, together, gated on A's field verification.

**STATUS 2026-08-29 — #22 COMPLETE. Both releases shipped and field-verified.**

- **Release A** — v0.35.0 (`3a06618`). Mid-turn spans + the disjointness fix. A7 PASSED: 2 spans,
  3 closers, 0 duplicate payloads through `spt api io-events`.
- **Release B** — v0.36.0 (`63d139a`). `[io] compliance = true`, local parser deleted, 10
  requirements retired onto DISPATCH_RESULTS, plus `REQ-HAZARD-ACROSS-CLEAR-DISPATCH-DROP` (found
  while re-walking the early returns; not in this plan's original task list). FIELD-VERIFIED
  end-to-end: `seq=62 AGENT_OUTPUT mid=True` carrying a tag -> `seq=63 MSG_OUT` (core parsed and
  committed the send; this adapter made zero send calls) -> `<DISPATCH_RESULTS> -> perri: delivered`
  arriving through the single documented channel.

Gate PASS and `traceable-reqs check` exit 0 on both. 509 tests. Nothing outstanding.

Grounded on the node-local docs (`http://localhost:5474`) read this session:
`harness-contract/manifest` (`[io]`, `[io.now_signal]`), `harness-contract/api` (`state
--payload-stdin`, `now-signal`), `shells/frames` (shortform grammar, suppression, DISPATCH_RESULTS,
the kind vocabulary and the 16 KB cap).

## Scope-defining findings (established, do not re-derive)

1. **Shortform is parsed off the STATE PAYLOAD, not off now-signal.** `harness-contract/api`:
   "*Parsing this payload for shortform happens only when the manifest declares `[io]` compliance*",
   directly under `state busy --payload-stdin` (USER_INPUT) / `state idle` (AGENT_OUTPUT).
   `now-signal --user-input` is documented as feeding ENDPOINT_MENTIONS / HINTS / MONICS only.
   ⇒ **v0.32.0 ships the idle payload only, so an operator-typed `@<…@>` would stop dispatching
   the moment we declare compliance.** This release MUST also ship the busy payload. This was the
   pre-flagged blocker; it is real.
2. **Nothing calls `spt api now-signal` today.** DISPATCH_RESULTS is *"the only channel"* that
   reports dispatch and seal outcomes — so deleting our confirm machinery without wiring now-signal
   leaves the author with no outcome channel at all. The migration is `api hint` → `api now-signal`
   (hint is a documented thin alias over the HINTS category; injecting both injects it twice).
3. **The vendored manifest schema is stale** — no `io` (and no `service`). Refresh it from
   `http://localhost:5474/manifest.schema.json`. That node-local URL is the CANONICAL one, not a
   fallback: `https://sabermage.github.io/spt-releases/manifest.schema.json` 404s permanently, as
   does the whole Pages host, because ADR-0036 ("The Forkening", 2026-07-14) killed public Pages and
   privated the releases repo — the schema's own `$id` already names localhost:5474, and the signed
   docs bundle ships with every release so the schema always matches the installed core. NOT an
   outage; I first reported it as one and doyle corrected the record. The real defect the report
   surfaced is that `docs-site/reference/schema.md` still calls the dead Pages URL "the stable URL"
   (hand-written prose the drift gate cannot see); core is amending that and two sibling pages.
4. **`tag_scan.rs` does NOT get deleted wholesale.** It also owns the commune-output shortcut
   (`>>commune<<`) and two transcript primitives (`assistant_texts_from_jsonl`,
   `complete_line_prefix`) that `turn_closing_output` — the AGENT_OUTPUT payload read — depends on.
   Delete the peer-dispatch half only: `TagSection`, `code_ranges`, `in_code`, `parse_tag_sections`,
   `push_section`, and `Dispatch.sends`.
5. **TRAP — the settle is load-bearing and about to be deleted by accident.** `handle_stop` calls
   `scan_and_dispatch(settle = true)` *before* `turn_closing_output`. That settle loop is what waits
   out CC writing the turn's final assistant message (MEASURED 2026-07-26: the message lands
   *after* Stop fires — `REQ-HAZARD-TAG-STOP-SCAN-EARLY`). Remove the scan and the payload read runs
   early, so the AGENT_OUTPUT we publish is the *second-to-last* message — and under compliance that
   silently drops every turn-final tag, which is exactly the regression this release exists to avoid.
   **The settle must move into the payload read.**

6. **`turn_closing_output` is CURSOR-BLIND, and that becomes a defect the moment spans ship.**
   It reads the transcript from **offset 0** and returns the last non-empty assistant text, while
   `scan_and_dispatch` has already run (finding 5) and advanced the byte cursor past that same text.
   Today this is invisible — the payload merely restates text our own parser just handled, and core
   parses none of it. Once PreToolUse reports mid-turn spans it is the overlap the contract forbids,
   reachable two ways: (a) the agent writes text A, calls a tool, and ends the turn with no further
   text — A is reported as a span and again as the closer; (b) a turn that produces no assistant
   text at all walks back into the PREVIOUS turn's final message and republishes it.

## UNBLOCKED 2026-08-29 — spt-releases#238 landed in spt-core v0.66.0

doyle reports v0.66.0 publish-verified at source (counter 101, tag==main==tested `d931dd63`).
`spt --version` = 0.66.0 on this node. Read on the node-local book after the update
(`shells/frames`, `harness-contract/manifest`, and `spt api state --help` — all three agree):

```
spt api state busy <id> --payload-stdin --mid   < the-span-just-produced
```

- `--mid` marks the payload a mid-turn span rather than the turn's close. Still `AGENT_OUTPUT`.
- Rides the **busy** arm. `--mid` at idle is refused by name (`STATE_MID_ON_IDLE`); `--mid` with
  no payload is refused (`STATE_MID_NO_PAYLOAD`).
- Surfaces as `mid="1"` on the frame and `"mid": true` in `api io-events`. **Present-only** — an
  event without it is a turn's close, exactly as every `AGENT_OUTPUT` was before the flag existed.
- Reporting spans is **optional**: an adapter that reports only at idle behaves as it did.

And the rule that shapes everything below:

> Report every span EXACTLY ONCE across the turn. spt-core does not deduplicate and never will.
> The spans you report mid-turn and the remainder you report at idle must be disjoint. Overlap is
> not refused; it is delivered twice.

Two further contract facts, recorded because they kill plausible shortcuts:

- **The 16 KB cap bounds the FRAME, not the parse** — now stated on the page, confirming doyle's
  2026-08-28 ruling of record. A tag past the cap still dispatches. Adapter-side truncation stays
  forbidden for the old reason: core's cut is announced, ours would not be.
- **A bare (odd, unpaired) `;;` marker is REFUSED mid-turn** as `SEAL_BARE_MIDTURN` — "through the
  end of your output" names text core has not been given yet, and a short seal looks exactly like a
  correct one. Pairs mint mid-turn normally. New behaviour to teach in Release B's brief text.

## TWO RELEASES, NOT ONE

The published ordering rule still forces the parser deletion and `[io] compliance = true` into the
*same* change. But **span reporting is independent of compliance** — with `[io]` absent, core parses
nothing, so a span is a record and nothing more.

That asymmetry earns a release boundary, because the dangerous property of this migration is
*disjointness*, and disjointness is cheap to measure while it is still harmless:

| | what an overlap costs | how it is detected |
|---|---|---|
| **Release A** (spans, compliance off) | a duplicated `AGENT_OUTPUT` record | `spt api io-events`, costs nobody anything |
| **Release B** (compliance on) | a duplicate **message delivered to a peer** | by the peer, after the fact |

Shipping both at once would put the first field test of disjointness on the far side of the flip
that makes a mistake externally visible. So **A ships spans and proves disjointness with core's own
instrument; B flips compliance and deletes the parser.**

## Release A — mid-turn spans, and the disjointness fix

**The fix for finding 6 is to stop having two readers.** The Stop payload comes from the same
cursor-guarded batch the Stop scan already consumed — `ScanOutcome.authored` — exactly as the
PreToolUse leg already feeds the now-signal poll. Disjointness then holds *by construction*, from
the same byte cursor that has prevented double tag-dispatch across PreToolUse and Stop since
v0.17.x, rather than from two call sites agreeing to stay in step. It also makes finding 5's
implicit dependency explicit instead of load-bearing-by-accident.

Semantic change, stated plainly: the closing payload becomes *"every assistant text not already
reported as a span"* instead of *"the last assistant message"*. On a turn with no tool calls these
are identical. On a tool-bearing turn the new form is the correct one — the old form silently
dropped the non-final messages, which IS the 12.5% loss measured below.

### Open design questions

1. **Span granularity** — one `--mid` call per assistant message, or one per PreToolUse batch
   (messages joined)? Batch is one spawn per tool call instead of N, and a joined body is still one
   contiguous span of the agent's output; the contract does not require message alignment.
   **Leaning batch**, matching what the now-signal leg already hands over.
2. **Empty-remainder Stop.** If the whole turn was streamed as spans, the contract says report idle
   **without** a payload. Must be a real arm, not an empty-string payload.
3. **Refusal fallback.** `--mid` lands in 0.66.0 and the manifest floor moves with this release, but
   the seam is cheap and the failure mode is the stuck-ACTIVE class. Reuse `report_busy_with_input`'s
   shape: read the exit code, and on ANY refusal fall back to the payload-free busy mark. A
   payload-free `state` call emits nothing, so the retry cannot double-publish.
4. **Quiet-latch skip?** Yes — same reasoning as the now-signal leg; an across-clear turn is about
   to be rebuilt.

### Hazards to guard (each becomes a test)

- **H1 — span/close overlap.** The defining hazard. A tool-bearing turn reports each assistant text
  exactly once across the span calls and the closing payload.
- **H2 — previous-turn bleed.** A turn with no new assistant text reports NO payload, not the prior
  turn's closer.
- **H3 — phantom spans.** `--mid` rides ONLY the PreToolUse span call. The UPS busy mark carries
  `USER_INPUT`; `interrupt_watch` re-asserts busy every tick; a `--mid` on either publishes agent
  output for text the agent never wrote.
- **H4 — commune bodies.** Already excluded from `ScanOutcome.authored`; that exclusion now also
  keeps a whole context dump out of the io funnel. Keep the test.
- **H5 — subagents.** `handle_pre_tool_use` returns early for a subagent
  (`REQ-HAZARD-SUBAGENT-DRAIN-STEAL`); a subagent's output is never the parent's span.
- **H6 — no adapter-side truncation.** The cap bounds the frame, not the parse. Never cut.

### Tasks — Release A

- **A1** Mint `REQ-IO-MIDTURN-SPAN-REPORT` and `REQ-HAZARD-IO-SPAN-OVERLAP` in
  `traceable-reqs.toml` FIRST, activated with the implementing commit.
- **A2** Re-point the Stop payload at `ScanOutcome.authored`; retire `turn_closing_output`.
  Covers H1/H2. Repoint `REQ-HAZARD-TAG-STOP-SCAN-EARLY` — the settle now feeds the payload
  through the scan rather than beside it, which is finding 5 discharged rather than dodged.
- **A3** Add the PreToolUse span report (`state busy --payload-stdin --mid`) fed from the same
  batch that already feeds the now-signal poll. Strict, with the payload-free fallback.
- **A4** Tests for H1–H6, each mutation-verified — break the guard, confirm the intended test
  fails, and confirm the mutation actually applied (a missed anchor prints nothing, which reads
  exactly like "caught").
- **A5** Manifest core floor → 0.66.0; version bump + CHANGELOG in product terms.
- **A6** Gate: `sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0. Three triples fresh.
  Publish per the runbook.
- **A7** FIELD VERIFY with core's own instrument: `spt api io-events <id> --after <seq> --json`
  over one real tool-bearing turn. Every assistant text appears exactly once; spans carry
  `"mid": true`; the closer does not. **This is Release B's gate.**

## Release B — the compliance flip (NOT started; gated on A7)

The tasks below were written for the single-release plan and are retargeted to B. The 2026-08-28
HOLD that produced them is discharged: its stated cause was #238, and #238 has landed.

One question to settle before B: our confirm UX (`sptc_tag_send`, the parked turn-end confirm,
`REQ-TAG-CONFIRM-NEXT-HOOK`) is ours and rich, while `DISPATCH_RESULTS` is core's and is declared
"the only channel, by design". B must not keep a second confirm channel alive alongside it.

### The superseded 2026-08-28 ruling, kept for its reasoning

## RULING — compliance was HELD in v0.33.0 (operator, 2026-08-28) — NOW DISCHARGED

doyle answered both questions from the published contract:

- **Mid-turn tags are an accepted loss under compliance.** Core parses shortform only out of the
  ingest edges the adapter reports, and AGENT_OUTPUT is the turn's *closing* output — so a tag in a
  non-final assistant message never reaches core's parser until `spt-releases#238` (mid-turn IO
  frames) lands. **A split parser is forbidden**: the declaration states our parsers are gone, full
  stop; keeping ours for mid-turn only reopens the double-dispatch window the gate exists to close.
  The two legal options are declare-and-lose-mid-turn, or hold the declaration and keep the whole
  local parser. doyle recommends holding.
- **Parse happens over the FULL payload we pass** — the 16 KB cap bounds the emitted frame body
  (that is what `truncated="1"` and the digest pointer are for), never the parse input. A tag in the
  tail of a >16 KB payload still dispatches. Ruling of record; the docs amendment is queued behind
  core's v0.65.0 tag window.

**Measured before deciding** (`hook-trace.log`, this node, ~25 h window, 726 trace lines, file at
147 KB against a 512 KB roll so the window is complete): **32 tag sends — 28 turn-end, 4 mid-turn
(12.5%)**. Mid-turn dispatch is genuinely load-bearing, and its loss would be silent at both ends.
One node, my own — not a fleet number.

**Operator ruling was HOLD**, and it named its own expiry: `[io].compliance` and the parser
deletion move to a follow-up once #238 lands. #238 landed in core v0.66.0 on 2026-08-29, so the
hold is spent — not overruled. The 12.5% figure below is what Release A's spans recover.

Consequently: **T5 and T7's retirements are OUT of this release**, T8 shrinks to nothing
user-facing (we keep teaching our own grammar because we still own it), and the cplugs skeleton
republish is **not** needed — no skill or brief text changes.

### The two questions that produced that ruling — both since answered

- **Mid-turn tags.** We dispatch at PreToolUse today. AGENT_OUTPUT is documented as *the turn's
  closing output*, so a tag in a non-final assistant message may not be seen by core at all.
  Accepted capability loss until `releases#238` (mid-turn framed emission), or something we can
  cover? Must be stated in the CHANGELOG either way — a silent loss is not acceptable.
- **Parse vs the 16 KB cap.** Is shortform parsed over the full payload we pass, or over the
  truncated frame body? A tail tag dies silently in the latter. Do not truncate adapter-side either
  way (`truncated="1"` is core's follow signal).

### Tasks — Release B

- **T1 — schema refresh.** Vendor the node-local `manifest.schema.json` into
  `adapter/manifest.schema.json`; record provenance. `sh ci/manifest/check-manifest.sh` green.
- **T2 — manifest.** `[io] compliance = true`; `[io.now_signal] max_lines` (bound the per-poll
  context). No `only`/`without` narrowing — `only` would risk suppressing DISPATCH_RESULTS, the one
  outcome channel. New `REQ-IO-COMPLIANCE-DECLARED`.
- **T3 — USER_INPUT payload.** UserPromptSubmit's `state busy` carries `--payload-stdin` with the
  user's prompt. Reuse the v0.32.0 strict-with-fallback seam (`spt_strict_stdin`, refusal ⇒ re-report
  without the payload) — `env.spt` is exit-code-blind and a refused busy mark is the stuck-ACTIVE
  hazard by a new road. **Exactly one payload-carrying busy per turn**: the PreToolUse busy mark and
  `interrupt_watch`'s re-assert stay payload-free (core emits per call, not per transition, and does
  not deduplicate). New `REQ-IO-USER-INPUT-PAYLOAD`.
- **T4 — settle carry-over (T5's precondition).** Move the transcript settle into
  `turn_closing_output` so the payload read still waits out CC's final write. Repoint
  `REQ-HAZARD-TAG-STOP-SCAN-EARLY` at the payload read and retitle it — the hazard did not go away,
  its consumer changed. Test must fail if the settle is removed.
- **T5 — delete the local parser.** `tag_scan.rs`: the peer-dispatch half (finding 4).
  `hook.rs`: `scan_and_dispatch`'s send fan-out, the confirm build, the stamp probe, the outcome
  ledger, the QUEUED tripwire, `park_turn_end_confirm` + the UPS/PreToolUse surfacing of parked
  confirms + `tag_confirm_rel` state file. Keep the commune scan and its byte cursor
  (`REQ-COMMUNE-OUTPUT-SHORTCUT`, `REQ-HAZARD-TAG-FIRST-RUN-DROP` still apply to it).
  Our suppression guard goes with the parser — core owns the grammar; never leave two.
- **T6 — now-signal.** Replace the `api hint` call with `api now-signal <id> --session <sid>
  --user-input … [--agent-output …] --spec-manifest`, injected at UserPromptSubmit and PreToolUse
  (the cadence the verb is built for). Delta-only, so a quiet turn costs zero context; emit nothing
  when it prints nothing. **`--user-input` is argv, not stdin** — cap what we pass (Windows
  command-length limit); the cap is safe here (mention/monic scanning), and must NOT be applied to
  the state payload. New `REQ-NOW-SIGNAL-INJECT`.
- **T7 — requirements.** Supersede with `required_stages = []` + a title saying what replaced them:
  `REQ-TAG-PEER-MESSAGING`, `REQ-TAG-SEND-STAMP-EXPLICIT`, `REQ-TAG-SEND-STAMP-PROBE`,
  `REQ-TAG-SEND-FAILURE-LOUD`, `REQ-TAG-SEND-QUEUED-VISIBLE`, `REQ-TAG-SEND-VERDICT-NOT-CATCHALL`,
  `REQ-TAG-SEND-PRETOOL-TRACE`, `REQ-TAG-SEND-OUTCOME-LEDGER`, `REQ-TAG-CONFIRM-NEXT-HOOK`,
  `REQ-HAZARD-TAG-BODY-CODE-SPAN`. Keep and repoint `REQ-HAZARD-TAG-STOP-SCAN-EARLY`; keep
  `REQ-HAZARD-TAG-FIRST-RUN-DROP` and `REQ-COMMUNE-OUTPUT-SHORTCUT`. Keep the KNOWN-HAZARDS entries
  honest — a hazard that moved is edited, never deleted.
- **T8 — agent-facing text.** The SessionStart brief teaches the grammar in our own words
  (`adapter/strings/`): stop restating a grammar core owns, and stop promising a confirmation that
  no longer arrives (outcomes now surface via the now-signal). `docs/` + `docs-site/` are
  drift-gated — regenerate.
- **T9 — release.** Bump `adapter/claude-spt.toml`, CHANGELOG (state the cross-version story:
  agents on older adapters keep their local parser; core parses only for adapters that declare
  compliance). Gate, build three triples fresh, package, tag, release, mirror. **cplugs skeleton
  republish is expected this time** — T8 touches skill/brief text.

## Gate

`sh ci/run-gates.sh` PASS **re-run after the last file lands**, and `traceable-reqs check` exit 0 —
both before the commit. The two read-only ints are flaky under concurrent cargo; re-run alone before
believing a red.
