# V0.15.0 — JIT build plan: activity-gated message delivery + send-modifier axes

**For: todlando (build), gated by doyle, validated by perri on real CC at publish.**
**Design (authoritative):** `docs/adr/0028-activity-gated-delivery-and-send-modifier-axes.md` + the CONTEXT.md glossary entries (`activity-gated delivery` / `message delivery axes` / `message metadata (json)`). Read ADR-0028 first — this plan is the task layer under it.
**Grilled** with operator + doyle 2026-06-23 (`/grill-with-docs`). **Follows v0.14.3** (the raw-inject removal it builds on — `REQ-HAZARD-IDLE-SILENT-NONDELIVERY`).

## REQs (registry-first — activate before tagging evidence)
- **`REQ-MSG-DELIVERY-AXES`** — already SEEDED in `traceable-reqs.toml` (`required_stages = ["doc"]`, doc landed = ADR-0028 + CONTEXT). **ACTIVATE to `["doc","impl","unit","int"]`** at the wave that delivers each stage (impl/unit incrementally per wave, int at the final wave — `traceable-per-wave-activation`).
- **`REQ-HAZARD-IDLE-SILENT-NONDELIVERY`** (v0.14.3, ACTIVE) — **AMEND** the title with the `--ephemeral` carve-out (the sole sender-opted-in silent-drop) AND deliver the **fault-transient fix** (ack-on-spool, see W1). Its title already flags the fault-transient as tracked-to-this-milestone.
- **`REQ-HAZARD-CHILD-CONSOLE-FLASH`** (EXISTING) — **EXTEND** coverage to the translation-binary spawn (W4 console-window fix). No new REQ — add the `[impl->…]`+unit at the translation site.
- **`REQ-RESUME-CONTEXT-PULL`** (NEW — parity wave **W5**, operator-directed 2026-06-24; **Tier-1 scope LOCKED**) — expose the adapter-callable resume-context verb (`spt api psyche-download`) AND append the not-yet-synthesized commune/signoff drop. Registered in `traceable-reqs.toml` with `required_stages = []` (rule 5); **activate `["doc","impl","unit","int"]` at W5**. Tier-2 (drift-stamp/`<current>`/memformat/Pulse-Log payload parity) is a **separate, deferred** parity item.

