# 0007 — Stub-based idle delivery

Date: 2026-07-10
Status: accepted

## Context

Idle delivery has always TYPED the full message/wake body into the session's input box via the
translation binary. Every injection race we have paid for scales with text length: the 0.13.2
submit-settle race, the 0.15.1 boundary-fold race (a paragraph wake submitted inside a `/rename`
argument, agent dormant ~9.5h), the INJECT_COMMIT_DEADLINE fault class. Full typing also renders
the body twice (input echo + turn context) and pollutes the transcript/RC view.

The operator's 2026-07-10 sprint asked for pointer-style delivery: type a tiny stub, deliver the
body out-of-band. Grill session (perri + operator, this date) resolved the design.

## Decision

Idle-delivered message bodies stop riding the input box. The translation binary parks the body
adapter-side and types only a **message stub** — `<msg from="<id>[@<node>]" id="<short-id>"/>`
for peer messages, `<wake/>` for checkpoint wakes — which auto-submits. The UserPromptSubmit
hook recognizes a **stub turn** by whole-prompt-exact match (trimmed prompt IS the stub; quoted
stubs never fire) and injects every parked body as additionalContext, individually framed.
Trailing stubs whose bodies already delivered get a one-line coalesced note.

- **Custody (interim → end-state):** bodies park in adapter-owned state now. *(Resolved at Slice C
  build, 2026-07-10: the msg park is HOME-anchored — `~/.spt-claude/msgpark/<eid>/` — NOT
  exe-relative like the wake park. The wake park's writer and reader are both the always-fresh
  hook (one binary resolution); the msg park's WRITER is the resident, possibly-stale translation
  binary while its READER is the fresh hook, so exe-relative custody would silently skew across an
  install-dir migration or a daemon own-copy — parked where nobody reads = silent delivery loss.
  Empirically today every resident translate runs from the install dir ({adapter_dir} manifest
  command, verified live 2026-07-10), so the HOME anchor buys the failure class, not a live
  instance.)* Once spt-core mints
  per-message short ids, the stub's `id` is fetched back via `spt endpoint msg <short-id>` and the
  park dissolves; a checkpoint's clear becomes `/clear <short-id>` (empirically the argument rides
  into the new session's transcript as its first entry), so the wake's id crosses the boundary
  inside the transcript itself — no state file.
- **Wake choreography:** the checkpoint arm/fire loopback (ADR-0004) is unchanged; only the fired
  sequence shrinks to rename-half + a 7-char `<wake/>` stub. Non-checkpoint clears stay silent
  (no parked wake ⇒ bare commit).
- **Scope:** stubs are the idle path only. Mid-turn PreToolUse drains are untouched. Internal
  control envelopes (rename, role-edit round-trip, checkpoint, checkpoint_fire) are commands to
  the translation binary, not messages — unaffected.
- **Escape hatch:** an undocumented sender-side json-payload key (the **full-inject override**)
  routes one message through legacy full-text typing, for senders that need the body to BE the
  visible prompt.
- **Digest parity:** the digest extractor learns to (1) surface our message frames out of the
  transcript's hook_success attachment entries — which also makes mid-turn PreToolUse deliveries
  digest-visible for the first time — and (2) collapse a stub input entry with its
  forward-adjacent attachment frames into one input record, so the operator's RC view keeps full
  message parity with the pre-stub world. Transcript noise (`<command-name>` triples collapsed to
  the bare command, `<local-command-caveat>`/boundary `<system-reminder>` spans stripped) is
  filtered in the same pass.

## Consequences

- The inject-race class dies structurally: nothing longer than a stub is ever typed at a boundary.
- Message bodies become invisible to anything that reads only typed prompts; the digest collapse
  is therefore REQUIRED, not cosmetic — it ships before or with the stub protocol.
- Skew is safe by construction: the hook binary always loads fresh from disk, so the only stale
  half is a still-running translation binary, which keeps full-typing — a contract the new hook
  still handles as a normal prompt.
- A hand-typed bare stub fires the drain (delivers parked bodies early / a no-op note) — accepted.
- Core asks filed to doyle ride one wave: per-message short ids on send receipts + poll frames,
  and the `spt endpoint msg <short-id>` fetch verb.

## Alternatives considered

- **Core relay-parked spool class** (bodies never leave the spool; stub-turn drains via plain
  `api poll`): cleanest custody, but gates the whole protocol on a core wave. Rejected as the
  interim; the short-id fetch end-state supersedes it anyway.
- **Own-perch `--active-only` re-send** as the park: works today but double-delivers every
  message, and a stub turn's `--include-deferred` drain would sweep unrelated active-only strays
  (confirm-backs, nudges). Rejected.
