1	---
2	name: v0150-w1-activity-gated-delivery
3	description: "v0.15.0 W1 (ACTIVE assignment, todlando) — activity-gated delivery substrate + fault-transient re-spool. Branch v0.15.0-messaging @8fd2e51. Authoritative plan = V0.15.0-PLAN.md + ADR-0028."
4	metadata:
5	  node_type: memory
6	  type: project
7	  originSessionId: 34b6e19b-97c5-41c1-b0df-d79dbfd01fd7
8	---
9
10	**v0.15.0 messaging milestone — W1 KICKOFF (doyle assigned, operator GO 2026-06-23).** Follows v0.14.3 (raw-inject removal [[v0143-raw-inject-removal]]). Branch **`v0.15.0-messaging` @8fd2e51** (off main 90852bf = v0.14.3 published counter 33). **AUTHORITATIVE TASK DOC = `V0.15.0-PLAN.md` (repo root) + `docs/adr/0028-activity-gated-delivery-and-send-modifier-axes.md` — READ BOTH FIRST.** doyle gates, perri validates real CC at publish. doyle re-runs ALL gates on the real commit (never trusts the green report — v0.14.3 discipline).
11
12	**MILESTONE (4 waves):** close the legacy-SPT parity gap = wire the scaffolded-but-DEAD activity-gated inbound routing (2 windows: ACTIVE=spool for hook-poll / IDLE=deliver+wake via binary→relay-poll→spool), then expose 3 orthogonal `spt send` axes — window (`--idle-only`/`--active-only`[renames `--deferred`]/default) × channel (`--prefer-native`/`--force-native`/unrestricted) × persistence (`--ephemeral`/durable) — + `--json-payload` (opaque `json=""` envelope attr). W1=substrate+fault-transient, W2=window+persistence axes (CLI+spool schema), W3=channel axis, W4=metadata+console-window fix+docs+final traceable.
13
14	**W1 SCOPE (NO send flags — make DEFAULT routing correct):**
15	1. ACTIVATE `REQ-MSG-DELIVERY-AXES` `+impl` (was doc-only seed). int at final wave (W4) per [[traceable-per-wave-activation]].
16	2. WIRE THE DEAD ROUTER: `crates/spt/src/api/delivery.rs` `is_idle(id)` (:97), `resolve_inject_methods(manifest, idle)` (:112), the discarded `let _methods = resolve_inject_methods(…)` (:147) → thread idle sentinel + methods into broker `dispatch_endpoint_input` (broker.rs ~:1875, the v0.14.3 spine). Route by `.idle`: ACTIVE → spool (delivered=false, hook-poll surfaces); IDLE (or idle-transition) → deliver immediately (translation-binary → relay-poll → spool fallback order).
17	3. IDLE-TRANSITION DRAIN (the WAKE, spt-core-driven via relay-poll per operator ruling — NOT adapter-mediated): when `api state idle` flips the `.idle` sentinel (`delivery.rs cmd_state` :57, perch.rs IDLE_SENTINEL), daemon DRAINS idle-window-eligible spooled inbound for the endpoint + delivers to binary/relay.
18	4. FAULT-TRANSIENT RE-SPOOL (closes the REQ-HAZARD-IDLE-SILENT-NONDELIVERY transient — Design-RESOLVED #2 (a)): `broker.rs run_inject_worker` fault path (~:1099, faults on no-commit/death + RETURNS dropping queued events) → RE-SPOOL queued-undelivered events to the endpoint's PERCH SPOOL (broker-side `spt_store::spool` write, delivered=false-equiv, poll-fed, NEVER lost). KEEP current caller-spool + delivered-bool model — NOT the full ack-on-spool relocation (later milestone, OUT OF SCOPE).
19
20	**W1 TESTS:** unit = routing decision-table (active→spool / idle→inject / idle-transition→drain) INJECTED sentinel, NO wall-clock. int (real broker+PTY, NEXTEST) = active→spool-not-inject · idle→reaches-PTY-via-binary · active→idle-transition drains+delivers · fault-mid-sequence re-spools (delivered=false, not lost). GATE = two-window routing correct + fault-transient closed.
21
22	**KEY ANCHORS (V0.15.0-PLAN.md §22-30):** delivery.rs (is_idle:97 / resolve_inject_methods:112 / dead let_methods:147 / cmd_state:57); broker.rs (dispatch_endpoint_input ~:1875 / run_inject_worker ~:1099); translation.rs TranslationChild::spawn:240 (W4 console-window CREATE_NO_WINDOW site); cli.rs Send clap ~:62 / --deferred :71-73 / cmd_send:3522 / try_broker_inject:3510; brain.rs inject_endpoint:1189; spool.rs (deferred col :59, spool_message_at/_deferred_at, drain_non_deferred_at/drain_all_at, ttl_seconds — W2 schema ext); event.rs compose_typed_event:189 (W4 json attr).
23
24	**GATE DISCIPLINE (MANDATORY, v0.14.3-paid, SAME broker seam):** `cargo nextest run -p spt-daemon` FULL crate (NOT bare cargo test = env-race; NOT just edited file = sibling escape [[shared-seam-change-run-all-seam-tests]]) · `cargo clippy --workspace --all-targets`=0 (incl `///` doc-comments, EVERY amend [[ci-clippy-preflight-workspace]]) · traceable EXIT0 · xtask gen/check docs-drift · rebuild bin · grep tests for OLD behavior before any rename ([[behavior-change-grep-tests-not-comments]]). everyone on LEGACY owl — `$OWL send doyle` ([[owl-send-not-legacy-spt-send]]).
25
26	**OUT OF SCOPE (doyle):** backlog-drain-on-binary-up (idle-transition drain + subsequent sends cover it; separate change); full ack-on-spool spool-relocation redesign (ADR-0028 end-state, later milestone). W4 also: AMEND REQ-HAZARD-IDLE-SILENT-NONDELIVERY title w/ `--ephemeral` carve-out (sole sender-opted-in silent-drop); EXTEND REQ-HAZARD-CHILD-CONSOLE-FLASH to translation-binary spawn.
27
28	**STATUS: W1 PART A SPINE STARTED — compiles (cargo check -p spt-daemon EXIT0), UNCOMMITTED on disk (v0.15.0-messaging). NOT gateable yet (test sweep + caller wiring remain). Checkpointed at deep context.**
29
30	**DONE (on disk, compiling):**
31	- `msg.rs`: `EndpointInjected` gained `#[serde(default)] pub spool_deferred: bool` (the activity-gate hint: delivered=false + spool_deferred=true ⇒ caller spools DEFERRED/active; false ⇒ NON-deferred/idle-relay-wakes; N-1-safe). `endpoint_injected_envelope(endpoint, delivered, spool_deferred)` — 3rd arg added.
32	- `broker.rs` `dispatch_endpoint_input`: ACTIVITY GATE — reads `spt_store::perch::resolve_idle_file(&req.endpoint, ParentHint::Infer).exists()`. Routes: `!idle`(ACTIVE)→reply(delivered=false, spool_deferred=TRUE), no inject; IDLE+working-binary→inject→reply(true,false); IDLE+no-binary→reply(false,false). None(no session)→reply(false,false). Tagged `[impl->REQ-MSG-DELIVERY-AXES]`.
33	- `msg.rs` units updated to 3-arg + renamed `endpoint_injected_envelope_carries_delivered_both_ways`→`_carries_delivered_and_spool_hint` (asserts active=deferred / idle=non-deferred / delivered / N-1 serde-default false). Tagged [unit->REQ-MSG-DELIVERY-AXES].
34
35	**REMAINING W1 (ordered) — NOT yet done:**
36	1. **ACTIVATE `REQ-MSG-DELIVERY-AXES` +impl** in traceable-reqs.toml (currently `["doc"]` → `["doc","impl"]`; unit/int at W4). NOT yet done — do before declaring (traceable will FAIL on the [impl->] tags I already added until activated... actually impl tags on a doc-only req = traceable error; ACTIVATE EARLY).
37	2. **brain.rs `inject_endpoint`** (:1189) returns `Ok(reply.delivered)` (bool) → change to return the hint too (e.g. `EndpointInjected` or `(bool,bool)`) so the caller sees spool_deferred.
38	3. **cli.rs `try_broker_inject`** (:3510, returns bool) + **`cmd_send`** (:3522): on delivered=false, spool with the broker hint — active→`deliver::send_deferred`, idle→`deliver::send`. (Keep existing `--deferred` flag; W2 renames it.)
39	4. **broker.rs `run_inject_worker`** fault path (~:1099): on fault (no-commit/death + RETURN), RE-SPOOL the queued-undelivered events to the endpoint's PERCH SPOOL (`spt_store::spool::spool_message_at`, NON-deferred/idle-eligible — doyle: a faulted idle-delivery belongs back in the idle window). Needs the endpoint id + the queued envelopes in the worker (currently the worker only has event_rx of envelopes; may need to thread endpoint id into Translation/the worker). GROUND THIS — the worker may not currently know its endpoint id.
40	5. **idle-transition DRAIN** (the wake): in `delivery.rs cmd_state` idle (spt crate) — on `.idle` flip, re-attempt delivery of non-deferred idle-spool (spt-hosted: re-inject via brain.inject_endpoint; harness-hosted: relay auto-surfaces). GROUND: does cmd_state have brain access?
41	6. **PART B**: `delivery.rs cmd_poll` (:147) kill `let _methods` discard → relay/poll honors manifest `[inject]` idle/activity methods (use the resolved methods, not discard).
42	7. **★ TEST SWEEP (BIG ripple, [[shared-seam-change-run-all-seam-tests]]):** the activity gate makes the DEFAULT (no `.idle`) route to ACTIVE→spool — so EVERY existing test that sends to a hosted binary expecting INJECTION now spools instead. MUST set `.idle` (write IDLE_SENTINEL, or `api state idle`) in: inject_control_wedge g1/g2/g3/g4/large_endpoint/p0_paste (those expecting inject), broker.rs endpoint_keyed_inject + wall_b. GREP all KIND_ENDPOINT_INPUT senders + deliver_endpoint_event/spawn_xlate_session helpers; add idle-set. Consider an idle-set in spawn_xlate_session helper. RE-RUN full `cargo nextest run -p spt-daemon` until green.
43	8. **NEW W1 tests** (doyle: don't JUST .idle-patch the inject tests — ADD dedicated active-path coverage for the NEW default→spool behavior): unit routing decision-table (injected sentinel: active→spool / idle→inject / idle-transition→drain, no wall-clock) + int (real broker+PTY nextest): **active→spool-NOT-inject (new default, its own test)** · idle→reaches-PTY-via-binary · active→idle-transition drains+delivers · fault-mid-sequence re-spools (delivered=false, not lost).
44	9. GATE: full nextest -p spt-daemon + clippy --workspace 0 + traceable EXIT0 + xtask check + rebuild bin. Surface green to doyle ($OWL send doyle); he re-runs all on the real commit.
45
46	**GROUND-FIRST NOTES for resume:** run_inject_worker may not know its endpoint id (step 4) — check Translation struct / build_translation; thread endpoint id if missing. cmd_state brain access (step 5). The `.idle` default-active ripple (step 7) is the biggest surface.
47
48	**(historical) GROUNDING — broker reads sentinel via spt_store::perch::resolve_idle_file (crate-boundary clean); resolve_inject_methods is spt-crate. doyle confirmed.**
49
50	**GROUNDING FINDINGS (read delivery.rs + perch.rs + v0.14.3 dispatch spine):**
51	- CRATE BOUNDARY: `is_idle` + `resolve_inject_methods` are in the **`spt` crate** (`api/delivery.rs`); `dispatch_endpoint_input` is in **spt-daemon** (spt→depends→spt-daemon, NOT vice-versa). Broker CANNOT call `delivery::resolve_inject_methods`.
52	- BUT the sentinel IS shared: **`spt_store::perch::resolve_idle_file(id, hint)`** (perch.rs:382) + `IDLE_SENTINEL`=".idle" (perch.rs:376) — broker reads idle directly via spt-store, no spt-crate dep. (also `resolve_echo_gate_file`:389, `.more-done` ECHO_GATE_SENTINEL:379.)
53	- `cmd_state(id,"idle",no_gate)` (delivery.rs:36) writes `.idle` + arms echo-gate; `"busy"` removes `.idle`. `is_idle`=resolve_idle_file.exists() (:97). The discarded `let _methods = resolve_inject_methods(manifest, is_idle(id))` is in `cmd_poll` (delivery.rs:147) — the hook-drain surface.
54	- spool API (spt-store::spool, broker-accessible): `spool_message_at(perch,from,body)`, `spool_message_deferred_at`, `drain_non_deferred_at`/`drain_all_at`, `pending_count_at`, `ttl_seconds`. `poll_drain` (delivery.rs:196) keys on resting gate.
55
56	**★ doyle DESIGN RULING (CONFIRMED, GO 2026-06-23) — W1 SPINE, 2 surfaces same wave:**
57	1. CRATE BOUNDARY confirmed — broker gate reads `.idle` DIRECTLY via spt_store::perch (the plan's "wire resolve_inject_methods INTO dispatch_endpoint_input" was imprecise about WHERE).
58	2. **PART A (spt-daemon broker, spt-HOSTED PTY path):** `dispatch_endpoint_input` gates via the sentinel → {IDLE+working-binary → INJECT (delivered=true) | ACTIVE → spool | IDLE+no-binary → spool}. (My route reading CORRECT.)
59	3. **PART B (spt, api/delivery.rs, HARNESS-HOSTED relay/poll path):** kill the `let _methods` discard in cmd_poll → relay/poll honors manifest `[inject]` idle/activity methods. SEPARATE surface, SAME wave. (So the let_methods fix IS in W1 scope — wired to honor methods, NOT a throwaway.)
60	4. RELAY-POLL COLLAPSE confirmed: "binary→relay-poll→spool" = binary-inject (spt-hosted idle) ELSE spool, where the spool's DRAINER is the relay/poll (api listen PUSHES for idle = the wake; hook-poll drains for active). idle+no-binary→spool surfaced by the live relay = spt-core-driven wake.
61	5. **★ THE #4 NUANCE (deferred-column REUSE — the elegant key):** active-spool vs idle-spool map onto the EXISTING `deferred` column — ACTIVE → spool **deferred=1** (relay SKIPS, hook-poll only, NO wake); IDLE+no-binary → spool **deferred=0** (relay PUSHES = wake). Broker replies delivered=false for BOTH active+binary AND idle+no-binary — but the CALLER must spool with the right deferred-ness. **RULING: EXTEND the KIND_ENDPOINT_INJECTED reply payload (EndpointInjected) with the activity/window hint** (additive bool, serde-default false = N-1-safe today's non-deferred; mirrors InputReq.ack v0.13.0 P1b) → cmd_send/deliver::send spools deferred(active)/non-deferred(idle) DETERMINISTICALLY off the broker's ONE sentinel read — NO caller TOCTOU re-read. deferred already MEANS "no live wake, hook-poll only" → clean reuse.
62	- **cmd_send false-branch:** spool deferred-ness per the broker hint — active→`deliver::send_deferred`, idle→`deliver::send`. (W1 keeps the existing `--deferred` flag; rename→`--active-only` is W2.)
63	- **idle-transition drain (the WAKE):** hook in `cmd_state` idle (delivery.rs:36, spt crate) — on the `.idle` flip, re-attempt delivery of non-deferred idle-spool (spt-hosted: re-inject via brain; harness-hosted: the relay auto-surfaces non-deferred spool, so the wake is automatic there).
64	- doyle: "propose back if cleaner seam for #4 — otherwise build." todlando: no cleaner seam; additive reply field = the clean seam. BUILDING.
65
66	**(historical) MY READING (proposed to doyle, confirmed above):** (1) activity gate in dispatch_endpoint_input reads sentinel via spt_store::perch::resolve_idle_file — ACTIVE→spool(delivered=false) / IDLE+working-binary→inject / IDLE+no-binary→spool. i.e. v0.14.3 binary-or-spool GATED so ACTIVE always spools. For spt-hosted, relay-poll = the spool feeds api-listen/poll, so binary→relay-poll→spool collapses to binary→spool at this seam. (2) resolve_inject_methods is the api-POLL-surface concern, NOT called in the broker; the `let _methods` fix is a SEPARATE cmd_poll cleanup — UNLESS doyle wants resolve_inject_methods/is_idle MOVED to spt-store/daemon + genuinely called in the broker route. **OPEN: doyle to confirm the resolve_inject_methods disposition (separate cmd_poll cleanup vs move-and-call-in-broker).** Context long post-v0.14.3 — may checkpoint/commune before the build.
67

---
name: v0150-w1-activity-gated-delivery
description: "v0.15.0 W1 (ACTIVE assignment, todlando) — activity-gated delivery substrate + fault-transient re-spool. Branch v0.15.0-messaging @8fd2e51. Authoritative plan = V0.15.0-PLAN.md + ADR-0028."
metadata:
  node_type: memory
  type: project
  originSessionId: 34b6e19b-97c5-41c1-b0df-d79dbfd01fd7
---

**v0.15.0 messaging milestone — W1 KICKOFF (doyle assigned, operator GO 2026-06-23).** Follows v0.14.3 (raw-inject removal [[v0143-raw-inject-removal]]). Branch **`v0.15.0-messaging` @8fd2e51** (off main 90852bf = v0.14.3 published counter 33). **AUTHORITATIVE TASK DOC = `V0.15.0-PLAN.md` (repo root) + `docs/adr/0028-activity-gated-delivery-and-send-modifier-axes.md` — READ BOTH FIRST.** doyle gates, perri validates real CC at publish. doyle re-runs ALL gates on the real commit (never trusts the green report — v0.14.3 discipline).

**MILESTONE (4 waves):** close the legacy-SPT parity gap = wire the scaffolded-but-DEAD activity-gated inbound routing (2 windows: ACTIVE=spool for hook-poll / IDLE=deliver+wake via binary→relay-poll→spool), then expose 3 orthogonal `spt send` axes — window (`--idle-only`/`--active-only`[renames `--deferred`]/default) × channel (`--prefer-native`/`--force-native`/unrestricted) × persistence (`--ephemeral`/durable) — + `--json-payload` (opaque `json=""` envelope attr). W1=substrate+fault-transient, W2=window+persistence axes (CLI+spool schema), W3=channel axis, W4=metadata+console-window fix+docs+final traceable.

**W1 SCOPE (NO send flags — make DEFAULT routing correct):**
1. ACTIVATE `REQ-MSG-DELIVERY-AXES` `+impl` (was doc-only seed). int at final wave (W4) per [[traceable-per-wave-activation]].
2. WIRE THE DEAD ROUTER: `crates/spt/src/api/delivery.rs` `is_idle(id)` (:97), `resolve_inject_methods(manifest, idle)` (:112), the discarded `let _methods = resolve_inject_methods(…)` (:147) → thread idle sentinel + methods into broker `dispatch_endpoint_input` (broker.rs ~:1875, the v0.14.3 spine). Route by `.idle`: ACTIVE → spool (delivered=false, hook-poll surfaces); IDLE (or idle-transition) → deliver immediately (translation-binary → relay-poll → spool fallback order).
3. IDLE-TRANSITION DRAIN (the WAKE, spt-core-driven via relay-poll per operator ruling — NOT adapter-mediated): when `api state idle` flips the `.idle` sentinel (`delivery.rs cmd_state` :57, perch.rs IDLE_SENTINEL), daemon DRAINS idle-window-eligible spooled inbound for the endpoint + delivers to binary/relay.
4. FAULT-TRANSIENT RE-SPOOL (closes the REQ-HAZARD-IDLE-SILENT-NONDELIVERY transient — Design-RESOLVED #2 (a)): `broker.rs run_inject_worker` fault path (~:1099, faults on no-commit/death + RETURNS dropping queued events) → RE-SPOOL queued-undelivered events to the endpoint's PERCH SPOOL (broker-side `spt_store::spool` write, delivered=false-equiv, poll-fed, NEVER lost). KEEP current caller-spool + delivered-bool model — NOT the full ack-on-spool relocation (later milestone, OUT OF SCOPE).

**W1 TESTS:** unit = routing decision-table (active→spool / idle→inject / idle-transition→drain) INJECTED sentinel, NO wall-clock. int (real broker+PTY, NEXTEST) = active→spool-not-inject · idle→reaches-PTY-via-binary · active→idle-transition drains+delivers · fault-mid-sequence re-spools (delivered=false, not lost). GATE = two-window routing correct + fault-transient closed.

**KEY ANCHORS (V0.15.0-PLAN.md §22-30):** delivery.rs (is_idle:97 / resolve_inject_methods:112 / dead let_methods:147 / cmd_state:57); broker.rs (dispatch_endpoint_input ~:1875 / run_inject_worker ~:1099); translation.rs TranslationChild::spawn:240 (W4 console-window CREATE_NO_WINDOW site); cli.rs Send clap ~:62 / --deferred :71-73 / cmd_send:3522 / try_broker_inject:3510; brain.rs inject_endpoint:1189; spool.rs (deferred col :59, spool_message_at/_deferred_at, drain_non_deferred_at/drain_all_at, ttl_seconds — W2 schema ext); event.rs compose_typed_event:189 (W4 json attr).

**GATE DISCIPLINE (MANDATORY, v0.14.3-paid, SAME broker seam):** `cargo nextest run -p spt-daemon` FULL crate (NOT bare cargo test = env-race; NOT just edited file = sibling escape [[shared-seam-change-run-all-seam-tests]]) · `cargo clippy --workspace --all-targets`=0 (incl `///` doc-comments, EVERY amend [[ci-clippy-preflight-workspace]]) · traceable EXIT0 · xtask gen/check docs-drift · rebuild bin · grep tests for OLD behavior before any rename ([[behavior-change-grep-tests-not-comments]]). everyone on LEGACY owl — `$OWL send doyle` ([[owl-send-not-legacy-spt-send]]).

**OUT OF SCOPE (doyle):** backlog-drain-on-binary-up (idle-transition drain + subsequent sends cover it; separate change); full ack-on-spool spool-relocation redesign (ADR-0028 end-state, later milestone). W4 also: AMEND REQ-HAZARD-IDLE-SILENT-NONDELIVERY title w/ `--ephemeral` carve-out (sole sender-opted-in silent-drop); EXTEND REQ-HAZARD-CHILD-CONSOLE-FLASH to translation-binary spawn.

**STATUS: W1 PART A SPINE STARTED — compiles (cargo check -p spt-daemon EXIT0), UNCOMMITTED on disk (v0.15.0-messaging). NOT gateable yet (test sweep + caller wiring remain). Checkpointed at deep context.**

**DONE (on disk, compiling):**
- `msg.rs`: `EndpointInjected` gained `#[serde(default)] pub spool_deferred: bool` (the activity-gate hint: delivered=false + spool_deferred=true ⇒ caller spools DEFERRED/active; false ⇒ NON-deferred/idle-relay-wakes; N-1-safe). `endpoint_injected_envelope(endpoint, delivered, spool_deferred)` — 3rd arg added.
- `broker.rs` `dispatch_endpoint_input`: ACTIVITY GATE — reads `spt_store::perch::resolve_idle_file(&req.endpoint, ParentHint::Infer).exists()`. Routes: `!idle`(ACTIVE)→reply(delivered=false, spool_deferred=TRUE), no inject; IDLE+working-binary→inject→reply(true,false); IDLE+no-binary→reply(false,false). None(no session)→reply(false,false). Tagged `[impl->REQ-MSG-DELIVERY-AXES]`.
- `msg.rs` units updated to 3-arg + renamed `endpoint_injected_envelope_carries_delivered_both_ways`→`_carries_delivered_and_spool_hint` (asserts active=deferred / idle=non-deferred / delivered / N-1 serde-default false). Tagged [unit->REQ-MSG-DELIVERY-AXES].

**REMAINING W1 (ordered) — NOT yet done:**
1. **ACTIVATE `REQ-MSG-DELIVERY-AXES` +impl** in traceable-reqs.toml (currently `["doc"]` → `["doc","impl"]`; unit/int at W4). NOT yet done — do before declaring (traceable will FAIL on the [impl->] tags I already added until activated... actually impl tags on a doc-only req = traceable error; ACTIVATE EARLY).
2. **brain.rs `inject_endpoint`** (:1189) returns `Ok(reply.delivered)` (bool) → change to return the hint too (e.g. `EndpointInjected` or `(bool,bool)`) so the caller sees spool_deferred.
3. **cli.rs `try_broker_inject`** (:3510, returns bool) + **`cmd_send`** (:3522): on delivered=false, spool with the broker hint — active→`deliver::send_deferred`, idle→`deliver::send`. (Keep existing `--deferred` flag; W2 renames it.)
4. **broker.rs `run_inject_worker`** fault path (~:1099): on fault (no-commit/death + RETURN), RE-SPOOL the queued-undelivered events to the endpoint's PERCH SPOOL (`spt_store::spool::spool_message_at`, NON-deferred/idle-eligible — doyle: a faulted idle-delivery belongs back in the idle window). Needs the endpoint id + the queued envelopes in the worker (currently the worker only has event_rx of envelopes; may need to thread endpoint id into Translation/the worker). GROUND THIS — the worker may not currently know its endpoint id.
5. **idle-transition DRAIN** (the wake): in `delivery.rs cmd_state` idle (spt crate) — on `.idle` flip, re-attempt delivery of non-deferred idle-spool (spt-hosted: re-inject via brain.inject_endpoint; harness-hosted: relay auto-surfaces). GROUND: does cmd_state have brain access?
6. **PART B**: `delivery.rs cmd_poll` (:147) kill `let _methods` discard → relay/poll honors manifest `[inject]` idle/activity methods (use the resolved methods, not discard).
7. **★ TEST SWEEP (BIG ripple, [[shared-seam-change-run-all-seam-tests]]):** the activity gate makes the DEFAULT (no `.idle`) route to ACTIVE→spool — so EVERY existing test that sends to a hosted binary expecting INJECTION now spools instead. MUST set `.idle` (write IDLE_SENTINEL, or `api state idle`) in: inject_control_wedge g1/g2/g3/g4/large_endpoint/p0_paste (those expecting inject), broker.rs endpoint_keyed_inject + wall_b. GREP all KIND_ENDPOINT_INPUT senders + deliver_endpoint_event/spawn_xlate_session helpers; add idle-set. Consider an idle-set in spawn_xlate_session helper. RE-RUN full `cargo nextest run -p spt-daemon` until green.
8. **NEW W1 tests** (doyle: don't JUST .idle-patch the inject tests — ADD dedicated active-path coverage for the NEW default→spool behavior): unit routing decision-table (injected sentinel: active→spool / idle→inject / idle-transition→drain, no wall-clock) + int (real broker+PTY nextest): **active→spool-NOT-inject (new default, its own test)** · idle→reaches-PTY-via-binary · active→idle-transition drains+delivers · fault-mid-sequence re-spools (delivered=false, not lost).
9. GATE: full nextest -p spt-daemon + clippy --workspace 0 + traceable EXIT0 + xtask check + rebuild bin. Surface green to doyle ($OWL send doyle); he re-runs all on the real commit.

**GROUND-FIRST NOTES for resume:** run_inject_worker may not know its endpoint id (step 4) — check Translation struct / build_translation; thread endpoint id if missing. cmd_state brain access (step 5). The `.idle` default-active ripple (step 7) is the biggest surface.

**(historical) GROUNDING — broker reads sentinel via spt_store::perch::resolve_idle_file (crate-boundary clean); resolve_inject_methods is spt-crate. doyle confirmed.**

**GROUNDING FINDINGS (read delivery.rs + perch.rs + v0.14.3 dispatch spine):**
- CRATE BOUNDARY: `is_idle` + `resolve_inject_methods` are in the **`spt` crate** (`api/delivery.rs`); `dispatch_endpoint_input` is in **spt-daemon** (spt→depends→spt-daemon, NOT vice-versa). Broker CANNOT call `delivery::resolve_inject_methods`.
- BUT the sentinel IS shared: **`spt_store::perch::resolve_idle_file(id, hint)`** (perch.rs:382) + `IDLE_SENTINEL`=".idle" (perch.rs:376) — broker reads idle directly via spt-store, no spt-crate dep. (also `resolve_echo_gate_file`:389, `.more-done` ECHO_GATE_SENTINEL:379.)
- `cmd_state(id,"idle",no_gate)` (delivery.rs:36) writes `.idle` + arms echo-gate; `"busy"` removes `.idle`. `is_idle`=resolve_idle_file.exists() (:97). The discarded `let _methods = resolve_inject_methods(manifest, is_idle(id))` is in `cmd_poll` (delivery.rs:147) — the hook-drain surface.
- spool API (spt-store::spool, broker-accessible): `spool_message_at(perch,from,body)`, `spool_message_deferred_at`, `drain_non_deferred_at`/`drain_all_at`, `pending_count_at`, `ttl_seconds`. `poll_drain` (delivery.rs:196) keys on resting gate.

**★ doyle DESIGN RULING (CONFIRMED, GO 2026-06-23) — W1 SPINE, 2 surfaces same wave:**
1. CRATE BOUNDARY confirmed — broker gate reads `.idle` DIRECTLY via spt_store::perch (the plan's "wire resolve_inject_methods INTO dispatch_endpoint_input" was imprecise about WHERE).
2. **PART A (spt-daemon broker, spt-HOSTED PTY path):** `dispatch_endpoint_input` gates via the sentinel → {IDLE+working-binary → INJECT (delivered=true) | ACTIVE → spool | IDLE+no-binary → spool}. (My route reading CORRECT.)
3. **PART B (spt, api/delivery.rs, HARNESS-HOSTED relay/poll path):** kill the `let _methods` discard in cmd_poll → relay/poll honors manifest `[inject]` idle/activity methods. SEPARATE surface, SAME wave. (So the let_methods fix IS in W1 scope — wired to honor methods, NOT a throwaway.)
4. RELAY-POLL COLLAPSE confirmed: "binary→relay-poll→spool" = binary-inject (spt-hosted idle) ELSE spool, where the spool's DRAINER is the relay/poll (api listen PUSHES for idle = the wake; hook-poll drains for active). idle+no-binary→spool surfaced by the live relay = spt-core-driven wake.
5. **★ THE #4 NUANCE (deferred-column REUSE — the elegant key):** active-spool vs idle-spool map onto the EXISTING `deferred` column — ACTIVE → spool **deferred=1** (relay SKIPS, hook-poll only, NO wake); IDLE+no-binary → spool **deferred=0** (relay PUSHES = wake). Broker replies delivered=false for BOTH active+binary AND idle+no-binary — but the CALLER must spool with the right deferred-ness. **RULING: EXTEND the KIND_ENDPOINT_INJECTED reply payload (EndpointInjected) with the activity/window hint** (additive bool, serde-default false = N-1-safe today's non-deferred; mirrors InputReq.ack v0.13.0 P1b) → cmd_send/deliver::send spools deferred(active)/non-deferred(idle) DETERMINISTICALLY off the broker's ONE sentinel read — NO caller TOCTOU re-read. deferred already MEANS "no live wake, hook-poll only" → clean reuse.
- **cmd_send false-branch:** spool deferred-ness per the broker hint — active→`deliver::send_deferred`, idle→`deliver::send`. (W1 keeps the existing `--deferred` flag; rename→`--active-only` is W2.)
- **idle-transition drain (the WAKE):** hook in `cmd_state` idle (delivery.rs:36, spt crate) — on the `.idle` flip, re-attempt delivery of non-deferred idle-spool (spt-hosted: re-inject via brain; harness-hosted: the relay auto-surfaces non-deferred spool, so the wake is automatic there).
- doyle: "propose back if cleaner seam for #4 — otherwise build." todlando: no cleaner seam; additive reply field = the clean seam. BUILDING.

**(historical) MY READING (proposed to doyle, confirmed above):** (1) activity gate in dispatch_endpoint_input reads sentinel via spt_store::perch::resolve_idle_file — ACTIVE→spool(delivered=false) / IDLE+working-binary→inject / IDLE+no-binary→spool. i.e. v0.14.3 binary-or-spool GATED so ACTIVE always spools. For spt-hosted, relay-poll = the spool feeds api-listen/poll, so binary→relay-poll→spool collapses to binary→spool at this seam. (2) resolve_inject_methods is the api-POLL-surface concern, NOT called in the broker; the `let _methods` fix is a SEPARATE cmd_poll cleanup — UNLESS doyle wants resolve_inject_methods/is_idle MOVED to spt-store/daemon + genuinely called in the broker route. **OPEN: doyle to confirm the resolve_inject_methods disposition (separate cmd_poll cleanup vs move-and-call-in-broker).** Context long post-v0.14.3 — may checkpoint/commune before the build.


1	# V0.15.0 — JIT build plan: activity-gated message delivery + send-modifier axes
2
3	**For: todlando (build), gated by doyle, validated by perri on real CC at publish.**
4	**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.
5	**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`).
6
7	## REQs (registry-first — activate before tagging evidence)
8	- **`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`).
9	- **`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.
10	- **`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.
11
12	## The milestone (one paragraph)
13	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.
14
15	## The model (ADR-0028, condensed)
16	A sent message carries one value per axis; each defaults to its unrestricted value:
17	- **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.
18	- **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*.
19	- **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.
20	- **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).
21
22	## Grounded seams / anchors (from the grill session)
23	- **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.
24	- **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.
25	- **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).
26	- **The translation driver**: `crates/spt-daemon/src/translation.rs` `TranslationChild::spawn` (:240) — also the W4 console-window site (missing `creation_flags(CREATE_NO_WINDOW)`).
27	- **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`.
28	- **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.
29	- **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).
30	- **The envelope**: `crates/spt-proto/src/event.rs` — `compose_typed_event(type, attrs, body)` (:189) + `event_attr_escape`. W4 renders `json=""`.
31
32	## Waves (dependency-ordered, each independently gateable)
33
34	### W1 — Activity-gated delivery substrate + fault-transient fix (the foundation)
35	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.
36	- 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.
37	- 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.
38	- Gate: the two-window routing is correct + the fault-transient closed.
39
40	### W2 — Delivery-window + persistence axes (CLI + spool schema)
41	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.
42	- 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`).
43	- 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.
44
45	### W3 — Channel restriction (native) axis
46	`--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).
47	- 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).
48	- 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.
49
50	### W4 — Metadata + console-window fix + docs + final traceable
51	`--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`.
52	- 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.
53
54	## Design-RESOLVED (operator + doyle, 2026-06-23)
55	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.
56	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.
57	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.)
58
59	## MANDATORY gate discipline (paid for in the v0.14.3 saga — non-negotiable)
60	This milestone touches the SAME broker/delivery shared seam as v0.14.3. Every wave, BEFORE surfacing green to doyle:
61	- **`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).
62	- **`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.
63	- **`traceable-reqs check`** EXIT 0 · **`cargo run -p xtask -- gen`/`check`** (docs-drift) · rebuild the real bin.
64	- **grep tests for OLD behavior** before any rename/contract change (`behavior-change-grep-tests-not-comments`).
65	- doyle re-runs ALL of the above on the real commit — never the executor's green report.
66
67	## Out of scope (doyle rulings)
68	- **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.
69	- 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.
70
71	## State
72	- 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).
73	- Console-window bug (`[[translation-binary-console-window-bug]]`) folds into W4 (operator-decided 2026-06-23).
74	- perri validates on real CC at publish (the established pattern): the 3 axes + json-payload + no-visible-window.
75

# 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.

## 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.

## 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.


On-disk spine + plan clear. Git shows more modified than memory's DONE list (brain.rs/cli.rs/spool.rs/delivery.rs too). Check actual diff state first.


Bash

git diff --stat && echo "=====UNTRACKED=====" && git status --short

Show diff stat and status


 crates/spt-daemon/src/brain.rs  |  12 ++--
 crates/spt-daemon/src/broker.rs | 110 +++++++++++++++++++++++++++-----
 crates/spt-daemon/src/msg.rs    |  74 ++++++++++++++++------
 crates/spt-store/src/spool.rs   |  16 +++++
 crates/spt/src/api/delivery.rs  | 136 ++++++++++++++++++++++++++++++++++++++--
 crates/spt/src/cli.rs           |  42 +++++++------
 traceable-reqs.toml             |   2 +-
 7 files changed, 326 insertions(+), 66 deletions(-)
=====UNTRACKED=====
 M crates/spt-daemon/src/brain.rs
 M crates/spt-daemon/src/broker.rs
 M crates/spt-daemon/src/msg.rs
 M crates/spt-store/src/spool.rs
 M crates/spt/src/api/delivery.rs
 M crates/spt/src/cli.rs
 M traceable-reqs.toml


More on disk than checkpoint recorded. Read full diff to see real state.


Full working diff


<persisted-output>
Output too large (33.7KB). Full output saved to: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\6f4b9adb-dc79-40e5-abc7-32cf52d06f6b\tool-results\bzw2epxiq.txt

Preview (first 2KB):
diff --git a/crates/spt-daemon/src/brain.rs b/crates/spt-daemon/src/brain.rs
index 26727cc..710181a 100644
--- a/crates/spt-daemon/src/brain.rs
+++ b/crates/spt-daemon/src/brain.rs
@@ -1182,11 +1182,13 @@ impl Brain {
     /// ID (REQ-SEND-SPT-HOSTED) — the endpoint-keyed inject a CLI `spt send` uses
     /// to reach an spt-hosted endpoint (broker holds its PTY, no relay). The
     /// broker resolves endpoint→session atomically and writes the bytes (a
-    /// pre-rendered `<EVENT>` envelope). Returns `true` when a hosted session was
-    /// found and written, `false` when none hosts the endpoint (the caller then
-    /// falls back to the spool).
+    /// pre-rendered `<EVENT>` envelope). Returns `(delivered, spool_deferred)`:
+    /// `delivered=true` = broker injected via translation binary; `spool_deferred=true`
+    /// = caller should spool deferred (active window), `false` = spool non-deferred
+    /// (idle window). When `delivered=true` the spool_deferred hint is false (no spool).
     // [impl->REQ-SEND-SPT-HOSTED]
-    pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result<bool> {
+    // [impl->REQ-MSG-DELIVERY-AXES]
+    pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result<(bool, bool)> {
         self.send(
             KIND_ENDPOINT_INPUT,
             serde_json::to_value(EndpointInputReq {
@@ -1200,7 +1202,7 @@ impl Brain {
                 BrokerEvent::Other(env) if env.kind == KIND_ENDPOINT_INJECTED => {
                     let reply: EndpointInjected = serde_json::from_value(env.payload)
                         .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
-                    return Ok(reply.delivered);
+                    return Ok((reply.delivered, reply.spool_deferred));
                 }
                 BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                 _ => continue,
...
</persisted-output>

diff --git a/crates/spt-daemon/src/brain.rs b/crates/spt-daemon/src/brain.rs
index 26727cc..710181a 100644
--- a/crates/spt-daemon/src/brain.rs
+++ b/crates/spt-daemon/src/brain.rs
@@ -1182,11 +1182,13 @@ impl Brain {
     /// ID (REQ-SEND-SPT-HOSTED) — the endpoint-keyed inject a CLI `spt send` uses
     /// to reach an spt-hosted endpoint (broker holds its PTY, no relay). The
     /// broker resolves endpoint→session atomically and writes the bytes (a
-    /// pre-rendered `<EVENT>` envelope). Returns `true` when a hosted session was
-    /// found and written, `false` when none hosts the endpoint (the caller then
-    /// falls back to the spool).
+    /// pre-rendered `<EVENT>` envelope). Returns `(delivered, spool_deferred)`:
+    /// `delivered=true` = broker injected via translation binary; `spool_deferred=true`
+    /// = caller should spool deferred (active window), `false` = spool non-deferred
+    /// (idle window). When `delivered=true` the spool_deferred hint is false (no spool).
     // [impl->REQ-SEND-SPT-HOSTED]
-    pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result<bool> {
+    // [impl->REQ-MSG-DELIVERY-AXES]
+    pub fn inject_endpoint(&mut self, endpoint: &str, bytes: &[u8]) -> io::Result<(bool, bool)> {
         self.send(
             KIND_ENDPOINT_INPUT,
             serde_json::to_value(EndpointInputReq {
@@ -1200,7 +1202,7 @@ impl Brain {
                 BrokerEvent::Other(env) if env.kind == KIND_ENDPOINT_INJECTED => {
                     let reply: EndpointInjected = serde_json::from_value(env.payload)
                         .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
-                    return Ok(reply.delivered);
+                    return Ok((reply.delivered, reply.spool_deferred));
                 }
                 BrokerEvent::Error { message } => return Err(io::Error::other(message)),
                 _ => continue,
diff --git a/crates/spt-daemon/src/broker.rs b/crates/spt-daemon/src/broker.rs
index b90fe2a..8ce4b59 100644
--- a/crates/spt-daemon/src/broker.rs
+++ b/crates/spt-daemon/src/broker.rs
@@ -1067,8 +1067,17 @@ fn build_translation(
             let w_child = Arc::clone(&child);
             let w_floor = Arc::clone(&floor);
             let w_faulted = Arc::clone(&faulted);
+            let w_endpoint = endpoint_id.to_string();
             thread::spawn(move || {
-                run_inject_worker(w_input, w_child, w_floor, event_rx, cmd_rx, w_faulted);
+                run_inject_worker(
+                    w_input,
+                    w_child,
+                    w_floor,
+                    event_rx,
+                    cmd_rx,
+                    w_faulted,
+                    w_endpoint,
+                );
             });
             Some(Arc::new(Translation {
                 floor,
@@ -1107,6 +1116,7 @@ fn read_translation_path(install_dir: &std::path::Path) -> Option<String> {

 // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
 // [impl->REQ-HAZARD-INJECT-CONTROL-COEXIST]
+// [impl->REQ-MSG-DELIVERY-AXES]
 fn run_inject_worker(
     input: Arc<InputWriter>,
     child: Arc<TranslationChild>,
@@ -1114,6 +1124,7 @@ fn run_inject_worker(
     event_rx: Receiver<String>,
     cmd_rx: Receiver<KeyCmd>,
     faulted: Arc<AtomicBool>,
+    endpoint: String,
 ) {
     // `event_rx` blocks here; events queue → one sequence at a time.
     for envelope in event_rx {
@@ -1122,11 +1133,20 @@ fn run_inject_worker(
         // to this event's sequence.
         while cmd_rx.try_recv().is_ok() {}

+        // Clone BEFORE send: ToBinary::Event moves the envelope, so we need the
+        // clone available for fault re-spool on either failure path.
+        let envelope_copy = envelope.clone();
         floor.lock().unwrap().open();
         if child.send(&ToBinary::Event { envelope }).is_err() {
             // stdin write failed → the binary is gone; release the floor + fault.
             flush_inject_floor(&floor, &input);
-            fault_translation(&faulted, &child, "stdin write failed");
+            respool_and_fault(
+                &endpoint,
+                envelope_copy,
+                &faulted,
+                &child,
+                "stdin write failed",
+            );
             return;
         }

@@ -1169,7 +1189,13 @@ fn run_inject_worker(
         flush_inject_floor(&floor, &input);

         if !committed {
-            fault_translation(&faulted, &child, "no {commit} within INJECT_COMMIT_DEADLINE");
+            respool_and_fault(
+                &endpoint,
+                envelope_copy,
+                &faulted,
+                &child,
+                "no {commit} within INJECT_COMMIT_DEADLINE",
+            );
             return;
         }
     }
@@ -1177,6 +1203,26 @@ fn run_inject_worker(
     child.terminate();
 }

+/// Re-spool the in-flight envelope as non-deferred (idle-eligible) so the
+/// relay/poll can surface it, then fault the translation binary.
+// [impl->REQ-MSG-DELIVERY-AXES]
+fn respool_and_fault(
+    endpoint: &str,
+    envelope: String,
+    faulted: &AtomicBool,
+    child: &TranslationChild,
+    why: &str,
+) {
+    let perch_path =
+        spt_store::perch::resolve_perch_path(endpoint, spt_store::perch::ParentHint::Infer);
+    if let Err(e) = spt_store::spool::spool_message_at(&perch_path, "", &envelope) {
+        eprintln!(
+            "TRANSLATION_FAULT_RESPOOL:{endpoint}: failed to re-spool ({e}) — message dropped"
+        );
+    }
+    fault_translation(faulted, child, why);
+}
+
 /// FAULT a translation binary: mark the session degraded (so inbound delivery
 /// SPOOLS until a working binary and `dispatch_input` stops pinging), log it, and
 /// reap the child. Never respawns — inbound spools (poll-fed), never raw-injected,
@@ -1907,10 +1953,35 @@ impl Broker {
         };
         match resolved {
             Some(translation) => {
-                // Layer E: a live (non-faulted) translation binary OWNS the
+                // ACTIVITY GATE (ADR-0028, v0.15.0): route by the endpoint's `.idle`
+                // sentinel, read ONCE here so the caller spools the right deferred-ness
+                // off this single read (no caller-side TOCTOU). An endpoint is ACTIVE
+                // until it explicitly declares idle (`api state idle`).
+                //   ACTIVE ⇒ the message waits for the receiver's own hook-poll: spool
+                //     DEFERRED (no live wake) — never injected mid-activity, even with a
+                //     binary attached (the InjectFloor mid-turn path is the W3 native
+                //     override, not the default).
+                //   IDLE  ⇒ deliver now via a working translation binary, else spool
+                //     NON-deferred (the live relay/poll surfaces it = the wake).
+                let idle = spt_store::perch::resolve_idle_file(
+                    &req.endpoint,
+                    spt_store::perch::ParentHint::Infer,
+                )
+                .exists();
+                if !idle {
+                    // [impl->REQ-MSG-DELIVERY-AXES]
+                    eprintln!(
+                        "ENDPOINT_INJECT:{}: endpoint ACTIVE -> spooled (deferred, hook-poll window), not injected",
+                        req.endpoint
+                    );
+                    send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, true));
+                    return Ok(());
+                }
+                // IDLE window: a live (non-faulted) translation binary OWNS the
                 // choreography — queue the whole `<EVENT>` envelope to the inject
-                // worker, which drives it through the binary and applies the
-                // resulting keystrokes atomically w.r.t. the controller.
+                // worker, which drives it through the binary and applies the resulting
+                // keystrokes atomically w.r.t. the controller.
+                // [impl->REQ-MSG-DELIVERY-AXES]
                 if let Some(tr) = &translation {
                     if !tr.faulted.load(Ordering::Relaxed) {
                         let envelope = String::from_utf8_lossy(&bytes).into_owned();
@@ -1920,30 +1991,35 @@ impl Broker {
                                 req.endpoint,
                                 bytes.len()
                             );
-                            send_frame(send, &endpoint_injected_envelope(&req.endpoint, true));
+                            send_frame(
+                                send,
+                                &endpoint_injected_envelope(&req.endpoint, true, false),
+                            );
                             return Ok(());
                         }
                         // worker receiver dropped → SPOOL (below), never raw inject.
                     }
                 }
-                // No WORKING translation binary (none declared, faulted, or its
-                // inject worker is gone) → tell the caller to SPOOL (delivered=false).
-                // Raw `payload + \r` is NOT a fallback: on a modern TUI it does not
-                // submit — a non-delivering pseudo-write that MASKED F-019. The
-                // inbound is never lost (it stays spooled, poll-fed) and the failure
-                // is LOUD so a mis-declared/repaired binary is diagnosable.
+                // IDLE + no WORKING translation binary (none declared, faulted, or its
+                // inject worker is gone) → tell the caller to SPOOL NON-deferred (idle
+                // window; the live relay/poll surfaces it = the wake). Raw `payload + \r`
+                // is NOT a fallback: on a modern TUI it does not submit — a non-delivering
+                // pseudo-write that MASKED F-019. The inbound is never lost (spooled,
+                // poll-fed) and the failure is LOUD so a mis-declared/repaired binary is
+                // diagnosable.
                 // [impl->REQ-MSG-IDLE-TRANSLATION-BINARY]
                 // [impl->REQ-HAZARD-IDLE-SILENT-NONDELIVERY]
                 eprintln!(
-                    "ENDPOINT_INJECT:{}: no working translation binary (absent/faulted/worker-gone) -> SPOOLED, not injected",
+                    "ENDPOINT_INJECT:{}: no working translation binary (absent/faulted/worker-gone) -> SPOOLED (idle window), not injected",
                     req.endpoint
                 );
-                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false));
+                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, false));
                 Ok(())
             }
-            // No hosted session for this endpoint — tell the caller to spool.
+            // No hosted session for this endpoint — tell the caller to spool
+            // NON-deferred (idle-eligible; a non-hosted target has no active window).
             None => {
-                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false));
+                send_frame(send, &endpoint_injected_envelope(&req.endpoint, false, false));
                 Ok(())
             }
         }
diff --git a/crates/spt-daemon/src/msg.rs b/crates/spt-daemon/src/msg.rs
index 815c93a..f8d7d26 100644
--- a/crates/spt-daemon/src/msg.rs
+++ b/crates/spt-daemon/src/msg.rs
@@ -702,9 +702,20 @@ pub struct EndpointInputReq {
 pub struct EndpointInjected {
     /// The endpoint the inject targeted.
     pub endpoint: String,
-    /// `true` = a hosted session was found and the bytes were written to its PTY;
-    /// `false` = no broker session hosts this endpoint (caller falls back to spool).
+    /// `true` = a hosted session was found, IDLE, with a working translation binary,
+    /// and the bytes were delivered (injected) to its PTY; `false` = the caller must
+    /// SPOOL (no hosted session, the endpoint is ACTIVE, or no working binary).
     pub delivered: bool,
+    /// The activity-gate hint (ADR-0028, v0.15.0): when `delivered == false`, the
+    /// deferred-ness the caller should spool with — decided from the broker's SINGLE
+    /// activity-sentinel read so there is no caller-side TOCTOU. `true` = the endpoint
+    /// was ACTIVE → spool DEFERRED (the receiver's hook-poll window only, no live
+    /// wake); `false` = IDLE / no hosted session → spool NON-deferred (the live
+    /// relay/poll surfaces it = the spt-core-driven wake). Additive + serde-defaulted
+    /// (`false`): an N-1 broker omits it and the caller spools non-deferred exactly as
+    /// in v0.14.3. Meaningless (ignored) when `delivered == true`.
+    #[serde(default)]
+    pub spool_deferred: bool,
 }

 /// `brain-restarted` payload — the broker's ack for [`KIND_BRAIN_RESTART`]
@@ -859,12 +870,17 @@ pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envel
 }

 /// Build an `endpoint-injected` reply envelope (REQ-SEND-SPT-HOSTED).
-pub fn endpoint_injected_envelope(endpoint: &str, delivered: bool) -> Envelope {
+pub fn endpoint_injected_envelope(
+    endpoint: &str,
+    delivered: bool,
+    spool_deferred: bool,
+) -> Envelope {
     Envelope::new(
         KIND_ENDPOINT_INJECTED,
         serde_json::to_value(EndpointInjected {
             endpoint: endpoint.to_string(),
             delivered,
+            spool_deferred,
         })
         .expect("EndpointInjected serializes"),
     )
@@ -1027,12 +1043,12 @@ mod tests {
         assert_eq!(back.endpoint, "wall-b");
         assert_eq!(decode_bytes(&back.data_b64).unwrap(), decode_bytes(&req.data_b64).unwrap());

-        let env = endpoint_injected_envelope("wall-b", true);
+        let env = endpoint_injected_envelope("wall-b", true, false);
         assert_eq!(env.kind, KIND_ENDPOINT_INJECTED);
         let ev: EndpointInjected = serde_json::from_value(env.payload).unwrap();
         assert_eq!((ev.endpoint.as_str(), ev.delivered), ("wall-b", true));
         let miss: EndpointInjected =
-            serde_json::from_value(endpoint_injected_envelope("x", false).payload).unwrap();
+            serde_json::from_value(endpoint_injected_envelope("x", false, false).payload).unwrap();
         assert!(!miss.delivered);
     }

@@ -1080,26 +1096,44 @@ mod tests {
         assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
     }

-    // [unit->REQ-HAZARD-IDLE-SILENT-NONDELIVERY] The `delivered` flag is the spool
-    // signal: `false` is what the CLI's `try_broker_inject` reads to fall through to
-    // `deliver::send` (SPOOL) instead of reporting "Sent". With raw inject removed,
-    // the no-working-binary path replies `delivered=false` — this asserts the
-    // envelope faithfully carries that bool BOTH ways, so a false (spool) verdict can
-    // never be silently rendered as a true (delivered) one on the wire.
+    // [unit->REQ-HAZARD-IDLE-SILENT-NONDELIVERY] [unit->REQ-MSG-DELIVERY-AXES] The
+    // `delivered` flag is the spool signal: `false` is what the CLI's
+    // `try_broker_inject` reads to fall through to `deliver::send` (SPOOL) instead of
+    // reporting "Sent". The additive `spool_deferred` hint (v0.15.0 activity gate)
+    // tells the caller HOW to spool when delivered=false: ACTIVE → deferred (hook-poll
+    // only), IDLE/no-session → non-deferred (relay wakes). This asserts the envelope
+    // carries both bools faithfully AND that an N-1 broker (no `spool_deferred` on the
+    // wire) defaults to `false` (non-deferred) = the v0.14.3 behavior.
     #[test]
-    fn endpoint_injected_envelope_carries_delivered_both_ways() {
-        let spooled = endpoint_injected_envelope("wall-b", false);
-        assert_eq!(spooled.kind, KIND_ENDPOINT_INJECTED);
-        let ev: EndpointInjected = serde_json::from_value(spooled.payload).unwrap();
+    fn endpoint_injected_envelope_carries_delivered_and_spool_hint() {
+        // ACTIVE spool: delivered=false + spool_deferred=true.
+        let active = endpoint_injected_envelope("wall-b", false, true);
+        assert_eq!(active.kind, KIND_ENDPOINT_INJECTED);
+        let ev: EndpointInjected = serde_json::from_value(active.payload).unwrap();
         assert_eq!(ev.endpoint, "wall-b");
-        assert!(
-            !ev.delivered,
-            "a no-working-binary reply MUST carry delivered=false so the caller spools"
-        );
+        assert!(!ev.delivered, "a spooled reply carries delivered=false");
+        assert!(ev.spool_deferred, "an ACTIVE-window spool hint is deferred");

+        // IDLE/no-binary spool: delivered=false + spool_deferred=false.
+        let idle: EndpointInjected =
+            serde_json::from_value(endpoint_injected_envelope("wall-b", false, false).payload)
+                .unwrap();
+        assert!(!idle.delivered);
+        assert!(!idle.spool_deferred, "an IDLE-window spool hint is non-deferred (relay wakes)");
+
+        // Delivered: delivered=true (spool_deferred ignored).
         let delivered: EndpointInjected =
-            serde_json::from_value(endpoint_injected_envelope("wall-b", true).payload).unwrap();
+            serde_json::from_value(endpoint_injected_envelope("wall-b", true, false).payload)
+                .unwrap();
         assert!(delivered.delivered, "a binary-delivered reply carries delivered=true");
+
+        // N-1: an old broker omits `spool_deferred` → serde-defaults to false.
+        let n1: EndpointInjected =
+            serde_json::from_value(json!({ "endpoint": "wall-b", "delivered": false })).unwrap();
+        assert!(
+            !n1.spool_deferred,
+            "an N-1 reply without spool_deferred defaults to non-deferred (v0.14.3 behavior)"
+        );
     }

     // [unit->REQ-HAZARD-INPUT-ACK-BACKPRESSURE] An `input` payload from an OLDER
diff --git a/crates/spt-store/src/spool.rs b/crates/spt-store/src/spool.rs
index 24a1c2b..359452c 100644
--- a/crates/spt-store/src/spool.rs
+++ b/crates/spt-store/src/spool.rs
@@ -307,6 +307,22 @@ pub fn peek_all_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, Stri
     Ok(rows)
 }

+/// Non-destructive read of undelivered NON-DEFERRED rows: `(id, from_id, body)`.
+pub fn peek_non_deferred_at(perch_path: &Path) -> rusqlite::Result<Vec<(i64, String, String)>> {
+    let conn = open_spool_at(perch_path)?;
+    purge_expired(&conn)?;
+    let mut stmt = conn.prepare(
+        "SELECT id, from_id, body FROM messages WHERE delivered = 0 AND deferred = 0 ORDER BY id ASC",
+    )?;
+    let rows = stmt
+        .query_map([], |row| {
+            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
+        })?
+        .filter_map(|r| r.ok())
+        .collect();
+    Ok(rows)
+}
+
 /// Count of undelivered, non-expired rows (all deferred statuses).
 pub fn pending_count_at(perch_path: &Path) -> rusqlite::Result<usize> {
     let conn = open_spool_at(perch_path)?;
diff --git a/crates/spt/src/api/delivery.rs b/crates/spt/src/api/delivery.rs
index c1df8f4..0045e9d 100644
--- a/crates/spt/src/api/delivery.rs
+++ b/crates/spt/src/api/delivery.rs
@@ -33,6 +33,7 @@ fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
 /// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
 /// arms the echo-gate unless `no_gate` (CONTEXT §api state).
 // [impl->REQ-SEAM-ACTIVITY]
+// [impl->REQ-MSG-DELIVERY-AXES]
 pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
     let idle = sentinel_path(id, IDLE_SENTINEL);
     match state {
@@ -52,6 +53,11 @@ pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
                     " (gate-armed)"
                 }
             );
+            // Idle-transition DRAIN: try to inject any queued non-deferred messages
+            // now that the sentinel is written and the broker will route them via the
+            // translation binary. Rows that don't inject stay in the spool for
+            // relay/poll. Best-effort: errors are silent (spool is the fallback).
+            drain_idle_window(id);
             0
         }
         "busy" => {
@@ -141,12 +147,19 @@ pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<In
 /// dissolves). Non-deferred only unless `include_deferred`.
 // [impl->REQ-API-2]
 // [impl->REQ-MSG-ENVELOPE]
+// [impl->REQ-MSG-DELIVERY-AXES]
 pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
-    // Inject-method selection is resolved (hook/relay) even though the M2a poll
-    // channel is the hook drain; PTY delivery lands with the broker in M3.
-    let _methods = resolve_inject_methods(manifest, is_idle(id));
-    for m in poll_drain(id, include_deferred) {
-        emit_event(&m.from, &m.body);
+    // Honor the manifest `[inject]` idle/activity method: only drain via the
+    // hook channel when Hook is in the resolved method set. An adapter declaring
+    // `idle = ["relay"]` never expects hook-poll to drain its idle messages —
+    // relay is its live channel; draining here would double-consume the rows.
+    // Default (no manifest / empty config after PTY-filter) = [Hook] → drain as
+    // before; existing adapters are unaffected.
+    let methods = resolve_inject_methods(manifest, is_idle(id));
+    if methods.contains(&InjectMethod::Hook) {
+        for m in poll_drain(id, include_deferred) {
+            emit_event(&m.from, &m.body);
+        }
     }
     0
 }
@@ -209,6 +222,52 @@ pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<spool::DrainedMsg> {
     drained.unwrap_or_default()
 }

+/// Idle-transition DRAIN: try to inject any queued non-deferred spool rows via
+/// the broker's translation binary NOW that the endpoint just declared idle.
+/// Rows that the broker successfully delivers are marked delivered; those that
+/// don't inject (no working binary, still-active race, IPC error) remain in the
+/// spool for relay/poll to surface. Best-effort: all errors are silent.
+// [impl->REQ-MSG-DELIVERY-AXES]
+fn drain_idle_window(id: &str) {
+    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
+    let rows = match spool::peek_non_deferred_at(&perch_path) {
+        Ok(r) if !r.is_empty() => r,
+        _ => return,
+    };
+    if !spt_daemon::is_running() {
+        return;
+    }
+    let now_ms = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_millis() as u64;
+    let mut brain = match spt_daemon::brain::Brain::cold_start(
+        &spt_daemon::broker_socket_name(),
+        now_ms,
+    ) {
+        Ok(b) => b,
+        Err(_) => return,
+    };
+    let mut delivered_ids = Vec::new();
+    for (msg_id, from, body) in rows {
+        let mut envelope = spt_msg::emit::render_event_whole(&from, &body);
+        envelope.push('\n');
+        match brain.inject_endpoint(id, envelope.as_bytes()) {
+            Ok((true, _)) => {
+                delivered_ids.push(msg_id);
+            }
+            _ => break, // binary absent/faulted for this endpoint — stop trying
+        }
+    }
+    if !delivered_ids.is_empty() {
+        let _ = spool::mark_delivered_at(&perch_path, &delivered_ids);
+        eprintln!(
+            "IDLE_DRAIN:{id}: injected {} queued message(s) via translation binary",
+            delivered_ids.len()
+        );
+    }
+}
+
 /// Emit one drained row to a hook-channel surface as ONE whole `<EVENT>` line
 /// (composed via [`spt_msg::emit::render_event_whole`]: typed bodies pass
 /// through verbatim, plain bodies compose `<EVENT type="msg" …>`; never
@@ -373,4 +432,71 @@ mod tests {
             vec![InjectMethod::Hook]
         );
     }
+
+    // [unit->REQ-MSG-DELIVERY-AXES] cmd_poll only drains when Hook is in the resolved method set.
+    #[test]
+    fn poll_only_drains_when_hook_method_present() {
+        let _h = isolated_home();
+        establish("bob");
+        let perch_path = perch::resolve_perch_path("bob", ParentHint::Infer);
+        spool::spool_message_at(&perch_path, "alice", "hello").unwrap();
+
+        // No manifest → defaults to [Hook] → drains.
+        assert_eq!(
+            poll_drain("bob", false).len(),
+            1,
+            "Hook method: row must drain"
+        );
+
+        // Re-spool for the relay test.
+        spool::spool_message_at(&perch_path, "alice", "hello again").unwrap();
+
+        // Manifest with relay-only idle method → Hook NOT in set → poll_drain is not
+        // called via cmd_poll (tested via the drain helper directly as a proxy).
+        // Verify the method resolution: a relay-only config does not contain Hook.
+        let m = Manifest::from_toml_str(
+            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"relay\"]\n",
+        )
+        .unwrap();
+        let methods = resolve_inject_methods(Some(&m), true);
+        assert!(
+            !methods.contains(&InjectMethod::Hook),
+            "relay-only idle config must not contain Hook: {methods:?}"
+        );
+        // Row still undelivered (relay handles it).
+        assert_eq!(
+            spool::pending_count_non_deferred_at(&perch_path).unwrap(),
+            1
+        );
+    }
+
+    // [unit->REQ-MSG-DELIVERY-AXES] idle-transition drain marks rows delivered after
+    // successful broker inject (simulated by verifying the peek + mark_delivered_at
+    // path without a live broker — we confirm the helper doesn't crash and the spool
+    // state is consistent; the live delivery is covered by the int gate).
+    #[test]
+    fn idle_drain_peek_returns_non_deferred_rows_only() {
+        let _h = isolated_home();
+        establish("carol");
+        let perch_path = perch::resolve_perch_path("carol", ParentHint::Infer);
+        spool::spool_message_at(&perch_path, "alice", "live one").unwrap();
+        spool::spool_message_deferred_at(&perch_path, "alice", "deferred one").unwrap();
+
+        let rows = spool::peek_non_deferred_at(&perch_path).unwrap();
+        assert_eq!(rows.len(), 1, "peek_non_deferred_at must exclude deferred rows");
+        assert!(rows[0].2.contains("live one"));
+
+        // Marking delivered does not touch the deferred row.
+        let ids: Vec<i64> = rows.iter().map(|r| r.0).collect();
+        spool::mark_delivered_at(&perch_path, &ids).unwrap();
+        assert_eq!(
+            spool::pending_count_non_deferred_at(&perch_path).unwrap(),
+            0
+        );
+        assert_eq!(
+            spool::pending_count_at(&perch_path).unwrap(),
+            1,
+            "deferred row survives mark_delivered_at on non-deferred ids"
+        );
+    }
 }
diff --git a/crates/spt/src/cli.rs b/crates/spt/src/cli.rs
index 4ec3559..0568aee 100644
--- a/crates/spt/src/cli.rs
+++ b/crates/spt/src/cli.rs
@@ -3502,20 +3502,23 @@ fn is_spt_hosted_no_relay(target: &str, owlery: &std::path::Path) -> bool {
 /// the SAME whole `<EVENT>` every arriving-message surface emits (ADR-0020, via
 /// [`render_event_whole`] — one whole envelope, never EVENT-PART-chunked: the PTY
 /// has no Monitor per-line cap), so the harness parses one format regardless of
-/// topology. Returns `true` only on a CONFIRMED broker write (so the caller
-/// reports `Sent`, un-lying "online"); any miss (daemon down, no hosted session,
-/// write error) returns `false` so the caller falls back to the spool — the
-/// message is never lost.
+/// topology. Returns `Some((delivered, spool_deferred))` on a successful broker
+/// round-trip: `delivered=true` = broker injected via translation binary (Sent);
+/// `spool_deferred=true` = endpoint is ACTIVE (spool deferred / hook-channel),
+/// `false` = endpoint is IDLE but no working binary (spool non-deferred / wake).
+/// Returns `None` on any miss (daemon down, no session, IPC error) so the caller
+/// falls back to the normal deliver path — the message is never lost.
 // [impl->REQ-SEND-SPT-HOSTED]
-fn try_broker_inject(target: &str, from: &str, body: &str) -> bool {
+// [impl->REQ-MSG-DELIVERY-AXES]
+fn try_broker_inject(target: &str, from: &str, body: &str) -> Option<(bool, bool)> {
     if !spt_daemon::is_running() {
-        return false;
+        return None;
     }
     let mut bytes = spt_msg::emit::render_event_whole(from, body);
     bytes.push('\n');
     match spt_daemon::brain::Brain::cold_start(&spt_daemon::broker_socket_name(), now_ms()) {
-        Ok(mut brain) => brain.inject_endpoint(target, bytes.as_bytes()).unwrap_or(false),
-        Err(_) => false,
+        Ok(mut brain) => brain.inject_endpoint(target, bytes.as_bytes()).ok(),
+        Err(_) => None,
     }
 }

@@ -3561,18 +3564,21 @@ fn cmd_send(

     // spt-hosted PTY-inject path (REQ-SEND-SPT-HOSTED): an spt-hosted endpoint
     // (broker holds its PTY, no `api listen` relay) would otherwise spool
-    // silently — inject the rendered <EVENT> straight into its PTY instead. Only
-    // a CONFIRMED broker write counts as Sent; any miss falls through to the
-    // normal deliver path (spool / WAN), so the message is never lost. Skipped
-    // for deferred (the no-interrupt hook channel) and empty bodies (let
-    // deliver::send return Empty).
+    // silently — inject the rendered <EVENT> straight into its PTY instead. The
+    // broker hint (spool_deferred) tells us WHICH spool channel to use when the
+    // binary is absent/idle: active window → deferred (hook-poll), idle window →
+    // non-deferred (relay/poll wakes it). Only a CONFIRMED broker write counts as
+    // Sent; any miss falls through to the normal deliver path, never lost.
+    // Skipped for --deferred (the explicit hook channel) and empty bodies.
+    // [impl->REQ-MSG-DELIVERY-AXES]
     let outcome = if deferred {
         deliver::send_deferred(&target, &from, body)
-    } else if !body.is_empty()
-        && is_spt_hosted_no_relay(&target, &owlery)
-        && try_broker_inject(&target, &from, body)
-    {
-        SendOutcome::Sent
+    } else if !body.i