## The milestone (one paragraph)
Close the **legacy-SPT parity gap** (never written down): wire the scaffolded-but-dead **activity-gated inbound routing** (two windows — *active* = spool for the receiver's hook-poll; *idle* = deliver+wake via translation-binary→relay-poll→spool), then expose per-message control as **three orthogonal axes** on `spt send` — **delivery window** (`--idle-only` / `--active-only` [renames `--deferred`] / default-both), **channel restriction** (`--prefer-native` / `--force-native` / unrestricted), **persistence** (`--ephemeral` / durable) — plus **`--json-payload`** (opaque adapter-parsed `json=""` envelope metadata). Fold in the fault-transient ack-on-spool fix and the translation-binary console-window fix.

## The model (ADR-0028, condensed)
A sent message carries one value per axis; each defaults to its unrestricted value:
- **window** (*when*): default both-windows · `--idle-only` (idle window; immediate if already idle) · `--active-only` (active window only — hook-poll, never wakes; = the renamed `--deferred`, `deferred=1` spool column + `api poll --include-deferred` keep their internal names). Mutually exclusive.
- **channel** (*through what*): unrestricted · `--prefer-native` (translation binary if running, else fall back) · `--force-native` (binary ONLY, no fallback/no spool-to-other-method). Composes with window; native does NOT respect the binary's idle-gating — the WINDOW says *when*, native says *through what*.
- **persistence** (*how long*): durable (default; spool until delivered/TTL) · `--ephemeral` (drop if undeliverable in the accepted window — at window-open with no live carrier, or at TTL, whichever first). The ONLY path permitted to drop silently.
- **metadata**: `--json-payload '<json>'` → a single attr-escaped `json="…"` envelope attr ALONGSIDE the body; pure verbatim passthrough across spool/TCP/WAN/EVENT-PART; parsed only by the receiving adapter; any sender (no spt-core authority).

## Grounded seams / anchors (from the grill session)
- **The discarded router**: `crates/spt/src/api/delivery.rs` — `is_idle(id)` (:97), `resolve_inject_methods(manifest, idle)` (:112), and the dead `let _methods = resolve_inject_methods(…)` (:147). W1 WIRES this.
- **The inbound-routing seam**: `crates/spt-daemon/src/broker.rs` `dispatch_endpoint_input` (~:1875) — currently routes working-binary→inject / no-working-binary→spool (delivered=false, v0.14.3). W1 adds the **activity gate** + the **fault-transient ack-on-spool**; W2/W3 add the **window/channel** routing.
- **The inject worker**: `broker.rs` `run_inject_worker` (~:1099) — faults on no-commit/death and RETURNS, dropping queued events (the fault-transient). W1 must re-spool drained events (or defer the ack).
- **The translation driver**: `crates/spt-daemon/src/translation.rs` `TranslationChild::spawn` (:240) — also the W4 console-window site (missing `creation_flags(CREATE_NO_WINDOW)`).
- **The send path**: `crates/spt/src/cli.rs` `Send` clap struct (~:62, the `--deferred` flag :71-73), `cmd_send` (:3522), `try_broker_inject` (:3510) → `brain.rs inject_endpoint` (:1189) returns `delivered` → false ⇒ caller spools via `deliver::send`.
- **The spool**: `crates/spt-store/src/spool.rs` — `deferred` column (:59), `spool_message_at`/`_deferred_at`, `drain_non_deferred_at`/`drain_all_at`, `ttl_seconds`. W2 extends the schema for window/channel/persistence per-message.
- **The activity sentinel**: `crates/spt/src/api/delivery.rs` `cmd_state` (:57 busy/idle) writes `.idle` (perch.rs `IDLE_SENTINEL`). W1 adds the **idle-transition drain** (the wake).
- **The envelope**: `crates/spt-proto/src/event.rs` — `compose_typed_event(type, attrs, body)` (:189) + `event_attr_escape`. W4 renders `json=""`.

## Waves (dependency-ordered, each independently gateable)

### W1 — Activity-gated delivery substrate + fault-transient fix (the foundation)
Make the DEFAULT (no-flag) routing correct: an inbound to a hosted endpoint routes by the `.idle` sentinel — **active** ⇒ spool (delivered=false, the caller/poll surfaces it via the hook channel); **idle** (or idle-transition before a hook drains) ⇒ deliver immediately (translation-binary → relay-poll → spool fallback). Wire `resolve_inject_methods` into `dispatch_endpoint_input` (kill the `let _methods` discard). Add the **idle-transition drain**: when `api state idle` flips the sentinel, the daemon drains idle-window-eligible spooled inbound for that endpoint and delivers them (the "wake"). **Fold in the fault-transient ack-on-spool fix**: an event accepted into the inject worker that the worker then drops on fault must be re-spooled (delivered=false), never acked-delivered-but-lost — close the `REQ-HAZARD-IDLE-SILENT-NONDELIVERY` transient gap.
- Build order: (1) activate `REQ-MSG-DELIVERY-AXES` `+impl`; (2) thread the idle sentinel + `resolve_inject_methods` into `dispatch_endpoint_input` routing; (3) idle-transition drain hook in `cmd_state idle` / the daemon's sentinel-flip path; (4) `run_inject_worker` fault path re-spools queued-undelivered events to the endpoint's perch spool (broker-side `spt_store::spool` write — RESOLVED (a), Design-RESOLVED #2): a fault-dropped inbound is preserved + poll-fed, never lost.
- Tests: unit — routing decision table (active→spool / idle→inject / idle-transition→drain) with INJECTED sentinel state, no wall-clock. int (real broker+PTY, nextest) — active endpoint: send → spooled, not injected; idle endpoint: send → reaches PTY via binary; active→idle transition: spooled msg drains+delivers; fault mid-sequence: a racing inbound re-spools (delivered=false), never lost.
- Gate: the two-window routing is correct + the fault-transient closed.

### W2 — Delivery-window + persistence axes (CLI + spool schema)
The `spt send` flags `--idle-only` / `--active-only` (RENAME `--deferred` → `--active-only`, keep `deferred=1` column + `api poll --include-deferred` internal names) + `--ephemeral`. Extend the spool schema to carry per-message **window** + **persistence** (new columns, additive migration like `deferred` was). Routing honors the window (idle-only suppresses the active-hook window; active-only suppresses the idle-wake window). Ephemeral evaporates at window-open-with-no-carrier or TTL.
- Build order: (1) clap flags (mutually-exclusive group for the window axis) + `cmd_send` plumbs them through `EndpointInputReq` / the spool insert; (2) spool migration (`window`, `ephemeral` columns); (3) the drain/route honors window + drops ephemeral on miss; (4) rename `--deferred`→`--active-only` and **grep tests/+ docs for the OLD flag string** (`behavior-change-grep-tests-not-comments`).
- Tests: unit — window×persistence decision table; ephemeral evaporation trigger (window-open no-carrier / TTL, injected clock). int — idle-only holds during active + delivers on idle; active-only never wakes; ephemeral drops vs durable spools.

### W3 — Channel restriction (native) axis
`--prefer-native` / `--force-native`. Routing: prefer-native ⇒ binary if running else standard fallback; force-native ⇒ binary ONLY (no fallback, no spool-to-other-method) — `force-native` + no live binary ⇒ report `delivered=false` to the sender but DON'T spool-to-relay (binary-or-nothing); `force-native` + `--ephemeral` ⇒ evaporate if no binary at window. Native bypasses idle-gating (the window flag owns timing).
- Build order: (1) clap flags (mutually-exclusive channel group); (2) `dispatch_endpoint_input` honors channel restriction in the route decision; (3) the no-binary + force-native contract (report-not-spool unless prefer); (4) compose-matrix with window (force-native+active-only = binary during active window).
- Tests: unit — channel×window compose matrix. int — force-native to no-binary endpoint: delivered=false, no relay fallback; prefer-native to no-binary: falls back; force-native+active-only: binary injects during active.

### W4 — Metadata + console-window fix + docs + final traceable
`--json-payload '<json>'` → `json=""` attr via `compose_typed_event` (attr-escaped, carried across all rails). The translation-binary **console-window fix** (`translation.rs:240`: `cfg(windows)` `cmd.creation_flags(0x0800_0000)` before `.spawn()`, mirror `runtime.rs:363/585`; `[impl->REQ-HAZARD-CHILD-CONSOLE-FLASH]` + unit). Docs: `cargo run -p xtask -- gen` (regen `reference.md` for the new flags — **no internal codes in clap `///`**, `cli-command-docs-drift`); MANIFEST/CONTEXT touch-ups if needed. AMEND `REQ-HAZARD-IDLE-SILENT-NONDELIVERY` title (ephemeral carve-out). Activate `REQ-MSG-DELIVERY-AXES` `+unit +int`.
- Tests: unit — `json=""` round-trips (compose→parse, attr-escape symmetry, survives EVENT-PART); translation spawn carries CREATE_NO_WINDOW (mirror `runtime.rs:750`). int — a `--json-payload` send arrives with the attr intact through spool+poll; (operator HITL) translation binary spawns with NO visible window on real Windows.

### W5 — Resume-context pull verb + pending-drop append (parity gap; operator-directed 2026-06-24, Tier-1 LOCKED)
**The gap (operator-found, doyle-grounded):** spt-core exposes NO adapter-callable verb to pull an agent's resume context. `resume::download_psyche_context` (resume.rs:88, composes `<live-role>`+`<live-context>`+`<project-context>` from the durable two-tier store) is INTERNAL — zero `spt` callers, no `ApiCmd` verb. resume.rs:9 documents the intended "adapter pulls it in its SessionStart hook" path but it was **never wired** — so a harness adapter's SessionStart hook cannot inject the agent's durable mind on resume at all (claude-spt today runs only `api boundary` session-rotation + an identity brief; **the agent resumes without its mind**). Parity precedent = legacy `claude_skill_owl/src/live/context.rs` (`download_payload` / `download_payload_for_injection` / `append_pending_sections`).

**Why core-owned (not adapter):** an adapter-side raw-file read RACES spt-core's ingest-delete (TOCTOU, ingest.rs:161 removes the drop on pulse-consume). The fold MUST live inside the single composer all resume pulls flow through.

**Tier-1 scope (LOCKED — operator-approved). Two parts:**
1. **EXPOSE the verb** — `spt api psyche-download <id> [--session-id <sid>]` → stdout = the composed brief, `project_id` resolved from the endpoint's bound cwd (`info::read_info` → cwd → project derive; **no `--project` arg**), auth-gated like sibling id-scoped verbs (the `gated(&id,&auth,…)` pattern). Empty store → `NO-CONTEXT` on stderr (mirror legacy). New public verb ⇒ **docs-drift gate** (`xtask gen` regen `reference.md`, no internal codes — `cli-command-docs-drift`).
2. **APPEND the pending drop** — `download_psyche_context` appends any commune/signoff drop **not yet synthesized into the durable tiers** as a distinct `<pending-commune>`/`<pending-signoff>` slice AFTER the durable slices. **Gate on synthesis-state, NOT on-disk** (operator ruling): in today's synchronous ingest (`ingest_drops`→`route_two_slice` writes durable THEN deletes the file, lifecycle.rs:466 @ `DEFAULT_PULSE_PERIOD` 5s) the two coincide — a watched-dir drop IS by definition pre-synthesis — so the v1 realization reads the manifest-declared `session.commune_dir`/`signoff_dir` (manifest.rs:208/210) for a present `<id>-commune.md`/`<id>-signoff.md`; the CONTRACT keys on synthesis-state so it stays correct when async Psyche synthesis lands (a consumed-but-not-yet-committed drop stays appended via a pending-synthesis staging set — forward hook). Strip the agent-checkpoint trigger sentinel (if a convention exists — coordinate w/ the claude-spt checkpoint feature). **PRESENTATION-ONLY:** never writes the durable store (spt-core sole writer, `REQ-HAZARD-DROP-FILE-SINGLE-WRITER`; mirror legacy's read-only / `process_file_drop`-sole-deleter discipline). **SELF-CLEARING:** once synthesis commits, the `<pending-*>` slice vanishes — no duplication.

**Tier-2 (DEFERRED — separate parity item, NOT v0.15.0):** `<psyche-stamp/>`/`<current/>`/drift-directive (orthogonal cross-machine-drift feature — spt-core has the sync/git-stamp machinery but `download_psyche_context` never composes it), `<memformat>` block (memformat deliberately deferred per CONTEXT-MEMORY.md, store unpopulated), Pulse Log (legacy echo artifact; the injection variant strips it anyway). The verb's slice shape is **additive-forward** so these slot in later without breaking adapter injection.

- Build order: (1) mint+activate `REQ-RESUME-CONTEXT-PULL` (`+doc +impl`, then `+unit +int` at gate); (2) `ApiCmd::PsycheDownload { id, session_id, auth }` arm (api/mod.rs enum + clap + dispatch) → handler resolves project from bound cwd, calls `download_psyche_context`, prints stdout (NO-CONTEXT→stderr), auth-gated via `gated()`; (3) thread the resolved `commune_dir`/`signoff_dir` into `download_psyche_context` (signature gains the drop dir(s) or a manifest handle) + the `<pending-*>` append with trigger-strip; (4) `xtask gen` regen `reference.md` (no internal codes) + MANIFEST/CONTEXT note.
- Tests: unit — compose WITH a pending drop (present → `<pending-commune>` appended after project) vs WITHOUT (absent → unchanged); trigger sentinel stripped; project-isolation preserved (other-project download = live only, no pending). int (nextest) — `spt api psyche-download <id>` emits the composed brief; a freshly-dropped-but-unpulsed commune surfaces in `<pending-commune>`; after pulse-ingest the same pull shows it in the durable tier and **no** `<pending-*>` (self-clearing).
- Gate: the verb emits the durable brief; a pre-synthesis drop is visible via `<pending-*>`; post-synthesis it's in the durable tiers with no duplication.
- Sequencing: standalone — did NOT block W3/W4 (done). doyle hands todlando this REQ + build-spec after the W4 gate-pass; W5 build follows. perri wires claude-spt SessionStart against the frozen Tier-1 contract (already sent).

### W6 — Public docs-site update (GitHub Pages) — operator-directed 2026-06-24
Per-wave `xtask gen` only regenerates the generated `cli/reference.md`; the hand-authored PROSE pages need direction for the v0.15.0 surfaces. **doyle authors** (design-owned); **publish via `gh workflow run docs-publish.yml --ref main` WITH the release** — VERSION numbers, NO internal M#/W#/REQ codes (`public-docs-version-not-milestone`, `cli-command-docs-drift`).
- **`harness-contract/integration-checklist.md`** (operator-flagged priority): ADD the `api psyche-download` SessionStart resume-pull row to Group 2 (the checklist covered `api boundary` *survival* but not the *pull-back-in* — was missing) + the claude-code worked example + an "Am I done?" floor item.
- **`harness-contract/api.md`**: the `psyche-download` verb entry (stdout shape incl `<pending-*>`, project-from-cwd, NO-CONTEXT, read-only).
- **`messaging/overview.md`**: replace the stale `--deferred` bullet with the three send axes (window `--active-only`[renamed, hidden `--deferred` alias]/`--idle-only`/default · channel `--prefer-native`/`--force-native` · persistence `--ephemeral` w/ the v0.15.0 ephemeral-partial caveat) + the `--json-payload` opaque-metadata bullet.
- **`cli/reference.md`**: already auto-regenerated per-wave — verify the new flags + verb render with no internal codes.

## Design-RESOLVED (operator + doyle, 2026-06-23)
1. **Idle-wake = spt-core-driven via the relay-poll** (operator ruling). spt-core is ACTIVE in the wake, NOT adapter-mediated: an idle-window message to an idle endpoint delivers via the **translation binary** (spt-hosted) or the **relay-poll** (harness-hosted — the `api listen` event stream that surfaces it into the agent's context, the Monitor-equivalent). Neither available ⇒ spool. So the W1 idle-window delivery order **`binary → relay-poll → spool`** stands, and spt-core owns the wake (mark idle-window + on idle-transition drain to the binary/relay). The adapter's idle hook is a fallback drain, not the primary wake.
2. **Fault-transient close = (a) broker-side re-spool** (doyle best-judgment, operator-delegated). On fault, `run_inject_worker` re-spools any queued-undelivered events to the endpoint's **perch spool** (poll-fed, delivered=false-equivalent) — so a fault-dropped inbound is preserved (recovered via poll/idle-drain), never silently LOST. Keeps the current caller-spool + `delivered`-bool model (no spool relocation). The FULL spool-centric *ack-on-spool* redesign (everything spools-first, drainers surface by channel — ADR-0028's end-state) is the architectural DIRECTION, noted for a later milestone, NOT v0.15.0 (it relocates the spool from caller→broker, too big for this cut). v0.15.0 closes the hazard the lighter way.
3. **Spool schema = 3 additive columns** (doyle best-judgment): `window` (`default`|`idle_only`|`active_only`), `channel` (`any`|`prefer_native`|`force_native`), `ephemeral` (bool). Migrate the existing `deferred` role into `window='active_only'` (sole user, no back-compat — the grill ruling); the drain functions key on `window` (and channel/ephemeral) instead of the binary `deferred`. Additive `ALTER TABLE` mirrors how `deferred` was added (spool.rs:67); queryable, no encoded blob. (W2.)

## MANDATORY gate discipline (paid for in the v0.14.3 saga — non-negotiable)
This milestone touches the SAME broker/delivery shared seam as v0.14.3. Every wave, BEFORE surfacing green to doyle:
- **`cargo nextest run -p spt-daemon`** — the FULL crate, process-per-test (NOT a bare `cargo test` — the inject_control_wedge/broker suites race a process-global `SPT_HOME`; `gate-int-tests-with-nextest-not-bare-cargo-test`). A shared-seam change breaks SIBLING tests — gate the WHOLE crate, not just the file you edited (`shared-seam-change-run-all-seam-tests`; the v0.14.3 wall_b escape).
- **`cargo clippy --workspace --all-targets`** = 0 warnings — INCLUDING doc-comment edits (`///` IS linted; CI denies warnings; `ci-clippy-preflight-workspace`). Run it on EVERY amend.
- **`traceable-reqs check`** EXIT 0 · **`cargo run -p xtask -- gen`/`check`** (docs-drift) · rebuild the real bin.
- **grep tests for OLD behavior** before any rename/contract change (`behavior-change-grep-tests-not-comments`).
- doyle re-runs ALL of the above on the real commit — never the executor's green report.

## Out of scope (doyle rulings)
- **Backlog-drain-on-binary-up** (auto-redeliver already-spooled the instant a live-update binary spawns) — the idle-transition drain + subsequent sends cover re-delivery; broker-backlog auto-drain is a separate change with ordering/exactly-once hazards. Follow-up only.
- The deeper async-ack redesign beyond the fault-transient close (W1 #2) — ack-on-spool for the transient is in; a full delivery-receipt protocol is not.

## State
- Branch: cut a fresh `v0.15.0-messaging` off main AFTER v0.14.3 publishes (counter 33). doyle's v0.15.0 design docs (CONTEXT.md, ROADMAP.md, docs/adr/0028, the `REQ-MSG-DELIVERY-AXES` toml block) are currently UNCOMMITTED in the `fix/v0143-raw-inject-removal` worktree — they get committed onto `v0.15.0-messaging` as the first commit (operator GO needed).
- Console-window bug (`[[translation-binary-console-window-bug]]`) folds into W4 (operator-decided 2026-06-23).
- perri validates on real CC at publish (the established pattern): the 3 axes + json-payload + no-visible-window.
